diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..00d4d5e4 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,18 @@ +# 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`. 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 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49e1399f..fc86910d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,24 +7,28 @@ jobs: runs-on: ${{ matrix.os }} strategy: + fail-fast: false max-parallel: 8 matrix: os: - ubuntu-latest - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12-dev", pypy3.9] + python-version: + ["3.9", "3.10", "3.11", "3.12", "3.13", 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 - with: - python-version: ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: true - - name: Install dependencies - run: - python -m pip install --upgrade pip - pip install tox tox-gh-actions + - name: Upgrade pip + run: python -m pip install --upgrade pip - - name: Test with tox - run: tox + - name: Install dependencies + run: pip install tox tox-gh-actions + + - name: Test with tox + run: tox 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/CHANGELOG.md b/CHANGELOG.md index 220d1888..c83661a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,47 @@ 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] +## [Unreleased] - 2025-06-24 + +- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. + +## [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 + +**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** + +* 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** @@ -304,7 +344,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 @@ -328,6 +368,13 @@ 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 +[#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 @@ -338,24 +385,31 @@ 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 +[@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 @@ -365,9 +419,13 @@ 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 +[@wrongontheinternet]: https://github.com/wrongontheinternet - -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.0.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 [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 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/README.md b/README.md index ddc8ba87..7594086b 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,20 +29,20 @@ 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: ```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: @@ -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 @@ -201,7 +205,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: 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 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/setup.cfg b/setup.cfg index 3fefd1f0..60effd2b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.0.0 +current_version = 1.1.1 commit = True tag = True diff --git a/setup.py b/setup.py index 8ceddf92..f3d43ca1 100644 --- a/setup.py +++ b/setup.py @@ -4,59 +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 :: 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/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 65ead461..c548aa39 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -3,21 +3,25 @@ import shlex import sys from contextlib import contextmanager -from subprocess import Popen -from typing import Any, Dict, IO, Iterator, List +from typing import IO, Any, Dict, Iterator, List, Optional + +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 from .version import __version__ -def enumerate_env(): +def enumerate_env() -> Optional[str]: """ Return a path for the ${pwd}/.env file. @@ -27,25 +31,37 @@ def enumerate_env(): 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 @@ -61,56 +77,60 @@ 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() +@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) + sys.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) @@ -119,38 +139,37 @@ def get(ctx: click.Context, key: Any) -> None: if stored_value: click.echo(stored_value) else: - exit(1) + sys.exit(1) @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}") else: - exit(1) + sys.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 @@ -159,16 +178,15 @@ 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) + click.echo("No command given.") + sys.exit(1) + 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 +198,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 +207,12 @@ 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() + 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() - return p.returncode + sys.exit(p.returncode) + else: + os.execvpe(command[0], args=command, env=cmd_env) 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/main.py b/src/dotenv/main.py index 383b79f4..b6de171c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,13 +1,13 @@ import io import logging import os +import pathlib import shutil import sys 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 @@ -16,16 +16,26 @@ # 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__) +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: 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 @@ -59,10 +69,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""" @@ -72,7 +82,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) @@ -100,8 +112,7 @@ def set_as_environment_variables(self) -> bool: return True def get(self, key: str) -> Optional[str]: - """ - """ + """ """ data = self.dict() if key in data: @@ -131,17 +142,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( @@ -161,9 +176,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: @@ -171,7 +185,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" @@ -218,7 +232,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 @@ -230,7 +246,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: @@ -254,7 +270,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) @@ -268,7 +284,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: @@ -279,11 +295,19 @@ def find_dotenv( """ def _is_interactive(): - """ Decide whether this is running in a REPL or IPython notebook """ - main = __import__('__main__', None, None, fromlist=['__file__']) - return not hasattr(main, '__file__') + """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: + 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: @@ -305,9 +329,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( @@ -332,8 +356,19 @@ 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 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" + ) + return False + if dotenv_path is None and stream is None: dotenv_path = find_dotenv() 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/src/dotenv/version.py b/src/dotenv/version.py index 5becc17c..a82b376d 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.1" 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 new file mode 100644 index 00000000..1c073471 --- /dev/null +++ b/tests/test_is_interactive.py @@ -0,0 +1,232 @@ +import builtins +import sys +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) diff --git a/tests/test_main.py b/tests/test_main.py index fd5e3903..08b41cd3 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"), @@ -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) @@ -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)], ) @@ -245,14 +245,140 @@ 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") 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 +443,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 +480,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"}), 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 fad86f73..186b3046 100644 --- a/tox.ini +++ b/tox.ini @@ -1,39 +1,40 @@ [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 + ruff + 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 + 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 + mypy --python-version=3.10 src tests + mypy --python-version=3.9 src tests [testenv:manifest] deps = check-manifest @@ -49,4 +50,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage report + coverage report