From 88ab6426fe918af486ffccdf6c37f43e9a74657b Mon Sep 17 00:00:00 2001 From: Sai Asish Y Date: Tue, 12 May 2026 21:09:09 -0700 Subject: [PATCH] fix: write set_key values containing single quotes as double-quoted Signed-off-by: Sai Asish Y --- CHANGELOG.md | 3 +++ src/dotenv/main.py | 18 ++++++++++++------ tests/test_main.py | 7 ++++--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00f08f58..e69b1289 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 - 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] +- `set_key` now writes values containing `'` double-quoted, so the resulting line is valid shell and can be `source`d by [@SAY-5] in [#647] ## [1.2.3] - 2026-08-16 @@ -446,6 +447,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#606]: https://github.com/theskumar/python-dotenv/pull/606 [#638]: https://github.com/theskumar/python-dotenv/pull/638 [#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#647]: https://github.com/theskumar/python-dotenv/pull/647 [#663]: https://github.com/theskumar/python-dotenv/pull/663 [#680]: https://github.com/theskumar/python-dotenv/pull/680 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -489,6 +491,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/naorlivne [@Noethix55555]: https://github.com/Noethix55555 +[@SAY-5]: https://github.com/SAY-5 [@qnighy]: https://github.com/qnighy [@rabinadk1]: https://github.com/rabinadk1 [@randomseed42]: https://github.com/randomseed42 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3123690a..17bb49fa 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -216,12 +216,18 @@ def set_key( ) if quote: - # 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}'" + if "'" in value_to_set: + # A single quote cannot be escaped inside a single-quoted shell + # value, so values containing one are written double-quoted to + # keep the file source-able. + escaped = value_to_set.replace("\\", "\\\\").replace('"', '\\"') + value_out = f'"{escaped}"' + else: + # The single-quoted-value parser decodes `\\`, so backslashes have + # to be escaped here for the value to survive a write/read + # round-trip. + escaped = value_to_set.replace("\\", "\\\\") + value_out = f"'{escaped}'" else: value_out = value_to_set if export: diff --git a/tests/test_main.py b/tests/test_main.py index 6f9d4c5c..babe236b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,10 +29,11 @@ def test_set_key_no_file(tmp_path): [ ("", "a", "", (True, "a", ""), "a=''\n"), ("", "a", "b", (True, "a", "b"), "a='b'\n"), - ("", "a", "'b'", (True, "a", "'b'"), "a='\\'b\\''\n"), + ("", "a", "'b'", (True, "a", "'b'"), "a=\"'b'\"\n"), ("", "a", '"b"', (True, "a", '"b"'), "a='\"b\"'\n"), - ("", "a", "b'c", (True, "a", "b'c"), "a='b\\'c'\n"), + ("", "a", "b'c", (True, "a", "b'c"), 'a="b\'c"\n'), ("", "a", 'b"c', (True, "a", 'b"c'), "a='b\"c'\n"), + ("", "a", 'I\'m "in"', (True, "a", 'I\'m "in"'), 'a="I\'m \\"in\\""\n'), ("a=b", "a", "c", (True, "a", "c"), "a='c'\n"), ("a=b\n", "a", "c", (True, "a", "c"), "a='c'\n"), ("a=b\n\n", "a", "c", (True, "a", "c"), "a='c'\n\n"), @@ -42,7 +43,7 @@ def test_set_key_no_file(tmp_path): ("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"), + ("", "a", "b\\'c", (True, "a", "b\\'c"), 'a="b\\\\\'c"\n'), ], ) def test_set_key(dotenv_path, before, key, value, expected, after):