From 85f43295ccb2d15d13da370954e5b85079f4a56c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:47:08 +0530 Subject: [PATCH 01/18] Fix gh-pages deployment permission issue - Add contents: write permission for pushing to gh-pages branch - Replace mkdocs gh-deploy with peaceiris/actions-gh-pages action - Split documentation build and deploy into separate steps for better reliability --- .github/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c56ca621..59e3e9b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,8 @@ jobs: permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write + # Required for pushing to gh-pages branch + contents: write steps: - uses: actions/checkout@v5 @@ -32,8 +34,14 @@ jobs: - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - name: Publish Documentation + - name: Build Documentation run: | pip install -r requirements-docs.txt pip install -e . - mkdocs gh-deploy --force + mkdocs build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site From d10ea618b4c87c14590709e7ca2c1fa4e28333f8 Mon Sep 17 00:00:00 2001 From: James Ouyang Date: Wed, 12 Nov 2025 13:59:42 -0800 Subject: [PATCH 02/18] fix: Add missing 3.14 PyPI classifier. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f8baeac1..0aeb9819 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", "Intended Audience :: Developers", "Intended Audience :: System Administrators", From 0525fc94d766875e0ee4089a4c6f44e6b6aefc0a Mon Sep 17 00:00:00 2001 From: Balaje Suri Date: Fri, 23 May 2025 11:02:55 +0200 Subject: [PATCH 03/18] skip 000 permission tests for root user --- tests/test_main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_main.py b/tests/test_main.py index 44961117..17b488c4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,6 +61,7 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" +@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") def test_set_key_permission_error(dotenv_path): dotenv_path.chmod(0o000) @@ -167,6 +168,7 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" +@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") def test_set_key_unauthorized_file(dotenv_path): dotenv_path.chmod(0o000) From cd48b58b2c0f907f61e023415a12dfaeb762cb7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 13:37:41 +0000 Subject: [PATCH 04/18] Bump actions/checkout from 5 to 6 in the github-actions group Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59e3e9b1..dc91d369 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: contents: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3151bb9..3f68669d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 From f54d29f3af5a38078f9db21b1c9b545ece4f1c08 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 10 Jan 2026 18:28:51 +0100 Subject: [PATCH 05/18] Fix formatting I somehow merged a malformatted PR earlier today. Let's fix the formatting first. --- tests/test_main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 17b488c4..76c1f70e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -61,7 +61,9 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" -@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") +@pytest.mark.skipif( + os.geteuid() == 0, reason="Root user can access files even with 000 permissions." +) def test_set_key_permission_error(dotenv_path): dotenv_path.chmod(0o000) @@ -168,7 +170,9 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" -@pytest.mark.skipif(os.geteuid() == 0, reason="Root user can access files even with 000 permissions.") +@pytest.mark.skipif( + os.geteuid() == 0, reason="Root user can access files even with 000 permissions." +) def test_set_key_unauthorized_file(dotenv_path): dotenv_path.chmod(0o000) From 9f3b8b50e4850d84d102640883560769dcb5553e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 09:43:58 +0100 Subject: [PATCH 06/18] Make `dotenv run` forward flags to given command (#607) Changes for users: - (BREAKING) Forward flags passed after `dotenv run` to the given command instead of interpreting them. - This means that an invocation such as `dotenv run ls --help` will show the help page of `ls` instead of that of `dotenv run`. - To pass flags to `dotenv run` itself, pass them right after `run`: `dotenv run --help` or `dotenv run --override ls`. - As usual, generic options should be passed right after `dotenv`: `dotenv --file path/to/env run ls` --- src/dotenv/cli.py | 13 ++++++++++--- tests/test_cli.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c548aa39..7a4c7adc 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -156,7 +156,13 @@ def unset(ctx: click.Context, key: Any) -> None: sys.exit(1) -@cli.command(context_settings={"ignore_unknown_options": True}) +@cli.command( + context_settings={ + "allow_extra_args": True, + "allow_interspersed_args": False, + "ignore_unknown_options": True, + } +) @click.pass_context @click.option( "--override/--no-override", @@ -164,7 +170,7 @@ def unset(ctx: click.Context, key: Any) -> None: help="Override variables from the environment file with those from the .env file.", ) @click.argument("commandline", nargs=-1, type=click.UNPROCESSED) -def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: +def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> None: """Run command with environment variables present.""" file = ctx.obj["FILE"] if not os.path.isfile(file): @@ -180,7 +186,8 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo("No command given.") sys.exit(1) - run_command(commandline, dotenv_as_dict) + + run_command([*commandline, *ctx.args], dotenv_as_dict) def run_command(command: List[str], env: Dict[str, str]) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 343fdb23..7cc4533d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import os +import subprocess from pathlib import Path -from typing import Optional +from typing import Optional, Sequence import pytest import sh @@ -10,6 +11,21 @@ from dotenv.version import __version__ +def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess: + """ + Invoke the `dotenv` CLI in a subprocess. + + This is necessary to test subcommands like `dotenv run` that replace the + current process. + """ + + return subprocess.run( + ["dotenv", *args], + capture_output=True, + text=True, + ) + + @pytest.mark.parametrize( "output_format,content,expected", ( @@ -249,3 +265,29 @@ def test_run_with_version(cli): assert result.exit_code == 0 assert result.output.strip().endswith(__version__) + + +def test_run_with_command_flags(dotenv_path): + """ + Check that command flags passed after `dotenv run` are not interpreted. + + Here, we want to run `printenv --version`, not `dotenv --version`. + """ + + result = invoke_sub(["--file", dotenv_path, "run", "printenv", "--version"]) + + assert result.returncode == 0 + assert result.stdout.strip().startswith("printenv ") + + +def test_run_with_dotenv_and_command_flags(cli, dotenv_path): + """ + Check that dotenv flags supersede command flags. + """ + + result = invoke_sub( + ["--version", "--file", dotenv_path, "run", "printenv", "--version"] + ) + + assert result.returncode == 0 + assert result.stdout.strip().startswith("dotenv, version") From 6d28eee2b3857b4d0cd5a542a7492358817ebd9e Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 10:25:44 +0100 Subject: [PATCH 07/18] Docs: Improve readability of reference page (#605) This uses some (but not all) of the settings recommended at https://mkdocstrings.github.io/python/usage/#recommended-settings. Changes to the "Reference" page: - Replace section heading with function name (instead of function declaration): This should be more readable. - The function declaration is now shown in a code block instead of the heading. - Added "Table of contents" to the right, to quickly navigate to a particular method. - Removed "Source code": It removes clutter from the page and probably wasn't very useful (but it can easily be added back if needed). --- mkdocs.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index ba77fa7f..3d55d899 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,7 +13,14 @@ markdown_extensions: - mdx_truly_sane_lists plugins: - - mkdocstrings + - mkdocstrings: + handlers: + python: + options: + separate_signature: true + show_root_heading: true + show_symbol_type_heading: true + show_symbol_type_toc: true - search nav: - Home: index.md From 25b04ae38f3fe6c16838fc0ff8a06a6ec8b44cab Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 10:26:31 +0100 Subject: [PATCH 08/18] Clean up "Related projects" section of the readme (#602) Changes: - Removed django-environ-2: The project was archived on GitHub. - Changed URL of dynaconf: The canonical repository changed. Those are very conservative choices. I haven't added projects, or removed projects that seemed less relevant. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 6df13fab..e36791a8 100644 --- a/README.md +++ b/README.md @@ -233,11 +233,10 @@ defined in the following list: Procfile-based applications. - [django-dotenv](https://github.com/jpadilla/django-dotenv) - [django-environ](https://github.com/joke2k/django-environ) -- [django-environ-2](https://github.com/sergeyklay/django-environ-2) - [django-configuration](https://github.com/jezdez/django-configurations) - [dump-env](https://github.com/sobolevn/dump-env) - [environs](https://github.com/sloria/environs) -- [dynaconf](https://github.com/rochacbruno/dynaconf) +- [dynaconf](https://github.com/dynaconf/dynaconf) - [parse_it](https://github.com/naorlivne/parse_it) - [python-decouple](https://github.com/HBNetwork/python-decouple) From e2e8e776b42e382ae38b44d3982dd649e7507dd4 Mon Sep 17 00:00:00 2001 From: cpackham-atlnz <85916201+cpackham-atlnz@users.noreply.github.com> Date: Mon, 12 Jan 2026 22:31:26 +1300 Subject: [PATCH 09/18] Fix license specifier (#597) Building with pip complains about the license property. Update to use the correct table format. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0aeb9819..577e497a 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 = "BSD-3-Clause" +license = { text = "BSD-3-Clause" } keywords = [ "environment variables", "deployments", From 4a22cf8993804aeede0c20b75bb1a29d3a99e9dc Mon Sep 17 00:00:00 2001 From: Naman Aarzoo <84902335+23f3001135@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:13:24 +0530 Subject: [PATCH 10/18] ci: enable testing on Python 3.14t (free-threaded) (#588) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f68669d..bd5d1ffc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: os: - ubuntu-latest python-version: - ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] + ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.9, pypy3.10] steps: - uses: actions/checkout@v6 From 1baaf04f336072e0ee324d5df9563ec767f14f81 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 12 Jan 2026 17:47:09 +0530 Subject: [PATCH 11/18] Drop Python 3.9 support and update to PyPy 3.11 (#608) Python 3.9 reached end-of-life on October 5, 2025. This commit removes support for Python 3.9 and updates the minimum required version to Python 3.10. Additionally, PyPy has been updated from 3.10 to 3.11, and proper support for Python 3.14 free-threading builds (3.14t) has been added with separate tox environments. Changes: - Update requires-python from >=3.9 to >=3.10 in pyproject.toml - Remove Python 3.9 classifier from package metadata - Remove Python 3.9 and PyPy 3.9 from CI test matrix - Update PyPy from 3.10 to 3.11 (latest stable version) - Remove py39 from tox envlist and gh-actions mapping - Remove mypy type checking for Python 3.9 - Add separate tox environments for py314 and py314t to properly support both regular and free-threading Python 3.14 builds - Update all tox environment references to include py314 and py314t The project now officially supports: - CPython: 3.10, 3.11, 3.12, 3.13, 3.14 (including free-threading) - PyPy: 3.11 --- .github/workflows/test.yml | 2 +- pyproject.toml | 3 +-- tox.ini | 14 +++++++------- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd5d1ffc..8d8ab246 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: os: - ubuntu-latest python-version: - ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.9, pypy3.10] + ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.11] steps: - uses: actions/checkout@v6 diff --git a/pyproject.toml b/pyproject.toml index 577e497a..1753fd89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -37,7 +36,7 @@ classifiers = [ "Environment :: Web Environment", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dynamic = ["version", "readme"] diff --git a/tox.ini b/tox.ini index d5959e1e..6a25a3d4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,15 +1,15 @@ [tox] -envlist = lint,py{39,310,311,312,313},pypy3,manifest,coverage-report +envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313, lint, manifest 3.14: py314 - pypy-3.9: pypy3 + 3.14t: py314t + pypy-3.11: pypy3 [testenv] deps = @@ -17,11 +17,11 @@ deps = pytest-cov sh >= 2.0.2, <3 click - py{39,310,311,312,313,3.14,pypy3}: ipython + py{310,311,312,313,314,314t,pypy3}: ipython commands = pytest --cov --cov-report=term-missing {posargs} depends = - py{39,310,311,312,313,314},pypy3: coverage-clean - coverage-report: py{39,310,311,312,313,314},pypy3 + py{310,311,312,313,314,314t},pypy3: coverage-clean + coverage-report: py{310,311,312,313,314,314t},pypy3 [testenv:lint] skip_install = true @@ -36,7 +36,7 @@ commands = mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests mypy --python-version=3.10 src tests - mypy --python-version=3.9 src tests + [testenv:format] skip_install = true From 7bd9e3dbfedc0983ad7d56d5570013035242bdf4 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Mon, 12 Jan 2026 13:22:58 +0100 Subject: [PATCH 12/18] Add Windows testing to CI (#604) Changes for users: none. Notes: - This adds CI testing with lowest and highest Python versions we support. - The main motivation for this is that we have Windows-specific code I'm worried I might break with improvements, like improvements in `dotenv run` error handling (coming soon). - I went for the least intrusive changes for now, and disabled tests which would fail unless they were trivial to adjust. - We have tests using `sh` (Unix-only module) which should be possible to fix later. Those tests are disabled on Windows. - Also tests relying on the fact that environment variables are case sensitive, which isn't the case on Windows. This is going to be more tricky to fix. Those tests are also disabled on Windows. - To check for the platform, I used `sys.platform == "win32"` everywhere, which seems to be the best practice. Co-authored-by: Saurabh Kumar --- .github/workflows/test.yml | 6 ++++ tests/test_cli.py | 11 ++++++- tests/test_fifo_dotenv.py | 4 +-- tests/test_ipython.py | 10 +++++++ tests/test_main.py | 59 +++++++++++++++++++++++++++++--------- tests/test_zip_imports.py | 6 +++- 6 files changed, 77 insertions(+), 19 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d8ab246..a20a689f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,12 @@ jobs: - ubuntu-latest python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", 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" steps: - uses: actions/checkout@v6 diff --git a/tests/test_cli.py b/tests/test_cli.py index 7cc4533d..ebc4fdd9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,15 +1,18 @@ import os import subprocess +import sys from pathlib import Path from typing import Optional, Sequence import pytest -import sh import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ +if sys.platform != "win32": + import sh + def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess: """ @@ -189,6 +192,7 @@ def test_set_no_file(cli): assert "Missing argument" in result.output +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_get_default_path(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -198,6 +202,7 @@ def test_get_default_path(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -207,6 +212,7 @@ def test_run(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -218,6 +224,7 @@ def test_run_with_existing_variable(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable_not_overridden(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b") @@ -229,6 +236,7 @@ def test_run_with_existing_variable_not_overridden(tmp_path): assert result == "c\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_none_value(tmp_path): with sh.pushd(tmp_path): (tmp_path / ".env").write_text("a=b\nc") @@ -238,6 +246,7 @@ def test_run_with_none_value(tmp_path): assert result == "b\n" +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_other_env(dotenv_path): dotenv_path.write_text("a=b") diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py index 4961adce..2aa31779 100644 --- a/tests/test_fifo_dotenv.py +++ b/tests/test_fifo_dotenv.py @@ -7,9 +7,7 @@ from dotenv import load_dotenv -pytestmark = pytest.mark.skipif( - sys.platform.startswith("win"), reason="FIFOs are Unix-only" -) +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="FIFOs are Unix-only") def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch): diff --git a/tests/test_ipython.py b/tests/test_ipython.py index f01b3ad7..6eda086b 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,4 +1,5 @@ import os +import sys from unittest import mock import pytest @@ -6,6 +7,9 @@ pytest.importorskip("IPython") +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_no_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -22,6 +26,9 @@ def test_ipython_existing_variable_no_override(tmp_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -38,6 +45,9 @@ def test_ipython_existing_variable_override(tmp_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_new_variable(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed diff --git a/tests/test_main.py b/tests/test_main.py index 76c1f70e..761bdad3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,15 +1,18 @@ import io import logging import os +import stat import sys import textwrap from unittest import mock import pytest -import sh import dotenv +if sys.platform != "win32": + import sh + def test_set_key_no_file(tmp_path): nx_path = tmp_path / "nx" @@ -62,15 +65,25 @@ def test_set_key_encoding(dotenv_path): @pytest.mark.skipif( - os.geteuid() == 0, reason="Root user can access files even with 000 permissions." + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", ) def test_set_key_permission_error(dotenv_path): - dotenv_path.chmod(0o000) + 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) with pytest.raises(PermissionError): dotenv.set_key(dotenv_path, "a", "b") - dotenv_path.chmod(0o600) + # Restore permissions + if sys.platform == "win32": + dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD) + else: + dotenv_path.chmod(0o600) assert dotenv_path.read_text() == "" @@ -170,16 +183,6 @@ def test_unset_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "" -@pytest.mark.skipif( - os.geteuid() == 0, reason="Root user can access files even with 000 permissions." -) -def test_set_key_unauthorized_file(dotenv_path): - dotenv_path.chmod(0o000) - - with pytest.raises(PermissionError): - dotenv.set_key(dotenv_path, "a", "x") - - def test_unset_non_existent_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") @@ -241,6 +244,9 @@ def test_find_dotenv_found(tmp_path): assert result == str(dotenv_path) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_existing_file(dotenv_path): dotenv_path.write_text("a=b") @@ -312,6 +318,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "flag_value", [ @@ -395,6 +404,9 @@ def test_load_dotenv_no_file_verbose(): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_no_override(dotenv_path): dotenv_path.write_text("a=b") @@ -405,6 +417,9 @@ def test_load_dotenv_existing_variable_no_override(dotenv_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_override(dotenv_path): dotenv_path.write_text("a=b") @@ -415,6 +430,9 @@ def test_load_dotenv_existing_variable_override(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -425,6 +443,9 @@ def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): assert os.environ == {"a": "c", "d": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -435,6 +456,9 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): assert os.environ == {"a": "b", "d": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_string_io_utf_8(): stream = io.StringIO("a=à") @@ -445,6 +469,9 @@ def test_load_dotenv_string_io_utf_8(): assert os.environ == {"a": "à"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_file_stream(dotenv_path): dotenv_path.write_text("a=b") @@ -456,6 +483,7 @@ def test_load_dotenv_file_stream(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_bytes(b"a=b") @@ -484,6 +512,9 @@ def test_dotenv_values_file(dotenv_path): assert result == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "env,string,interpolate,expected", [ diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 5c0fb88d..0b57a1c5 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -5,7 +5,10 @@ from unittest import mock from zipfile import ZipFile -import sh +import pytest + +if sys.platform != "win32": + import sh def walk_to_root(path: str): @@ -62,6 +65,7 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): import child1.child2.test # noqa +@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): zip_file_path = setup_zipfile( tmp_path, From c8de2887c00198c22842c5ae5e92d1747467363c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 12 Jan 2026 18:05:22 +0530 Subject: [PATCH 13/18] ci: improve workflow efficiency with best practices (#609) - Limit workflow triggers to main branch for pushes - Run tests on PRs targeting any branch - Add concurrency control to cancel outdated workflow runs - Set 15-minute timeout to prevent hung jobs - Follows GitHub Actions best practices for open-source projects --- .github/workflows/test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a20a689f..66056f6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,19 @@ name: Run Tests -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false From 09d7cee32459e7abdcb5c9d8122a552589c06a9c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 12 Jan 2026 18:33:32 +0530 Subject: [PATCH 14/18] docs: clarify override behavior and document FIFO support (#610) - Explicitly state that override=False is the default behavior in the Getting Started section for better clarity - Add note about FIFO (named pipes) support on Unix systems in the File format section - Improve formatting consistency: - Standardize TOC list markers (use '-' instead of '*') - Fix line wrapping and spacing issues throughout --- README.md | 137 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 71 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index e36791a8..a08d6141 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,19 @@ [![Build Status][build_status_badge]][build_status_link] [![PyPI version][pypi_badge]][pypi_link] -python-dotenv reads key-value pairs from a `.env` file and can set them as environment -variables. It helps in the development of applications following the +python-dotenv reads key-value pairs from a `.env` file and can set them as +environment variables. It helps in the development of applications following the [12-factor](https://12factor.net/) principles. - [Getting Started](#getting-started) - [Other Use Cases](#other-use-cases) - * [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) - * [Parse configuration as a stream](#parse-configuration-as-a-stream) - * [Load .env files in IPython](#load-env-files-in-ipython) + - [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) + - [Parse configuration as a stream](#parse-configuration-as-a-stream) + - [Load .env files in IPython](#load-env-files-in-ipython) - [Command-line Interface](#command-line-interface) - [File format](#file-format) - * [Multiline values](#multiline-values) - * [Variable expansion](#variable-expansion) + - [Multiline values](#multiline-values) + - [Variable expansion](#variable-expansion) - [Related Projects](#related-projects) - [Acknowledgements](#acknowledgements) @@ -25,13 +25,13 @@ variables. It helps in the development of applications following the pip install python-dotenv ``` -If your application takes its configuration from environment variables, like a 12-factor -application, launching it in development is not very practical because you have to set -those environment variables yourself. +If your application takes its configuration from environment variables, like a +12-factor application, launching it in development is not very practical because +you have to set those environment variables yourself. -To help you with that, you can add python-dotenv to your application to make it load the -configuration from a `.env` file when it is present (e.g. in development) while remaining -configurable via the environment: +To help you with that, you can add python-dotenv to your application to make it +load the configuration from a `.env` file when it is present (e.g. in +development) while remaining configurable via the environment: ```python from dotenv import load_dotenv @@ -46,10 +46,10 @@ By default, `load_dotenv()` will: - Look for a `.env` file in the same directory as the Python script (or higher up the directory tree). - Read each key-value pair and add it to `os.environ`. -- **Not override** an environment variable that is already set, unless you explicitly pass `override=True`. +- **Not override** existing environment variables (`override=False`). Pass `override=True` to override existing variables. -To configure the development environment, add a `.env` in the root directory of your -project: +To configure the development environment, add a `.env` in the root directory of +your project: ``` . @@ -57,7 +57,8 @@ project: └── foo.py ``` -The syntax of `.env` files supported by python-dotenv is similar to that of Bash: +The syntax of `.env` files supported by python-dotenv is similar to that of +Bash: ```bash # Development settings @@ -66,22 +67,21 @@ ADMIN_EMAIL=admin@${DOMAIN} ROOT_URL=${DOMAIN}/app ``` -If you use variables in values, ensure they are surrounded with `{` and `}`, like -`${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. +If you use variables in values, ensure they are surrounded with `{` and `}`, +like `${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. -You will probably want to add `.env` to your `.gitignore`, especially if it contains -secrets like a password. +You will probably want to add `.env` to your `.gitignore`, especially if it +contains secrets like a password. -See the section "File format" below for more information about what you can write in a -`.env` file. +See the section "[File format](#file-format)" below for more information about what you can write in a `.env` file. ## Other Use Cases ### Load configuration without altering the environment -The function `dotenv_values` works more or less the same way as `load_dotenv`, except it -doesn't touch the environment, it just returns a `dict` with the values parsed from the -`.env` file. +The function `dotenv_values` works more or less the same way as `load_dotenv`, +except it doesn't touch the environment, it just returns a `dict` with the +values parsed from the `.env` file. ```python from dotenv import dotenv_values @@ -104,9 +104,9 @@ config = { ### Parse configuration as a stream -`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their `stream` -argument. It is thus possible to load the variables from sources other than the -filesystem (e.g. the network). +`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their +`stream` argument. It is thus possible to load the variables from sources other +than the filesystem (e.g. the network). ```python from io import StringIO @@ -119,7 +119,7 @@ load_dotenv(stream=config) ### Load .env files in IPython -You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a +You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a `.env` file: ```python @@ -140,12 +140,14 @@ Optional flags: ### Disable load_dotenv -Set `PYTHON_DOTENV_DISABLED=1` to disable `load_dotenv()` from loading .env files or streams. Useful when you can't modify third-party package calls or in production. +Set `PYTHON_DOTENV_DISABLED=1` to disable `load_dotenv()` from loading .env +files or streams. Useful when you can't modify third-party package calls or in +production. ## Command-line Interface -A CLI interface `dotenv` is also included, which helps you manipulate the `.env` file -without manually opening it. +A CLI interface `dotenv` is also included, which helps you manipulate the `.env` +file without manually opening it. ```shell $ pip install "python-dotenv[cli]" @@ -166,13 +168,14 @@ Run `dotenv --help` for more information about the options and subcommands. ## File format -The format is not formally specified and still improves over time. That being said, -`.env` files should mostly look like Bash files. +The format is not formally specified and still improves over time. That being +said, `.env` files should mostly look like Bash files. Reading from FIFOs (named +pipes) on Unix systems is also supported. -Keys can be unquoted or single-quoted. Values can be unquoted, single- or double-quoted. -Spaces before and after keys, equal signs, and values are ignored. Values can be followed -by a comment. Lines can start with the `export` directive, which does not affect their -interpretation. +Keys can be unquoted or single-quoted. Values can be unquoted, single- or +double-quoted. Spaces before and after keys, equal signs, and values are +ignored. Values can be followed by a comment. Lines can start with the `export` +directive, which does not affect their interpretation. Allowed escape sequences: @@ -181,8 +184,8 @@ Allowed escape sequences: ### Multiline values -It is possible for single- or double-quoted values to span multiple lines. The following -examples are equivalent: +It is possible for single- or double-quoted values to span multiple lines. The +following examples are equivalent: ```bash FOO="first line @@ -201,26 +204,27 @@ A variable can have no value: FOO ``` -It results in `dotenv_values` associating that variable name with the value `None` (e.g. -`{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores such variables. +It results in `dotenv_values` associating that variable name with the value +`None` (e.g. `{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores +such variables. -This shouldn't be confused with `FOO=`, in which case the variable is associated with the -empty string. +This shouldn't be confused with `FOO=`, in which case the variable is associated +with the empty string. ### Variable expansion python-dotenv can interpolate variables using POSIX variable expansion. -With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the -first of the values defined in the following list: +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable +is the first of the values defined in the following list: - Value of that variable in the `.env` file. - Value of that variable in the environment. - Default value, if provided. - Empty string. -With `load_dotenv(override=False)`, the value of a variable is the first of the values -defined in the following list: +With `load_dotenv(override=False)`, the value of a variable is the first of the +values defined in the following list: - Value of that variable in the environment. - Value of that variable in the `.env` file. @@ -229,26 +233,27 @@ defined in the following list: ## Related Projects -- [Honcho](https://github.com/nickstenning/honcho) - For managing - Procfile-based applications. -- [django-dotenv](https://github.com/jpadilla/django-dotenv) -- [django-environ](https://github.com/joke2k/django-environ) -- [django-configuration](https://github.com/jezdez/django-configurations) -- [dump-env](https://github.com/sobolevn/dump-env) -- [environs](https://github.com/sloria/environs) -- [dynaconf](https://github.com/dynaconf/dynaconf) -- [parse_it](https://github.com/naorlivne/parse_it) -- [python-decouple](https://github.com/HBNetwork/python-decouple) +- [environs](https://github.com/sloria/environs) +- [Honcho](https://github.com/nickstenning/honcho) +- [dump-env](https://github.com/sobolevn/dump-env) +- [dynaconf](https://github.com/dynaconf/dynaconf) +- [parse_it](https://github.com/naorlivne/parse_it) +- [django-dotenv](https://github.com/jpadilla/django-dotenv) +- [django-environ](https://github.com/joke2k/django-environ) +- [python-decouple](https://github.com/HBNetwork/python-decouple) +- [django-configuration](https://github.com/jezdez/django-configurations) ## Acknowledgements -This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and -[Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not have been possible -without the support of these [awesome -people](https://github.com/theskumar/python-dotenv/graphs/contributors). +This project is currently maintained by [Saurabh Kumar][saurabh-homepage] and +[Bertrand Bonnefoy-Claudet][gh-bbc2] and would not have been possible without +the support of these [awesome people][contributors]. -[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg -[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml -[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg +[gh-bbc2]: https://github.com/bbc2 +[saurabh-homepage]: https://saurabh-kumar.com [pypi_link]: https://badge.fury.io/py/python-dotenv +[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg [python_streams]: https://docs.python.org/3/library/io.html +[contributors]: https://github.com/theskumar/python-dotenv/graphs/contributors +[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml +[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg From 43340da220fb4ca4f95357bbe21a3c7f8f1278b1 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sat, 28 Feb 2026 17:54:53 +0100 Subject: [PATCH 15/18] Remove the use of `sh` in tests (#612) This commit has the following benefits: - Remove `sh` as a development dependency. - Increase test coverage on Windows. - Improve the robustness of some tests against leftover `.env` files in the repository. - This is not perfect yet: If you have a `.env` file in your repository, it still disrupts some tests (for `find_dotenv`). - Improve the readability of error messages for some tests. --- requirements.txt | 1 - tests/test_cli.py | 127 ++++++++++++++++---------------------- tests/test_lib.py | 46 ++++++++++++++ tests/test_main.py | 14 +++-- tests/test_zip_imports.py | 47 +++++++------- tox.ini | 1 - 6 files changed, 132 insertions(+), 104 deletions(-) create mode 100644 tests/test_lib.py diff --git a/requirements.txt b/requirements.txt index d3d0199f..4a9f28a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ click ipython pytest-cov pytest>=3.9 -sh>=2 tox wheel ruff diff --git a/tests/test_cli.py b/tests/test_cli.py index ebc4fdd9..02bdb764 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,32 +1,13 @@ import os -import subprocess -import sys from pathlib import Path -from typing import Optional, Sequence +from typing import Optional import pytest import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ - -if sys.platform != "win32": - import sh - - -def invoke_sub(args: Sequence[str]) -> subprocess.CompletedProcess: - """ - Invoke the `dotenv` CLI in a subprocess. - - This is necessary to test subcommands like `dotenv run` that replace the - current process. - """ - - return subprocess.run( - ["dotenv", *args], - capture_output=True, - text=True, - ) +from tests.test_lib import check_process, run_dotenv @pytest.mark.parametrize( @@ -192,111 +173,109 @@ def test_set_no_file(cli): assert "Missing argument" in result.output -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_get_default_path(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("get", "a") + result = run_dotenv(["get", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "y"}) - result = sh.dotenv("run", "printenv", "a", _env=env) + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path, env=env) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_existing_variable_not_overridden(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "C"}) - result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) + result = run_dotenv( + ["run", "--no-override", "printenv", "A"], cwd=tmp_path, env=env + ) - assert result == "c\n" + check_process(result, exit_code=0, stdout="C\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_run_with_none_value(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b\nc") + (tmp_path / ".env").write_text("A=x\nc") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") -def test_run_with_other_env(dotenv_path): - dotenv_path.write_text("a=b") +def test_run_with_other_env(dotenv_path, tmp_path): + dotenv_path.write_text("A=x") - result = sh.dotenv("--file", dotenv_path, "run", "printenv", "a") + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "A"], + cwd=tmp_path, + ) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -def test_run_without_cmd(cli): - result = cli.invoke(dotenv_cli, ["run"]) +def test_run_without_cmd(tmp_path): + result = run_dotenv(["run"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_run_with_invalid_cmd(cli): - result = cli.invoke(dotenv_cli, ["run", "i_do_not_exist"]) +def test_run_with_invalid_cmd(tmp_path): + result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_run_with_version(cli): - result = cli.invoke(dotenv_cli, ["--version"]) +def test_run_with_version(tmp_path): + result = run_dotenv(["--version"], cwd=tmp_path) - assert result.exit_code == 0 - assert result.output.strip().endswith(__version__) + check_process(result, exit_code=0) + assert result.stdout.strip().endswith(__version__) -def test_run_with_command_flags(dotenv_path): +def test_run_with_command_flags(dotenv_path, tmp_path): """ Check that command flags passed after `dotenv run` are not interpreted. Here, we want to run `printenv --version`, not `dotenv --version`. """ - result = invoke_sub(["--file", dotenv_path, "run", "printenv", "--version"]) + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, + ) - assert result.returncode == 0 + check_process(result, exit_code=0) assert result.stdout.strip().startswith("printenv ") -def test_run_with_dotenv_and_command_flags(cli, dotenv_path): +def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): """ Check that dotenv flags supersede command flags. """ - result = invoke_sub( - ["--version", "--file", dotenv_path, "run", "printenv", "--version"] + result = run_dotenv( + ["--version", "--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, ) - assert result.returncode == 0 + check_process(result, exit_code=0) assert result.stdout.strip().startswith("dotenv, version") diff --git a/tests/test_lib.py b/tests/test_lib.py new file mode 100644 index 00000000..eb9d5204 --- /dev/null +++ b/tests/test_lib.py @@ -0,0 +1,46 @@ +import subprocess +from pathlib import Path +from typing import Sequence + + +def run_dotenv( + args: Sequence[str], + cwd: str | Path | None = None, + env: dict | None = None, +) -> subprocess.CompletedProcess: + """ + Run the `dotenv` CLI in a subprocess with the given arguments. + """ + + process = subprocess.run( + ["dotenv", *args], + capture_output=True, + text=True, + cwd=cwd, + env=env, + ) + + return process + + +def check_process( + process: subprocess.CompletedProcess, + exit_code: int, + stdout: str | None = None, +): + """ + Check that the process completed with the expected exit code and output. + + This provides better error messages than directly checking the attributes. + """ + + assert process.returncode == exit_code, ( + f"Unexpected exit code {process.returncode} (expected {exit_code})\n" + f"stdout:\n{process.stdout}\n" + f"stderr:\n{process.stderr}" + ) + + if stdout is not None: + assert process.stdout == stdout, ( + f"Unexpected output: {process.stdout.strip()!r} (expected {stdout!r})" + ) diff --git a/tests/test_main.py b/tests/test_main.py index 761bdad3..616c3b0c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,6 +2,7 @@ import logging import os import stat +import subprocess import sys import textwrap from unittest import mock @@ -10,9 +11,6 @@ import dotenv -if sys.platform != "win32": - import sh - def test_set_key_no_file(tmp_path): nx_path = tmp_path / "nx" @@ -483,7 +481,6 @@ def test_load_dotenv_file_stream(dotenv_path): assert os.environ == {"a": "b"} -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_in_current_dir(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_bytes(b"a=b") @@ -499,9 +496,14 @@ def test_load_dotenv_in_current_dir(tmp_path): ) os.chdir(tmp_path) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + text=True, + check=True, + ) - assert result == "b\n" + assert result.stdout == "b\n" def test_dotenv_values_file(dotenv_path): diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 0b57a1c5..6a263502 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -1,22 +1,19 @@ import os +import posixpath +import subprocess import sys import textwrap from typing import List from unittest import mock from zipfile import ZipFile -import pytest - -if sys.platform != "win32": - import sh - def walk_to_root(path: str): last_dir = None current_dir = path while last_dir != current_dir: yield current_dir - (parent_dir, _) = os.path.split(current_dir) + parent_dir = posixpath.dirname(current_dir) last_dir, current_dir = current_dir, parent_dir @@ -32,12 +29,11 @@ def setup_zipfile(path, files: List[FileToAdd]): with ZipFile(zip_file_path, "w") as zipfile: for f in files: zipfile.writestr(data=f.content, zinfo_or_arcname=f.path) - for dirname in walk_to_root(os.path.dirname(f.path)): + for dirname in walk_to_root(posixpath.dirname(f.path)): if dirname not in dirs_init_py_added_to: - print(os.path.join(dirname, "__init__.py")) - zipfile.writestr( - data="", zinfo_or_arcname=os.path.join(dirname, "__init__.py") - ) + init_path = posixpath.join(dirname, "__init__.py") + print(f"setup_zipfile: {init_path}") + zipfile.writestr(data="", zinfo_or_arcname=init_path) dirs_init_py_added_to.add(dirname) return zip_file_path @@ -65,7 +61,6 @@ def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): import child1.child2.test # noqa -@pytest.mark.skipif(sys.platform == "win32", reason="sh module doesn't support Windows") def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): zip_file_path = setup_zipfile( tmp_path, @@ -83,24 +78,32 @@ def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): ], ) dotenv_path = tmp_path / ".env" - dotenv_path.write_bytes(b"a=b") + dotenv_path.write_bytes(b"A=x") code_path = tmp_path / "code.py" code_path.write_text( textwrap.dedent( f""" - import os - import sys + import os + import sys - sys.path.append("{zip_file_path}") + sys.path.append({str(zip_file_path)!r}) - import child1.child2.test + import child1.child2.test - print(os.environ['a']) - """ + print(os.environ['A']) + """ ) ) - os.chdir(str(tmp_path)) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + check=True, + cwd=tmp_path, + text=True, + env={ + k: v for k, v in os.environ.items() if k.upper() != "A" + }, # env without 'A' + ) - assert result == "b\n" + assert result.stdout == "x\n" diff --git a/tox.ini b/tox.ini index 6a25a3d4..6d1f25f8 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,6 @@ python = deps = pytest pytest-cov - sh >= 2.0.2, <3 click py{310,311,312,313,314,314t,pypy3}: ipython commands = pytest --cov --cov-report=term-missing {posargs} From 790c5c02991100aa1bf41ee5330aca75edc51311 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Sun, 1 Mar 2026 16:16:10 +0100 Subject: [PATCH 16/18] Merge commit from fork Changes for users: - (BREAKING) `dotenv.set_key` and `dotenv.unset_key` used to follow symlinks in some situations. This is no longer the case. For that behavior to be restored in all cases, `follow_symlinks=True` should be used. - (BREAKING) In the CLI, `set` and `unset` used to follow symlinks in some situations. This is no longer the case. - (BREAKING) `dotenv.set_key`, `dotenv.unset_key` and the CLI commands `set` and `unset` used to reset the file mode of the modified .env file to `0o600` in some situations. This is no longer the case: The original mode of the file is now preserved. Is the file needed to be created or wasn't a regular file, mode `0o600` is used. --- src/dotenv/cli.py | 15 +++++- src/dotenv/main.py | 71 ++++++++++++++++++++----- tests/test_main.py | 128 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 15 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 7a4c7adc..47eec047 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -114,7 +114,13 @@ def list_values(ctx: click.Context, output_format: str) -> None: @click.argument("key", required=True) @click.argument("value", required=True) def set_value(ctx: click.Context, key: Any, value: Any) -> None: - """Store the given key/value.""" + """ + Store the given key/value. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ + file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] export = ctx.obj["EXPORT"] @@ -146,7 +152,12 @@ def get(ctx: click.Context, key: Any) -> None: @click.pass_context @click.argument("key", required=True) def unset(ctx: click.Context, key: Any) -> None: - """Removes the given key.""" + """ + Removes the given key. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] success, key = unset_key(file, key, quote) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1d6bf0b0..48e5245a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,7 +2,6 @@ import logging import os import pathlib -import shutil import stat import sys import tempfile @@ -14,9 +13,7 @@ from .variables import parse_variables # A type alias for a string path to be used for the paths in this file. -# These paths may flow to `open()` and `shutil.move()`; `shutil.move()` -# only accepts string paths, not byte paths or file descriptors. See -# https://github.com/python/typeshed/pull/6832. +# These paths may flow to `open()` and `os.replace()`. StrPath = Union[str, "os.PathLike[str]"] logger = logging.getLogger(__name__) @@ -142,21 +139,54 @@ def get_key( def rewrite( path: StrPath, encoding: Optional[str], + follow_symlinks: bool = False, ) -> Iterator[Tuple[IO[str], IO[str]]]: - pathlib.Path(path).touch() + if follow_symlinks: + path = os.path.realpath(path) - with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest: + try: + source: IO[str] = open(path, encoding=encoding) + try: + path_stat = os.lstat(path) + original_mode: Optional[int] = ( + stat.S_IMODE(path_stat.st_mode) + if stat.S_ISREG(path_stat.st_mode) + else None + ) + except BaseException: + source.close() + raise + except FileNotFoundError: + 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: + dest_path = pathlib.Path(dest.name) error = None + try: - with open(path, encoding=encoding) as source: + with source: yield (source, dest) except BaseException as err: error = err if error is None: - shutil.move(dest.name, path) + try: + if original_mode is not None: + os.chmod(dest_path, original_mode) + + os.replace(dest_path, path) + except BaseException: + dest_path.unlink(missing_ok=True) + raise else: - os.unlink(dest.name) + dest_path.unlink(missing_ok=True) raise error from None @@ -167,12 +197,16 @@ def set_key( quote_mode: str = "always", export: bool = False, encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, ) -> Tuple[Optional[bool], str, str]: """ Adds or Updates a key/value to the given .env - If the .env path given doesn't exist, fails instead of risking creating - an orphan .env somewhere in the filesystem + The target .env file is created if it doesn't exist. + + This function doesn't follow symlinks by default, to avoid accidentally + 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 quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") @@ -190,7 +224,10 @@ def set_key( else: line_out = f"{key_to_set}={value_out}\n" - with rewrite(dotenv_path, encoding=encoding) as (source, dest): + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): replaced = False missing_newline = False for mapping in with_warn_for_invalid_lines(parse_stream(source)): @@ -213,19 +250,27 @@ def unset_key( key_to_unset: str, quote_mode: str = "always", encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, ) -> Tuple[Optional[bool], str]: """ Removes a given key from the given `.env` file. If the .env path given doesn't exist, fails. If the given key doesn't exist in the .env, fails. + + This function doesn't follow symlinks by default, to avoid accidentally + 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 not os.path.exists(dotenv_path): logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) return None, key_to_unset removed = False - with rewrite(dotenv_path, encoding=encoding) as (source, dest): + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_unset: removed = True diff --git a/tests/test_main.py b/tests/test_main.py index 616c3b0c..50703af0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -62,6 +62,86 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" +@pytest.mark.skipif( + sys.platform == "win32", reason="file mode bits behave differently on Windows" +) +def test_set_key_preserves_file_mode(dotenv_path): + dotenv_path.write_text("a=x\n") + dotenv_path.chmod(0o640) + mode_before = stat.S_IMODE(dotenv_path.stat().st_mode) + + dotenv.set_key(dotenv_path, "a", "y") + + mode_after = stat.S_IMODE(dotenv_path.stat().st_mode) + assert mode_before == mode_after + + +def test_rewrite_closes_file_handle_on_lstat_failure(tmp_path): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("a=x\n") + real_open = open + opened_handles = [] + + def tracking_open(*args, **kwargs): + handle = real_open(*args, **kwargs) + opened_handles.append(handle) + return handle + + with mock.patch("dotenv.main.os.lstat", side_effect=FileNotFoundError): + with mock.patch("dotenv.main.open", side_effect=tracking_open): + dotenv.set_key(dotenv_path, "a", "x") + + assert opened_handles, "expected at least one file to be opened" + assert all(handle.closed for handle in opened_handles) + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_symlink_to_existing_file(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "y") + + assert target.read_text() == "a=x\n" + assert not symlink.is_symlink() + assert "a='y'" in symlink.read_text() + assert stat.S_IMODE(symlink.stat().st_mode) == 0o600 + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_symlink_to_missing_file(tmp_path): + target = tmp_path / "nx" + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "x") + + assert not target.exists() + assert not symlink.is_symlink() + assert symlink.read_text() == "a='x'\n" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_follow_symlinks(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "y", follow_symlinks=True) + + assert target.read_text() == "a='y'\n" + assert symlink.is_symlink() + + @pytest.mark.skipif( sys.platform != "win32" and os.geteuid() == 0, reason="Root user can access files even with 000 permissions.", @@ -195,6 +275,54 @@ def test_unset_non_existent_file(tmp_path): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_symlink_to_existing_file(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.unset_key(symlink, "a") + + assert target.read_text() == "a=x\n" + assert not symlink.is_symlink() + assert symlink.read_text() == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_symlink_to_missing_file(tmp_path): + target = tmp_path / "nx" + symlink = tmp_path / ".env" + symlink.symlink_to(target) + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(symlink, "a") + + assert result == (None, "a") + assert symlink.is_symlink() + mock_warning.assert_called_once() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_follow_symlinks(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=b\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.unset_key(symlink, "a", follow_symlinks=True) + + assert target.read_text() == "" + assert symlink.is_symlink() + + def prepare_file_hierarchy(path): """ Create a temporary folder structure like the following: From eb202520e5933c9daf42501e1e42fdb0144002c8 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 1 Mar 2026 20:59:53 +0530 Subject: [PATCH 17/18] docs: update changelog for v1.2.2 --- CHANGELOG.md | 193 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 117 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b362fdd..ab35b253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.2.2] - 2026-03-01 + +### Added + +- Support for Python 3.14, including the free-threaded (3.14t) build. (#) + +### Changed + +- The `dotenv run` command now forwards flags directly to the specified command by [@bbc2] in [#607] +- Improved documentation clarity regarding override behavior and the reference page. +- Updated PyPy support to version 3.11. +- Documentation for FIFO file support. +- Dropped Support for Python 3.9. + +### Fixed + +- Improved `set_key` and `unset_key` behavior when interacting with symlinks by [@bbc2] in [#790c5](https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311) +- Corrected the license specifier and added missing Python 3.14 classifiers in package metadata by [@JYOuyang] in [#590] + +### Breaking Changes + +- `dotenv.set_key` and `dotenv.unset_key` used to follow symlinks in some + situations. This is no longer the case. For that behavior to be restored in + all cases, `follow_symlinks=True` should be used. + +- In the CLI, `set` and `unset` used to follow symlinks in some situations. This + is no longer the case. + +- `dotenv.set_key`, `dotenv.unset_key` and the CLI commands `set` and `unset` + used to reset the file mode of the modified .env file to `0o600` in some + situations. This is no longer the case: The original mode of the file is now + preserved. Is the file needed to be created or wasn't a regular file, mode + `0o600` is used. + ## [1.2.1] - 2025-10-26 - Move more config to `pyproject.toml`, removed `setup.cfg` @@ -20,9 +54,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -* CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563](https://github.com/theskumar/python-dotenv/pull/563) -* CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) - +- CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563](https://github.com/theskumar/python-dotenv/pull/563) +- CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) ## [1.1.0] - 2025-03-25 @@ -43,56 +76,56 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **Fixed** -* Gracefully handle code which has been imported from a zipfile ([#456] by [@samwyma]) -* Allow modules using `load_dotenv` to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) -* Fix file not closed after deletion, handle error in the rewrite function ([#469] by [@Qwerty-133]) +- Gracefully handle code which has been imported from a zipfile ([#456] by [@samwyma]) +- Allow modules using `load_dotenv` to be reloaded when launched in a separate thread ([#497] by [@freddyaboulton]) +- Fix file not closed after deletion, handle error in the rewrite function ([#469] by [@Qwerty-133]) **Misc** -* Use pathlib.Path in tests ([#466] by [@eumiro]) -* Fix year in release date in changelog.md ([#454] by [@jankislinger]) -* Use https in README links ([#474] by [@Nicals]) + +- Use pathlib.Path in tests ([#466] by [@eumiro]) +- Fix year in release date in changelog.md ([#454] by [@jankislinger]) +- Use https in README links ([#474] by [@Nicals]) ## [1.0.0] - 2023-02-24 **Fixed** -* Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) -* Handle situations where the cwd does not exist. (#446 by [@jctanner]) +- Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) +- Handle situations where the cwd does not exist. (#446 by [@jctanner]) ## [0.21.1] - 2023-01-21 **Added** -* Use Python 3.11 non-beta in CI (#438 by [@bbc2]) -* Modernize variables code (#434 by [@Nougat-Waffle]) -* Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) -* Improve conciseness of cli.py and __init__.py (#439 by [@Nougat-Waffle]) -* Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) -* Updated License to align with BSD OSI template (#433 by [@lsmith77]) - +- Use Python 3.11 non-beta in CI (#438 by [@bbc2]) +- Modernize variables code (#434 by [@Nougat-Waffle]) +- Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) +- Improve conciseness of cli.py and **init**.py (#439 by [@Nougat-Waffle]) +- Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) +- Updated License to align with BSD OSI template (#433 by [@lsmith77]) **Fixed** -* Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) -* Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) -* Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) +- Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) +- Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) +- Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) ## [0.21.0] - 2022-09-03 **Added** -* CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) -* `load_dotenv` function now returns `False`. (#388 by [@larsks]) -* CLI: add --format= option to list command. (#407 by [@sammck]) +- CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) +- `load_dotenv` function now returns `False`. (#388 by [@larsks]) +- CLI: add --format= option to list command. (#407 by [@sammck]) **Fixed** -* Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) -* Use `open` instead of `io.open`. (#389 by [@rabinadk1]) -* Improve documentation for variables without a value (#390 by [@bbc2]) -* Add `parse_it` to Related Projects (#410 by [@naorlivne]) -* Update README.md (#415 by [@harveer07]) -* Improve documentation with direct use of MkDocs (#398 by [@bbc2]) +- Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) +- Use `open` instead of `io.open`. (#389 by [@rabinadk1]) +- Improve documentation for variables without a value (#390 by [@bbc2]) +- Add `parse_it` to Related Projects (#410 by [@naorlivne]) +- Update README.md (#415 by [@harveer07]) +- Improve documentation with direct use of MkDocs (#398 by [@bbc2]) ## [0.20.0] - 2022-03-24 @@ -124,16 +157,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **Changed** -- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 +- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). **Added** - The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, - os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). +os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). - The `stream` argument of `load_dotenv` and `dotenv_values` can now be a text stream (`IO[str]`), which includes values like `io.StringIO("foo")` and `open("file.env", - "r")` (#348 by [@bbc2]). +"r")` (#348 by [@bbc2]). ## [0.18.0] - 2021-06-20 @@ -271,6 +304,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fix Unicode error in Python 2, introduced in 0.10.0. ([@bbc2])([#176]) ## 0.10.1 + - Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158]) ## 0.10.0 @@ -283,7 +317,6 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Drop Python 3.3 support ([@greyli]) - Fix stderr/-out/-in redirection ([@venthur]) - ## 0.9.0 - Add `--version` parameter to cli ([@venthur]) @@ -292,81 +325,82 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 0.8.1 -- Add tests for docs ([@Flimm]) -- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) +- Add tests for docs ([@Flimm]) +- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) ## 0.8.0 -- `set_key` and `unset_key` only modified the affected file instead of - parsing and re-writing file, this causes comments and other file - entact as it is. -- Add support for `export` prefix in the line. -- Internal refractoring ([@theskumar]) -- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) +- `set_key` and `unset_key` only modified the affected file instead of + parsing and re-writing file, this causes comments and other file + entact as it is. +- Add support for `export` prefix in the line. +- Internal refractoring ([@theskumar]) +- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) ## 0.7.1 -- Remove hard dependency on iPython ([@theskumar]) +- Remove hard dependency on iPython ([@theskumar]) ## 0.7.0 -- Add support to override system environment variable via .env. - ([@milonimrod](https://github.com/milonimrod)) - ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) -- Disable ".env not found" warning by default - ([@maxkoryukov](https://github.com/maxkoryukov)) - ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) +- Add support to override system environment variable via .env. + ([@milonimrod](https://github.com/milonimrod)) + ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) +- Disable ".env not found" warning by default + ([@maxkoryukov](https://github.com/maxkoryukov)) + ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) ## 0.6.5 -- Add support for special characters `\`. - ([@pjona](https://github.com/pjona)) - ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) +- Add support for special characters `\`. + ([@pjona](https://github.com/pjona)) + ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) ## 0.6.4 -- Fix issue with single quotes ([@Flimm]) - ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) +- Fix issue with single quotes ([@Flimm]) + ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) ## 0.6.3 -- Handle unicode exception in setup.py - ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) +- Handle unicode exception in setup.py + ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) ## 0.6.2 -- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) -- Add iPython Support - ([@tillahoffmann](https://github.com/tillahoffmann)) +- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) +- Add iPython Support + ([@tillahoffmann](https://github.com/tillahoffmann)) ## 0.6.0 -- Drop support for Python 2.6 -- Handle escaped characters and newlines in quoted values. (Thanks - [@iameugenejo](https://github.com/iameugenejo)) -- Remove any spaces around unquoted key/value. (Thanks - [@paulochf](https://github.com/paulochf)) -- Added POSIX variable expansion. (Thanks - [@hugochinchilla](https://github.com/hugochinchilla)) +- Drop support for Python 2.6 +- Handle escaped characters and newlines in quoted values. (Thanks + [@iameugenejo](https://github.com/iameugenejo)) +- Remove any spaces around unquoted key/value. (Thanks + [@paulochf](https://github.com/paulochf)) +- Added POSIX variable expansion. (Thanks + [@hugochinchilla](https://github.com/hugochinchilla)) ## 0.5.1 -- Fix `find_dotenv` - it now start search from the file where this - function is called from. +- Fix `find_dotenv` - it now start search from the file where this + function is called from. ## 0.5.0 -- Add `find_dotenv` method that will try to find a `.env` file. - (Thanks [@isms](https://github.com/isms)) +- Add `find_dotenv` method that will try to find a `.env` file. + (Thanks [@isms](https://github.com/isms)) ## 0.4.0 -- cli: Added `-q/--quote` option to control the behaviour of quotes - around values in `.env`. (Thanks - [@hugochinchilla](https://github.com/hugochinchilla)). -- Improved test coverage. +- cli: Added `-q/--quote` option to control the behaviour of quotes + around values in `.env`. (Thanks + [@hugochinchilla](https://github.com/hugochinchilla)). +- Improved test coverage. + [#78]: https://github.com/theskumar/python-dotenv/issues/78 [#121]: https://github.com/theskumar/python-dotenv/issues/121 [#148]: https://github.com/theskumar/python-dotenv/issues/148 @@ -386,8 +420,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#569]: https://github.com/theskumar/python-dotenv/issues/569 [#583]: https://github.com/theskumar/python-dotenv/issues/583 [#586]: https://github.com/theskumar/python-dotenv/issues/586 +[#590]: https://github.com/theskumar/python-dotenv/issues/590 +[#607]: https://github.com/theskumar/python-dotenv/issues/607 + [@23f3001135]: https://github.com/23f3001135 [@EpicWink]: https://github.com/EpicWink [@Flimm]: https://github.com/Flimm @@ -437,8 +474,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve - -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...HEAD +[@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.2...HEAD +[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 [1.2.0]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 From 36004e0e34be7665ff2b11a8a4005144f76f176d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 1 Mar 2026 21:26:49 +0530 Subject: [PATCH 18/18] =?UTF-8?q?Bump=20version:=201.2.1=20=E2=86=92=201.2?= =?UTF-8?q?.2?= 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 a72da630..646b9e7b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.1 +current_version = 1.2.2 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index a955fdae..bc86c944 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.2.2"