Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`.
Expand Down Expand Up @@ -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:
Expand All @@ -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`.
Expand All @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down