From 18399c5d9ed65614f0ce629f8612cd6231e0d16c Mon Sep 17 00:00:00 2001 From: Jamal Date: Wed, 30 Sep 2026 08:24:27 +0200 Subject: [PATCH 1/2] fix: name the .env path in set_key and unset_key write errors rewrite() creates its temporary file in the target's directory before it touches the target. When that directory is missing or not writable, the OSError named the temporary file instead of the .env path. The error now carries the .env path. `dotenv set` and `dotenv unset` print a short error and exit with code 2 on an OSError, as `get` and `list` do through stream_file. Fixes #710. --- src/dotenv/cli.py | 12 ++++++++++-- src/dotenv/main.py | 22 +++++++++++++++------- tests/test_cli.py | 33 +++++++++++++++++++++++++++++++++ tests/test_main.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 9 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 72c81816..376b50e1 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -124,7 +124,11 @@ def set_value(ctx: click.Context, key: Any, value: Any) -> None: file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] export = ctx.obj["EXPORT"] - success, key, value = set_key(file, key, value, quote, export) + try: + success, key, value = set_key(file, key, value, quote, export) + except OSError as exc: + print(f"Error writing env file: {exc}", file=sys.stderr) + sys.exit(2) if success: click.echo(f"{key}={value}") else: @@ -160,7 +164,11 @@ def unset(ctx: click.Context, key: Any) -> None: """ file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] - success, key = unset_key(file, key, quote) + try: + success, key = unset_key(file, key, quote) + except OSError as exc: + print(f"Error writing env file: {exc}", file=sys.stderr) + sys.exit(2) if success: click.echo(f"Successfully removed {key}") else: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 3123690a..95ee9db2 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -160,13 +160,21 @@ def rewrite( source = io.StringIO("") original_mode = None - with tempfile.NamedTemporaryFile( - mode="w", - encoding=encoding, - delete=False, - prefix=".tmp_", - dir=os.path.dirname(os.path.abspath(path)), - ) as dest: + try: + temp_file = tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + delete=False, + prefix=".tmp_", + dir=os.path.dirname(os.path.abspath(path)), + ) + except OSError as err: + source.close() + # Report the target path, not the name of the temporary file. + err.filename = os.fspath(path) + raise + + with temp_file as dest: dest_path = pathlib.Path(dest.name) error = None diff --git a/tests/test_cli.py b/tests/test_cli.py index a6d3daf5..dcf7914c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -174,6 +174,39 @@ def test_set_no_file(cli): assert "Missing argument" in result.output +def test_set_missing_directory(cli, tmp_path): + dotenv_path = tmp_path / "nx_dir" / ".env" + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "set", "a", "b"]) + + assert result.exit_code == 2, result.output + assert "Error writing env file" in result.output + assert str(dotenv_path) in result.output + assert ".tmp_" not in result.output + + +@pytest.mark.skipif( + sys.platform == "win32" or os.geteuid() == 0, + reason="Directory permissions are not enforced on Windows or for root.", +) +def test_unset_read_only_directory(cli, tmp_path): + directory = tmp_path / "ro" + directory.mkdir() + dotenv_path = directory / ".env" + dotenv_path.write_text("a=b\n") + directory.chmod(0o555) + + try: + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) + finally: + directory.chmod(0o755) + + assert result.exit_code == 2, result.output + assert "Error writing env file" in result.output + assert str(dotenv_path) in result.output + assert dotenv_path.read_text() == "a=b\n" + + def test_get_default_path(tmp_path): (tmp_path / ".env").write_text("A=x") diff --git a/tests/test_main.py b/tests/test_main.py index 6f9d4c5c..ded4f1f6 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -195,6 +195,38 @@ def test_set_key_permission_error(dotenv_path): assert dotenv_path.read_text() == "" +def test_set_key_missing_directory(tmp_path): + dotenv_path = tmp_path / "nx_dir" / ".env" + + with pytest.raises(FileNotFoundError) as exc_info: + dotenv.set_key(dotenv_path, "a", "b") + + assert exc_info.value.filename == str(dotenv_path) + assert not dotenv_path.parent.exists() + + +@pytest.mark.skipif( + sys.platform == "win32" or os.geteuid() == 0, + reason="Directory permissions are not enforced on Windows or for root.", +) +def test_set_key_read_only_directory(tmp_path): + directory = tmp_path / "ro" + directory.mkdir() + dotenv_path = directory / ".env" + dotenv_path.write_text("a=x\n") + directory.chmod(0o555) + + try: + with pytest.raises(PermissionError) as exc_info: + dotenv.set_key(dotenv_path, "a", "y") + finally: + directory.chmod(0o755) + + assert exc_info.value.filename == str(dotenv_path) + assert dotenv_path.read_text() == "a=x\n" + assert list(directory.iterdir()) == [dotenv_path] + + def test_get_key_no_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") From f57cae90be3f15faa714018785dc62987d3b2c83 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 11:17:15 +0530 Subject: [PATCH 2/2] test: compare the exact CLI error so path quoting works on Windows; add CHANGELOG entry --- CHANGELOG.md | 3 +++ tests/test_cli.py | 15 ++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa470c6a..ea81ecc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed - 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] ## [1.2.4] - 2026-10-01 @@ -459,6 +460,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#680]: https://github.com/theskumar/python-dotenv/pull/680 [#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 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -493,6 +495,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@h1whelan]: https://github.com/h1whelan [@harveer07]: https://github.com/harveer07 [@jadutter]: https://github.com/jadutter +[@jamalkamaladdin]: https://github.com/jamalkamaladdin [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner [@kurtmckee]: https://github.com/kurtmckee diff --git a/tests/test_cli.py b/tests/test_cli.py index f3292f00..b6d29c20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -196,10 +196,10 @@ def test_set_missing_directory(cli, tmp_path): result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "set", "a", "b"]) - assert result.exit_code == 2, result.output - assert "Error writing env file" in result.output - assert str(dotenv_path) in result.output - assert ".tmp_" not in result.output + assert (result.exit_code, result.output) == ( + 2, + f"Error writing env file: [Errno 2] No such file or directory: {str(dotenv_path)!r}\n", + ) @pytest.mark.skipif( @@ -218,9 +218,10 @@ def test_unset_read_only_directory(cli, tmp_path): finally: directory.chmod(0o755) - assert result.exit_code == 2, result.output - assert "Error writing env file" in result.output - assert str(dotenv_path) in result.output + assert (result.exit_code, result.output) == ( + 2, + f"Error writing env file: [Errno 13] Permission denied: {str(dotenv_path)!r}\n", + ) assert dotenv_path.read_text() == "a=b\n"