From fa4ce9c4c11577eb98af062914df89ef94ade57d Mon Sep 17 00:00:00 2001 From: Zihan Dai Date: Tue, 20 Oct 2026 22:03:36 +1100 Subject: [PATCH 1/2] Don't rewrite the .env file when unset_key finds no such key --- src/dotenv/main.py | 30 +++++++++++++++++++----------- tests/test_main.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..4520d1aa 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -285,6 +285,10 @@ def set_key( return True, key_to_set, value_to_set +class _KeyNotFound(Exception): + pass + + def unset_key( dotenv_path: StrPath, key_to_unset: str, @@ -311,17 +315,21 @@ def unset_key( return None, key_to_unset removed = False - with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( - source, - dest, - ): - for mapping in with_warn_for_invalid_lines(parse_stream(source)): - if mapping.key == key_to_unset: - removed = True - else: - dest.write(mapping.original.string) - - if not removed: + try: + with rewrite( + dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks + ) as (source, dest): + for mapping in with_warn_for_invalid_lines(parse_stream(source)): + if mapping.key == key_to_unset: + removed = True + else: + dest.write(mapping.original.string) + if not removed: + # Abandon the rewrite: there is nothing to change, and replacing + # the file anyway would, for instance, turn a symlink into a + # regular file. + raise _KeyNotFound() + except _KeyNotFound: logger.warning( "Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path ) diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..07c03670 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -512,6 +512,41 @@ def test_unset_key_symlink_to_existing_file(tmp_path): assert symlink.read_text() == "" +def test_unset_non_existent_value_leaves_file_untouched(dotenv_path): + dotenv_path.write_bytes(b"a=b\r\nc=d\r\n") + inode_before = dotenv_path.stat().st_ino + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(dotenv_path, "x") + + assert result == (None, "x") + assert dotenv_path.read_bytes() == b"a=b\r\nc=d\r\n" + assert dotenv_path.stat().st_ino == inode_before + assert list(dotenv_path.parent.iterdir()) == [dotenv_path] + mock_warning.assert_called_once_with( + "Key %s not removed from %s - key doesn't exist.", "x", dotenv_path + ) + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_symlink_non_existent_value(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning"): + result = dotenv.unset_key(symlink, "b") + + assert result == (None, "b") + assert symlink.is_symlink() + assert target.read_text() == "a=x\n" + + @pytest.mark.skipif( sys.platform == "win32", reason="symlinks require elevated privileges on Windows" ) From aed14e46dc101bfcc9f27b2112e564968eef9908 Mon Sep 17 00:00:00 2001 From: Zihan Dai Date: Tue, 20 Oct 2026 22:04:33 +1100 Subject: [PATCH 2/2] Add changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..7d16d3c5 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] +- `unset_key` and `dotenv unset` no longer rewrite the `.env` file when the key isn't in it, so a symlinked `.env` stays a symlink and its line endings and permissions are left alone by [@PDGGK] in [#734] ## [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 +[#734]: https://github.com/theskumar/python-dotenv/pull/734 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -477,6 +479,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@MohammedAlkindi]: https://github.com/MohammedAlkindi [@Nicals]: https://github.com/Nicals [@Nougat-Waffle]: https://github.com/Nougat-Waffle +[@PDGGK]: https://github.com/PDGGK [@Qwerty-133]: https://github.com/Qwerty-133 [@ReinerBRO]: https://github.com/ReinerBRO [@ROTl24]: https://github.com/ROTl24