diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..c6f814ec 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] +- Reading a `.env` file no longer rewrites a carriage return inside a value to `\n`, so a value written by `set_key` round-trips byte-for-byte and reading a path agrees with reading the same bytes from a stream by [@Sreekant13] in [#724] ## [1.2.4] - 2026-10-01 @@ -467,6 +468,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#700]: https://github.com/theskumar/python-dotenv/pull/700 [#711]: https://github.com/theskumar/python-dotenv/pull/711 [#714]: https://github.com/theskumar/python-dotenv/pull/714 +[#724]: https://github.com/theskumar/python-dotenv/pull/724 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -518,6 +520,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@sammck]: https://github.com/sammck [@samwyma]: https://github.com/samwyma [@ShamikOfficial]: https://github.com/ShamikOfficial +[@Sreekant13]: https://github.com/Sreekant13 [@sidharth-sudhir]: https://github.com/sidharth-sudhir [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..5635a60c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -64,7 +64,12 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): - with open(self.dotenv_path, encoding=self.encoding) as stream: + # newline="" disables universal newline translation so that a + # carriage return inside a (quoted) value is not rewritten to "\n" + # before parsing. The parser handles CRLF, CR and LF line endings + # itself, so reading a path now matches reading the same bytes from + # a stream. + with open(self.dotenv_path, encoding=self.encoding, newline="") as stream: yield stream elif self.stream is not None: yield self.stream @@ -168,7 +173,10 @@ def rewrite( path = os.path.realpath(path) try: - source: IO[str] = open(path, encoding=encoding) + # newline="" preserves the exact bytes of untouched lines (and of any + # carriage return inside a value) instead of letting universal-newline + # translation rewrite them before they are echoed back out. + source: IO[str] = open(path, encoding=encoding, newline="") try: path_stat = os.lstat(path) original_mode: Optional[int] = ( @@ -187,6 +195,7 @@ def rewrite( temp_file = tempfile.NamedTemporaryFile( mode="w", encoding=encoding, + newline="", delete=False, prefix=".tmp_", dir=os.path.dirname(os.path.abspath(path)), diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..d491ad30 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -71,6 +71,12 @@ def test_set_key(dotenv_path, before, key, value, expected, after): "a\\nb", "plain", "", + # A real newline or carriage return in the value must survive the + # write/read file round-trip and not be rewritten by universal-newline + # translation (a lone "\r" used to be read back as "\n"). + "a\nb", + "a\rb", + "a\r\nb", ], ) def test_set_key_round_trips(dotenv_path, value):