From efcad264f3673827021ec673ffa0f4a809d9f474 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 8 Feb 2026 12:39:49 -0800 Subject: [PATCH 1/3] fix: apply expanduser to dotenv_path (#525) Apply os.path.expanduser() / Path.expanduser() to dotenv_path so that ~ is properly expanded to the user's home directory. The fix preserves the original type (str stays str, Path stays Path) and is applied in DotEnv.__init__, set_key, and unset_key. --- src/dotenv/main.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1d6bf0b0..9f35282b 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -53,6 +53,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 @@ -174,6 +178,10 @@ def set_key( If the .env path given doesn't exist, fails instead of risking creating an orphan .env somewhere in the filesystem """ + 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}") @@ -220,6 +228,10 @@ def unset_key( If the .env path given doesn't exist, fails. If the given key doesn't exist in the .env, fails. """ + 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 From 6cd93a5a2ce8b2258d078308ce36ecd10ed2db3a Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 12:17:59 +0530 Subject: [PATCH 2/3] fix: expand ~ in the CLI --file option, add tests The library fix covered set and unset, which go through set_key and unset_key, but list, get and run open --file directly, so `dotenv --file=~/.env list` still failed. Expand it once in the cli group. Test every library entry point with str and Path inputs, and every CLI subcommand. Add a CHANGELOG entry. --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 2 ++ tests/test_cli.py | 28 +++++++++++++++++++++ tests/test_main.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb54e1cb..e4a5cead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 @@ -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 diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 44495e9b..c06c7068 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -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} diff --git a/tests/test_cli.py b/tests/test_cli.py index b6d29c20..8123c9dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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) diff --git a/tests/test_main.py b/tests/test_main.py index 27c5b5fd..cd21d70d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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") From b4a1835454fbc2369f0c1a966c43048f81c33666 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 12:22:54 +0530 Subject: [PATCH 3/3] docs: note that dotenv_path expands a leading ~ --- src/dotenv/main.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 58f5c72c..7dfc3cba 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -435,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. @@ -489,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.