Skip to content

Commit f7b18d9

Browse files
fix: round-trip backslashes through set_key (#680)
`set_key` writes single-quoted values but only escaped single quotes, not backslashes. The single-quoted-value parser decodes `\` and `\'`, so any value with a backslash came back wrong: set_key(".env", "P", r"C:\Users") # writes P='C:\Users' get_key(".env", "P") # -> "C:Users" Escape backslashes before single quotes when writing. That alone is not enough for a value that ends in a backslash. The quoted-value patterns treated `\'` as an escape but not `\`, so in `P='back\'` the second backslash paired with the closing quote. `[^']` matches newlines, so the match ran on into the following lines and the binding failed to parse, taking the next entries with it. Both the single- and double-quoted patterns now treat a backslash as escaping whatever follows it. Fixes #661 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 751f8c1 commit f7b18d9

5 files changed

Lines changed: 111 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1010
### Fixed
1111

1212
- Strip a leading UTF-8 BOM from `.env` file contents so the first variable is no longer silently lost when the file is saved with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan] in [#640]
13+
- `set_key` now escapes backslashes, so values containing them (Windows paths, regular expressions) survive a write/read round-trip. Quoted values ending in an escaped backslash are no longer mis-parsed as an escaped quote, which used to swallow the following lines by [@dchaudhari7177] in [#661]
1314

1415
## [1.2.2] - 2026-03-01
1516

@@ -435,6 +436,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]).
435436
[#497]: https://github.com/theskumar/python-dotenv/pull/497
436437
[#161]: https://github.com/theskumar/python-dotenv/issues/161
437438
[#640]: https://github.com/theskumar/python-dotenv/pull/640
439+
[#661]: https://github.com/theskumar/python-dotenv/issues/661
438440
[790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311
439441

440442
<!-- contributors -->
@@ -452,6 +454,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]).
452454
[@bbc2]: https://github.com/bbc2
453455
[@befeleme]: https://github.com/befeleme
454456
[@cjauvin]: https://github.com/cjauvin
457+
[@dchaudhari7177]: https://github.com/dchaudhari7177
455458
[@eaf]: https://github.com/eaf
456459
[@earlbread]: https://github.com/earlbread
457460
[@eekstunt]: https://github.com/eekstunt

src/dotenv/main.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,12 @@ def set_key(
216216
)
217217

218218
if quote:
219-
value_out = "'{}'".format(value_to_set.replace("'", "\\'"))
219+
# The single-quoted-value parser decodes `\\` and `\'`, so both have to
220+
# be escaped here for the value to survive a write/read round-trip.
221+
# Backslashes first, otherwise the backslash added by the quote
222+
# escaping would be escaped in turn.
223+
escaped = value_to_set.replace("\\", "\\\\").replace("'", "\\'")
224+
value_out = f"'{escaped}'"
220225
else:
221226
value_out = value_to_set
222227
if export:

src/dotenv/parser.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]:
2222
_single_quoted_key = make_regex(r"'([^']+)'")
2323
_unquoted_key = make_regex(r"([^=\#\s]+)")
2424
_equal_sign = make_regex(r"(=[^\S\r\n]*)")
25-
_single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'")
26-
_double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"')
25+
# A backslash always escapes the character after it, so that an escaped
26+
# backslash (`\\`) is not mistaken for the start of an escaped quote.
27+
_single_quoted_value = make_regex(r"'((?:\\.|[^'\\])*)'", extra_flags=re.DOTALL)
28+
_double_quoted_value = make_regex(r'"((?:\\.|[^"\\])*)"', extra_flags=re.DOTALL)
2729
_unquoted_value = make_regex(r"([^\r\n]*)")
2830
_comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?")
2931
_end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r|$)")

tests/test_main.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ def test_set_key_no_file(tmp_path):
4040
("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"),
4141
("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"),
4242
("a=b", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"),
43+
("", "a", "b\\c", (True, "a", "b\\c"), "a='b\\\\c'\n"),
44+
("", "a", "b\\", (True, "a", "b\\"), "a='b\\\\'\n"),
45+
("", "a", "b\\'c", (True, "a", "b\\'c"), "a='b\\\\\\'c'\n"),
4346
],
4447
)
4548
def test_set_key(dotenv_path, before, key, value, expected, after):
@@ -54,6 +57,32 @@ def test_set_key(dotenv_path, before, key, value, expected, after):
5457
mock_warning.assert_not_called()
5558

5659

60+
@pytest.mark.parametrize(
61+
"value",
62+
[
63+
"C:\\Users",
64+
"C:\\Users\\",
65+
"\\d+",
66+
"back\\",
67+
"a\\'b",
68+
"it's",
69+
'say "hi"',
70+
"a\\nb",
71+
"plain",
72+
"",
73+
],
74+
)
75+
def test_set_key_round_trips(dotenv_path, value):
76+
dotenv_path.write_text("")
77+
78+
dotenv.set_key(dotenv_path, "a", value)
79+
dotenv.set_key(dotenv_path, "b", "sentinel")
80+
81+
assert dotenv.get_key(dotenv_path, "a") == value
82+
# A value that is mis-tokenized can swallow the lines that follow it.
83+
assert dotenv.get_key(dotenv_path, "b") == "sentinel"
84+
85+
5786
def test_set_key_encoding(dotenv_path):
5887
encoding = "latin-1"
5988

tests/test_parser.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,75 @@
295295
)
296296
],
297297
),
298+
(
299+
"a='b\\\\c'",
300+
[
301+
Binding(
302+
key="a",
303+
value="b\\c",
304+
original=Original(string="a='b\\\\c'", line=1),
305+
error=False,
306+
)
307+
],
308+
),
309+
(
310+
'a="b\\\\c"',
311+
[
312+
Binding(
313+
key="a",
314+
value="b\\c",
315+
original=Original(string='a="b\\\\c"', line=1),
316+
error=False,
317+
)
318+
],
319+
),
320+
# An escaped backslash at the end of the value must not be read as the
321+
# start of an escaped quote, which would swallow the following lines.
322+
(
323+
"a='b\\\\'\nc='d'",
324+
[
325+
Binding(
326+
key="a",
327+
value="b\\",
328+
original=Original(string="a='b\\\\'\n", line=1),
329+
error=False,
330+
),
331+
Binding(
332+
key="c",
333+
value="d",
334+
original=Original(string="c='d'", line=2),
335+
error=False,
336+
),
337+
],
338+
),
339+
(
340+
'a="b\\\\"\nc="d"',
341+
[
342+
Binding(
343+
key="a",
344+
value="b\\",
345+
original=Original(string='a="b\\\\"\n', line=1),
346+
error=False,
347+
),
348+
Binding(
349+
key="c",
350+
value="d",
351+
original=Original(string='c="d"', line=2),
352+
error=False,
353+
),
354+
],
355+
),
356+
(
357+
"a='b\\\\\\'c'",
358+
[
359+
Binding(
360+
key="a",
361+
value="b\\'c",
362+
original=Original(string="a='b\\\\\\'c'", line=1),
363+
error=False,
364+
)
365+
],
366+
),
298367
(
299368
"a=à",
300369
[

0 commit comments

Comments
 (0)