From 9ecf5808dcfd6ebe06a2458af8da28fb50615b05 Mon Sep 17 00:00:00 2001 From: f14114069-del Date: Tue, 15 Sep 2026 13:25:12 +0800 Subject: [PATCH 1/3] Document blank-line preservation when modifying dotenv keys --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f08f58..0af8f382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Preserve blank lines before keys updated by `set_key` or removed by `unset_key`. - An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] ## [1.2.3] - 2026-08-16 From 3fae2344d02ec83ab2226852095048831453619e Mon Sep 17 00:00:00 2001 From: f14114069-del Date: Tue, 15 Sep 2026 13:25:46 +0800 Subject: [PATCH 2/3] Preserve blank lines when setting or unsetting keys --- src/dotenv/main.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3123690a..5d463444 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -190,6 +190,13 @@ def rewrite( raise error from None +def _get_leading_blank_lines(string: str) -> str: + """Return complete blank lines before a binding, excluding its indentation.""" + whitespace = string[: len(string) - len(string.lstrip())] + last_newline = max(whitespace.rfind("\n"), whitespace.rfind("\r")) + return whitespace[: last_newline + 1] + + def set_key( dotenv_path: StrPath, key_to_set: str, @@ -204,6 +211,8 @@ def set_key( The target .env file is created if it doesn't exist. + Blank lines preceding an updated key are preserved. + This function doesn't follow symlinks by default, to avoid accidentally modifying a file at a potentially untrusted path. If you don't need this protection and need symlinks to be followed, use `follow_symlinks`. @@ -237,6 +246,8 @@ def set_key( missing_newline = False for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_set: + # The parser includes preceding blank lines in the binding. + dest.write(_get_leading_blank_lines(mapping.original.string)) dest.write(line_out) replaced = True else: @@ -263,6 +274,8 @@ def unset_key( If the .env path given doesn't exist, fails. If the given key doesn't exist in the .env, fails. + Blank lines preceding a removed key are preserved. + This function doesn't follow symlinks by default, to avoid accidentally modifying a file at a potentially untrusted path. If you don't need this protection and need symlinks to be followed, use `follow_symlinks`. @@ -278,6 +291,7 @@ def unset_key( ): for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_unset: + dest.write(_get_leading_blank_lines(mapping.original.string)) removed = True else: dest.write(mapping.original.string) From e59ca6a815d3ccf230d8aa508c3d1ba9cbdd5af5 Mon Sep 17 00:00:00 2001 From: f14114069-del Date: Tue, 15 Sep 2026 13:26:25 +0800 Subject: [PATCH 3/3] Test blank-line preservation for set_key and unset_key --- tests/test_main.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 6f9d4c5c..f7adba16 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -83,6 +83,49 @@ def test_set_key_round_trips(dotenv_path, value): assert dotenv.get_key(dotenv_path, "b") == "sentinel" +@pytest.mark.parametrize("operation", ["set", "unset"]) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.parametrize( + "before,key,after_set,after_unset", + [ + ("\n\na=old\nb=keep\n", "a", "\n\na='new'\nb=keep\n", "\n\nb=keep\n"), + ( + "# heading\n \t\n\na=old\n\nb=keep\n", + "a", + "# heading\n \t\n\na='new'\n\nb=keep\n", + "# heading\n \t\n\n\nb=keep\n", + ), + ( + "a=one\n\n\ta=two\n\nb=keep\n", + "a", + "a='new'\n\na='new'\n\nb=keep\n", + "\n\nb=keep\n", + ), + ( + "\n\na='first\n\nsecond'\n\nb=keep\n", + "a", + "\n\na='new'\n\nb=keep\n", + "\n\n\nb=keep\n", + ), + ("\n'a'=old", "a", "\na='new'\n", "\n"), + ("\n\ta\n", "a", "\na='new'\n", "\n"), + ], +) +def test_modify_key_preserves_blank_lines( + dotenv_path, operation, newline, before, key, after_set, after_unset +): + dotenv_path.write_bytes(before.replace("\n", newline).encode("utf-8")) + + if operation == "set": + assert dotenv.set_key(dotenv_path, key, "new") == (True, key, "new") + expected = after_set + else: + assert dotenv.unset_key(dotenv_path, key) == (True, key) + expected = after_unset + + assert dotenv_path.read_text() == expected + + def test_set_key_encoding(dotenv_path): encoding = "latin-1"