diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..f136f33d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] - `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615] - `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] +- `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.4] - 2026-10-01 @@ -460,6 +461,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#640]: https://github.com/theskumar/python-dotenv/pull/640 [#615]: https://github.com/theskumar/python-dotenv/pull/615 [#648]: https://github.com/theskumar/python-dotenv/pull/648 +[#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 [#686]: https://github.com/theskumar/python-dotenv/pull/686 @@ -512,6 +514,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 5faa7f0e..8dd58194 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -251,12 +251,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 930ab171..4d8c2b95 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -30,10 +30,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"), @@ -43,7 +44,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):