From f215c0274dc4c310e5ca5feb4c6bddade25ece21 Mon Sep 17 00:00:00 2001 From: Shamik Basu Date: Wed, 30 Sep 2026 22:34:45 -0700 Subject: [PATCH 01/12] fix: dotenv get exits 0 for empty string values (#700) Fixes #699 --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 7 +++---- tests/test_cli.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02bad5aa..92f2a708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- `dotenv get` no longer exits with code 1 for empty string values (`KEY=`) by [@ShamikOfficial] in [#700] - An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] - `dotenv run --no-override` now expands variable references with the same precedence as `load_dotenv(override=False)`, so a value like `${BASE}/suffix` uses the existing `BASE` from the environment instead of the one from the `.env` file by [@ROTl24] in [#698] @@ -450,6 +451,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#663]: https://github.com/theskumar/python-dotenv/pull/663 [#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 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -497,6 +499,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@randomseed42]: https://github.com/randomseed42 [@sammck]: https://github.com/sammck [@samwyma]: https://github.com/samwyma +[@ShamikOfficial]: https://github.com/ShamikOfficial [@sidharth-sudhir]: https://github.com/sidharth-sudhir [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 72c81816..c78231cf 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -141,11 +141,10 @@ def get(ctx: click.Context, key: Any) -> None: with stream_file(file) as stream: values = dotenv_values(stream=stream) - stored_value = values.get(key) - if stored_value: - click.echo(stored_value) - else: + # Empty strings are valid values; only missing keys / bare keys (None) fail. + if key not in values or values[key] is None: sys.exit(1) + click.echo(values[key]) @cli.command() diff --git a/tests/test_cli.py b/tests/test_cli.py index a6d3daf5..1833b9d8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,6 +68,23 @@ def test_get_existing_value(cli, dotenv_path): assert (result.exit_code, result.output) == (0, "b\n") +def test_get_empty_string_value(cli, dotenv_path): + """Empty string values must not be treated as missing (truthiness trap).""" + dotenv_path.write_text("a=\n") + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) + + assert (result.exit_code, result.output) == (0, "\n") + + +def test_get_value_without_equals(cli, dotenv_path): + dotenv_path.write_text("a") + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) + + assert (result.exit_code, result.output) == (1, "") + + def test_get_non_existent_value(cli, dotenv_path): result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) From 4a7abd00261e51baba958e15f443c66165042ea2 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 11:05:28 +0530 Subject: [PATCH 02/12] docs: add 1.2.4 release notes (#663, #698, #700) --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f2a708..ac0fe339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.2.4] - 2026-10-01 + ### Fixed - `dotenv get` no longer exits with code 1 for empty string values (`KEY=`) by [@ShamikOfficial] in [#700] @@ -514,7 +516,8 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@JYOuyang]: https://github.com/JYOuyang [@burnout-projects]: https://github.com/burnout-projects [@cpackham-atlnz]: https://github.com/cpackham-atlnz -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.4...HEAD +[1.2.4]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...v1.2.4 [1.2.3]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3 [1.2.2]: https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2 [1.2.1]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...v1.2.1 From a565c2cc41599c48eabc6b7b7f5b826d43c5a6d7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 11:05:30 +0530 Subject: [PATCH 03/12] =?UTF-8?q?Bump=20version:=201.2.3=20=E2=86=92=201.2?= =?UTF-8?q?.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9bb4df0b..aa28bcfd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.3 +current_version = 1.2.4 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 10aa336c..b3f9ac7f 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.3" +__version__ = "1.2.4" From 828bdfe5ba91b736b545561ebb53326f1f88f6e5 Mon Sep 17 00:00:00 2001 From: Kurt McKee Date: Thu, 1 Oct 2026 00:46:47 -0500 Subject: [PATCH 04/12] fix: use an SPDX string for project.license to silence the build deprecation warning (#648) --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0fe339..aa470c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] + ## [1.2.4] - 2026-10-01 ### Fixed @@ -450,6 +454,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 +[#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 [#698]: https://github.com/theskumar/python-dotenv/pull/698 @@ -490,6 +495,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@jadutter]: https://github.com/jadutter [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner +[@kurtmckee]: https://github.com/kurtmckee [@larsks]: https://github.com/larsks [@lsmith77]: https://github.com/lsmith77 [@matthewfranglen]: https://github.com/matthewfranglen diff --git a/pyproject.toml b/pyproject.toml index 1753fd89..1619e9d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Read key-value pairs from a .env file and set them as environment authors = [ {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, ] -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" keywords = [ "environment variables", "deployments", From 6a49d6d88fd9bd99bef8d63b9eb6f2e46ecc7072 Mon Sep 17 00:00:00 2001 From: Jamal Ali Date: Thu, 1 Oct 2026 09:50:13 +0400 Subject: [PATCH 05/12] fix: name the .env path in set_key and unset_key write errors (#711) Fixes #710 --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 12 ++++++++++-- src/dotenv/main.py | 22 +++++++++++++++------- tests/test_cli.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_main.py | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 9 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/src/dotenv/cli.py b/src/dotenv/cli.py index c78231cf..44495e9b 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: @@ -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: 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 1833b9d8..b6d29c20 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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") 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 6bdf86adb2b8e3d9e3031b3a81f24851c8bfca0e Mon Sep 17 00:00:00 2001 From: Mohammed Alkindi Date: Wed, 30 Sep 2026 23:35:40 -0700 Subject: [PATCH 06/12] fix: don't leak or mask errors when rewrite cleanup fails on Windows (#686) When writing a read-only .env fails on Windows, rewrite() no longer leaves a .tmp_* file behind, and the error raised is the one from the failed write rather than from the cleanup. --- CHANGELOG.md | 3 ++ src/dotenv/main.py | 23 +++++++++++-- tests/test_main.py | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea81ecc7..eb54e1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,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] ## [1.2.4] - 2026-10-01 @@ -458,6 +459,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#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 +[#686]: https://github.com/theskumar/python-dotenv/pull/686 [#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 @@ -468,6 +470,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@23f3001135]: https://github.com/23f3001135 [@EpicWink]: https://github.com/EpicWink [@Flimm]: https://github.com/Flimm +[@MohammedAlkindi]: https://github.com/MohammedAlkindi [@Nicals]: https://github.com/Nicals [@Nougat-Waffle]: https://github.com/Nougat-Waffle [@Qwerty-133]: https://github.com/Qwerty-133 diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 95ee9db2..13f6776c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -135,6 +135,25 @@ def get_key( return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get) +def _discard_temp_file(path: pathlib.Path) -> None: + """ + Delete `rewrite`'s temporary file, ignoring any failure to do so. + + This runs while another exception is propagating, so it must not raise: + that error is the one worth reporting. On Windows, a file whose mode has + no owner-write bit carries the read-only attribute and can't be unlinked, + so the mode is reset before a second attempt. + """ + try: + path.unlink(missing_ok=True) + except OSError: + try: + path.chmod(stat.S_IWRITE | stat.S_IREAD) + path.unlink(missing_ok=True) + except OSError: + logger.warning("python-dotenv could not remove the temporary file %s", path) + + @contextmanager def rewrite( path: StrPath, @@ -191,10 +210,10 @@ def rewrite( os.replace(dest_path, path) except BaseException: - dest_path.unlink(missing_ok=True) + _discard_temp_file(dest_path) raise else: - dest_path.unlink(missing_ok=True) + _discard_temp_file(dest_path) raise error from None diff --git a/tests/test_main.py b/tests/test_main.py index ded4f1f6..27c5b5fd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ import io import logging import os +import pathlib import stat import subprocess import sys @@ -195,6 +196,48 @@ def test_set_key_permission_error(dotenv_path): assert dotenv_path.read_text() == "" +@pytest.mark.skipif( + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", +) +def test_set_key_permission_error_leaves_no_temp_file(dotenv_path): + if sys.platform == "win32": + # On Windows, make file read-only + dotenv_path.chmod(stat.S_IREAD) + else: + # On Unix, remove all permissions + dotenv_path.chmod(0o000) + + try: + with pytest.raises(PermissionError): + dotenv.set_key(dotenv_path, "a", "b") + + assert list(dotenv_path.parent.glob(".tmp_*")) == [] + finally: + # Restore permissions + if sys.platform == "win32": + dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD) + else: + dotenv_path.chmod(0o600) + + +def test_rewrite_reports_original_error_when_cleanup_fails(dotenv_path, caplog): + replace_error = OSError("replace failed") + + with mock.patch("dotenv.main.os.replace", side_effect=replace_error): + with mock.patch.object( + pathlib.Path, "unlink", side_effect=OSError("unlink failed") + ): + with pytest.raises(OSError) as excinfo: + dotenv.set_key(dotenv_path, "a", "b") + + assert excinfo.value is replace_error + [temp_file] = dotenv_path.parent.glob(".tmp_*") + assert caplog.messages == [ + f"python-dotenv could not remove the temporary file {temp_file}" + ] + + def test_set_key_missing_directory(tmp_path): dotenv_path = tmp_path / "nx_dir" / ".env" @@ -227,6 +270,49 @@ def test_set_key_read_only_directory(tmp_path): assert list(directory.iterdir()) == [dotenv_path] +def windows_read_only_semantics(path): + # On Windows, a file without the owner-write bit can't be replaced or deleted. + return path.exists() and not path.stat().st_mode & stat.S_IWUSR + + +@pytest.mark.parametrize( + "rewrite", + [ + lambda path: dotenv.set_key(path, "a", "y"), + lambda path: dotenv.unset_key(path, "a"), + ], + ids=["set_key", "unset_key"], +) +def test_rewrite_read_only_file_leaves_no_temp_file(tmp_path, rewrite): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("a=x\n") + dotenv_path.chmod(stat.S_IREAD) + real_replace = os.replace + real_unlink = pathlib.Path.unlink + + def replace(src, dst): + if windows_read_only_semantics(pathlib.Path(dst)): + raise PermissionError( + 13, "Access is denied", os.fspath(src), None, os.fspath(dst) + ) + real_replace(src, dst) + + def unlink(self, missing_ok=False): + if windows_read_only_semantics(self): + raise PermissionError(13, "Access is denied", str(self)) + real_unlink(self, missing_ok=missing_ok) + + with mock.patch("dotenv.main.os.replace", side_effect=replace): + with mock.patch.object(pathlib.Path, "unlink", unlink): + with pytest.raises(PermissionError) as exc_info: + rewrite(dotenv_path) + + dotenv_path.chmod(stat.S_IREAD | stat.S_IWRITE) + assert exc_info.value.filename2 == str(dotenv_path) + assert dotenv_path.read_text() == "a=x\n" + assert list(tmp_path.iterdir()) == [dotenv_path] + + def test_get_key_no_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") From 791414804eff08a23f0b7970968e1717e3b28e66 Mon Sep 17 00:00:00 2001 From: Varun Chawla <34209028+veeceey@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:56:42 -0700 Subject: [PATCH 07/12] fix: expand ~ in dotenv_path and the CLI --file option (#615) 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. Fixes #525 --- CHANGELOG.md | 3 +++ src/dotenv/cli.py | 2 ++ src/dotenv/main.py | 18 ++++++++++++-- tests/test_cli.py | 28 +++++++++++++++++++++ tests/test_main.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) 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/src/dotenv/main.py b/src/dotenv/main.py index 13f6776c..7dfc3cba 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -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 @@ -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}") @@ -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 @@ -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. @@ -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. 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 0b2880591780a426b0551436f47a17e7a76d954f Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Thu, 1 Oct 2026 13:04:34 +0530 Subject: [PATCH 08/12] fix: expand ~ in find_dotenv and the %dotenv magic (#714) find_dotenv and the IPython %dotenv magic now expand a leading ~ in the file name. Follows #615 (#525). --- CHANGELOG.md | 2 ++ README.md | 1 + src/dotenv/main.py | 4 ++++ tests/test_ipython.py | 15 +++++++++++++++ tests/test_main.py | 13 +++++++++++++ 5 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a5cead..e5f25414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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] +- `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] ## [1.2.4] - 2026-10-01 @@ -465,6 +466,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#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 +[#714]: https://github.com/theskumar/python-dotenv/pull/714 [790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 diff --git a/README.md b/README.md index a08d6141..3400e67c 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ You can also specify a path: ```python %dotenv relative/or/absolute/path/to/.env +%dotenv ~/path/to/.env ``` Optional flags: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 7dfc3cba..5faa7f0e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -381,8 +381,12 @@ def find_dotenv( """ Search in increasingly higher folders for the given file + A leading `~` in `filename` is expanded to the user's home directory, and + an absolute `filename` is returned as is if it exists. + Returns path to the file if found, or an empty string otherwise """ + filename = os.path.expanduser(filename) def _is_interactive(): """Decide whether this is running in a REPL or IPython notebook""" diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 6eda086b..93bffd66 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -61,3 +61,18 @@ def test_ipython_new_variable(tmp_path): ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "b"} + + +@mock.patch.dict(os.environ, {}, clear=False) +def test_ipython_path_expands_user(tmp_path, monkeypatch): + from IPython.terminal.embed import InteractiveShellEmbed + + (tmp_path / ".env").write_text("dotenv_ipython_home=b\n") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "~/.env") + + assert os.environ["dotenv_ipython_home"] == "b" diff --git a/tests/test_main.py b/tests/test_main.py index cd21d70d..930ab171 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -591,6 +591,19 @@ def test_find_dotenv_found(tmp_path): assert result == str(dotenv_path) +def test_find_dotenv_expands_user(tmp_path, monkeypatch): + 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 = dotenv.find_dotenv("~/.env", usecwd=True) + + assert result == f"{home}/.env" + + @pytest.mark.skipif( sys.platform == "win32", reason="This test assumes case-sensitive variable names" ) From bad5771ce54fd643538f177aab1465e4375e2039 Mon Sep 17 00:00:00 2001 From: fengguodong <152643312+CodingFeng101@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:13:19 -0400 Subject: [PATCH 09/12] Handle CRLF invalid lines in parser recovery (#669) * Handle CRLF invalid lines in parser * Test line numbers after CRLF parser recovery --------- Co-authored-by: Bertrand Bonnefoy-Claudet --- src/dotenv/parser.py | 2 +- tests/test_parser.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index d9d47583..b3b55070 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -29,7 +29,7 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _unquoted_value = make_regex(r"([^\r\n]*)") _comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?") _end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r|$)") -_rest_of_line = make_regex(r"[^\r\n]*(?:\r|\n|\r\n)?") +_rest_of_line = make_regex(r"[^\r\n]*(?:\r\n|\n|\r)?") _double_quote_escapes = make_regex(r"\\[\\'\"abfnrtv]") _single_quote_escapes = make_regex(r"\\[\\']") diff --git a/tests/test_parser.py b/tests/test_parser.py index 667eae15..982305a3 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -480,6 +480,46 @@ ) ], ), + ( + "a: b\r\nc=d", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b\r\n", line=1), + error=True, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a: b\r\nc=d\r\ne: f", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b\r\n", line=1), + error=True, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d\r\n", line=2), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="e: f", line=3), + error=True, + ), + ], + ), ( "a=b\nc=d", [ From 6c800cc617943322536334f539ed4665207bc766 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 9 Nov 2026 14:03:37 +0100 Subject: [PATCH 10/12] Modernize CI (#743) Benefits: - Replace unofficial `tox-gh-actions` plugin with its maintained successor `tox-gh` - Switch to uv for slightly faster dependency installation and built-in caching This is heavily inspired from what is done by tox-gh: https://github.com/tox-dev/tox-gh/blob/6efb80fc24df3c6e08a3f99b55949d4fba3f248d/.github/workflows/check.yaml Around 20% less CI usage when cached. --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 32 +++++++++++++++++++++----------- MANIFEST.in | 1 + requirements-ci.txt | 5 +++++ tox.ini | 21 ++++++++++++--------- 5 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 requirements-ci.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 369ebc8e..1e455693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,10 @@ jobs: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.x" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8e7fa81..40da9fb2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,20 +34,30 @@ jobs: - os: windows-latest python-version: "3.14" + env: + # Select the tox envs for this matrix entry (see `[gh]` in `tox.ini`). + TOX_GH_MAJOR_MINOR: ${{ matrix.python-version }} + # Use uv-managed interpreters everywhere. + UV_PYTHON_PREFERENCE: only-managed + steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true + version-file: requirements-ci.txt + enable-cache: true + cache-dependency-glob: | + pyproject.toml + tox.ini + requirements-ci.txt - - name: Upgrade pip - run: python -m pip install --upgrade pip + - name: Install tox + run: uv tool install --python 3.14 --with-requirements requirements-ci.txt tox - - name: Install dependencies - run: pip install tox tox-gh-actions + - name: Setup test suite + run: tox run -v --notest --skip-missing-interpreters false - - name: Test with tox - run: tox + - name: Run test suite + run: tox run --skip-pkg-install diff --git a/MANIFEST.in b/MANIFEST.in index bf0d47e6..afa35896 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,5 +9,6 @@ include .coveragerc include .editorconfig include Makefile include requirements.txt +include requirements-ci.txt include requirements-docs.txt include src/dotenv/py.typed diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 00000000..002cc7aa --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,5 @@ +# Tooling installed by `.github/workflows/test.yml`. +tox==4.67.4 +tox-gh==1.9.1 +tox-uv==1.37.2 +uv==0.13.6 diff --git a/tox.ini b/tox.ini index 6d1f25f8..7ce8f0c8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,30 @@ [tox] envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report -[gh-actions] +[gh] python = - 3.10: py310 - 3.11: py311 - 3.12: py312 - 3.13: py313, lint, manifest - 3.14: py314 - 3.14t: py314t - pypy-3.11: pypy3 + 3.10 = py310 + 3.11 = py311 + 3.12 = py312 + 3.13 = py313, lint, manifest + 3.14 = py314 + 3.14t = py314t + pypy3.11 = pypy3 [testenv] deps = pytest pytest-cov click - py{310,311,312,313,314,314t,pypy3}: ipython + py{310,311,312,313,314,314t},pypy3: ipython commands = pytest --cov --cov-report=term-missing {posargs} depends = py{310,311,312,313,314,314t},pypy3: coverage-clean coverage-report: py{310,311,312,313,314,314t},pypy3 +[testenv:pypy3] +base_python = pypy3.11 + [testenv:lint] skip_install = true deps = From 555d78f76ebdb6621cd0774f457f64cf7c6ef0c0 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 9 Nov 2026 14:31:55 +0100 Subject: [PATCH 11/12] Add support for Python 3.15 (#744) --- .github/workflows/test.yml | 6 +++--- CHANGELOG.md | 4 ++++ pyproject.toml | 1 + tox.ini | 12 +++++++----- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40da9fb2..7a3c535a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,13 +26,13 @@ jobs: os: - ubuntu-latest python-version: - ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.11] + ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15", "3.15t", pypy3.11] include: # Windows: Test lowest and highest supported Python versions - os: windows-latest python-version: "3.10" - os: windows-latest - python-version: "3.14" + python-version: "3.15" env: # Select the tox envs for this matrix entry (see `[gh]` in `tox.ini`). @@ -54,7 +54,7 @@ jobs: requirements-ci.txt - name: Install tox - run: uv tool install --python 3.14 --with-requirements requirements-ci.txt tox + run: uv tool install --python 3.15 --with-requirements requirements-ci.txt tox - name: Setup test suite run: tox run -v --notest --skip-missing-interpreters false diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f25414..71c9740d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- Add support for Python 3.15. + ### Fixed - Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] diff --git a/pyproject.toml b/pyproject.toml index 1619e9d4..7dc2a711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: PyPy", "Intended Audience :: Developers", "Intended Audience :: System Administrators", diff --git a/tox.ini b/tox.ini index 7ce8f0c8..1f49dc26 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report +envlist = lint,py{310,311,312,313,314,315,315t},pypy3,manifest,coverage-report [gh] python = @@ -8,7 +8,8 @@ python = 3.12 = py312 3.13 = py313, lint, manifest 3.14 = py314 - 3.14t = py314t + 3.15 = py315 + 3.15t = py315t pypy3.11 = pypy3 [testenv] @@ -16,11 +17,11 @@ deps = pytest pytest-cov click - py{310,311,312,313,314,314t},pypy3: ipython + py{310,311,312,313,314,315,315t},pypy3: ipython commands = pytest --cov --cov-report=term-missing {posargs} depends = - py{310,311,312,313,314,314t},pypy3: coverage-clean - coverage-report: py{310,311,312,313,314,314t},pypy3 + py{310,311,312,313,314,315,315t},pypy3: coverage-clean + coverage-report: py{310,311,312,313,314,315,315t},pypy3 [testenv:pypy3] base_python = pypy3.11 @@ -33,6 +34,7 @@ deps = commands = ruff check src tests ruff format --check src tests + mypy --python-version=3.15 src tests mypy --python-version=3.14 src tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests From 4fbc6ff07215367ece731006855e732d1405992c Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 9 Nov 2026 14:35:05 +0100 Subject: [PATCH 12/12] dotenv run: Use PATH on Windows to find the command to run (#742) --- src/dotenv/cli.py | 14 ++++++++--- tests/test_cli.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c06c7068..83849690 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,6 +1,7 @@ import json import os import shlex +import shutil import sys from contextlib import contextmanager from typing import IO, Any, Dict, Iterator, List, Optional @@ -235,11 +236,18 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: cmd_env.update(env) if sys.platform == "win32": - # execvpe on Windows returns control immediately - # rather than once the command has finished. + # Resolve the command as suggested in Python's docs + # (https://docs.python.org/3/library/subprocess.html#popen-constructor). + executable = shutil.which(command[0], path=cmd_env.get("PATH")) try: + # `execvpe` on Windows returns control immediately. p = Popen( - command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env + command, + executable=executable, + universal_newlines=True, + bufsize=0, + shell=False, + env=cmd_env, ) except FileNotFoundError: print(f"Command not found: {command[0]}", file=sys.stderr) diff --git a/tests/test_cli.py b/tests/test_cli.py index 8123c9dd..a719e195 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -344,6 +344,68 @@ def test_file_option_expands_user(tmp_path, args, expected_stdout, expected_cont assert sorted(tmp_path.iterdir()) == [home] +def make_command(directory: Path, name: str, output: str) -> None: + """ + Create a command named `name` in `directory` which prints `output`. + """ + + if sys.platform == "win32": + (directory / f"{name}.cmd").write_text(f"@echo {output}\n") + else: + path = directory / name + path.write_text(f"#!/bin/sh\necho {output}\n") + path.chmod(0o755) + + +def test_run_with_path_from_env_file(tmp_path): + """ + Check that the command is looked up in the `PATH` set by the .env file. + """ + + # We use two commands (one prints "x", the other "y"). + dotenv_bin_dir = tmp_path / "dotenv-bin" + dotenv_bin_dir.mkdir() + make_command(directory=dotenv_bin_dir, name="dotenv-test-command", output="x") + start_bin_dir = tmp_path / "start-bin" + start_bin_dir.mkdir() + make_command(directory=start_bin_dir, name="dotenv-test-command", output="y") + start_env = { + **os.environ, + "PATH": os.pathsep.join([str(start_bin_dir), os.environ["PATH"]]), + } + # Unquoted, so that a Windows `PATH` ending with `\` isn't read as an escape. + (tmp_path / ".env").write_text(f"PATH={dotenv_bin_dir}{os.pathsep}${{PATH}}") + + result = run_dotenv(["run", "dotenv-test-command"], cwd=tmp_path, env=start_env) + + check_process(result, exit_code=0, stdout="x\n") + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific lookup") +def test_run_python_uses_path_on_windows(dotenv_path, tmp_path): + """ + Check that `dotenv run python` runs the first `python` in `PATH`, as with an + activated virtual environment (#373). + + A fake `python` placed first in `PATH` prints "x", while the real interpreter + would run the `-c` code and print "y" instead. Without resolving the command + against `PATH`, Windows picks the real interpreter. + """ + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + make_command(directory=bin_dir, name="python", output="x") # Creates `python.cmd`. + env = {**os.environ, "PATH": os.pathsep.join([str(bin_dir), os.environ["PATH"]])} + + result = run_dotenv( + ["--file", str(dotenv_path), "run", "python", "-c", "print('y')"], + cwd=tmp_path, + env=env, + ) + + check_process(result, exit_code=0, stdout="x\n") + + def test_run_without_cmd(tmp_path): result = run_dotenv(["run"], cwd=tmp_path)