diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..7e3d2eb2 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] +- A variable without a value (`FOO`) in a `.env` file no longer hides the environment or a `${FOO:-default}` default when it is referenced by a later variable, so `FOO\nBAR=${FOO}` now expands to the value of `FOO` from the environment instead of an empty string by [@TheNetherWatcher] in [#728] ## [1.2.4] - 2026-10-01 @@ -467,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 +[#728]: https://github.com/theskumar/python-dotenv/pull/728 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -533,6 +535,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@JYOuyang]: https://github.com/JYOuyang [@burnout-projects]: https://github.com/burnout-projects [@cpackham-atlnz]: https://github.com/cpackham-atlnz +[@TheNetherWatcher]: https://github.com/TheNetherWatcher [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 [1.2.3]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..b03f84ea 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -341,12 +341,15 @@ def resolve_variables( result = None else: atoms = parse_variables(value) + # A variable without a value (`FOO`) is not set, so it must not + # hide the environment or a default when it is referenced. + file_values = {k: v for k, v in new_values.items() if v is not None} env: Dict[str, Optional[str]] = {} if override: env.update(os.environ) # type: ignore - env.update(new_values) + env.update(file_values) else: - env.update(new_values) + env.update(file_values) env.update(os.environ) # type: ignore result = "".join(atom.resolve(env) for atom in atoms) diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..7e1a2abd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -816,6 +816,20 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): assert os.environ == {"a": "b", "d": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) +@pytest.mark.parametrize("override", [False, True]) +@mock.patch.dict(os.environ, {"a": "c"}, clear=True) +def test_load_dotenv_var_without_value_used_in_file(dotenv_path, override): + dotenv_path.write_text('a\nb\nd="${a}"\ne="${b:-f}"') + + result = dotenv.load_dotenv(dotenv_path, override=override) + + assert result is True + assert os.environ == {"a": "c", "d": "c", "e": "f"} + + @pytest.mark.skipif( sys.platform == "win32", reason="This test assumes case-sensitive variable names" ) @@ -910,6 +924,10 @@ def test_dotenv_values_file(dotenv_path): ({"b": "c"}, "b=d\na=${b}", True, {"a": "d", "b": "d"}), ({}, "a=b\na=c\nd=${a}", True, {"a": "c", "d": "c"}), ({}, "a=b\nc=${a}\nd=e\nc=${d}", True, {"a": "b", "c": "e", "d": "e"}), + # Defined in file without a value + ({"b": "c"}, "b\na=${b}", True, {"a": "c", "b": None}), + ({}, "b\na=${b}", True, {"a": "", "b": None}), + ({}, "b\na=${b:-d}", True, {"a": "d", "b": None}), ], ) def test_dotenv_values_string_io(env, string, interpolate, expected):