Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

<!-- contributors -->
Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/dotenv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -159,7 +163,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:
Expand Down
22 changes: 15 additions & 7 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,40 @@ 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, result.output) == (
2,
f"Error writing env file: [Errno 2] No such file or directory: {str(dotenv_path)!r}\n",
)


@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, 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"


def test_get_default_path(tmp_path):
(tmp_path / ".env").write_text("A=x")

Expand Down
32 changes: 32 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading