Skip to content

Commit bdee098

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 bdee098

File tree

2 files changed

+146
-7
lines changed

2 files changed

+146
-7
lines changed

src/dotenv/main.py

Lines changed: 35 additions & 7 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,7 +76,12 @@ def _get_stream(self) -> Iterator[IO[str]]:
6576
elif self.stream is not None:
6677
yield self.stream
6778
else:
68-
if self.verbose:
79+
if self.strict:
80+
raise FileNotFoundError(
81+
"python-dotenv could not find configuration file %s."
82+
% (self.dotenv_path or ".env"),
83+
)
84+
elif self.verbose:
6985
logger.info(
7086
"python-dotenv could not find configuration file %s.",
7187
self.dotenv_path or ".env",
@@ -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: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,114 @@ def test_dotenv_values_file_stream(dotenv_path):
695695
result = dotenv.dotenv_values(stream=f)
696696

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

0 commit comments

Comments
 (0)