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 @@ -12,6 +12,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 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]
- `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]

## [1.2.4] - 2026-10-01

Expand Down Expand Up @@ -456,6 +457,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]).
[#606]: https://github.com/theskumar/python-dotenv/pull/606
[#638]: https://github.com/theskumar/python-dotenv/pull/638
[#640]: https://github.com/theskumar/python-dotenv/pull/640
[#615]: https://github.com/theskumar/python-dotenv/pull/615
[#648]: https://github.com/theskumar/python-dotenv/pull/648
[#663]: https://github.com/theskumar/python-dotenv/pull/663
[#680]: https://github.com/theskumar/python-dotenv/pull/680
Expand Down Expand Up @@ -520,6 +522,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]).
[@theGOTOguy]: https://github.com/theGOTOguy
[@theskumar]: https://github.com/theskumar
[@ulyssessouza]: https://github.com/ulyssessouza
[@veeceey]: https://github.com/veeceey
[@venthur]: https://github.com/venthur
[@wrongontheinternet]: https://github.com/wrongontheinternet
[@x-yuri]: https://github.com/x-yuri
Expand Down
2 changes: 2 additions & 0 deletions src/dotenv/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ def enumerate_env() -> Optional[str]:
@click.pass_context
def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None:
"""This script is used to set, get or unset values from a .env file."""
if file is not None:
file = os.path.expanduser(file)
ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file}


Expand Down
18 changes: 16 additions & 2 deletions src/dotenv/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ def __init__(
override: bool = True,
) -> None:
self.dotenv_path: Optional[StrPath] = dotenv_path
if isinstance(dotenv_path, str):
self.dotenv_path = os.path.expanduser(dotenv_path)
elif dotenv_path is not None:
self.dotenv_path = pathlib.Path(dotenv_path).expanduser()
self.stream: Optional[IO[str]] = stream
self._dict: Optional[Dict[str, Optional[str]]] = None
self.verbose: bool = verbose
Expand Down Expand Up @@ -235,6 +239,10 @@ def set_key(
modifying a file at a potentially untrusted path. If you don't need this
protection and need symlinks to be followed, use `follow_symlinks`.
"""
if isinstance(dotenv_path, str):
dotenv_path = os.path.expanduser(dotenv_path)
else:
dotenv_path = pathlib.Path(dotenv_path).expanduser()
if quote_mode not in ("always", "auto", "never"):
raise ValueError(f"Unknown quote_mode: {quote_mode}")

Expand Down Expand Up @@ -294,6 +302,10 @@ def unset_key(
modifying a file at a potentially untrusted path. If you don't need this
protection and need symlinks to be followed, use `follow_symlinks`.
"""
if isinstance(dotenv_path, str):
dotenv_path = os.path.expanduser(dotenv_path)
else:
dotenv_path = pathlib.Path(dotenv_path).expanduser()
if not os.path.exists(dotenv_path):
logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path)
return None, key_to_unset
Expand Down Expand Up @@ -423,7 +435,8 @@ def load_dotenv(
"""Parse a .env file and then load all the variables found as environment variables.

Parameters:
dotenv_path: Absolute or relative path to .env file.
dotenv_path: Absolute or relative path to .env file. A leading `~` is
expanded to the user's home directory.
stream: Text stream (such as `io.StringIO`) with .env content, used if
`dotenv_path` is `None`.
verbose: Whether to output a warning the .env file is missing.
Expand Down Expand Up @@ -477,7 +490,8 @@ def dotenv_values(
`{"foo": None}`

Parameters:
dotenv_path: Absolute or relative path to the .env file.
dotenv_path: Absolute or relative path to the .env file. A leading `~` is
expanded to the user's home directory.
stream: `StringIO` object with .env content, used if `dotenv_path` is `None`.
verbose: Whether to output a warning if the .env file is missing.
interpolate: Whether to interpolate variables using POSIX variable expansion.
Expand Down
28 changes: 28 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,34 @@ def test_run_with_other_env(dotenv_path, tmp_path):
check_process(result, exit_code=0, stdout="x\n")


@pytest.mark.parametrize(
"args,expected_stdout,expected_content",
[
(["list"], "a=x\n", "a=x\n"),
(["get", "a"], "x\n", "a=x\n"),
(["set", "b", "y"], "b=y\n", "a=x\nb='y'\n"),
(["unset", "a"], "Successfully removed a\n", ""),
(
["run", sys.executable, "-c", "import os; print(os.environ['a'])"],
"x\n",
"a=x\n",
),
],
ids=["list", "get", "set", "unset", "run"],
)
def test_file_option_expands_user(tmp_path, args, expected_stdout, expected_content):
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("a=x\n")
env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home)}

result = run_dotenv(["--file", "~/.env", *args], cwd=tmp_path, env=env)

check_process(result, exit_code=0, stdout=expected_stdout)
assert (home / ".env").read_text() == expected_content
assert sorted(tmp_path.iterdir()) == [home]


def test_run_without_cmd(tmp_path):
result = run_dotenv(["run"], cwd=tmp_path)

Expand Down
61 changes: 61 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,67 @@ def unlink(self, missing_ok=False):
assert list(tmp_path.iterdir()) == [dotenv_path]


@pytest.mark.parametrize("path_type", [str, pathlib.Path])
@pytest.mark.parametrize(
"call,expected_result,expected_content",
[
(lambda path: dotenv.dotenv_values(path), {"a": "x"}, "a=x\n"),
(lambda path: dotenv.get_key(path, "a"), "x", "a=x\n"),
(lambda path: dotenv.set_key(path, "b", "y"), (True, "b", "y"), "a=x\nb='y'\n"),
(lambda path: dotenv.unset_key(path, "a"), (True, "a"), ""),
],
ids=["dotenv_values", "get_key", "set_key", "unset_key"],
)
def test_dotenv_path_expands_user(
tmp_path, monkeypatch, path_type, call, expected_result, expected_content
):
home = tmp_path / "home"
home.mkdir()
(home / ".env").write_text("a=x\n")
monkeypatch.setenv("HOME", str(home))
monkeypatch.setenv("USERPROFILE", str(home))
monkeypatch.chdir(tmp_path)

result = call(path_type("~/.env"))

assert result == expected_result
assert (home / ".env").read_text() == expected_content
assert sorted(tmp_path.iterdir()) == [home]


@mock.patch.dict(os.environ, {}, clear=True)
def test_load_dotenv_expands_user(tmp_path, monkeypatch):
(tmp_path / ".env").write_text("a=x\n")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))

result = dotenv.load_dotenv("~/.env")

assert result is True
assert os.environ["a"] == "x"


@pytest.mark.parametrize(
"path,expected",
[
("~/.env", "{home}/.env"),
(pathlib.Path("~/.env"), pathlib.Path("{home}/.env")),
(".env", ".env"),
(None, None),
],
)
def test_dotenv_path_keeps_type(tmp_path, monkeypatch, path, expected):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
if expected is not None:
expected = type(expected)(str(expected).format(home=tmp_path))

result = DotEnv(path).dotenv_path

assert result == expected
assert type(result) is type(expected)


def test_get_key_no_file(tmp_path):
nx_path = tmp_path / "nx"
logger = logging.getLogger("dotenv.main")
Expand Down
Loading