diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..0c6c924a 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] +- A leading UTF-8 BOM is now preserved when `set_key` or `unset_key` rewrites a `.env` file, instead of being silently dropped by [@MohammedAlkindi] in [#687] ## [1.2.4] - 2026-10-01 @@ -463,6 +464,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#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 +[#687]: https://github.com/theskumar/python-dotenv/pull/687 [#698]: https://github.com/theskumar/python-dotenv/pull/698 [#700]: https://github.com/theskumar/python-dotenv/pull/700 [#711]: https://github.com/theskumar/python-dotenv/pull/711 @@ -510,6 +512,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@lsmith77]: https://github.com/lsmith77 [@matthewfranglen]: https://github.com/matthewfranglen [@mgorny]: https://github.com/mgorny +[@MohammedAlkindi]: https://github.com/MohammedAlkindi [@naorlivne]: https://github.com/naorlivne [@Noethix55555]: https://github.com/Noethix55555 [@qnighy]: https://github.com/qnighy diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..f7d5f177 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -203,6 +203,14 @@ def rewrite( try: with source: + # The parser strips a leading BOM, so it never reaches the + # bindings the caller writes out. Carry it across here, or + # rewriting a file that has one silently drops it. + if source.seekable(): + if source.read(1) == "\ufeff": + dest.write("\ufeff") + else: + source.seek(0) yield (source, dest) except BaseException as err: error = err diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..12cc5f27 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -107,6 +107,17 @@ def test_set_key_preserves_file_mode(dotenv_path): assert mode_before == mode_after +def test_set_key_preserves_byte_order_mark(dotenv_path): + dotenv_path.write_bytes(b"\xef\xbb\xbfa=x\n") + + dotenv.set_key(dotenv_path, "b", "y") + + contents = dotenv_path.read_bytes() + assert contents.startswith(b"\xef\xbb\xbf") + assert contents.count(b"\xef\xbb\xbf") == 1 + assert dotenv.dotenv_values(dotenv_path) == {"a": "x", "b": "y"} + + def test_rewrite_closes_file_handle_on_lstat_failure(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_text("a=x\n") @@ -482,6 +493,17 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" +def test_unset_preserves_byte_order_mark(dotenv_path): + dotenv_path.write_bytes(b"\xef\xbb\xbfa=x\nb=y\n") + + result = dotenv.unset_key(dotenv_path, "b") + + assert result == (True, "b") + contents = dotenv_path.read_bytes() + assert contents.startswith(b"\xef\xbb\xbf") + assert dotenv.dotenv_values(dotenv_path) == {"a": "x"} + + def test_unset_non_existent_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main")