Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 18 additions & 2 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
import pathlib
import re
import stat
import sys
import tempfile
Expand Down Expand Up @@ -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
Expand All @@ -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()
)
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
49 changes: 49 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()