Skip to content

Commit 3fab2a8

Browse files
feat: add strict parameter to load_dotenv() and dotenv_values()
Add opt-in strict mode that raises exceptions instead of silently ignoring errors. When `strict=True`: - `FileNotFoundError` is raised if the .env file is not found - `ValueError` is raised if any line cannot be parsed, with line number Defaults to `False` for full backwards compatibility. Closes #631 Related: #467, #297, #321, #520, #591
1 parent fa4e6a9 commit 3fab2a8

File tree

2 files changed

+150
-6
lines changed

2 files changed

+150
-6
lines changed

src/dotenv/main.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,22 @@ def _load_dotenv_disabled() -> bool:
2929
return value in {"1", "true", "t", "yes", "y"}
3030

3131

32-
def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]:
32+
def with_warn_for_invalid_lines(
33+
mappings: Iterator[Binding],
34+
strict: bool = False,
35+
) -> Iterator[Binding]:
3336
for mapping in mappings:
3437
if mapping.error:
35-
logger.warning(
36-
"python-dotenv could not parse statement starting at line %s",
37-
mapping.original.line,
38-
)
38+
if strict:
39+
raise ValueError(
40+
"python-dotenv could not parse statement starting at line %s"
41+
% mapping.original.line,
42+
)
43+
else:
44+
logger.warning(
45+
"python-dotenv could not parse statement starting at line %s",
46+
mapping.original.line,
47+
)
3948
yield mapping
4049

4150

@@ -48,6 +57,7 @@ def __init__(
4857
encoding: Optional[str] = None,
4958
interpolate: bool = True,
5059
override: bool = True,
60+
strict: bool = False,
5161
) -> None:
5262
self.dotenv_path: Optional[StrPath] = dotenv_path
5363
self.stream: Optional[IO[str]] = stream
@@ -56,6 +66,7 @@ def __init__(
5666
self.encoding: Optional[str] = encoding
5767
self.interpolate: bool = interpolate
5868
self.override: bool = override
69+
self.strict: bool = strict
5970

6071
@contextmanager
6172
def _get_stream(self) -> Iterator[IO[str]]:
@@ -65,6 +76,11 @@ def _get_stream(self) -> Iterator[IO[str]]:
6576
elif self.stream is not None:
6677
yield self.stream
6778
else:
79+
if self.strict:
80+
raise FileNotFoundError(
81+
"python-dotenv could not find configuration file %s."
82+
% (self.dotenv_path or ".env"),
83+
)
6884
if self.verbose:
6985
logger.info(
7086
"python-dotenv could not find configuration file %s.",
@@ -90,7 +106,9 @@ def dict(self) -> Dict[str, Optional[str]]:
90106

91107
def parse(self) -> Iterator[Tuple[str, Optional[str]]]:
92108
with self._get_stream() as stream:
93-
for mapping in with_warn_for_invalid_lines(parse_stream(stream)):
109+
for mapping in with_warn_for_invalid_lines(
110+
parse_stream(stream), strict=self.strict
111+
):
94112
if mapping.key is not None:
95113
yield mapping.key, mapping.value
96114

@@ -387,6 +405,7 @@ def load_dotenv(
387405
override: bool = False,
388406
interpolate: bool = True,
389407
encoding: Optional[str] = "utf-8",
408+
strict: bool = False,
390409
) -> bool:
391410
"""Parse a .env file and then load all the variables found as environment variables.
392411
@@ -399,6 +418,9 @@ def load_dotenv(
399418
from the `.env` file.
400419
interpolate: Whether to interpolate variables using POSIX variable expansion.
401420
encoding: Encoding to be used to read the file.
421+
strict: Whether to raise errors instead of silently ignoring them. When
422+
``True``, a ``FileNotFoundError`` is raised if the .env file is not
423+
found and a ``ValueError`` is raised if any line cannot be parsed.
402424
Returns:
403425
Bool: True if at least one environment variable is set else False
404426
@@ -426,6 +448,7 @@ def load_dotenv(
426448
interpolate=interpolate,
427449
override=override,
428450
encoding=encoding,
451+
strict=strict,
429452
)
430453
return dotenv.set_as_environment_variables()
431454

@@ -436,6 +459,7 @@ def dotenv_values(
436459
verbose: bool = False,
437460
interpolate: bool = True,
438461
encoding: Optional[str] = "utf-8",
462+
strict: bool = False,
439463
) -> Dict[str, Optional[str]]:
440464
"""
441465
Parse a .env file and return its content as a dict.
@@ -450,6 +474,9 @@ def dotenv_values(
450474
verbose: Whether to output a warning if the .env file is missing.
451475
interpolate: Whether to interpolate variables using POSIX variable expansion.
452476
encoding: Encoding to be used to read the file.
477+
strict: Whether to raise errors instead of silently ignoring them. When
478+
``True``, a ``FileNotFoundError`` is raised if the .env file is not
479+
found and a ``ValueError`` is raised if any line cannot be parsed.
453480
454481
If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the
455482
.env file.
@@ -464,6 +491,7 @@ def dotenv_values(
464491
interpolate=interpolate,
465492
override=True,
466493
encoding=encoding,
494+
strict=strict,
467495
).dict()
468496

469497

tests/test_main.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,119 @@ def test_dotenv_values_file_stream(dotenv_path):
695695
result = dotenv.dotenv_values(stream=f)
696696

697697
assert result == {"a": "b"}
698+
699+
700+
# --- strict mode tests ---
701+
702+
703+
class TestStrictMode:
704+
"""Tests for the strict parameter on load_dotenv and dotenv_values."""
705+
706+
def test_load_dotenv_strict_file_not_found(self, tmp_path):
707+
nx_path = tmp_path / "nonexistent" / ".env"
708+
709+
with pytest.raises(
710+
FileNotFoundError, match="could not find configuration file"
711+
):
712+
dotenv.load_dotenv(nx_path, strict=True)
713+
714+
def test_load_dotenv_strict_empty_path_not_found(self, tmp_path):
715+
os.chdir(tmp_path)
716+
717+
with pytest.raises(
718+
FileNotFoundError, match="could not find configuration file"
719+
):
720+
dotenv.load_dotenv(str(tmp_path / ".env"), strict=True)
721+
722+
@pytest.mark.skipif(
723+
sys.platform == "win32",
724+
reason="This test assumes case-sensitive variable names",
725+
)
726+
@mock.patch.dict(os.environ, {}, clear=True)
727+
def test_load_dotenv_strict_valid_file(self, dotenv_path):
728+
dotenv_path.write_text("a=b")
729+
730+
result = dotenv.load_dotenv(dotenv_path, strict=True)
731+
732+
assert result is True
733+
assert os.environ == {"a": "b"}
734+
735+
def test_load_dotenv_strict_parse_error(self, dotenv_path):
736+
dotenv_path.write_text("a: b")
737+
738+
with pytest.raises(
739+
ValueError, match="could not parse statement starting at line 1"
740+
):
741+
dotenv.load_dotenv(dotenv_path, strict=True)
742+
743+
def test_load_dotenv_strict_parse_error_line_number(self, dotenv_path):
744+
dotenv_path.write_text("valid=ok\ninvalid: line\n")
745+
746+
with pytest.raises(ValueError, match="starting at line 2"):
747+
dotenv.load_dotenv(dotenv_path, strict=True)
748+
749+
def test_load_dotenv_non_strict_file_not_found(self, tmp_path):
750+
"""Non-strict mode preserves existing behavior: returns False, no exception."""
751+
nx_path = tmp_path / ".env"
752+
753+
result = dotenv.load_dotenv(nx_path, strict=False)
754+
755+
assert result is False
756+
757+
def test_load_dotenv_non_strict_parse_error(self, dotenv_path):
758+
"""Non-strict mode preserves existing behavior: warns, doesn't raise."""
759+
dotenv_path.write_text("a: b")
760+
logger = logging.getLogger("dotenv.main")
761+
762+
with mock.patch.object(logger, "warning") as mock_warning:
763+
result = dotenv.load_dotenv(dotenv_path, strict=False)
764+
765+
assert result is False
766+
mock_warning.assert_called_once()
767+
768+
def test_dotenv_values_strict_file_not_found(self, tmp_path):
769+
nx_path = tmp_path / ".env"
770+
771+
with pytest.raises(
772+
FileNotFoundError, match="could not find configuration file"
773+
):
774+
dotenv.dotenv_values(nx_path, strict=True)
775+
776+
def test_dotenv_values_strict_valid_file(self, dotenv_path):
777+
dotenv_path.write_text("a=b\nc=d")
778+
779+
result = dotenv.dotenv_values(dotenv_path, strict=True)
780+
781+
assert result == {"a": "b", "c": "d"}
782+
783+
def test_dotenv_values_strict_parse_error(self, dotenv_path):
784+
dotenv_path.write_text("good=value\nbad: line")
785+
786+
with pytest.raises(
787+
ValueError, match="could not parse statement starting at line 2"
788+
):
789+
dotenv.dotenv_values(dotenv_path, strict=True)
790+
791+
def test_dotenv_values_strict_with_stream(self):
792+
stream = io.StringIO("a=b")
793+
794+
result = dotenv.dotenv_values(stream=stream, strict=True)
795+
796+
assert result == {"a": "b"}
797+
798+
def test_dotenv_values_strict_stream_parse_error(self):
799+
stream = io.StringIO("bad: line")
800+
801+
with pytest.raises(
802+
ValueError, match="could not parse statement starting at line 1"
803+
):
804+
dotenv.dotenv_values(stream=stream, strict=True)
805+
806+
def test_load_dotenv_strict_default_is_false(self, dotenv_path):
807+
"""Verify strict defaults to False (backwards compatible)."""
808+
dotenv_path.write_text("a: b")
809+
810+
# Should not raise — strict defaults to False
811+
result = dotenv.load_dotenv(dotenv_path)
812+
813+
assert result is False

0 commit comments

Comments
 (0)