From 89c310846f3ed9147d997d1c639ba464bb9d3eb6 Mon Sep 17 00:00:00 2001 From: fan wu <336798967+FanWu-ai@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:05:28 +0900 Subject: [PATCH] Preserve quoted dotenv key names when setting values --- CHANGELOG.md | 2 ++ src/dotenv/main.py | 20 +++++++++++++++++-- tests/test_cli.py | 8 ++++++++ tests/test_main.py | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..fe6ac404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Preserve key names containing whitespace, `#`, or `=` when writing them with `set_key` or `dotenv set`. + - Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] - `set_key`, `unset_key` and the `dotenv set`/`unset` commands now name the `.env` path instead of an internal temporary file when its directory is missing or not writable, and the CLI prints a short error and exits with code 2 instead of a traceback by [@jamalkamaladdin] in [#711] - `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] diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 5faa7f0e..8c7dd0f9 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,6 +2,7 @@ import logging import os import pathlib +import re import stat import sys import tempfile @@ -234,6 +235,9 @@ def set_key( Adds or Updates a key/value to the given .env The target .env file is created if it doesn't exist. + Keys are quoted when required by the dotenv syntax, independently of + ``quote_mode``. A key that cannot be represented raises ``ValueError`` + before the file is modified. This function doesn't follow symlinks by default, to avoid accidentally modifying a file at a potentially untrusted path. If you don't need this @@ -246,6 +250,18 @@ def set_key( if quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") + # Keys may be single-quoted in dotenv files, independently of value quoting. + if ( + key_to_set + and not key_to_set.startswith("'") + and re.fullmatch(r"[^=#\s]+", key_to_set) + ): + key_out = key_to_set + elif key_to_set and "'" not in key_to_set: + key_out = f"'{key_to_set}'" + else: + raise ValueError("Unsupported dotenv key") + quote = quote_mode == "always" or ( quote_mode == "auto" and not value_to_set.isalnum() ) @@ -260,9 +276,9 @@ def set_key( else: value_out = value_to_set if export: - line_out = f"export {key_to_set}={value_out}\n" + line_out = f"export {key_out}={value_out}\n" else: - line_out = f"{key_to_set}={value_out}\n" + line_out = f"{key_out}={value_out}\n" with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( source, diff --git a/tests/test_cli.py b/tests/test_cli.py index 8123c9dd..4aa4a9df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -415,3 +415,11 @@ def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): check_process(result, exit_code=0) assert result.stdout.strip().startswith("dotenv, version") + + +@pytest.mark.parametrize("key", ["APP NAME", "APP#NAME", "APP=NAME"]) +def test_set_quoted_key(cli, dotenv_path, key): + dotenv_path.write_text(f"'{key}'=old\nSAFE=untouched\n") + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "set", key, "new"]) + assert result.exit_code == 0, result.output + assert dotenv.dotenv_values(dotenv_path) == {key: "new", "SAFE": "untouched"} diff --git a/tests/test_main.py b/tests/test_main.py index 930ab171..f982e9f7 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -948,3 +948,52 @@ def test_dotenv_values_empty_value_with_inline_comment(string, expected): result = dotenv.dotenv_values(stream=io.StringIO(string)) assert result == expected + + +@pytest.mark.parametrize( + "key", ["APP NAME", "APP#NAME", "APP=NAME", " APP ", "APP\tNAME"] +) +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("quote_mode", ["always", "auto", "never"]) +def test_set_key_round_trips_quoted_keys(dotenv_path, key, existing, quote_mode): + before = f"'{key}'=old\n" if existing else "" + dotenv_path.write_text(before + "SAFE=untouched\n") + + assert dotenv.set_key(dotenv_path, key, "new", quote_mode=quote_mode) == ( + True, + key, + "new", + ) + assert dotenv.dotenv_values(dotenv_path) == {key: "new", "SAFE": "untouched"} + assert dotenv.get_key(dotenv_path, key) == "new" + + +@pytest.mark.parametrize("key", ["", "'APP", "APP NAME'", "APP#NAME'"]) +def test_set_key_rejects_unrepresentable_keys_without_modifying_file(dotenv_path, key): + original = "SAFE=untouched\n" + dotenv_path.write_text(original) + with pytest.raises(ValueError, match="key"): + dotenv.set_key(dotenv_path, key, "new") + assert dotenv_path.read_text() == original + + +@pytest.mark.parametrize("key", ["APP", "APP'NAME", 'APP"NAME', "APP-NAME", "APP.NAME"]) +def test_set_key_preserves_unquoted_key_spelling(dotenv_path, key): + dotenv.set_key(dotenv_path, key, "new") + assert dotenv_path.read_text() == f"{key}='new'\n" + assert dotenv.dotenv_values(dotenv_path) == {key: "new"} + + +@pytest.mark.parametrize("key", ["APP NAME", "APP#NAME", "APP=NAME"]) +def test_set_key_export_round_trips_quoted_keys(dotenv_path, key): + dotenv.set_key(dotenv_path, key, "new", export=True) + assert dotenv_path.read_text() == f"export '{key}'='new'\n" + assert dotenv.dotenv_values(dotenv_path) == {key: "new"} + + +@pytest.mark.parametrize("key", ["", "APP NAME'"]) +def test_set_key_rejects_unrepresentable_keys_without_creating_file(tmp_path, key): + path = tmp_path / "missing.env" + with pytest.raises(ValueError, match="key"): + dotenv.set_key(path, key, "new") + assert not path.exists()