From d0213b8203ddd3e363b4738c956db790861d1c82 Mon Sep 17 00:00:00 2001 From: Boris Kozak Date: Sun, 8 Nov 2026 16:09:07 +0000 Subject: [PATCH 1/2] fix: warn when the given .env path is a directory (#427) --- CHANGELOG.md | 1 + src/dotenv/main.py | 8 +++++++- tests/test_main.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..a54e87e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] - `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615] - `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] +- `load_dotenv` and `dotenv_values` now warn when the given `.env` path is a directory instead of silently loading nothing by [@boriskozak] in [#427] ## [1.2.4] - 2026-10-01 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..fc8f794f 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -69,7 +69,13 @@ def _get_stream(self) -> Iterator[IO[str]]: elif self.stream is not None: yield self.stream else: - if self.verbose: + if self.dotenv_path and os.path.exists(self.dotenv_path): + logger.warning( + "python-dotenv could not load configuration file %s: " + "it is a directory, not a file.", + self.dotenv_path, + ) + elif self.verbose: logger.info( "python-dotenv could not find configuration file %s.", self.dotenv_path or ".env", diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..a50fcc97 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -764,6 +764,37 @@ def test_load_dotenv_no_file_verbose(): ) +@pytest.mark.parametrize("verbose", [False, True]) +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_directory_warns(tmp_path, verbose): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.load_dotenv(tmp_path, verbose=verbose) + + assert result is False + assert os.environ == {} + mock_warning.assert_called_once_with( + "python-dotenv could not load configuration file %s: " + "it is a directory, not a file.", + tmp_path, + ) + + +def test_dotenv_values_directory_warns(tmp_path): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.dotenv_values(tmp_path) + + assert result == {} + mock_warning.assert_called_once_with( + "python-dotenv could not load configuration file %s: " + "it is a directory, not a file.", + tmp_path, + ) + + @pytest.mark.skipif( sys.platform == "win32", reason="This test assumes case-sensitive variable names" ) From cfc75fe88981516d7424ac3846f3b3c0adb2232c Mon Sep 17 00:00:00 2001 From: Boris Kozak Date: Sun, 8 Nov 2026 16:13:58 +0000 Subject: [PATCH 2/2] Address review: accurate warning wording, changelog links, test gaps --- CHANGELOG.md | 4 +++- src/dotenv/main.py | 2 +- tests/test_main.py | 26 +++++++++++++++++++++----- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a54e87e7..dc09191e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] - `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615] - `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] -- `load_dotenv` and `dotenv_values` now warn when the given `.env` path is a directory instead of silently loading nothing by [@boriskozak] in [#427] +- `load_dotenv`, `dotenv_values` and `get_key` now warn when the given `.env` path is a directory instead of silently loading nothing by [@boriskozak] in [#427] ## [1.2.4] - 2026-10-01 @@ -468,6 +468,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#700]: https://github.com/theskumar/python-dotenv/pull/700 [#711]: https://github.com/theskumar/python-dotenv/pull/711 [#714]: https://github.com/theskumar/python-dotenv/pull/714 +[#427]: https://github.com/theskumar/python-dotenv/issues/427 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -533,6 +534,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@zueve]: https://github.com/zueve [@JYOuyang]: https://github.com/JYOuyang [@burnout-projects]: https://github.com/burnout-projects +[@boriskozak]: https://github.com/boriskozak [@cpackham-atlnz]: https://github.com/cpackham-atlnz [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.4...HEAD [1.2.4]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...v1.2.4 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index fc8f794f..91391ea1 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -72,7 +72,7 @@ def _get_stream(self) -> Iterator[IO[str]]: if self.dotenv_path and os.path.exists(self.dotenv_path): logger.warning( "python-dotenv could not load configuration file %s: " - "it is a directory, not a file.", + "it is not a file.", self.dotenv_path, ) elif self.verbose: diff --git a/tests/test_main.py b/tests/test_main.py index a50fcc97..26287200 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -769,16 +769,19 @@ def test_load_dotenv_no_file_verbose(): def test_load_dotenv_directory_warns(tmp_path, verbose): logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "warning") as mock_warning: + with ( + mock.patch.object(logger, "warning") as mock_warning, + mock.patch.object(logger, "info") as mock_info, + ): result = dotenv.load_dotenv(tmp_path, verbose=verbose) assert result is False assert os.environ == {} mock_warning.assert_called_once_with( - "python-dotenv could not load configuration file %s: " - "it is a directory, not a file.", + "python-dotenv could not load configuration file %s: it is not a file.", tmp_path, ) + mock_info.assert_not_called() def test_dotenv_values_directory_warns(tmp_path): @@ -789,12 +792,25 @@ def test_dotenv_values_directory_warns(tmp_path): assert result == {} mock_warning.assert_called_once_with( - "python-dotenv could not load configuration file %s: " - "it is a directory, not a file.", + "python-dotenv could not load configuration file %s: it is not a file.", tmp_path, ) +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_directory_with_stream_loads(tmp_path): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.load_dotenv( + tmp_path, stream=io.StringIO("FOO=bar\n"), verbose=True + ) + + assert result is True + assert os.environ["FOO"] == "bar" + mock_warning.assert_not_called() + + @pytest.mark.skipif( sys.platform == "win32", reason="This test assumes case-sensitive variable names" )