diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 491634d9..016341e4 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. @@ -394,11 +413,17 @@ 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. 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. Returns: Bool: True if at least one environment variable is set else False @@ -426,6 +451,7 @@ def load_dotenv( interpolate=interpolate, override=override, encoding=encoding, + strict=strict, ) return dotenv.set_as_environment_variables() @@ -436,6 +462,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. @@ -447,9 +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. @@ -464,6 +497,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