From 439f4fe44d632a7a207ed6813053317c44a3a263 Mon Sep 17 00:00:00 2001 From: dchaudhari7177 <111210939+dchaudhari7177@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:39:03 +0530 Subject: [PATCH] fix: make values containing backslashes round-trip through set_key `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 --- CHANGELOG.md | 3 ++ src/dotenv/main.py | 7 ++++- src/dotenv/parser.py | 6 ++-- tests/test_main.py | 29 +++++++++++++++++++ tests/test_parser.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa81cc6e..98fb10ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - 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] +- `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] ## [1.2.2] - 2026-03-01 @@ -435,6 +436,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#497]: https://github.com/theskumar/python-dotenv/pull/497 [#161]: https://github.com/theskumar/python-dotenv/issues/161 [#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#661]: https://github.com/theskumar/python-dotenv/issues/661 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -452,6 +454,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@bbc2]: https://github.com/bbc2 [@befeleme]: https://github.com/befeleme [@cjauvin]: https://github.com/cjauvin +[@dchaudhari7177]: https://github.com/dchaudhari7177 [@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread [@eekstunt]: https://github.com/eekstunt diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3c4608d5..3123690a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -216,7 +216,12 @@ def set_key( ) if quote: - value_out = "'{}'".format(value_to_set.replace("'", "\\'")) + # The single-quoted-value parser decodes `\\` and `\'`, so both have to + # be escaped here for the value to survive a write/read round-trip. + # Backslashes first, otherwise the backslash added by the quote + # escaping would be escaped in turn. + escaped = value_to_set.replace("\\", "\\\\").replace("'", "\\'") + value_out = f"'{escaped}'" else: value_out = value_to_set if export: diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 66773604..02648ab0 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -22,8 +22,10 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _single_quoted_key = make_regex(r"'([^']+)'") _unquoted_key = make_regex(r"([^=\#\s]+)") _equal_sign = make_regex(r"(=[^\S\r\n]*)") -_single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'") -_double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"') +# A backslash always escapes the character after it, so that an escaped +# backslash (`\\`) is not mistaken for the start of an escaped quote. +_single_quoted_value = make_regex(r"'((?:\\.|[^'\\])*)'", extra_flags=re.DOTALL) +_double_quoted_value = make_regex(r'"((?:\\.|[^"\\])*)"', extra_flags=re.DOTALL) _unquoted_value = make_regex(r"([^\r\n]*)") _comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?") _end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r|$)") diff --git a/tests/test_main.py b/tests/test_main.py index 1c33c808..48dd7bf4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -40,6 +40,9 @@ def test_set_key_no_file(tmp_path): ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"), ("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), ("a=b", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), + ("", "a", "b\\c", (True, "a", "b\\c"), "a='b\\\\c'\n"), + ("", "a", "b\\", (True, "a", "b\\"), "a='b\\\\'\n"), + ("", "a", "b\\'c", (True, "a", "b\\'c"), "a='b\\\\\\'c'\n"), ], ) 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): mock_warning.assert_not_called() +@pytest.mark.parametrize( + "value", + [ + "C:\\Users", + "C:\\Users\\", + "\\d+", + "back\\", + "a\\'b", + "it's", + 'say "hi"', + "a\\nb", + "plain", + "", + ], +) +def test_set_key_round_trips(dotenv_path, value): + dotenv_path.write_text("") + + dotenv.set_key(dotenv_path, "a", value) + dotenv.set_key(dotenv_path, "b", "sentinel") + + assert dotenv.get_key(dotenv_path, "a") == value + # A value that is mis-tokenized can swallow the lines that follow it. + assert dotenv.get_key(dotenv_path, "b") == "sentinel" + + def test_set_key_encoding(dotenv_path): encoding = "latin-1" diff --git a/tests/test_parser.py b/tests/test_parser.py index 4ec5a5af..dd638d9b 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -295,6 +295,75 @@ ) ], ), + ( + "a='b\\\\c'", + [ + Binding( + key="a", + value="b\\c", + original=Original(string="a='b\\\\c'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\\\\c"', + [ + Binding( + key="a", + value="b\\c", + original=Original(string='a="b\\\\c"', line=1), + error=False, + ) + ], + ), + # An escaped backslash at the end of the value must not be read as the + # start of an escaped quote, which would swallow the following lines. + ( + "a='b\\\\'\nc='d'", + [ + Binding( + key="a", + value="b\\", + original=Original(string="a='b\\\\'\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c='d'", line=2), + error=False, + ), + ], + ), + ( + 'a="b\\\\"\nc="d"', + [ + Binding( + key="a", + value="b\\", + original=Original(string='a="b\\\\"\n', line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string='c="d"', line=2), + error=False, + ), + ], + ), + ( + "a='b\\\\\\'c'", + [ + Binding( + key="a", + value="b\\'c", + original=Original(string="a='b\\\\\\'c'", line=1), + error=False, + ) + ], + ), ( "a=à", [