From 0b94ac0822241eb526828cf506048fb0525d5c38 Mon Sep 17 00:00:00 2001 From: Freddy Boulton Date: Mon, 22 Jan 2024 21:45:11 -0800 Subject: [PATCH 01/59] Allow modules using load_dotenv to be reloaded when launched in a separate thread (#497) Update `is_interactive` code --- src/dotenv/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 383b79f4..20c7782e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -280,7 +280,10 @@ def find_dotenv( def _is_interactive(): """ Decide whether this is running in a REPL or IPython notebook """ - main = __import__('__main__', None, None, fromlist=['__file__']) + try: + main = __import__('__main__', None, None, fromlist=['__file__']) + except ModuleNotFoundError: + return False return not hasattr(main, '__file__') if usecwd or _is_interactive() or getattr(sys, 'frozen', False): From 6ff139147559eff4d124c038ec5a4b60ffcf3033 Mon Sep 17 00:00:00 2001 From: Qwerty-133 <74311372+Qwerty-133@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:21:15 +0530 Subject: [PATCH 02/59] Fix temporary file is deleted before closing, in the rewrite function (#468) Currently, if an error is raised while using files from the rewrite function, then the temporary file is deleted before closing it. This is okay on unix, but unlinking open files causes an error on Windows. --- src/dotenv/main.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 20c7782e..7bc54285 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,6 +1,7 @@ import io import logging import os +import pathlib import shutil import sys import tempfile @@ -131,17 +132,21 @@ def rewrite( path: StrPath, encoding: Optional[str], ) -> Iterator[Tuple[IO[str], IO[str]]]: - if not os.path.isfile(path): - with open(path, mode="w", encoding=encoding) as source: - source.write("") + pathlib.Path(path).touch() + with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest: + error = None try: with open(path, encoding=encoding) as source: yield (source, dest) - except BaseException: - os.unlink(dest.name) - raise - shutil.move(dest.name, path) + except BaseException as err: + error = err + + if error is None: + shutil.move(dest.name, path) + else: + os.unlink(dest.name) + raise error from None def set_key( From b1eebbaaab2cf3e1c48fa5c7ad88cfb00e4b5e54 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 23 Jan 2024 11:34:15 +0530 Subject: [PATCH 03/59] Add python 3.12 and pypy3.10 to test runner --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49e1399f..68503d45 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,10 +11,10 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", pypy3.9] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 From 42dc08664bc7cef185a139137a39126a030f272c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 23 Jan 2024 11:49:30 +0530 Subject: [PATCH 04/59] Update changelog for 1.0.1 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 220d1888..f63a1f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,20 @@ 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). -## [1.0.0] +## [1.0.1] - 2024-01-23 + +**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]) + +**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]) + +## [1.0.0] - 2023-02-24 **Fixed** @@ -328,6 +341,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#176]: https://github.com/theskumar/python-dotenv/issues/176 [#183]: https://github.com/theskumar/python-dotenv/issues/183 [#359]: https://github.com/theskumar/python-dotenv/issues/359 +[#469]: https://github.com/theskumar/python-dotenv/issues/469 +[#456]: https://github.com/theskumar/python-dotenv/issues/456 +[#466]: https://github.com/theskumar/python-dotenv/issues/466 +[#454]: https://github.com/theskumar/python-dotenv/issues/454 +[#474]: https://github.com/theskumar/python-dotenv/issues/474 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky @@ -341,21 +359,27 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@eggplants]: https://github.com/@eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 +[@eumiro]: https://github.com/eumiro [@Flimm]: https://github.com/Flimm +[@freddyaboulton]: https://github.com/freddyaboulton [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli [@harveer07]: https://github.com/@harveer07 [@jadutter]: https://github.com/jadutter +[@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner [@larsks]: https://github.com/@larsks [@lsmith77]: https://github.com/lsmith77 [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/@naorlivne +[@Nicals]: https://github.com/Nicals [@Nougat-Waffle]: https://github.com/Nougat-Waffle [@qnighy]: https://github.com/qnighy +[@Qwerty-133]: https://github.com/Qwerty-133 [@rabinadk1]: https://github.com/@rabinadk1 [@sammck]: https://github.com/@sammck +[@samwyma]: https://github.com/samwyma [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy [@theGOTOguy]: https://github.com/theGOTOguy @@ -367,7 +391,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...HEAD +[1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 [0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 [0.21.0]: https://github.com/theskumar/python-dotenv/compare/v0.20.0...v0.21.0 From d6c0b9638349a7dd605d60ee555ff60421c1a594 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 23 Jan 2024 12:00:33 +0530 Subject: [PATCH 05/59] Bumpversion 1.0.0 -> 1.0.1 --- src/dotenv/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 5becc17c..5c4105cd 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.0.1" From 6d6070cc43cf5a774b757acb5499b16913cddf32 Mon Sep 17 00:00:00 2001 From: Bertrand Bonnefoy-Claudet Date: Thu, 4 Apr 2024 22:55:17 +0200 Subject: [PATCH 06/59] Add a security policy This is a basic security policy, mostly to provide an email address. I took inspiration from the example provided by GitHub and the policy from the Pallets project. --- .github/SECURITY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .github/SECURITY.md diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..dbdabeb1 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,19 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| --------- | ------------------ | +| latest | :white_check_mark: | +| 0.x | :x: | + +## Reporting a Vulnerability + +If you believe you have identified a security issue with Python-dotenv, please email +python-dotenv@saurabh-kumar.com. A maintainer will contact you acknowledging the report +and how to continue. + +Be sure to include as much detail as necessary in your report. As with reporting normal +issues, a minimal reproducible example will help the maintainers address the issue faster. +If you are able, you may also include a fix for the issue generated with `git +format-patch`. From bf20c809882c56291cde997722dcb7516e395473 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 11 Mar 2024 15:20:51 +0100 Subject: [PATCH 07/59] Keep GitHub Actions up to date with GitHub's Dependabot Fixes warnings like at the bottom right of https://github.com/theskumar/python-dotenv/actions/runs/7980672386 * https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot * https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem --- .github/dependabot.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..be006de9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: weekly From 8c9381e7ab617a4cde425e3df4684417fade72f5 Mon Sep 17 00:00:00 2001 From: Christian Clauss Date: Mon, 8 Apr 2024 18:17:29 +0200 Subject: [PATCH 08/59] ci: fix multiline string in test.yml & use fail-fast strategy (#514) * Fix multiline string in test.yml * strategy: fail-fast: false * Update test.yml --- .github/workflows/test.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68503d45..7c73b8b8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false max-parallel: 8 matrix: os: @@ -17,14 +18,15 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install dependencies - run: - python -m pip install --upgrade pip - pip install tox tox-gh-actions + run: pip install tox tox-gh-actions - name: Test with tox run: tox From 08937a1911c042ed3fc7cbeeb4d1d5a73d2674ed Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 29 Apr 2024 10:01:16 +0530 Subject: [PATCH 09/59] docs: clearify default behaviour of load_dotenv closes https://github.com/theskumar/python-dotenv/issues/457 --- README.md | 4 ++-- src/dotenv/main.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ddc8ba87..1eca986d 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,13 @@ configurable via the environment: ```python from dotenv import load_dotenv -load_dotenv() # take environment variables from .env. +load_dotenv() # take environment variables # Code of your application, which uses environment variables (e.g. from `os.environ` or # `os.getenv`) as if they came from the actual environment. ``` -By default, `load_dotenv` doesn't override existing environment variables. +By default, `load_dotenv` doesn't override existing environment variables and looks for a `.env` file in same directory as python script or searches for it incrementally higher up. To configure the development environment, add a `.env` in the root directory of your project: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 7bc54285..052de054 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -340,7 +340,9 @@ def load_dotenv( Bool: True if at least one environment variable is set else False If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the - .env file. + .env file with it's default parameters. If you need to change the default parameters + of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result + to this function as `dotenv_path`. """ if dotenv_path is None and stream is None: dotenv_path = find_dotenv() From 4543837fc674f82f131c6a1e0e7e897461feaffd Mon Sep 17 00:00:00 2001 From: eekstunt <51318131+eekstunt@users.noreply.github.com> Date: Thu, 18 Jul 2024 04:56:47 +0100 Subject: [PATCH 10/59] Enhance dotenv run: Switch to execvpe for better resource management and signal handling (#523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current implementation of `dotenv run` CLI uses `subprocess.Popen`, which spawns a child process to execute the specified command. ``` p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) ``` After spawning the child process, it exits with the same exit code returned by the child process. ``` ret = run_command(commandline, dotenv_as_dict) exit(ret) ``` ### We can enhance `dotenv run` usage dramatically while preserving exactly the same behaviour By switching to `os.execvpe` instead of `subprocess.Popen`, we can replace the parent dotenv process with the new process specified by the user. This results in only one active process—the program the user intended to run. **Benefits:** 1. No hanging parent process `dotenv run` acts as a launcher, so after executing `dotenv run redis-server`, only the Redis server process remains. The dotenv process, along with its Python interpreter, is completely replaced. This prevents the dotenv process from consuming RAM and other resources, which would otherwise persist until the Redis server exits. 2. Proper signal handling When using `subprocess.Popen`, the parent process (e.g., `dotenv`) remains responsible for handling and forwarding signals, which can lead to issues if the command doesn’t receive them directly. For instance, in Docker, if Redis was started without `exec`, it may not get important signals like `SIGTERM` when the container stops, potentially resulting in improper shutdowns or zombie processes. Using `os.execvpe` ensures that the command receives signals directly, improving reliability and making `dotenv` more suitable for production environments and improving reliability for DevOps engineers managing containerized applications. All current logic will be preserved because dotenv run does not do anything special except propagate the child process exit code. Thanks / @eekstunt --- src/dotenv/cli.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 65ead461..b5a97f8b 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,7 +3,6 @@ import shlex import sys from contextlib import contextmanager -from subprocess import Popen from typing import Any, Dict, IO, Iterator, List try: @@ -161,14 +160,13 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo('No command given.') exit(1) - ret = run_command(commandline, dotenv_as_dict) - exit(ret) + run_command(commandline, dotenv_as_dict) -def run_command(command: List[str], env: Dict[str, str]) -> int: - """Run command in sub process. +def run_command(command: List[str], env: Dict[str, str]) -> None: + """Replace the current process with the specified command. - Runs the command in a sub process with the variables from `env` + Replaces the current process with the specified command and the variables from `env` added in the current environment variables. Parameters @@ -180,8 +178,8 @@ def run_command(command: List[str], env: Dict[str, str]) -> int: Returns ------- - int - The return code of the command + None + This function does not return any value. It replaces the current process with the new one. """ # copy the current environment variables and add the vales from @@ -189,11 +187,4 @@ def run_command(command: List[str], env: Dict[str, str]) -> int: cmd_env = os.environ.copy() cmd_env.update(env) - p = Popen(command, - universal_newlines=True, - bufsize=0, - shell=False, - env=cmd_env) - _, _ = p.communicate() - - return p.returncode + os.execvpe(command[0], args=command, env=cmd_env) From 4d505f2c9bc3569791e64bca0f2e4300f43df0e0 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Wed, 24 Jul 2024 03:20:57 +0800 Subject: [PATCH 11/59] ci: add py3.13 to test.yml (#527) * ci: add py3.13 to test.yml * Improve type hints * fix typo --- .github/workflows/test.yml | 3 ++- src/dotenv/cli.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c73b8b8..74a24ddd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", pypy3.9, pypy3.10] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] steps: - uses: actions/checkout@v4 @@ -21,6 +21,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Upgrade pip run: python -m pip install --upgrade pip diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index b5a97f8b..33ae1485 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,7 +3,7 @@ import shlex import sys from contextlib import contextmanager -from typing import Any, Dict, IO, Iterator, List +from typing import Any, Dict, IO, Iterator, List, Optional try: import click @@ -16,7 +16,7 @@ from .version import __version__ -def enumerate_env(): +def enumerate_env() -> Optional[str]: """ Return a path for the ${pwd}/.env file. From 533f8ac83c7873391053c1854e539afb7e124a2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= <16805946+edgarrmondragon@users.noreply.github.com> Date: Thu, 31 Oct 2024 23:12:30 -0600 Subject: [PATCH 12/59] Add Python 3.13 trove classifier (#535) --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 8ceddf92..b03b8568 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ def read_files(files): 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: Implementation :: PyPy', 'Intended Audience :: Developers', 'Intended Audience :: System Administrators', From 2b8635b79f1aa15cade0950117d4e7d12c298766 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:42:56 +0530 Subject: [PATCH 13/59] Bump the github-actions group with 2 updates (#529) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 2 to 4 - [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/v2...v4) Updates `actions/setup-python` from 2 to 5 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v2...v5) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7666da09..67668d53 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies From 41593889b63bba7f6af22279968e88727ebf5d62 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 9 Mar 2025 23:27:31 +0530 Subject: [PATCH 14/59] Add support for python 3.13 and drop 3.8 (#551) fixes #550 --- .github/workflows/test.yml | 35 ++++++++++--------- CHANGELOG.md | 13 +++++-- setup.cfg | 6 ++-- setup.py | 70 +++++++++++++++++++++----------------- tox.ini | 40 +++++++++++----------- 5 files changed, 91 insertions(+), 73 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74a24ddd..fc86910d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,22 +12,23 @@ jobs: matrix: os: - ubuntu-latest - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] + python-version: + ["3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - - - name: Upgrade pip - run: python -m pip install --upgrade pip - - - name: Install dependencies - run: pip install tox tox-gh-actions - - - name: Test with tox - run: tox + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true + + - name: Upgrade pip + run: python -m pip install --upgrade pip + + - name: Install dependencies + run: pip install tox tox-gh-actions + + - name: Test with tox + run: tox diff --git a/CHANGELOG.md b/CHANGELOG.md index f63a1f93..a198b1f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,19 @@ 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). + +## [Unreleased] + +- Drop support for Python 3.8 +- Add support for python 3.13 +- Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] + ## [1.0.1] - 2024-01-23 **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]) +* 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** @@ -317,7 +324,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 0.5.1 -- Fix find\_dotenv - it now start search from the file where this +- Fix `find_dotenv` - it now start search from the file where this function is called from. ## 0.5.0 @@ -346,6 +353,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#466]: https://github.com/theskumar/python-dotenv/issues/466 [#454]: https://github.com/theskumar/python-dotenv/issues/454 [#474]: https://github.com/theskumar/python-dotenv/issues/474 +[#523]: https://github.com/theskumar/python-dotenv/issues/523 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky @@ -356,6 +364,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@cjauvin]: https://github.com/cjauvin [@eaf]: https://github.com/eaf [@earlbread]: https://github.com/earlbread +[@eekstunt]: https://github.com/eekstunt [@eggplants]: https://github.com/@eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 diff --git a/setup.cfg b/setup.cfg index 3fefd1f0..4a8f11ac 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.0 +current_version = 1.0.1 commit = True tag = True @@ -24,7 +24,7 @@ relative_files = True source = dotenv [coverage:paths] -source = +source = src/dotenv .tox/*/lib/python*/site-packages/dotenv .tox/pypy*/site-packages/dotenv @@ -32,6 +32,6 @@ source = [coverage:report] show_missing = True include = */site-packages/dotenv/* -exclude_lines = +exclude_lines = if IS_TYPE_CHECKING: pragma: no cover diff --git a/setup.py b/setup.py index b03b8568..f3d43ca1 100644 --- a/setup.py +++ b/setup.py @@ -4,60 +4,68 @@ def read_files(files): data = [] for file in files: - with open(file, encoding='utf-8') as f: + with open(file, encoding="utf-8") as f: data.append(f.read()) return "\n".join(data) -long_description = read_files(['README.md', 'CHANGELOG.md']) +long_description = read_files(["README.md", "CHANGELOG.md"]) meta = {} -with open('./src/dotenv/version.py', encoding='utf-8') as f: +with open("./src/dotenv/version.py", encoding="utf-8") as f: exec(f.read(), meta) setup( name="python-dotenv", description="Read key-value pairs from a .env file and set them as environment variables", long_description=long_description, - long_description_content_type='text/markdown', - version=meta['__version__'], + long_description_content_type="text/markdown", + version=meta["__version__"], author="Saurabh Kumar", author_email="me+github@saurabh-kumar.com", url="https://github.com/theskumar/python-dotenv", - keywords=['environment variables', 'deployments', 'settings', 'env', 'dotenv', - 'configurations', 'python'], - packages=['dotenv'], - package_dir={'': 'src'}, + keywords=[ + "environment variables", + "deployments", + "settings", + "env", + "dotenv", + "configurations", + "python", + ], + packages=["dotenv"], + package_dir={"": "src"}, package_data={ - 'dotenv': ['py.typed'], + "dotenv": ["py.typed"], }, - python_requires=">=3.8", + python_requires=">=3.9", extras_require={ - 'cli': ['click>=5.0', ], + "cli": [ + "click>=5.0", + ], }, entry_points={ "console_scripts": [ "dotenv=dotenv.__main__:cli", ], }, - license='BSD-3-Clause', + license="BSD-3-Clause", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Programming Language :: Python :: Implementation :: PyPy', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Topic :: System :: Systems Administration', - 'Topic :: Utilities', - 'Environment :: Web Environment', - ] + "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", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: PyPy", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Topic :: System :: Systems Administration", + "Topic :: Utilities", + "Environment :: Web Environment", + ], ) diff --git a/tox.ini b/tox.ini index fad86f73..057a1ae9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,39 +1,39 @@ [tox] -envlist = lint,py{38,39,310,311,312-dev},pypy3,manifest,coverage-report +envlist = lint,py{39,310,311,312,313},pypy3,manifest,coverage-report [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 - 3.11: py311, lint, manifest - 3.12-dev: py312-dev + 3.11: py311 + 3.12: py312 + 3.13: py313, lint, manifest pypy-3.9: pypy3 [testenv] deps = - pytest - pytest-cov - sh >= 2.0.2, <3 - click - py{38,39,310,311,py312-dev,pypy3}: ipython + pytest + pytest-cov + sh >= 2.0.2, <3 + click + py{39,310,311,312,313,pypy3}: ipython commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} depends = - py{38,39,310,311,312-dev},pypy3: coverage-clean - coverage-report: py{38,39,310,311,312-dev},pypy3 + py{39,310,311,312,313},pypy3: coverage-clean + coverage-report: py{39,310,311,312,313},pypy3 [testenv:lint] skip_install = true deps = - flake8 - mypy + flake8 + mypy commands = - flake8 src tests - 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 - mypy --python-version=3.8 src tests + flake8 src tests + mypy --python-version=3.13 src tests + 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:manifest] deps = check-manifest @@ -49,4 +49,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage report + coverage report From 3c19c03dd41bd930d115aeb570f64e794d436c5f Mon Sep 17 00:00:00 2001 From: Rod Elias Date: Sun, 9 Mar 2025 14:59:52 -0300 Subject: [PATCH 15/59] s/Python-dotenv/python-dotenv/ (#516) This commit uses the name `python-dotenv` instead of `Python-dotenv` in the README.md file --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1eca986d..e92949ef 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![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 +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. @@ -29,7 +29,7 @@ If your application takes its configuration from environment variables, like a 1 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 +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: @@ -201,7 +201,7 @@ empty string. ### Variable expansion -Python-dotenv can interpolate variables using POSIX 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: From 9acba4af31757e99e2d6e6700de621ee8f9b98ae Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 9 Mar 2025 23:44:09 +0530 Subject: [PATCH 16/59] Some more s/Python-dotenv/python-dotenv/ (#552) --- .github/SECURITY.md | 5 ++--- src/dotenv/main.py | 49 +++++++++++++++++++++++---------------------- tests/test_main.py | 43 ++++++++++++++++++--------------------- 3 files changed, 47 insertions(+), 50 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index dbdabeb1..00d4d5e4 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -9,11 +9,10 @@ ## Reporting a Vulnerability -If you believe you have identified a security issue with Python-dotenv, please email +If you believe you have identified a security issue with python-dotenv, please email python-dotenv@saurabh-kumar.com. A maintainer will contact you acknowledging the report and how to continue. Be sure to include as much detail as necessary in your report. As with reporting normal issues, a minimal reproducible example will help the maintainers address the issue faster. -If you are able, you may also include a fix for the issue generated with `git -format-patch`. +If you are able, you may also include a fix for the issue generated with `git format-patch`. diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 052de054..0c81bba5 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -7,8 +7,7 @@ import tempfile from collections import OrderedDict from contextlib import contextmanager -from typing import (IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, - Union) +from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union from .parser import Binding, parse_stream from .variables import parse_variables @@ -17,7 +16,7 @@ # 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. -StrPath = Union[str, 'os.PathLike[str]'] +StrPath = Union[str, "os.PathLike[str]"] logger = logging.getLogger(__name__) @@ -26,7 +25,7 @@ def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding for mapping in mappings: if mapping.error: logger.warning( - "Python-dotenv could not parse statement starting at line %s", + "python-dotenv could not parse statement starting at line %s", mapping.original.line, ) yield mapping @@ -60,10 +59,10 @@ def _get_stream(self) -> Iterator[IO[str]]: else: if self.verbose: logger.info( - "Python-dotenv could not find configuration file %s.", - self.dotenv_path or '.env', + "python-dotenv could not find configuration file %s.", + self.dotenv_path or ".env", ) - yield io.StringIO('') + yield io.StringIO("") def dict(self) -> Dict[str, Optional[str]]: """Return dotenv as dict""" @@ -73,7 +72,9 @@ def dict(self) -> Dict[str, Optional[str]]: raw_values = self.parse() if self.interpolate: - self._dict = OrderedDict(resolve_variables(raw_values, override=self.override)) + self._dict = OrderedDict( + resolve_variables(raw_values, override=self.override) + ) else: self._dict = OrderedDict(raw_values) @@ -101,8 +102,7 @@ def set_as_environment_variables(self) -> bool: return True def get(self, key: str) -> Optional[str]: - """ - """ + """ """ data = self.dict() if key in data: @@ -166,9 +166,8 @@ def set_key( if quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") - quote = ( - quote_mode == "always" - or (quote_mode == "auto" and not value_to_set.isalnum()) + quote = quote_mode == "always" or ( + quote_mode == "auto" and not value_to_set.isalnum() ) if quote: @@ -176,7 +175,7 @@ def set_key( else: value_out = value_to_set if export: - line_out = f'export {key_to_set}={value_out}\n' + line_out = f"export {key_to_set}={value_out}\n" else: line_out = f"{key_to_set}={value_out}\n" @@ -223,7 +222,9 @@ def unset_key( dest.write(mapping.original.string) if not removed: - logger.warning("Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path) + logger.warning( + "Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path + ) return None, key_to_unset return removed, key_to_unset @@ -235,7 +236,7 @@ def resolve_variables( ) -> Mapping[str, Optional[str]]: new_values: Dict[str, Optional[str]] = {} - for (name, value) in values: + for name, value in values: if value is None: result = None else: @@ -259,7 +260,7 @@ def _walk_to_root(path: str) -> Iterator[str]: Yield directories starting from the given directory up to the root """ if not os.path.exists(path): - raise IOError('Starting path not found') + raise IOError("Starting path not found") if os.path.isfile(path): path = os.path.dirname(path) @@ -273,7 +274,7 @@ def _walk_to_root(path: str) -> Iterator[str]: def find_dotenv( - filename: str = '.env', + filename: str = ".env", raise_error_if_not_found: bool = False, usecwd: bool = False, ) -> str: @@ -284,14 +285,14 @@ def find_dotenv( """ def _is_interactive(): - """ Decide whether this is running in a REPL or IPython notebook """ + """Decide whether this is running in a REPL or IPython notebook""" try: - main = __import__('__main__', None, None, fromlist=['__file__']) + main = __import__("__main__", None, None, fromlist=["__file__"]) except ModuleNotFoundError: return False - return not hasattr(main, '__file__') + return not hasattr(main, "__file__") - if usecwd or _is_interactive() or getattr(sys, 'frozen', False): + if usecwd or _is_interactive() or getattr(sys, "frozen", False): # Should work without __file__, e.g. in REPL or IPython notebook. path = os.getcwd() else: @@ -313,9 +314,9 @@ def _is_interactive(): return check_path if raise_error_if_not_found: - raise IOError('File not found') + raise IOError("File not found") - return '' + return "" def load_dotenv( diff --git a/tests/test_main.py b/tests/test_main.py index fd5e3903..2d63eec1 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,9 +28,9 @@ def test_set_key_no_file(tmp_path): ("", "a", "", (True, "a", ""), "a=''\n"), ("", "a", "b", (True, "a", "b"), "a='b'\n"), ("", "a", "'b'", (True, "a", "'b'"), "a='\\'b\\''\n"), - ("", "a", "\"b\"", (True, "a", '"b"'), "a='\"b\"'\n"), + ("", "a", '"b"', (True, "a", '"b"'), "a='\"b\"'\n"), ("", "a", "b'c", (True, "a", "b'c"), "a='b\\'c'\n"), - ("", "a", "b\"c", (True, "a", "b\"c"), "a='b\"c'\n"), + ("", "a", 'b"c', (True, "a", 'b"c'), "a='b\"c'\n"), ("a=b", "a", "c", (True, "a", "c"), "a='c'\n"), ("a=b\n", "a", "c", (True, "a", "c"), "a='c'\n"), ("a=b\n\n", "a", "c", (True, "a", "c"), "a='c'\n\n"), @@ -75,20 +75,20 @@ def test_get_key_no_file(tmp_path): nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") - with mock.patch.object(logger, "info") as mock_info, \ - mock.patch.object(logger, "warning") as mock_warning: + with ( + mock.patch.object(logger, "info") as mock_info, + mock.patch.object(logger, "warning") as mock_warning, + ): result = dotenv.get_key(nx_path, "foo") assert result is None mock_info.assert_has_calls( calls=[ - mock.call("Python-dotenv could not find configuration file %s.", nx_path) + mock.call("python-dotenv could not find configuration file %s.", nx_path) ], ) mock_warning.assert_has_calls( - calls=[ - mock.call("Key %s not found in %s.", "foo", nx_path) - ], + calls=[mock.call("Key %s not found in %s.", "foo", nx_path)], ) @@ -249,10 +249,12 @@ def test_load_dotenv_no_file_verbose(): logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "info") as mock_info: - result = dotenv.load_dotenv('.does_not_exist', verbose=True) + result = dotenv.load_dotenv(".does_not_exist", verbose=True) assert result is False - mock_info.assert_called_once_with("Python-dotenv could not find configuration file %s.", ".does_not_exist") + mock_info.assert_called_once_with( + "python-dotenv could not find configuration file %s.", ".does_not_exist" + ) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) @@ -317,21 +319,23 @@ def test_load_dotenv_file_stream(dotenv_path): def test_load_dotenv_in_current_dir(tmp_path): - dotenv_path = tmp_path / '.env' - dotenv_path.write_bytes(b'a=b') - code_path = tmp_path / 'code.py' - code_path.write_text(textwrap.dedent(""" + dotenv_path = tmp_path / ".env" + dotenv_path.write_bytes(b"a=b") + code_path = tmp_path / "code.py" + code_path.write_text( + textwrap.dedent(""" import dotenv import os dotenv.load_dotenv(verbose=True) print(os.environ['a']) - """)) + """) + ) os.chdir(tmp_path) result = sh.Command(sys.executable)(code_path) - assert result == 'b\n' + assert result == "b\n" def test_dotenv_values_file(dotenv_path): @@ -352,30 +356,23 @@ def test_dotenv_values_file(dotenv_path): ({"b": "c"}, "a=${b}", True, {"a": "c"}), ({"b": "c"}, "a=${b:-d}", False, {"a": "${b:-d}"}), ({"b": "c"}, "a=${b:-d}", True, {"a": "c"}), - # Defined in file ({}, "b=c\na=${b}", True, {"a": "c", "b": "c"}), - # Undefined ({}, "a=${b}", True, {"a": ""}), ({}, "a=${b:-d}", True, {"a": "d"}), - # With quotes ({"b": "c"}, 'a="${b}"', True, {"a": "c"}), ({"b": "c"}, "a='${b}'", True, {"a": "c"}), - # With surrounding text ({"b": "c"}, "a=x${b}y", True, {"a": "xcy"}), - # Self-referential ({"a": "b"}, "a=${a}", True, {"a": "b"}), ({}, "a=${a}", True, {"a": ""}), ({"a": "b"}, "a=${a:-c}", True, {"a": "b"}), ({}, "a=${a:-c}", True, {"a": "c"}), - # Reused ({"b": "c"}, "a=${b}${b}", True, {"a": "cc"}), - # Re-defined and used in file ({"b": "c"}, "b=d\na=${b}", True, {"a": "d", "b": "d"}), ({}, "a=b\na=c\nd=${a}", True, {"a": "c", "d": "c"}), From 8dd413e84b1fb1b3368c02106aab07a533fae015 Mon Sep 17 00:00:00 2001 From: randomseed42 <50793718+randomseed42@users.noreply.github.com> Date: Tue, 25 Mar 2025 16:33:52 +0800 Subject: [PATCH 17/59] Add _is_debugger so load_dotenv will work in pdb (#553) --- src/dotenv/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 0c81bba5..1848d602 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -292,7 +292,10 @@ def _is_interactive(): return False return not hasattr(main, "__file__") - if usecwd or _is_interactive() or getattr(sys, "frozen", False): + def _is_debugger(): + return sys.gettrace() is not None + + if usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False): # Should work without __file__, e.g. in REPL or IPython notebook. path = os.getcwd() else: From c89fb6d41c0a25f670b34ba05f392260eaa6ccd1 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 15:38:56 +0530 Subject: [PATCH 18/59] Update changelog --- CHANGELOG.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a198b1f7..3544da86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unrelased] -- Drop support for Python 3.8 +**Feature** - Add support for python 3.13 - Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] +**Fixed** +- `find_dotenv` and `load_dotenv` now correctly looks up at the current directory when running in debugger or pdb ([#553] by [@randomseed42]) + +**Misc** +- Drop support for Python 3.8 + ## [1.0.1] - 2024-01-23 **Fixed** @@ -354,6 +360,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#454]: https://github.com/theskumar/python-dotenv/issues/454 [#474]: https://github.com/theskumar/python-dotenv/issues/474 [#523]: https://github.com/theskumar/python-dotenv/issues/523 +[#553]: https://github.com/theskumar/python-dotenv/issues/553 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky @@ -398,7 +405,7 @@ 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 - +[@randomseed42]: https://github.com/zueve [Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...HEAD [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 From 2198b698c021851201261fac27884ee8db6553d5 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 15:41:24 +0530 Subject: [PATCH 19/59] =?UTF-8?q?Bump=20version:=201.0.1=20=E2=86=92=201.1?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 6 +++--- src/dotenv/version.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4a8f11ac..02dc0695 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.1 +current_version = 1.1.0 commit = True tag = True @@ -24,7 +24,7 @@ relative_files = True source = dotenv [coverage:paths] -source = +source = src/dotenv .tox/*/lib/python*/site-packages/dotenv .tox/pypy*/site-packages/dotenv @@ -32,6 +32,6 @@ source = [coverage:report] show_missing = True include = */site-packages/dotenv/* -exclude_lines = +exclude_lines = if IS_TYPE_CHECKING: pragma: no cover diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 5c4105cd..6849410a 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.1.0" From 36c6270db41e1e88be4ec21d0fb876ba0c79d363 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 15:42:28 +0530 Subject: [PATCH 20/59] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3544da86..ec525352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unrelased] +## [1.1.0] - 2025-03-25 **Feature** - Add support for python 3.13 From 6a02ef5a1034d66338811757df07a113a1169af6 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 25 Mar 2025 16:23:46 +0530 Subject: [PATCH 21/59] update mkdocs -> mkdocstrings config --- mkdocs.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 331965df..ba77fa7f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,13 +13,7 @@ markdown_extensions: - mdx_truly_sane_lists plugins: - - mkdocstrings: - handlers: - python: - rendering: - show_root_heading: yes - show_submodules: no - separate_signature: yes + - mkdocstrings - search nav: - Home: index.md From 01f899733de664cda0550207067eb36a1795062f Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 31 Mar 2025 13:29:19 +0530 Subject: [PATCH 22/59] docs update --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec525352..f1afd06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.1.0] - 2025-03-25 **Feature** + - Add support for python 3.13 - Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] **Fixed** + - `find_dotenv` and `load_dotenv` now correctly looks up at the current directory when running in debugger or pdb ([#553] by [@randomseed42]) **Misc** + - Drop support for Python 3.8 ## [1.0.1] - 2024-01-23 @@ -407,7 +410,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@zueve]: https://github.com/zueve [@randomseed42]: https://github.com/zueve -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 [0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 From 8411987b9301f716245074872afa30646e9b9eb7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Fri, 30 May 2025 22:23:02 +0530 Subject: [PATCH 23/59] fix: ensure find_dotenv work reliably on python 3.13 (#563) --- src/dotenv/main.py | 2 + tests/test_is_interactive.py | 227 +++++++++++++++++++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 tests/test_is_interactive.py diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1848d602..8e6a7cf4 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -286,6 +286,8 @@ def find_dotenv( def _is_interactive(): """Decide whether this is running in a REPL or IPython notebook""" + if hasattr(sys, "ps1") or hasattr(sys, "ps2"): + return True try: main = __import__("__main__", None, None, fromlist=["__file__"]) except ModuleNotFoundError: diff --git a/tests/test_is_interactive.py b/tests/test_is_interactive.py new file mode 100644 index 00000000..f56378e9 --- /dev/null +++ b/tests/test_is_interactive.py @@ -0,0 +1,227 @@ +import sys +import builtins +from unittest import mock +from dotenv.main import find_dotenv + + +class TestIsInteractive: + """Tests for the _is_interactive helper function within find_dotenv. + + The _is_interactive function is used by find_dotenv to determine if the code + is running in an interactive environment (like a REPL, IPython notebook, etc.) + versus a normal script execution. + + Interactive environments include: + - Python REPL (has sys.ps1 or sys.ps2) + - IPython notebooks (no __file__ in __main__) + - Interactive shells + + Non-interactive environments include: + - Normal script execution (has __file__ in __main__) + - Module imports + + Examples of the behavior: + >>> import sys + >>> # In a REPL: + >>> hasattr(sys, 'ps1') # True + >>> # In a script: + >>> hasattr(sys, 'ps1') # False + """ + + def _create_dotenv_file(self, tmp_path): + """Helper to create a test .env file.""" + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("TEST=value") + return dotenv_path + + def _setup_subdir_and_chdir(self, tmp_path, monkeypatch): + """Helper to create subdirectory and change to it.""" + test_dir = tmp_path / "subdir" + test_dir.mkdir() + monkeypatch.chdir(test_dir) + return test_dir + + def _remove_ps_attributes(self, monkeypatch): + """Helper to remove ps1/ps2 attributes if they exist.""" + if hasattr(sys, "ps1"): + monkeypatch.delattr(sys, "ps1") + if hasattr(sys, "ps2"): + monkeypatch.delattr(sys, "ps2") + + def _mock_main_import(self, monkeypatch, mock_main_module): + """Helper to mock __main__ module import.""" + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "__main__": + return mock_main_module + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + def _mock_main_import_error(self, monkeypatch): + """Helper to mock __main__ module import that raises ModuleNotFoundError.""" + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "__main__": + raise ModuleNotFoundError("No module named '__main__'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + def test_is_interactive_with_ps1(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when sys.ps1 exists.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock sys.ps1 to simulate interactive shell + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_with_ps2(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when sys.ps2 exists.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock sys.ps2 to simulate multi-line interactive input + monkeypatch.setattr(sys, "ps2", "... ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_module_not_found(self, tmp_path, monkeypatch): + """Test that _is_interactive returns False when __main__ module import fails.""" + self._remove_ps_attributes(monkeypatch) + self._mock_main_import_error(monkeypatch) + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # Since _is_interactive() returns False, find_dotenv should not find anything + # without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_main_without_file(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when __main__ has no __file__ attribute.""" + self._remove_ps_attributes(monkeypatch) + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock __main__ module without __file__ attribute + mock_main = mock.MagicMock() + del mock_main.__file__ # Remove __file__ attribute + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_with_file(self, tmp_path, monkeypatch): + """Test that _is_interactive returns False when __main__ has __file__ attribute.""" + self._remove_ps_attributes(monkeypatch) + + # Mock __main__ module with __file__ attribute + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # Since _is_interactive() returns False, find_dotenv should not find anything + # without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_precedence_ps1_over_main(self, tmp_path, monkeypatch): + """Test that ps1/ps2 attributes take precedence over __main__ module check.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Set ps1 attribute + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + + # Mock __main__ module with __file__ attribute (which would normally return False) + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # ps1 should take precedence, so _is_interactive() returns True + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when both ps1 and ps2 exist.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Set both ps1 and ps2 attributes + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + monkeypatch.setattr(sys, "ps2", "... ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # Should return True with either attribute present + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, monkeypatch): + """Test _is_interactive when __main__ has __file__ attribute set to None.""" + self._remove_ps_attributes(monkeypatch) + + # Mock __main__ module with __file__ = None + mock_main = mock.MagicMock() + mock_main.__file__ = None + + self._mock_main_import(monkeypatch, mock_main) + + # Mock sys.gettrace to ensure debugger detection returns False + monkeypatch.setattr("sys.gettrace", lambda: None) + + monkeypatch.chdir(tmp_path) + + # __file__ = None should still be considered non-interactive + # and with no debugger, find_dotenv should not search from cwd + result = find_dotenv() + assert result == "" + + def test_is_interactive_no_ps_attributes_and_normal_execution(self, tmp_path, monkeypatch): + """Test normal script execution scenario where _is_interactive should return False.""" + self._remove_ps_attributes(monkeypatch) + + # Don't mock anything - let it use the real __main__ module + # which should have a __file__ attribute in normal execution + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # In normal execution, _is_interactive() should return False + # so find_dotenv should not find anything without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_with_usecwd_override(self, tmp_path, monkeypatch): + """Test that usecwd=True overrides _is_interactive behavior.""" + self._remove_ps_attributes(monkeypatch) + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock __main__ module with __file__ attribute (non-interactive) + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # Even though _is_interactive() returns False, usecwd=True should find the file + result = find_dotenv(usecwd=True) + assert result == str(dotenv_path) From 9d85edb3b8652de4601f9ad8a7a49ad9909f898a Mon Sep 17 00:00:00 2001 From: Jake Owen <30642941+wrongontheinternet@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:24:26 +1000 Subject: [PATCH 24/59] fix(cli): issue with execvpe on Windows (#566) Fix dotenv run on Windows: execvpe is bad Co-authored-by: Jake Owen --- src/dotenv/cli.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 33ae1485..075a7af1 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -5,6 +5,9 @@ from contextlib import contextmanager from typing import Any, Dict, IO, Iterator, List, Optional +if sys.platform == 'win32': + from subprocess import Popen + try: import click except ImportError: @@ -187,4 +190,16 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: cmd_env = os.environ.copy() cmd_env.update(env) - os.execvpe(command[0], args=command, env=cmd_env) + if sys.platform == 'win32': + # execvpe on Windows returns control immediately + # rather than once the command has finished. + p = Popen(command, + universal_newlines=True, + bufsize=0, + shell=False, + env=cmd_env) + _, _ = p.communicate() + + exit(p.returncode) + else: + os.execvpe(command[0], args=command, env=cmd_env) From 667e82f18d6e5306894c8746c46b1da2d031bd23 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 09:02:34 +0530 Subject: [PATCH 25/59] update changelog --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1afd06d..0669eaed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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). +## [1.1.1] - 2025-06-24 + +## 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) + ## [1.1.0] - 2025-03-25 @@ -409,8 +416,10 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve [@randomseed42]: https://github.com/zueve +[@wrongontheinternet]: https://github.com/wrongontheinternet -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...1.1.1 [1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 From 16e660d384b942b11879b44500afbbe021650448 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 09:47:34 +0530 Subject: [PATCH 26/59] =?UTF-8?q?Bump=20version:=201.1.0=20=E2=86=92=201.1?= =?UTF-8?q?.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 02dc0695..60effd2b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.0 +current_version = 1.1.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 6849410a..a82b376d 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" From 02b68577f37da2c4f4b9377d7a0ca2b58fdacf20 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 11:22:38 +0530 Subject: [PATCH 27/59] style: upgrade to use ruff (#567) --- .pre-commit-config.yaml | 8 + CONTRIBUTING.md | 22 +- MANIFEST.in | 2 +- Makefile | 6 +- requirements.txt | 4 +- ruff.toml | 19 + src/dotenv/__init__.py | 32 +- src/dotenv/cli.py | 115 +++--- src/dotenv/ipython.py | 29 +- src/dotenv/parser.py | 29 +- tests/conftest.py | 4 +- tests/test_cli.py | 88 +++-- tests/test_ipython.py | 1 - tests/test_is_interactive.py | 11 +- tests/test_main.py | 2 +- tests/test_parser.py | 702 +++++++++++++++++++++++++++-------- tests/test_utils.py | 24 +- tests/test_variables.py | 2 +- tests/test_zip_imports.py | 19 +- tox.ini | 5 +- 20 files changed, 803 insertions(+), 321 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 ruff.toml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..60d0365c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.0 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fac71bff..49840fa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,23 +7,29 @@ a pull request. Executing the tests: - $ pip install -r requirements.txt - $ pip install -e . - $ flake8 - $ pytest + $ uv venv + $ uv pip install -r requirements.txt + $ uv pip install -e . + $ uv ruff check . + $ uv format . + $ uv run pytest or with [tox](https://pypi.org/project/tox/) installed: $ tox +Use of pre-commit is recommended: + + $ uv run precommit install + + Documentation is published with [mkdocs](): ```shell -$ pip install -r requirements-docs.txt -$ pip install -e . -$ mkdocs serve +$ uv pip install -r requirements-docs.txt +$ uv pip install -e . +$ uv run mkdocs serve ``` Open http://127.0.0.1:8000/ to view the documentation locally. - diff --git a/MANIFEST.in b/MANIFEST.in index 9c457e66..bf0d47e6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include LICENSE *.md *.yml *.toml +include LICENSE *.md *.yml *.yaml *.toml include tox.ini recursive-include docs *.md diff --git a/Makefile b/Makefile index 5b58c4c2..e5bcb308 100644 --- a/Makefile +++ b/Makefile @@ -24,9 +24,9 @@ sdist: clean ls -l dist test: - pip install -e . - flake8 . - py.test tests/ + uv pip install -e . + ruff check . + pytest tests/ coverage: coverage run --source=dotenv --omit='*tests*' -m py.test tests/ -v --tb=native diff --git a/requirements.txt b/requirements.txt index af7e1bc4..660c5dcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ -black~=22.3.0 bumpversion click -flake8>=2.2.3 ipython pytest-cov pytest>=3.9 @@ -9,3 +7,5 @@ sh>=2 tox twine wheel +ruff +pre-commit diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..a2e0feca --- /dev/null +++ b/ruff.toml @@ -0,0 +1,19 @@ +[lint] +select = [ + # pycodestyle + "E4", + "E7", + "E9", + + # Pyflakes + "F", + + # flake8-bugbear + "B", + + # iSort + "I", + + # flake8-builtins + "A", +] diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 7f4c631b..dde24a01 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,11 +1,11 @@ from typing import Any, Optional -from .main import (dotenv_values, find_dotenv, get_key, load_dotenv, set_key, - unset_key) +from .main import dotenv_values, find_dotenv, get_key, load_dotenv, set_key, unset_key def load_ipython_extension(ipython: Any) -> None: from .ipython import load_ipython_extension + load_ipython_extension(ipython) @@ -21,29 +21,31 @@ def get_cli_string( Useful for converting a arguments passed to a fabric task to be passed to a `local` or `run` command. """ - command = ['dotenv'] + command = ["dotenv"] if quote: - command.append(f'-q {quote}') + command.append(f"-q {quote}") if path: - command.append(f'-f {path}') + command.append(f"-f {path}") if action: command.append(action) if key: command.append(key) if value: - if ' ' in value: + if " " in value: command.append(f'"{value}"') else: command.append(value) - return ' '.join(command).strip() + return " ".join(command).strip() -__all__ = ['get_cli_string', - 'load_dotenv', - 'dotenv_values', - 'get_key', - 'set_key', - 'unset_key', - 'find_dotenv', - 'load_ipython_extension'] +__all__ = [ + "get_cli_string", + "load_dotenv", + "dotenv_values", + "get_key", + "set_key", + "unset_key", + "find_dotenv", + "load_ipython_extension", +] diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 075a7af1..c43c63b5 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,16 +3,18 @@ import shlex import sys from contextlib import contextmanager -from typing import Any, Dict, IO, Iterator, List, Optional +from typing import IO, Any, Dict, Iterator, List, Optional -if sys.platform == 'win32': +if sys.platform == "win32": from subprocess import Popen try: import click except ImportError: - sys.stderr.write('It seems python-dotenv is not installed with cli option. \n' - 'Run pip install "python-dotenv[cli]" to fix this.') + sys.stderr.write( + "It seems python-dotenv is not installed with cli option. \n" + 'Run pip install "python-dotenv[cli]" to fix this.' + ) sys.exit(1) from .main import dotenv_values, set_key, unset_key @@ -29,25 +31,37 @@ def enumerate_env() -> Optional[str]: cwd = os.getcwd() except FileNotFoundError: return None - path = os.path.join(cwd, '.env') + path = os.path.join(cwd, ".env") return path @click.group() -@click.option('-f', '--file', default=enumerate_env(), - type=click.Path(file_okay=True), - help="Location of the .env file, defaults to .env file in current working directory.") -@click.option('-q', '--quote', default='always', - type=click.Choice(['always', 'never', 'auto']), - help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.") -@click.option('-e', '--export', default=False, - type=click.BOOL, - help="Whether to write the dot file as an executable bash script.") +@click.option( + "-f", + "--file", + default=enumerate_env(), + type=click.Path(file_okay=True), + help="Location of the .env file, defaults to .env file in current working directory.", +) +@click.option( + "-q", + "--quote", + default="always", + type=click.Choice(["always", "never", "auto"]), + help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.", +) +@click.option( + "-e", + "--export", + default=False, + type=click.BOOL, + help="Whether to write the dot file as an executable bash script.", +) @click.version_option(version=__version__) @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.""" - ctx.obj = {'QUOTE': quote, 'EXPORT': export, 'FILE': file} + ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file} @contextmanager @@ -66,53 +80,57 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]: exit(2) -@cli.command() +@cli.command(name="list") @click.pass_context -@click.option('--format', default='simple', - type=click.Choice(['simple', 'json', 'shell', 'export']), - help="The format in which to display the list. Default format is simple, " - "which displays name=value without quotes.") -def list(ctx: click.Context, format: bool) -> None: +@click.option( + "--format", + "output_format", + default="simple", + type=click.Choice(["simple", "json", "shell", "export"]), + help="The format in which to display the list. Default format is simple, " + "which displays name=value without quotes.", +) +def list_values(ctx: click.Context, output_format: str) -> None: """Display all the stored key/value.""" - file = ctx.obj['FILE'] + file = ctx.obj["FILE"] with stream_file(file) as stream: values = dotenv_values(stream=stream) - if format == 'json': + if output_format == "json": click.echo(json.dumps(values, indent=2, sort_keys=True)) else: - prefix = 'export ' if format == 'export' else '' + prefix = "export " if output_format == "export" else "" for k in sorted(values): v = values[k] if v is not None: - if format in ('export', 'shell'): + if output_format in ("export", "shell"): v = shlex.quote(v) - click.echo(f'{prefix}{k}={v}') + click.echo(f"{prefix}{k}={v}") -@cli.command() +@cli.command(name="set") @click.pass_context -@click.argument('key', required=True) -@click.argument('value', required=True) -def set(ctx: click.Context, key: Any, value: Any) -> 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.""" - file = ctx.obj['FILE'] - quote = ctx.obj['QUOTE'] - export = ctx.obj['EXPORT'] + file = ctx.obj["FILE"] + quote = ctx.obj["QUOTE"] + export = ctx.obj["EXPORT"] success, key, value = set_key(file, key, value, quote, export) if success: - click.echo(f'{key}={value}') + click.echo(f"{key}={value}") else: exit(1) @cli.command() @click.pass_context -@click.argument('key', required=True) +@click.argument("key", required=True) def get(ctx: click.Context, key: Any) -> None: """Retrieve the value for the given key.""" - file = ctx.obj['FILE'] + file = ctx.obj["FILE"] with stream_file(file) as stream: values = dotenv_values(stream=stream) @@ -126,11 +144,11 @@ def get(ctx: click.Context, key: Any) -> None: @cli.command() @click.pass_context -@click.argument('key', required=True) +@click.argument("key", required=True) def unset(ctx: click.Context, key: Any) -> None: """Removes the given key.""" - file = ctx.obj['FILE'] - quote = ctx.obj['QUOTE'] + file = ctx.obj["FILE"] + quote = ctx.obj["QUOTE"] success, key = unset_key(file, key, quote) if success: click.echo(f"Successfully removed {key}") @@ -138,21 +156,20 @@ def unset(ctx: click.Context, key: Any) -> None: exit(1) -@cli.command(context_settings={'ignore_unknown_options': True}) +@cli.command(context_settings={"ignore_unknown_options": True}) @click.pass_context @click.option( "--override/--no-override", default=True, help="Override variables from the environment file with those from the .env file.", ) -@click.argument('commandline', nargs=-1, type=click.UNPROCESSED) +@click.argument("commandline", nargs=-1, type=click.UNPROCESSED) def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: """Run command with environment variables present.""" - file = ctx.obj['FILE'] + file = ctx.obj["FILE"] if not os.path.isfile(file): raise click.BadParameter( - f'Invalid value for \'-f\' "{file}" does not exist.', - ctx=ctx + f"Invalid value for '-f' \"{file}\" does not exist.", ctx=ctx ) dotenv_as_dict = { k: v @@ -161,7 +178,7 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: } if not commandline: - click.echo('No command given.') + click.echo("No command given.") exit(1) run_command(commandline, dotenv_as_dict) @@ -190,14 +207,10 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: cmd_env = os.environ.copy() cmd_env.update(env) - if sys.platform == 'win32': + if sys.platform == "win32": # execvpe on Windows returns control immediately # rather than once the command has finished. - p = Popen(command, - universal_newlines=True, - bufsize=0, - shell=False, - env=cmd_env) + p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) _, _ = p.communicate() exit(p.returncode) diff --git a/src/dotenv/ipython.py b/src/dotenv/ipython.py index 7df727cd..4e7edbbf 100644 --- a/src/dotenv/ipython.py +++ b/src/dotenv/ipython.py @@ -1,24 +1,35 @@ from IPython.core.magic import Magics, line_magic, magics_class # type: ignore -from IPython.core.magic_arguments import (argument, magic_arguments, # type: ignore - parse_argstring) # type: ignore +from IPython.core.magic_arguments import ( + argument, + magic_arguments, + parse_argstring, +) # type: ignore from .main import find_dotenv, load_dotenv @magics_class class IPythonDotEnv(Magics): - @magic_arguments() @argument( - '-o', '--override', action='store_true', - help="Indicate to override existing variables" + "-o", + "--override", + action="store_true", + help="Indicate to override existing variables", + ) + @argument( + "-v", + "--verbose", + action="store_true", + help="Indicate function calls to be verbose", ) @argument( - '-v', '--verbose', action='store_true', - help="Indicate function calls to be verbose" + "dotenv_path", + nargs="?", + type=str, + default=".env", + help="Search in increasingly higher folders for the `dotenv_path`", ) - @argument('dotenv_path', nargs='?', type=str, default='.env', - help='Search in increasingly higher folders for the `dotenv_path`') @line_magic def dotenv(self, line): args = parse_argstring(self.dotenv, line) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index 735f14a3..eb100b47 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -1,7 +1,14 @@ import codecs import re -from typing import (IO, Iterator, Match, NamedTuple, Optional, # noqa:F401 - Pattern, Sequence, Tuple) +from typing import ( + IO, + Iterator, + Match, + NamedTuple, + Optional, + Pattern, + Sequence, +) def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: @@ -73,15 +80,15 @@ def set_mark(self) -> None: def get_marked(self) -> Original: return Original( - string=self.string[self.mark.chars:self.position.chars], + string=self.string[self.mark.chars : self.position.chars], line=self.mark.line, ) def peek(self, count: int) -> str: - return self.string[self.position.chars:self.position.chars + count] + return self.string[self.position.chars : self.position.chars + count] def read(self, count: int) -> str: - result = self.string[self.position.chars:self.position.chars + count] + result = self.string[self.position.chars : self.position.chars + count] if len(result) < count: raise Error("read: End of string") self.position.advance(result) @@ -91,13 +98,13 @@ def read_regex(self, regex: Pattern[str]) -> Sequence[str]: match = regex.match(self.string, self.position.chars) if match is None: raise Error("read_regex: Pattern not found") - self.position.advance(self.string[match.start():match.end()]) + self.position.advance(self.string[match.start() : match.end()]) return match.groups() def decode_escapes(regex: Pattern[str], string: str) -> str: def decode_match(match: Match[str]) -> str: - return codecs.decode(match.group(0), 'unicode-escape') # type: ignore + return codecs.decode(match.group(0), "unicode-escape") # type: ignore return regex.sub(decode_match, string) @@ -120,14 +127,14 @@ def parse_unquoted_value(reader: Reader) -> str: def parse_value(reader: Reader) -> str: char = reader.peek(1) - if char == u"'": + if char == "'": (value,) = reader.read_regex(_single_quoted_value) return decode_escapes(_single_quote_escapes, value) - elif char == u'"': + elif char == '"': (value,) = reader.read_regex(_double_quoted_value) return decode_escapes(_double_quote_escapes, value) - elif char in (u"", u"\n", u"\r"): - return u"" + elif char in ("", "\n", "\r"): + return "" else: return parse_unquoted_value(reader) diff --git a/tests/conftest.py b/tests/conftest.py index 69193de0..cc6f0f07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,6 @@ def cli(): @pytest.fixture def dotenv_path(tmp_path): - path = tmp_path / '.env' - path.write_bytes(b'') + path = tmp_path / ".env" + path.write_bytes(b"") yield path diff --git a/tests/test_cli.py b/tests/test_cli.py index fc309b48..343fdb23 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,9 @@ import os -import sh from pathlib import Path from typing import Optional import pytest +import sh import dotenv from dotenv.cli import cli as dotenv_cli @@ -11,26 +11,28 @@ @pytest.mark.parametrize( - "format,content,expected", + "output_format,content,expected", ( - (None, "x='a b c'", '''x=a b c\n'''), - ("simple", "x='a b c'", '''x=a b c\n'''), - ("simple", """x='"a b c"'""", '''x="a b c"\n'''), - ("simple", '''x="'a b c'"''', '''x='a b c'\n'''), - ("json", "x='a b c'", '''{\n "x": "a b c"\n}\n'''), + (None, "x='a b c'", """x=a b c\n"""), + ("simple", "x='a b c'", """x=a b c\n"""), + ("simple", """x='"a b c"'""", """x="a b c"\n"""), + ("simple", '''x="'a b c'"''', """x='a b c'\n"""), + ("json", "x='a b c'", """{\n "x": "a b c"\n}\n"""), ("shell", "x='a b c'", "x='a b c'\n"), - ("shell", """x='"a b c"'""", '''x='"a b c"'\n'''), - ("shell", '''x="'a b c'"''', '''x=''"'"'a b c'"'"''\n'''), + ("shell", """x='"a b c"'""", """x='"a b c"'\n"""), + ("shell", '''x="'a b c'"''', """x=''"'"'a b c'"'"''\n"""), ("shell", "x='a\nb\nc'", "x='a\nb\nc'\n"), - ("export", "x='a b c'", '''export x='a b c'\n'''), - ) + ("export", "x='a b c'", """export x='a b c'\n"""), + ), ) -def test_list(cli, dotenv_path, format: Optional[str], content: str, expected: str): - dotenv_path.write_text(content + '\n') +def test_list( + cli, dotenv_path, output_format: Optional[str], content: str, expected: str +): + dotenv_path.write_text(content + "\n") - args = ['--file', dotenv_path, 'list'] + args = ["--file", dotenv_path, "list"] if format is not None: - args.extend(['--format', format]) + args.extend(["--format", output_format]) result = cli.invoke(dotenv_cli, args) @@ -38,21 +40,21 @@ def test_list(cli, dotenv_path, format: Optional[str], content: str, expected: s def test_list_non_existent_file(cli): - result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'list']) + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "list"]) assert result.exit_code == 2, result.output assert "Error opening env file" in result.output def test_list_not_a_file(cli): - result = cli.invoke(dotenv_cli, ['--file', '.', 'list']) + result = cli.invoke(dotenv_cli, ["--file", ".", "list"]) assert result.exit_code == 2, result.output assert "Error opening env file" in result.output def test_list_no_file(cli): - result = cli.invoke(dotenv.cli.list, []) + result = cli.invoke(dotenv.cli.list_values, []) assert (result.exit_code, result.output) == (1, "") @@ -60,26 +62,26 @@ def test_list_no_file(cli): def test_get_existing_value(cli, dotenv_path): dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) assert (result.exit_code, result.output) == (0, "b\n") def test_get_non_existent_value(cli, dotenv_path): - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) assert (result.exit_code, result.output) == (1, "") def test_get_non_existent_file(cli): - result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "get", "a"]) assert result.exit_code == 2 assert "Error opening env file" in result.output def test_get_not_a_file(cli): - result = cli.invoke(dotenv_cli, ['--file', '.', 'get', 'a']) + result = cli.invoke(dotenv_cli, ["--file", ".", "get", "a"]) assert result.exit_code == 2 assert "Error opening env file" in result.output @@ -88,14 +90,14 @@ def test_get_not_a_file(cli): def test_unset_existing_value(cli, dotenv_path): dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'unset', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) assert (result.exit_code, result.output) == (0, "Successfully removed a\n") assert dotenv_path.read_text() == "" def test_unset_non_existent_value(cli, dotenv_path): - result = cli.invoke(dotenv_cli, ['--file', dotenv_path, 'unset', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) assert (result.exit_code, result.output) == (1, "") assert dotenv_path.read_text() == "" @@ -105,16 +107,26 @@ def test_unset_non_existent_value(cli, dotenv_path): "quote_mode,variable,value,expected", ( ("always", "a", "x", "a='x'\n"), - ("never", "a", "x", 'a=x\n'), + ("never", "a", "x", "a=x\n"), ("auto", "a", "x", "a=x\n"), ("auto", "a", "x y", "a='x y'\n"), ("auto", "a", "$", "a='$'\n"), - ) + ), ) def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_path, "--export", "false", "--quote", quote_mode, "set", variable, value] + [ + "--file", + dotenv_path, + "--export", + "false", + "--quote", + quote_mode, + "set", + variable, + value, + ], ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) @@ -126,12 +138,22 @@ def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expect ( (Path(".nx_file"), "true", "a", "x", "export a='x'\n"), (Path(".nx_file"), "false", "a", "x", "a='x'\n"), - ) + ), ) def test_set_export(cli, dotenv_path, export_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_path, "--quote", "always", "--export", export_mode, "set", variable, value] + [ + "--file", + dotenv_path, + "--quote", + "always", + "--export", + export_mode, + "set", + variable, + value, + ], ) assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) @@ -139,7 +161,7 @@ def test_set_export(cli, dotenv_path, export_mode, variable, value, expected): def test_set_non_existent_file(cli): - result = cli.invoke(dotenv.cli.set, ["a", "b"]) + result = cli.invoke(dotenv.cli.set_value, ["a", "b"]) assert (result.exit_code, result.output) == (1, "") @@ -209,21 +231,21 @@ def test_run_with_other_env(dotenv_path): def test_run_without_cmd(cli): - result = cli.invoke(dotenv_cli, ['run']) + result = cli.invoke(dotenv_cli, ["run"]) assert result.exit_code == 2 assert "Invalid value for '-f'" in result.output def test_run_with_invalid_cmd(cli): - result = cli.invoke(dotenv_cli, ['run', 'i_do_not_exist']) + result = cli.invoke(dotenv_cli, ["run", "i_do_not_exist"]) assert result.exit_code == 2 assert "Invalid value for '-f'" in result.output def test_run_with_version(cli): - result = cli.invoke(dotenv_cli, ['--version']) + result = cli.invoke(dotenv_cli, ["--version"]) assert result.exit_code == 0 assert result.output.strip().endswith(__version__) diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 960479ba..f01b3ad7 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -3,7 +3,6 @@ import pytest - pytest.importorskip("IPython") diff --git a/tests/test_is_interactive.py b/tests/test_is_interactive.py index f56378e9..1c073471 100644 --- a/tests/test_is_interactive.py +++ b/tests/test_is_interactive.py @@ -1,6 +1,7 @@ -import sys import builtins +import sys from unittest import mock + from dotenv.main import find_dotenv @@ -175,7 +176,9 @@ def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch): result = find_dotenv() assert result == str(dotenv_path) - def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, monkeypatch): + def test_is_interactive_main_module_with_file_attribute_none( + self, tmp_path, monkeypatch + ): """Test _is_interactive when __main__ has __file__ attribute set to None.""" self._remove_ps_attributes(monkeypatch) @@ -195,7 +198,9 @@ def test_is_interactive_main_module_with_file_attribute_none(self, tmp_path, mon result = find_dotenv() assert result == "" - def test_is_interactive_no_ps_attributes_and_normal_execution(self, tmp_path, monkeypatch): + def test_is_interactive_no_ps_attributes_and_normal_execution( + self, tmp_path, monkeypatch + ): """Test normal script execution scenario where _is_interactive should return False.""" self._remove_ps_attributes(monkeypatch) diff --git a/tests/test_main.py b/tests/test_main.py index 2d63eec1..dfd19274 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -64,7 +64,7 @@ def test_set_key_encoding(dotenv_path): def test_set_key_permission_error(dotenv_path): dotenv_path.chmod(0o000) - with pytest.raises(Exception): + with pytest.raises(PermissionError): dotenv.set_key(dotenv_path, "a", "b") dotenv_path.chmod(0o600) diff --git a/tests/test_parser.py b/tests/test_parser.py index b0621173..43386e5a 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -5,166 +5,548 @@ from dotenv.parser import Binding, Original, parse_stream -@pytest.mark.parametrize("test_input,expected", [ - (u"", []), - (u"a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"a=b", line=1), error=False)]), - (u"'a'=b", [Binding(key=u"a", value=u"b", original=Original(string=u"'a'=b", line=1), error=False)]), - (u"[=b", [Binding(key=u"[", value=u"b", original=Original(string=u"[=b", line=1), error=False)]), - (u" a = b ", [Binding(key=u"a", value=u"b", original=Original(string=u" a = b ", line=1), error=False)]), - (u"export a=b", [Binding(key=u"a", value=u"b", original=Original(string=u"export a=b", line=1), error=False)]), - ( - u" export 'a'=b", - [Binding(key=u"a", value=u"b", original=Original(string=u" export 'a'=b", line=1), error=False)], - ), - (u"# a=b", [Binding(key=None, value=None, original=Original(string=u"# a=b", line=1), error=False)]), - (u"a=b#c", [Binding(key=u"a", value=u"b#c", original=Original(string=u"a=b#c", line=1), error=False)]), - ( - u'a=b #c', - [Binding(key=u"a", value=u"b", original=Original(string=u"a=b #c", line=1), error=False)], - ), - ( - u'a=b\t#c', - [Binding(key=u"a", value=u"b", original=Original(string=u"a=b\t#c", line=1), error=False)], - ), - ( - u"a=b c", - [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c", line=1), error=False)], - ), - ( - u"a=b\tc", - [Binding(key=u"a", value=u"b\tc", original=Original(string=u"a=b\tc", line=1), error=False)], - ), - ( - u"a=b c", - [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c", line=1), error=False)], - ), - ( - u"a=b\u00a0 c", - [Binding(key=u"a", value=u"b\u00a0 c", original=Original(string=u"a=b\u00a0 c", line=1), error=False)], - ), - ( - u"a=b c ", - [Binding(key=u"a", value=u"b c", original=Original(string=u"a=b c ", line=1), error=False)], - ), - ( - u"a='b c '", - [Binding(key=u"a", value=u"b c ", original=Original(string=u"a='b c '", line=1), error=False)], - ), - ( - u'a="b c "', - [Binding(key=u"a", value=u"b c ", original=Original(string=u'a="b c "', line=1), error=False)], - ), - ( - u"export export_a=1", - [ - Binding(key=u"export_a", value=u"1", original=Original(string=u"export export_a=1", line=1), error=False) - ], - ), - ( - u"export port=8000", - [Binding(key=u"port", value=u"8000", original=Original(string=u"export port=8000", line=1), error=False)], - ), - (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1), error=False)]), - (u"a='b\nc'", [Binding(key=u"a", value=u"b\nc", original=Original(string=u"a='b\nc'", line=1), error=False)]), - (u'a="b\nc"', [Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"', line=1), error=False)]), - (u'a="b\\nc"', [Binding(key=u"a", value=u'b\nc', original=Original(string=u'a="b\\nc"', line=1), error=False)]), - (u"a='b\\nc'", [Binding(key=u"a", value=u'b\\nc', original=Original(string=u"a='b\\nc'", line=1), error=False)]), - (u'a="b\\"c"', [Binding(key=u"a", value=u'b"c', original=Original(string=u'a="b\\"c"', line=1), error=False)]), - (u"a='b\\'c'", [Binding(key=u"a", value=u"b'c", original=Original(string=u"a='b\\'c'", line=1), error=False)]), - (u"a=à", [Binding(key=u"a", value=u"à", original=Original(string=u"a=à", line=1), error=False)]), - (u'a="à"', [Binding(key=u"a", value=u"à", original=Original(string=u'a="à"', line=1), error=False)]), - ( - u'no_value_var', - [Binding(key=u'no_value_var', value=None, original=Original(string=u"no_value_var", line=1), error=False)], - ), - (u'a: b', [Binding(key=None, value=None, original=Original(string=u"a: b", line=1), error=True)]), - ( - u"a=b\nc=d", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), - ], - ), - ( - u"a=b\rc=d", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), - ], - ), - ( - u"a=b\r\nc=d", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\r\n", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"c=d", line=2), error=False), - ], - ), - ( - u'a=\nb=c', - [ - Binding(key=u"a", value=u'', original=Original(string=u'a=\n', line=1), error=False), - Binding(key=u"b", value=u'c', original=Original(string=u"b=c", line=2), error=False), - ] - ), - ( - u"\n\n", - [ - Binding(key=None, value=None, original=Original(string=u"\n\n", line=1), error=False), - ] - ), - ( - u"a=b\n\n", - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=None, value=None, original=Original(string=u"\n", line=2), error=False), - ] - ), - ( - u'a=b\n\nc=d', - [ - Binding(key=u"a", value=u"b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=u"c", value=u"d", original=Original(string=u"\nc=d", line=2), error=False), - ] - ), - ( - u'a="\nb=c', - [ - Binding(key=None, value=None, original=Original(string=u'a="\n', line=1), error=True), - Binding(key=u"b", value=u"c", original=Original(string=u"b=c", line=2), error=False), - ] - ), - ( - u'# comment\na="b\nc"\nd=e\n', - [ - Binding(key=None, value=None, original=Original(string=u"# comment\n", line=1), error=False), - Binding(key=u"a", value=u"b\nc", original=Original(string=u'a="b\nc"\n', line=2), error=False), - Binding(key=u"d", value=u"e", original=Original(string=u"d=e\n", line=4), error=False), - ], - ), - ( - u'a=b\n# comment 1', - [ - Binding(key="a", value="b", original=Original(string=u"a=b\n", line=1), error=False), - Binding(key=None, value=None, original=Original(string=u"# comment 1", line=2), error=False), - ], - ), - ( - u'# comment 1\n# comment 2', - [ - Binding(key=None, value=None, original=Original(string=u"# comment 1\n", line=1), error=False), - Binding(key=None, value=None, original=Original(string=u"# comment 2", line=2), error=False), - ], - ), - ( - u'uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\na=b', - [ - Binding(key=u'uglyKey[%$', - value=u'S3cr3t_P4ssw#rD', - original=Original(string=u"uglyKey[%$=\"S3cr3t_P4ssw#rD\" #\n", line=1), error=False), - Binding(key=u"a", value=u"b", original=Original(string=u'a=b', line=2), error=False), - ], - ), -]) +@pytest.mark.parametrize( + "test_input,expected", + [ + ("", []), + ( + "a=b", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b", line=1), + error=False, + ) + ], + ), + ( + "'a'=b", + [ + Binding( + key="a", + value="b", + original=Original(string="'a'=b", line=1), + error=False, + ) + ], + ), + ( + "[=b", + [ + Binding( + key="[", + value="b", + original=Original(string="[=b", line=1), + error=False, + ) + ], + ), + ( + " a = b ", + [ + Binding( + key="a", + value="b", + original=Original(string=" a = b ", line=1), + error=False, + ) + ], + ), + ( + "export a=b", + [ + Binding( + key="a", + value="b", + original=Original(string="export a=b", line=1), + error=False, + ) + ], + ), + ( + " export 'a'=b", + [ + Binding( + key="a", + value="b", + original=Original(string=" export 'a'=b", line=1), + error=False, + ) + ], + ), + ( + "# a=b", + [ + Binding( + key=None, + value=None, + original=Original(string="# a=b", line=1), + error=False, + ) + ], + ), + ( + "a=b#c", + [ + Binding( + key="a", + value="b#c", + original=Original(string="a=b#c", line=1), + error=False, + ) + ], + ), + ( + "a=b #c", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b #c", line=1), + error=False, + ) + ], + ), + ( + "a=b\t#c", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\t#c", line=1), + error=False, + ) + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\tc", + [ + Binding( + key="a", + value="b\tc", + original=Original(string="a=b\tc", line=1), + error=False, + ) + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\u00a0 c", + [ + Binding( + key="a", + value="b\u00a0 c", + original=Original(string="a=b\u00a0 c", line=1), + error=False, + ) + ], + ), + ( + "a=b c ", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c ", line=1), + error=False, + ) + ], + ), + ( + "a='b c '", + [ + Binding( + key="a", + value="b c ", + original=Original(string="a='b c '", line=1), + error=False, + ) + ], + ), + ( + 'a="b c "', + [ + Binding( + key="a", + value="b c ", + original=Original(string='a="b c "', line=1), + error=False, + ) + ], + ), + ( + "export export_a=1", + [ + Binding( + key="export_a", + value="1", + original=Original(string="export export_a=1", line=1), + error=False, + ) + ], + ), + ( + "export port=8000", + [ + Binding( + key="port", + value="8000", + original=Original(string="export port=8000", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\nc'", + [ + Binding( + key="a", + value="b\nc", + original=Original(string="a='b\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + 'a="b\\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\nc'", + [ + Binding( + key="a", + value="b\\nc", + original=Original(string="a='b\\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\\"c"', + [ + Binding( + key="a", + value='b"c', + original=Original(string='a="b\\"c"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\'c'", + [ + Binding( + key="a", + value="b'c", + original=Original(string="a='b\\'c'", line=1), + error=False, + ) + ], + ), + ( + "a=à", + [ + Binding( + key="a", + value="à", + original=Original(string="a=à", line=1), + error=False, + ) + ], + ), + ( + 'a="à"', + [ + Binding( + key="a", + value="à", + original=Original(string='a="à"', line=1), + error=False, + ) + ], + ), + ( + "no_value_var", + [ + Binding( + key="no_value_var", + value=None, + original=Original(string="no_value_var", line=1), + error=False, + ) + ], + ), + ( + "a: b", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b", line=1), + error=True, + ) + ], + ), + ( + "a=b\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\rc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\r", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\r\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\r\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=\nb=c", + [ + Binding( + key="a", + value="", + original=Original(string="a=\n", line=1), + error=False, + ), + Binding( + key="b", + value="c", + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + "\n\n", + [ + Binding( + key=None, + value=None, + original=Original(string="\n\n", line=1), + error=False, + ), + ], + ), + ( + "a=b\n\n", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="\n", line=2), + error=False, + ), + ], + ), + ( + "a=b\n\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="\nc=d", line=2), + error=False, + ), + ], + ), + ( + 'a="\nb=c', + [ + Binding( + key=None, + value=None, + original=Original(string='a="\n', line=1), + error=True, + ), + Binding( + key="b", + value="c", + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + '# comment\na="b\nc"\nd=e\n', + [ + Binding( + key=None, + value=None, + original=Original(string="# comment\n", line=1), + error=False, + ), + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"\n', line=2), + error=False, + ), + Binding( + key="d", + value="e", + original=Original(string="d=e\n", line=4), + error=False, + ), + ], + ), + ( + "a=b\n# comment 1", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="# comment 1", line=2), + error=False, + ), + ], + ), + ( + "# comment 1\n# comment 2", + [ + Binding( + key=None, + value=None, + original=Original(string="# comment 1\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="# comment 2", line=2), + error=False, + ), + ], + ), + ( + 'uglyKey[%$="S3cr3t_P4ssw#rD" #\na=b', + [ + Binding( + key="uglyKey[%$", + value="S3cr3t_P4ssw#rD", + original=Original( + string='uglyKey[%$="S3cr3t_P4ssw#rD" #\n', line=1 + ), + error=False, + ), + Binding( + key="a", + value="b", + original=Original(string="a=b", line=2), + error=False, + ), + ], + ), + ], +) def test_parse_stream(test_input, expected): result = parse_stream(io.StringIO(test_input)) diff --git a/tests/test_utils.py b/tests/test_utils.py index d691f0e7..93b8bae2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,12 +2,18 @@ def test_to_cli_string(): - assert c() == 'dotenv' - assert c(path='/etc/.env') == 'dotenv -f /etc/.env' - assert c(path='/etc/.env', action='list') == 'dotenv -f /etc/.env list' - assert c(action='list') == 'dotenv list' - assert c(action='get', key='DEBUG') == 'dotenv get DEBUG' - assert c(action='set', key='DEBUG', value='True') == 'dotenv set DEBUG True' - assert c(action='set', key='SECRET', value='=@asdfasf') == 'dotenv set SECRET =@asdfasf' - assert c(action='set', key='SECRET', value='a b') == 'dotenv set SECRET "a b"' - assert c(action='set', key='SECRET', value='a b', quote="always") == 'dotenv -q always set SECRET "a b"' + assert c() == "dotenv" + assert c(path="/etc/.env") == "dotenv -f /etc/.env" + assert c(path="/etc/.env", action="list") == "dotenv -f /etc/.env list" + assert c(action="list") == "dotenv list" + assert c(action="get", key="DEBUG") == "dotenv get DEBUG" + assert c(action="set", key="DEBUG", value="True") == "dotenv set DEBUG True" + assert ( + c(action="set", key="SECRET", value="=@asdfasf") + == "dotenv set SECRET =@asdfasf" + ) + assert c(action="set", key="SECRET", value="a b") == 'dotenv set SECRET "a b"' + assert ( + c(action="set", key="SECRET", value="a b", quote="always") + == 'dotenv -q always set SECRET "a b"' + ) diff --git a/tests/test_variables.py b/tests/test_variables.py index 86b06466..6f2b2203 100644 --- a/tests/test_variables.py +++ b/tests/test_variables.py @@ -27,7 +27,7 @@ Literal(value="e"), ], ), - ] + ], ) def test_parse_variables(value, expected): result = parse_variables(value) diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 46d3c02e..5c0fb88d 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -1,11 +1,12 @@ import os import sys -import sh import textwrap from typing import List from unittest import mock from zipfile import ZipFile +import sh + def walk_to_root(path: str): last_dir = None @@ -25,16 +26,16 @@ def __init__(self, content: str, path: str): def setup_zipfile(path, files: List[FileToAdd]): zip_file_path = path / "test.zip" dirs_init_py_added_to = set() - with ZipFile(zip_file_path, "w") as zip: + with ZipFile(zip_file_path, "w") as zipfile: for f in files: - zip.writestr(data=f.content, zinfo_or_arcname=f.path) - for dir in walk_to_root(os.path.dirname(f.path)): - if dir not in dirs_init_py_added_to: - print(os.path.join(dir, "__init__.py")) - zip.writestr( - data="", zinfo_or_arcname=os.path.join(dir, "__init__.py") + zipfile.writestr(data=f.content, zinfo_or_arcname=f.path) + for dirname in walk_to_root(os.path.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") ) - dirs_init_py_added_to.add(dir) + dirs_init_py_added_to.add(dirname) return zip_file_path diff --git a/tox.ini b/tox.ini index 057a1ae9..186b3046 100644 --- a/tox.ini +++ b/tox.ini @@ -25,10 +25,11 @@ depends = [testenv:lint] skip_install = true deps = - flake8 + ruff mypy commands = - flake8 src tests + ruff check src + ruff check tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests From 666984de9a730a54438362b1adedd09bb1e9f5c7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 12:21:50 +0530 Subject: [PATCH 28/59] Use sys.exit() instead of exit() (#568) --- src/dotenv/cli.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c43c63b5..c548aa39 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -77,7 +77,7 @@ def stream_file(path: os.PathLike) -> Iterator[IO[str]]: yield stream except OSError as exc: print(f"Error opening env file: {exc}", file=sys.stderr) - exit(2) + sys.exit(2) @cli.command(name="list") @@ -122,7 +122,7 @@ def set_value(ctx: click.Context, key: Any, value: Any) -> None: if success: click.echo(f"{key}={value}") else: - exit(1) + sys.exit(1) @cli.command() @@ -139,7 +139,7 @@ def get(ctx: click.Context, key: Any) -> None: if stored_value: click.echo(stored_value) else: - exit(1) + sys.exit(1) @cli.command() @@ -153,7 +153,7 @@ def unset(ctx: click.Context, key: Any) -> None: if success: click.echo(f"Successfully removed {key}") else: - exit(1) + sys.exit(1) @cli.command(context_settings={"ignore_unknown_options": True}) @@ -179,7 +179,7 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo("No command given.") - exit(1) + sys.exit(1) run_command(commandline, dotenv_as_dict) @@ -213,6 +213,6 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) _, _ = p.communicate() - exit(p.returncode) + sys.exit(p.returncode) else: os.execvpe(command[0], args=command, env=cmd_env) From c715d19fb88e81f04cf3506a3c2c2812621d1b46 Mon Sep 17 00:00:00 2001 From: matthewfranglen Date: Tue, 24 Jun 2025 11:03:25 +0100 Subject: [PATCH 29/59] feat: add `PYTHON_DOTENV_DISABLED` flag to disable load_dotenv (fixes #510) (#569) Co-authored-by: Saurabh Kumar --- README.md | 4 ++ src/dotenv/main.py | 15 ++++++ tests/test_main.py | 124 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/README.md b/README.md index e92949ef..7594086b 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ Optional flags: - `-o` to override existing variables. - `-v` for increased verbosity. +### 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. + ## Command-line Interface A CLI interface `dotenv` is also included, which helps you manipulate the `.env` file diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 8e6a7cf4..63fbbfcf 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -350,6 +350,12 @@ def load_dotenv( of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result to this function as `dotenv_path`. """ + if _load_dotenv_disabled(): + logger.debug( + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) + return False + if dotenv_path is None and stream is None: dotenv_path = find_dotenv() @@ -398,3 +404,12 @@ def dotenv_values( override=True, encoding=encoding, ).dict() + +def _load_dotenv_disabled() -> bool: + """ + Determine if dotenv loading has been disabled. + """ + if "PYTHON_DOTENV_DISABLED" not in os.environ: + return False + value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() + return value in {"1", "true", "t", "yes", "y"} diff --git a/tests/test_main.py b/tests/test_main.py index dfd19274..08b41cd3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -245,6 +245,130 @@ def test_load_dotenv_existing_file(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.parametrize( + "flag_value", + [ + "true", + "yes", + "1", + "t", + "y", + "True", + "Yes", + "TRUE", + "YES", + "T", + "Y", + ], +) +def test_load_dotenv_disabled(dotenv_path, flag_value): + expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value} + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is False + assert os.environ == expected_environ + + +@pytest.mark.parametrize( + "flag_value", + [ + "true", + "yes", + "1", + "t", + "y", + "True", + "Yes", + "TRUE", + "YES", + "T", + "Y", + ], +) +def test_load_dotenv_disabled_notification(dotenv_path, flag_value): + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + logger = logging.getLogger("dotenv.main") + with mock.patch.object(logger, "debug") as mock_debug: + result = dotenv.load_dotenv(dotenv_path) + + assert result is False + mock_debug.assert_called_once_with( + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) + + +@pytest.mark.parametrize( + "flag_value", + [ + "", + "false", + "no", + "0", + "f", + "n", + "False", + "No", + "FALSE", + "NO", + "F", + "N", + ], +) +def test_load_dotenv_enabled(dotenv_path, flag_value): + expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value, "a": "b"} + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + assert os.environ == expected_environ + + +@pytest.mark.parametrize( + "flag_value", + [ + "", + "false", + "no", + "0", + "f", + "n", + "False", + "No", + "FALSE", + "NO", + "F", + "N", + ], +) +def test_load_dotenv_enabled_no_notification(dotenv_path, flag_value): + with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + dotenv_path.write_text("a=b") + + logger = logging.getLogger("dotenv.main") + with mock.patch.object(logger, "debug") as mock_debug: + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + mock_debug.assert_not_called() + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_doesnt_disable_itself(dotenv_path): + dotenv_path.write_text("PYTHON_DOTENV_DISABLED=true") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + assert os.environ == {"PYTHON_DOTENV_DISABLED": "true"} + + def test_load_dotenv_no_file_verbose(): logger = logging.getLogger("dotenv.main") From 16f2bdad2ebbaae72790514cce713d2d22ab0f7c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Tue, 24 Jun 2025 15:53:52 +0530 Subject: [PATCH 30/59] Update spacing and docs --- CHANGELOG.md | 6 +++++- src/dotenv/main.py | 26 +++++++++++++++----------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0669eaed..c83661a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,13 @@ 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). +## [Unreleased] - 2025-06-24 + +- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. + ## [1.1.1] - 2025-06-24 -## Fixed +### 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) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 63fbbfcf..b6de171c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -21,6 +21,16 @@ logger = logging.getLogger(__name__) +def _load_dotenv_disabled() -> bool: + """ + Determine if dotenv loading has been disabled. + """ + if "PYTHON_DOTENV_DISABLED" not in os.environ: + return False + value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() + return value in {"1", "true", "t", "yes", "y"} + + def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: for mapping in mappings: if mapping.error: @@ -349,11 +359,14 @@ def load_dotenv( .env file with it's default parameters. If you need to change the default parameters of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result to this function as `dotenv_path`. + + If the environment variable `PYTHON_DOTENV_DISABLED` is set to a truthy value, + .env loading is disabled. """ if _load_dotenv_disabled(): logger.debug( - "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" - ) + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) return False if dotenv_path is None and stream is None: @@ -404,12 +417,3 @@ def dotenv_values( override=True, encoding=encoding, ).dict() - -def _load_dotenv_disabled() -> bool: - """ - Determine if dotenv loading has been disabled. - """ - if "PYTHON_DOTENV_DISABLED" not in os.environ: - return False - value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() - return value in {"1", "true", "t", "yes", "y"} From a2bc2b3d3099d4fb74f8fd4782f0f6a747fd0fea Mon Sep 17 00:00:00 2001 From: Naman Aarzoo <84902335+23f3001135@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:27:15 +0530 Subject: [PATCH 31/59] Added Python@3.14: Github CI & tox.ini (#579) --- .github/workflows/test.yml | 2 +- tox.ini | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc86910d..b2df31f9 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", pypy3.9, pypy3.10] + ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] steps: - uses: actions/checkout@v4 diff --git a/tox.ini b/tox.ini index 186b3046..7082d974 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ python = 3.11: py311 3.12: py312 3.13: py313, lint, manifest + 3.14: py314 pypy-3.9: pypy3 [testenv] @@ -16,11 +17,11 @@ deps = pytest-cov sh >= 2.0.2, <3 click - py{39,310,311,312,313,pypy3}: ipython + py{39,310,311,312,313,3.14,pypy3}: ipython commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} depends = - py{39,310,311,312,313},pypy3: coverage-clean - coverage-report: py{39,310,311,312,313},pypy3 + py{39,310,311,312,313,314},pypy3: coverage-clean + coverage-report: py{39,310,311,312,313,314},pypy3 [testenv:lint] skip_install = true @@ -30,6 +31,7 @@ deps = commands = ruff check src ruff check tests + mypy --python-version=3.14 src tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests From 9f722ce65c3fbb3bb789f80152dd6b36e8017e55 Mon Sep 17 00:00:00 2001 From: Tomiwa Kunle Oluwadare Date: Tue, 14 Oct 2025 11:59:18 +0100 Subject: [PATCH 32/59] docs: clarify what load_dotenv() does in README (#575) * docs: clarify what load_dotenv() does in README * docs: clarify what load_dotenv() does in README --- README.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7594086b..9582057a 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,20 @@ configurable via the environment: ```python from dotenv import load_dotenv -load_dotenv() # take environment variables +load_dotenv() # reads variables from a .env file and sets them in os.environ +``` + # Code of your application, which uses environment variables (e.g. from `os.environ` or # `os.getenv`) as if they came from the actual environment. -``` -By default, `load_dotenv` doesn't override existing environment variables and looks for a `.env` file in same directory as python script or searches for it incrementally higher up. + +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`. To configure the development environment, add a `.env` in the root directory of your project: From f288da176ff6db0bd5c1ae249453eb7ef5afbca1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:30:13 +0530 Subject: [PATCH 33/59] Bump the github-actions group across 1 directory with 2 updates (#577) Bumps the github-actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-python](https://github.com/actions/setup-python). Updates `actions/checkout` from 4 to 5 - [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/v4...v5) Updates `actions/setup-python` from 5 to 6 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67668d53..a5689dc1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.x' - name: Install dependencies diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b2df31f9..e3151bb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,10 @@ jobs: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true From 8b4e13d0ca3619f89cec4ac744ef4be6afd3fa23 Mon Sep 17 00:00:00 2001 From: Laurie O Date: Sun, 26 Oct 2025 23:14:16 +1000 Subject: [PATCH 34/59] Move project metadata and config to pyproject.toml (#583) * Build with 'build' * Declare build backend and requirements * Move project metadata and config to pyproject.toml --- .github/workflows/release.yml | 2 +- Makefile | 2 +- pyproject.toml | 61 ++++++++++++++++++++++++++++++ setup.cfg | 3 -- setup.py | 71 ----------------------------------- 5 files changed, 63 insertions(+), 76 deletions(-) create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5689dc1..59b71797 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install build twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} diff --git a/Makefile b/Makefile index e5bcb308..39f90d1e 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ release-test: sdist twine upload --repository-url https://test.pypi.org/legacy/ dist/* sdist: clean - python setup.py sdist bdist_wheel + python -m build -d dist . ls -l dist test: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..3ddd1360 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,61 @@ +[build-system] +requires = ["setuptools >= 77.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-dotenv" +description = "Read key-value pairs from a .env file and set them as environment variables" +authors = [ + {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, +] +license = "BSD-3-Clause" +keywords = [ + "environment variables", + "deployments", + "settings", + "env", + "dotenv", + "configurations", + "python", +] +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", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: PyPy", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Topic :: System :: Systems Administration", + "Topic :: Utilities", + "Environment :: Web Environment", +] + +requires-python = ">=3.9" + +dynamic = ["version", "readme"] + +[project.urls] +Source = "https://github.com/theskumar/python-dotenv" + +[project.optional-dependencies] +cli = [ + "click>=5.0", +] + +[project.scripts] +dotenv = "dotenv.__main__:cli" + +[tool.setuptools] +packages = ["dotenv"] +package-dir = {"" = "src"} +package-data = {dotenv = ["py.typed"]} + +[tool.setuptools.dynamic] +version = {attr = "dotenv.version.__version__"} +readme = {file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown"} diff --git a/setup.cfg b/setup.cfg index 60effd2b..8ab2d3af 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,9 +13,6 @@ exclude = .tox,.git,docs,venv,.venv,build check_untyped_defs = true ignore_missing_imports = true -[metadata] -description_file = README.md - [tool:pytest] testpaths = tests diff --git a/setup.py b/setup.py deleted file mode 100644 index f3d43ca1..00000000 --- a/setup.py +++ /dev/null @@ -1,71 +0,0 @@ -from setuptools import setup - - -def read_files(files): - data = [] - for file in files: - with open(file, encoding="utf-8") as f: - data.append(f.read()) - return "\n".join(data) - - -long_description = read_files(["README.md", "CHANGELOG.md"]) - -meta = {} -with open("./src/dotenv/version.py", encoding="utf-8") as f: - exec(f.read(), meta) - -setup( - name="python-dotenv", - description="Read key-value pairs from a .env file and set them as environment variables", - long_description=long_description, - long_description_content_type="text/markdown", - version=meta["__version__"], - author="Saurabh Kumar", - author_email="me+github@saurabh-kumar.com", - url="https://github.com/theskumar/python-dotenv", - keywords=[ - "environment variables", - "deployments", - "settings", - "env", - "dotenv", - "configurations", - "python", - ], - packages=["dotenv"], - package_dir={"": "src"}, - package_data={ - "dotenv": ["py.typed"], - }, - python_requires=">=3.9", - extras_require={ - "cli": [ - "click>=5.0", - ], - }, - entry_points={ - "console_scripts": [ - "dotenv=dotenv.__main__:cli", - ], - }, - license="BSD-3-Clause", - 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", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: Implementation :: PyPy", - "Intended Audience :: Developers", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Topic :: System :: Systems Administration", - "Topic :: Utilities", - "Environment :: Web Environment", - ], -) From 80cfe9f47d429190a7976bd47295f8c4527eec89 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 18:55:53 +0530 Subject: [PATCH 35/59] Fix build command --- .github/workflows/release.yml | 40 +++++++++++++++++------------------ Makefile | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59b71797..227aa679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,24 +8,24 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - make release + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + make release - - name: Publish Documentation - run: | - pip install -r requirements-docs.txt - pip install -e . - mkdocs gh-deploy --force + - name: Publish Documentation + run: | + pip install -r requirements-docs.txt + pip install -e . + mkdocs gh-deploy --force diff --git a/Makefile b/Makefile index 39f90d1e..934b07a4 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ release-test: sdist twine upload --repository-url https://test.pypi.org/legacy/ dist/* sdist: clean - python -m build -d dist . + python -m build -o dist . ls -l dist test: From 1fe11cc737ee4399e9c51d1b69b0dd858f6b4669 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:05:03 +0530 Subject: [PATCH 36/59] upadate changelog --- CHANGELOG.md | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c83661a4..f61549ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,11 @@ 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). -## [Unreleased] - 2025-06-24 +## [1.2.0] - 2025-10-26 -- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. +- Upgrade build system to use PEP 517 & PEP 518 to use `build` and `pyproject.toml` by [@EpicWink] in [#583] +- Add support for Python 3.14 by [@23f3001135] in [#579](https://github.com/theskumar/python-dotenv/pull/563) +- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. by [@matthewfranglen] in [#569] ## [1.1.1] - 2025-06-24 @@ -375,7 +377,15 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#474]: https://github.com/theskumar/python-dotenv/issues/474 [#523]: https://github.com/theskumar/python-dotenv/issues/523 [#553]: https://github.com/theskumar/python-dotenv/issues/553 +[#569]: https://github.com/theskumar/python-dotenv/issues/569 +[#583]: https://github.com/theskumar/python-dotenv/issues/583 +[@23f3001135]: https://github.com/23f3001135 +[@EpicWink]: https://github.com/EpicWink +[@Flimm]: https://github.com/Flimm +[@Nicals]: https://github.com/Nicals +[@Nougat-Waffle]: https://github.com/Nougat-Waffle +[@Qwerty-133]: https://github.com/Qwerty-133 [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky [@andrewsmith]: https://github.com/andrewsmith @@ -390,7 +400,6 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 [@eumiro]: https://github.com/eumiro -[@Flimm]: https://github.com/Flimm [@freddyaboulton]: https://github.com/freddyaboulton [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui @@ -401,13 +410,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@jctanner]: https://github.com/jctanner [@larsks]: https://github.com/@larsks [@lsmith77]: https://github.com/lsmith77 +[@matthewfranglen]: https://github.com/matthewfranglen [@mgorny]: https://github.com/mgorny [@naorlivne]: https://github.com/@naorlivne -[@Nicals]: https://github.com/Nicals -[@Nougat-Waffle]: https://github.com/Nougat-Waffle [@qnighy]: https://github.com/qnighy -[@Qwerty-133]: https://github.com/Qwerty-133 [@rabinadk1]: https://github.com/@rabinadk1 +[@randomseed42]: https://github.com/zueve [@sammck]: https://github.com/@sammck [@samwyma]: https://github.com/samwyma [@snobu]: https://github.com/snobu @@ -416,14 +424,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur +[@wrongontheinternet]: https://github.com/wrongontheinternet [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve -[@randomseed42]: https://github.com/zueve -[@wrongontheinternet]: https://github.com/wrongontheinternet -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...HEAD -[1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...1.1.1 +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...HEAD +[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 [1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 From 5bf882241c607445bf02cf5b241535d62e2b99c1 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:05:14 +0530 Subject: [PATCH 37/59] =?UTF-8?q?Bump=20version:=201.1.1=20=E2=86=92=201.2?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.cfg | 2 +- src/dotenv/version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.cfg b/setup.cfg index 8ab2d3af..608e1b02 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.1.1 +current_version = 1.2.0 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index a82b376d..c68196d1 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.2.0" From 8ed4f79d202eba582b44bdf1f5deb726dd68783d Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:11:03 +0530 Subject: [PATCH 38/59] Update docs requirements --- requirements-docs.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements-docs.txt b/requirements-docs.txt index 7f8b71f3..b09a710d 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,5 +1,5 @@ -mdx_truly_sane_lists~=1.2 -mkdocs-include-markdown-plugin~=3.3.0 -mkdocs-material~=8.2.9 -mkdocstrings[python]~=0.18.1 -mkdocs~=1.3.0 +mdx_truly_sane_lists>=1.3 +mkdocs-include-markdown-plugin>=6.0.0 +mkdocs-material>=9.5.0 +mkdocstrings[python]>=0.24.0 +mkdocs>=1.5.0 From 222ce2cc58ebc82ba78da8781269267b9f585932 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:29:30 +0530 Subject: [PATCH 39/59] Update to use trusted publisher on pypi --- .github/workflows/release.yml | 24 ++++++++++++++++-------- Makefile | 7 ------- requirements.txt | 2 +- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 227aa679..c56ca621 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,24 +5,32 @@ on: types: [created] jobs: - deploy: + publish: runs-on: ubuntu-latest + # Specifying a GitHub environment is optional, but strongly encouraged + environment: release + permissions: + # IMPORTANT: this permission is mandatory for trusted publishing + id-token: write + steps: - uses: actions/checkout@v5 + - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.x" + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install build twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - make release + pip install build + + - name: Build package distributions + run: make sdist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 - name: Publish Documentation run: | diff --git a/Makefile b/Makefile index 934b07a4..1064482f 100644 --- a/Makefile +++ b/Makefile @@ -12,13 +12,6 @@ clean-pyc: find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + -release: sdist - twine check dist/* - twine upload dist/* - -release-test: sdist - twine upload --repository-url https://test.pypi.org/legacy/ dist/* - sdist: clean python -m build -o dist . ls -l dist diff --git a/requirements.txt b/requirements.txt index 660c5dcc..d3d0199f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ pytest-cov pytest>=3.9 sh>=2 tox -twine wheel ruff +build pre-commit From 76999e741d87e958ebd74e3ae9834c0514e77a59 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:44:54 +0530 Subject: [PATCH 40/59] Move more config pyproject.toml --- .bumpversion.cfg | 6 ++++++ Makefile | 5 ++++- pyproject.toml | 28 ++++++++++++++++++++++++++++ setup.cfg | 34 ---------------------------------- tests/test_main.py | 18 +++++++++++++----- tox.ini | 9 +++++++-- 6 files changed, 58 insertions(+), 42 deletions(-) create mode 100644 .bumpversion.cfg delete mode 100644 setup.cfg diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 00000000..2dcc3175 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,6 @@ +[bumpversion] +current_version = 1.2.0 +commit = True +tag = True + +[bumpversion:file:src/dotenv/version.py] diff --git a/Makefile b/Makefile index 1064482f..718f2b2e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: clean-pyc clean-build test +.PHONY: clean-pyc clean-build test fmt clean: clean-build clean-pyc @@ -21,6 +21,9 @@ test: ruff check . pytest tests/ +fmt: + ruff format src tests + coverage: coverage run --source=dotenv --omit='*tests*' -m py.test tests/ -v --tb=native coverage report diff --git a/pyproject.toml b/pyproject.toml index 3ddd1360..f476c6f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,3 +59,31 @@ package-data = {dotenv = ["py.typed"]} [tool.setuptools.dynamic] version = {attr = "dotenv.version.__version__"} readme = {file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown"} + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] + +[tool.coverage.run] +relative_files = true +source = ["dotenv"] + +[tool.coverage.paths] +source = [ + "src/dotenv", + ".tox/*/lib/python*/site-packages/dotenv", + ".tox/pypy*/site-packages/dotenv", +] + +[tool.coverage.report] +show_missing = true +include = ["*/site-packages/dotenv/*"] +exclude_lines = [ + "if IS_TYPE_CHECKING:", + "pragma: no cover", +] + +[tool.mypy] +check_untyped_defs = true +ignore_missing_imports = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 608e1b02..00000000 --- a/setup.cfg +++ /dev/null @@ -1,34 +0,0 @@ -[bumpversion] -current_version = 1.2.0 -commit = True -tag = True - -[bumpversion:file:src/dotenv/version.py] - -[flake8] -max-line-length = 120 -exclude = .tox,.git,docs,venv,.venv,build - -[mypy] -check_untyped_defs = true -ignore_missing_imports = true - -[tool:pytest] -testpaths = tests - -[coverage:run] -relative_files = True -source = dotenv - -[coverage:paths] -source = - src/dotenv - .tox/*/lib/python*/site-packages/dotenv - .tox/pypy*/site-packages/dotenv - -[coverage:report] -show_missing = True -include = */site-packages/dotenv/* -exclude_lines = - if IS_TYPE_CHECKING: - pragma: no cover diff --git a/tests/test_main.py b/tests/test_main.py index 08b41cd3..44961117 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -263,7 +263,9 @@ def test_load_dotenv_existing_file(dotenv_path): ) def test_load_dotenv_disabled(dotenv_path, flag_value): expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value} - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") result = dotenv.load_dotenv(dotenv_path) @@ -289,7 +291,9 @@ def test_load_dotenv_disabled(dotenv_path, flag_value): ], ) def test_load_dotenv_disabled_notification(dotenv_path, flag_value): - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") logger = logging.getLogger("dotenv.main") @@ -298,7 +302,7 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): assert result is False mock_debug.assert_called_once_with( - "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" ) @@ -321,7 +325,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): ) def test_load_dotenv_enabled(dotenv_path, flag_value): expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value, "a": "b"} - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") result = dotenv.load_dotenv(dotenv_path) @@ -348,7 +354,9 @@ def test_load_dotenv_enabled(dotenv_path, flag_value): ], ) def test_load_dotenv_enabled_no_notification(dotenv_path, flag_value): - with mock.patch.dict(os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): dotenv_path.write_text("a=b") logger = logging.getLogger("dotenv.main") diff --git a/tox.ini b/tox.ini index 7082d974..38b51258 100644 --- a/tox.ini +++ b/tox.ini @@ -29,8 +29,8 @@ deps = ruff mypy commands = - ruff check src - ruff check tests + ruff check src tests + ruff format --check src tests mypy --python-version=3.14 src tests mypy --python-version=3.13 src tests mypy --python-version=3.12 src tests @@ -38,6 +38,11 @@ commands = mypy --python-version=3.10 src tests mypy --python-version=3.9 src tests +[testenv:format] +skip_install = true +deps = ruff +commands = ruff format src tests + [testenv:manifest] deps = check-manifest skip_install = true From 467ee22fccb2fb7ccda71a0d9e37c6ea3cb8d993 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 19:52:04 +0530 Subject: [PATCH 41/59] Fix test failures after moving config to pyproject.toml - Remove --cov-config setup.cfg reference in tox.ini since setup.cfg was deleted - Fix coverage report configuration by replacing 'include' with 'omit' to exclude tests - Coverage now reads configuration from pyproject.toml automatically --- pyproject.toml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f476c6f1..f8baeac1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,7 +78,7 @@ source = [ [tool.coverage.report] show_missing = true -include = ["*/site-packages/dotenv/*"] +omit = ["*/tests/*"] exclude_lines = [ "if IS_TYPE_CHECKING:", "pragma: no cover", diff --git a/tox.ini b/tox.ini index 38b51258..d5959e1e 100644 --- a/tox.ini +++ b/tox.ini @@ -18,7 +18,7 @@ deps = sh >= 2.0.2, <3 click py{39,310,311,312,313,3.14,pypy3}: ipython -commands = pytest --cov --cov-report=term-missing --cov-config setup.cfg {posargs} +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 From 3af77d3029eb717aeec0a3c25f751b6a614a6d3c Mon Sep 17 00:00:00 2001 From: Sidharth Sudhir <59572198+sidharth-sudhir@users.noreply.github.com> Date: Sun, 26 Oct 2025 10:27:02 -0400 Subject: [PATCH 42/59] Support reading .env from FIFOs (Unix) (#586) * Support reading .env from FIFOs (Unix) * handle FileNotFoundError in FIFO checks and add FIFO load test --------- Co-authored-by: Sidharth Sudhir --- src/dotenv/main.py | 20 ++++++++++++++++++-- tests/test_fifo_dotenv.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/test_fifo_dotenv.py diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b6de171c..1d6bf0b0 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -3,6 +3,7 @@ import os import pathlib import shutil +import stat import sys import tempfile from collections import OrderedDict @@ -61,7 +62,7 @@ def __init__( @contextmanager def _get_stream(self) -> Iterator[IO[str]]: - if self.dotenv_path and os.path.isfile(self.dotenv_path): + if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): with open(self.dotenv_path, encoding=self.encoding) as stream: yield stream elif self.stream is not None: @@ -325,7 +326,7 @@ def _is_debugger(): for dirname in _walk_to_root(path): check_path = os.path.join(dirname, filename) - if os.path.isfile(check_path): + if _is_file_or_fifo(check_path): return check_path if raise_error_if_not_found: @@ -417,3 +418,18 @@ def dotenv_values( override=True, encoding=encoding, ).dict() + + +def _is_file_or_fifo(path: StrPath) -> bool: + """ + Return True if `path` exists and is either a regular file or a FIFO. + """ + if os.path.isfile(path): + return True + + try: + st = os.stat(path) + except (FileNotFoundError, OSError): + return False + + return stat.S_ISFIFO(st.st_mode) diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py new file mode 100644 index 00000000..4961adce --- /dev/null +++ b/tests/test_fifo_dotenv.py @@ -0,0 +1,33 @@ +import os +import pathlib +import sys +import threading + +import pytest + +from dotenv import load_dotenv + +pytestmark = pytest.mark.skipif( + sys.platform.startswith("win"), reason="FIFOs are Unix-only" +) + + +def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch): + fifo = tmp_path / ".env" + os.mkfifo(fifo) # create named pipe + + def writer(): + with open(fifo, "w", encoding="utf-8") as w: + w.write("MY_PASSWORD=pipe-secret\n") + + t = threading.Thread(target=writer) + t.start() + + # Ensure env is clean + monkeypatch.delenv("MY_PASSWORD", raising=False) + + ok = load_dotenv(dotenv_path=str(fifo), override=True) + t.join(timeout=2) + + assert ok is True + assert os.getenv("MY_PASSWORD") == "pipe-secret" From b87807fcad6e74332c3c63a75c92ce5814fa7a55 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:02:48 +0530 Subject: [PATCH 43/59] Update changelog --- CHANGELOG.md | 9 +++++++++ Makefile | 4 ++++ README.md | 5 +---- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61549ff..1b362fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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). +## [1.2.1] - 2025-10-26 + +- Move more config to `pyproject.toml`, removed `setup.cfg` +- Add support for reading `.env` from FIFOs (Unix) by [@sidharth-sudhir] in [#586] + ## [1.2.0] - 2025-10-26 - Upgrade build system to use PEP 517 & PEP 518 to use `build` and `pyproject.toml` by [@EpicWink] in [#583] @@ -361,6 +366,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@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 @@ -379,7 +385,9 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#553]: https://github.com/theskumar/python-dotenv/issues/553 [#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 + [@23f3001135]: https://github.com/23f3001135 [@EpicWink]: https://github.com/EpicWink [@Flimm]: https://github.com/Flimm @@ -418,6 +426,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@randomseed42]: https://github.com/zueve [@sammck]: https://github.com/@sammck [@samwyma]: https://github.com/samwyma +[@sidharth-sudhir]: https://github.com/sidharth-sudhir [@snobu]: https://github.com/snobu [@techalchemy]: https://github.com/techalchemy [@theGOTOguy]: https://github.com/theGOTOguy diff --git a/Makefile b/Makefile index 718f2b2e..7433322f 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,12 @@ clean: clean-build clean-pyc clean-build: rm -fr build/ + rm -rf .mypy_cache/ + rm -rf .tox/ + rm -rf site/ rm -fr dist/ rm -fr src/*.egg-info + rm .coverage clean-pyc: find . -name '*.pyc' -exec rm -f {} + diff --git a/README.md b/README.md index 9582057a..6df13fab 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,13 @@ configurable via the environment: from dotenv import load_dotenv load_dotenv() # reads variables from a .env file and sets them in os.environ -``` - # Code of your application, which uses environment variables (e.g. from `os.environ` or # `os.getenv`) as if they came from the actual environment. - +``` 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`. From 8716196891532eeb67d24a513e8d975437f5e8b7 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:35:18 +0530 Subject: [PATCH 44/59] =?UTF-8?q?Bump=20version:=201.2.0=20=E2=86=92=201.2?= =?UTF-8?q?.1?= 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 2dcc3175..a72da630 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.0 +current_version = 1.2.1 commit = True tag = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index c68196d1..a955fdae 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.0" +__version__ = "1.2.1" From eaf2a9129ccec6febda0f741eb3bb852c3f947bd Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:37:34 +0530 Subject: [PATCH 45/59] Do not remove .coverage file --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 7433322f..78866a60 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,6 @@ clean-build: rm -rf site/ rm -fr dist/ rm -fr src/*.egg-info - rm .coverage clean-pyc: find . -name '*.pyc' -exec rm -f {} + From 85f43295ccb2d15d13da370954e5b85079f4a56c Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Sun, 26 Oct 2025 20:47:08 +0530 Subject: [PATCH 46/59] 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 47/59] 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 48/59] 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 49/59] 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 50/59] 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 51/59] 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 52/59] 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 53/59] 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 54/59] 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 55/59] 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 56/59] 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 57/59] 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 58/59] 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 59/59] 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