From bdee098ac8b070a5615ea091d47f6f0efbfdb7ca Mon Sep 17 00:00:00 2001 From: "ahsan.sheraz" Date: Sun, 15 Mar 2026 13:17:10 +0100 Subject: [PATCH 1/2] 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 --- src/dotenv/main.py | 42 ++++++++++++++--- tests/test_main.py | 111 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 491634d9..1bfd273a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -29,13 +29,22 @@ def _load_dotenv_disabled() -> bool: return value in {"1", "true", "t", "yes", "y"} -def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: +def with_warn_for_invalid_lines( + mappings: Iterator[Binding], + strict: bool = False, +) -> Iterator[Binding]: for mapping in mappings: if mapping.error: - logger.warning( - "python-dotenv could not parse statement starting at line %s", - mapping.original.line, - ) + if strict: + raise ValueError( + "python-dotenv could not parse statement starting at line %s" + % mapping.original.line, + ) + else: + logger.warning( + "python-dotenv could not parse statement starting at line %s", + mapping.original.line, + ) yield mapping @@ -48,6 +57,7 @@ def __init__( encoding: Optional[str] = None, interpolate: bool = True, override: bool = True, + strict: bool = False, ) -> None: self.dotenv_path: Optional[StrPath] = dotenv_path self.stream: Optional[IO[str]] = stream @@ -56,6 +66,7 @@ def __init__( self.encoding: Optional[str] = encoding self.interpolate: bool = interpolate self.override: bool = override + self.strict: bool = strict @contextmanager def _get_stream(self) -> Iterator[IO[str]]: @@ -65,7 +76,12 @@ def _get_stream(self) -> Iterator[IO[str]]: elif self.stream is not None: yield self.stream else: - if self.verbose: + if self.strict: + raise FileNotFoundError( + "python-dotenv could not find configuration file %s." + % (self.dotenv_path or ".env"), + ) + elif self.verbose: logger.info( "python-dotenv could not find configuration file %s.", self.dotenv_path or ".env", @@ -90,7 +106,9 @@ def dict(self) -> Dict[str, Optional[str]]: def parse(self) -> Iterator[Tuple[str, Optional[str]]]: with self._get_stream() as stream: - for mapping in with_warn_for_invalid_lines(parse_stream(stream)): + for mapping in with_warn_for_invalid_lines( + parse_stream(stream), strict=self.strict + ): if mapping.key is not None: yield mapping.key, mapping.value @@ -387,6 +405,7 @@ def load_dotenv( override: bool = False, interpolate: bool = True, encoding: Optional[str] = "utf-8", + strict: bool = False, ) -> bool: """Parse a .env file and then load all the variables found as environment variables. @@ -399,6 +418,9 @@ def load_dotenv( from the `.env` file. interpolate: Whether to interpolate variables using POSIX variable expansion. encoding: Encoding to be used to read the file. + strict: Whether to raise errors instead of silently ignoring them. When + ``True``, a ``FileNotFoundError`` is raised if the .env file is not + found and a ``ValueError`` is raised if any line cannot be parsed. Returns: Bool: True if at least one environment variable is set else False @@ -426,6 +448,7 @@ def load_dotenv( interpolate=interpolate, override=override, encoding=encoding, + strict=strict, ) return dotenv.set_as_environment_variables() @@ -436,6 +459,7 @@ def dotenv_values( verbose: bool = False, interpolate: bool = True, encoding: Optional[str] = "utf-8", + strict: bool = False, ) -> Dict[str, Optional[str]]: """ Parse a .env file and return its content as a dict. @@ -450,6 +474,9 @@ def dotenv_values( verbose: Whether to output a warning if the .env file is missing. interpolate: Whether to interpolate variables using POSIX variable expansion. encoding: Encoding to be used to read the file. + strict: Whether to raise errors instead of silently ignoring them. When + ``True``, a ``FileNotFoundError`` is raised if the .env file is not + found and a ``ValueError`` is raised if any line cannot be parsed. If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the .env file. @@ -464,6 +491,7 @@ def dotenv_values( interpolate=interpolate, override=True, encoding=encoding, + strict=strict, ).dict() diff --git a/tests/test_main.py b/tests/test_main.py index 50703af0..6b4545f8 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -695,3 +695,114 @@ def test_dotenv_values_file_stream(dotenv_path): result = dotenv.dotenv_values(stream=f) assert result == {"a": "b"} + + +def test_load_dotenv_strict_file_not_found(tmp_path): + nx_path = tmp_path / "nonexistent" / ".env" + + with pytest.raises(FileNotFoundError, match="could not find configuration file"): + dotenv.load_dotenv(nx_path, strict=True) + + +def test_load_dotenv_strict_empty_path_not_found(tmp_path): + os.chdir(tmp_path) + + with pytest.raises(FileNotFoundError, match="could not find configuration file"): + dotenv.load_dotenv(str(tmp_path / ".env"), strict=True) + + +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_strict_valid_file(dotenv_path): + dotenv_path.write_text("a=b") + + result = dotenv.load_dotenv(dotenv_path, strict=True) + + assert result is True + assert os.environ == {"a": "b"} + + +def test_load_dotenv_strict_parse_error(dotenv_path): + dotenv_path.write_text("a: b") + + with pytest.raises( + ValueError, match="could not parse statement starting at line 1" + ): + dotenv.load_dotenv(dotenv_path, strict=True) + + +def test_load_dotenv_strict_parse_error_line_number(dotenv_path): + dotenv_path.write_text("valid=ok\ninvalid: line\n") + + with pytest.raises(ValueError, match="starting at line 2"): + dotenv.load_dotenv(dotenv_path, strict=True) + + +def test_load_dotenv_non_strict_file_not_found(tmp_path): + nx_path = tmp_path / ".env" + + result = dotenv.load_dotenv(nx_path, strict=False) + + assert result is False + + +def test_load_dotenv_non_strict_parse_error(dotenv_path): + dotenv_path.write_text("a: b") + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.load_dotenv(dotenv_path, strict=False) + + assert result is False + mock_warning.assert_called_once() + + +def test_dotenv_values_strict_file_not_found(tmp_path): + nx_path = tmp_path / ".env" + + with pytest.raises(FileNotFoundError, match="could not find configuration file"): + dotenv.dotenv_values(nx_path, strict=True) + + +def test_dotenv_values_strict_valid_file(dotenv_path): + dotenv_path.write_text("a=b\nc=d") + + result = dotenv.dotenv_values(dotenv_path, strict=True) + + assert result == {"a": "b", "c": "d"} + + +def test_dotenv_values_strict_parse_error(dotenv_path): + dotenv_path.write_text("good=value\nbad: line") + + with pytest.raises( + ValueError, match="could not parse statement starting at line 2" + ): + dotenv.dotenv_values(dotenv_path, strict=True) + + +def test_dotenv_values_strict_with_stream(): + stream = io.StringIO("a=b") + + result = dotenv.dotenv_values(stream=stream, strict=True) + + assert result == {"a": "b"} + + +def test_dotenv_values_strict_stream_parse_error(): + stream = io.StringIO("bad: line") + + with pytest.raises( + ValueError, match="could not parse statement starting at line 1" + ): + dotenv.dotenv_values(stream=stream, strict=True) + + +def test_load_dotenv_strict_default_is_false(dotenv_path): + dotenv_path.write_text("a: b") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is False From 60df80fa6287f6095763a868a34a54d5c3ca6edb Mon Sep 17 00:00:00 2001 From: "ahsan.sheraz" Date: Sun, 15 Mar 2026 13:56:19 +0100 Subject: [PATCH 2/2] docs: clarify strict takes precedence over verbose in docstrings --- src/dotenv/main.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1bfd273a..016341e4 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -413,7 +413,8 @@ def load_dotenv( dotenv_path: Absolute or relative path to .env file. stream: Text stream (such as `io.StringIO`) with .env content, used if `dotenv_path` is `None`. - verbose: Whether to output a warning the .env file is missing. + verbose: Whether to output a warning the .env file is missing. Ignored + when ``strict`` is ``True`` (strict raises instead of warning). override: Whether to override the system environment variables with the variables from the `.env` file. interpolate: Whether to interpolate variables using POSIX variable expansion. @@ -421,6 +422,8 @@ def load_dotenv( strict: Whether to raise errors instead of silently ignoring them. When ``True``, a ``FileNotFoundError`` is raised if the .env file is not found and a ``ValueError`` is raised if any line cannot be parsed. + Takes precedence over ``verbose`` — when both are ``True``, the + exception is raised without emitting a warning first. Returns: Bool: True if at least one environment variable is set else False @@ -471,12 +474,15 @@ def dotenv_values( Parameters: dotenv_path: Absolute or relative path to the .env file. stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. - verbose: Whether to output a warning if the .env file is missing. + verbose: Whether to output a warning if the .env file is missing. Ignored + when ``strict`` is ``True`` (strict raises instead of warning). interpolate: Whether to interpolate variables using POSIX variable expansion. encoding: Encoding to be used to read the file. strict: Whether to raise errors instead of silently ignoring them. When ``True``, a ``FileNotFoundError`` is raised if the .env file is not found and a ``ValueError`` is raised if any line cannot be parsed. + Takes precedence over ``verbose`` — when both are ``True``, the + exception is raised without emitting a warning first. If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the .env file.