From 0b94ac0822241eb526828cf506048fb0525d5c38 Mon Sep 17 00:00:00 2001 From: Freddy Boulton Date: Mon, 22 Jan 2024 21:45:11 -0800 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] =?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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] =?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/30] 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/30] 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/30] 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/30] 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"}