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 a3abd994..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 @@ -23,3 +23,9 @@ jobs: TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | make release + + - name: Publish Documentation + run: | + pip install -r requirements-docs.txt + pip install -e . + mkdocs gh-deploy --force diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2865cf85..fc86910d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,21 +5,30 @@ on: [push, pull_request] jobs: test: runs-on: ${{ matrix.os }} + strategy: + fail-fast: false max-parallel: 8 matrix: os: - ubuntu-latest - python-version: [3.5, 3.6, 3.7, 3.8, 3.9, pypy3] + python-version: + ["3.9", "3.10", "3.11", "3.12", "3.13", pypy3.9, pypy3.10] + steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - 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/.gitignore b/.gitignore index 172047ac..ba1234ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea +.vscode/ # Created by https://www.gitignore.io/api/python # Edit at https://www.gitignore.io/?templates=python 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 d1305894..c83661a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,122 @@ 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 + +* 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** + +* Drop support for python 3.7, add python 3.12-dev (#449 by [@theskumar]) +* Handle situations where the cwd does not exist. (#446 by [@jctanner]) + +## [0.21.1] - 2023-01-21 + +**Added** + +* Use Python 3.11 non-beta in CI (#438 by [@bbc2]) +* Modernize variables code (#434 by [@Nougat-Waffle]) +* Modernize main.py and parser.py code (#435 by [@Nougat-Waffle]) +* Improve conciseness of cli.py and __init__.py (#439 by [@Nougat-Waffle]) +* Improve error message for `get` and `list` commands when env file can't be opened (#441 by [@bbc2]) +* Updated License to align with BSD OSI template (#433 by [@lsmith77]) + + +**Fixed** + +* Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) +* Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) +* Fix type hint for dotenv_path var, add StrPath alias (#432 by [@eaf]) + +## [0.21.0] - 2022-09-03 + +**Added** + +* CLI: add support for invocations via 'python -m'. (#395 by [@theskumar]) +* `load_dotenv` function now returns `False`. (#388 by [@larsks]) +* CLI: add --format= option to list command. (#407 by [@sammck]) + +**Fixed** + +* Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) +* Use `open` instead of `io.open`. (#389 by [@rabinadk1]) +* Improve documentation for variables without a value (#390 by [@bbc2]) +* Add `parse_it` to Related Projects (#410 by [@naorlivne]) +* Update README.md (#415 by [@harveer07]) +* Improve documentation with direct use of MkDocs (#398 by [@bbc2]) + +## [0.20.0] - 2022-03-24 + +**Added** + +- Add `encoding` (`Optional[str]`) parameter to `get_key`, `set_key` and `unset_key`. + (#379 by [@bbc2]) + +**Fixed** + +- Use dict to specify the `entry_points` parameter of `setuptools.setup` (#376 by + [@mgorny]). +- Don't build universal wheels (#387 by [@bbc2]). + +## [0.19.2] - 2021-11-11 + +**Fixed** + +- In `set_key`, add missing newline character before new entry if necessary. (#361 by + [@bbc2]) + +## [0.19.1] - 2021-08-09 + +**Added** + +- Add support for Python 3.10. (#359 by [@theskumar]) + ## [0.19.0] - 2021-07-24 -### Changed +**Changed** - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). -### Added +**Added** - The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). @@ -22,7 +130,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.18.0] - 2021-06-20 -### Changed +**Changed** - Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in `set_key` (#330 by [@bbc2]). @@ -35,23 +143,23 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.17.1] - 2021-04-29 -### Fixed +**Fixed** - Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). ## [0.17.0] - 2021-04-02 -### Changed +**Changed** - Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). -### Added +**Added** - Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). ## [0.16.0] - 2021-03-27 -### Changed +**Changed** - The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is now `"utf-8"` instead of `None` (#306 by [@bbc2]). @@ -59,17 +167,17 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.15.0] - 2020-10-28 -### Added +**Added** - Add `--export` option to `set` to make it prepend the binding with `export` (#270 by [@jadutter]). -### Changed +**Changed** - Make `set` command create the `.env` file in the current directory if no `.env` file was found (#270 by [@jadutter]). -### Fixed +**Fixed** - Fix potentially empty expanded value for duplicate key (#260 by [@bbc2]). - Fix import error on Python 3.5.0 and 3.5.1 (#267 by [@gongqingkui]). @@ -78,30 +186,30 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.14.0] - 2020-07-03 -### Changed +**Changed** - Privilege definition in file over the environment in variable expansion (#256 by [@elbehery95]). -### Fixed +**Fixed** - Improve error message for when file isn't found (#245 by [@snobu]). - Use HTTPS URL in package meta data (#251 by [@ekohl]). ## [0.13.0] - 2020-04-16 -### Added +**Added** - Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). ## [0.12.0] - 2020-02-28 -### Changed +**Changed** - Use current working directory to find `.env` when bundled by PyInstaller (#213 by [@gergelyk]). -### Fixed +**Fixed** - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). - Fix `dotenv run` crashing on environment variables without values (#237 by [@yannham]). @@ -109,23 +217,23 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.11.0] - 2020-02-07 -### Added +**Added** - Add `interpolate` argument to `load_dotenv` and `dotenv_values` to disable interpolation (#232 by [@ulyssessouza]). -### Changed +**Changed** - Use logging instead of warnings (#231 by [@bbc2]). -### Fixed +**Fixed** - Fix installation in non-UTF-8 environments (#225 by [@altendky]). - Fix PyPI classifiers (#228 by [@bbc2]). ## [0.10.5] - 2020-01-19 -### Fixed +**Fixed** - Fix handling of malformed lines and lines without a value (#222 by [@bbc2]): - Don't print warning when key has no value. @@ -134,7 +242,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.10.4] - 2020-01-17 -### Added +**Added** - Make typing optional (#179 by [@techalchemy]). - Print a warning on malformed line (#211 by [@bbc2]). @@ -236,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 @@ -259,8 +367,15 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#172]: https://github.com/theskumar/python-dotenv/issues/172 [#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 -[@Flimm]: https://github.com/Flimm [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky [@andrewsmith]: https://github.com/andrewsmith @@ -268,24 +383,55 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@bbc2]: https://github.com/bbc2 [@befeleme]: https://github.com/befeleme [@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 [@theskumar]: https://github.com/theskumar [@ulyssessouza]: https://github.com/ulyssessouza [@venthur]: https://github.com/venthur [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve - -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...HEAD +[@randomseed42]: https://github.com/zueve +[@wrongontheinternet]: https://github.com/wrongontheinternet + +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...1.1.1 +[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 +[0.20.0]: https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0 +[0.19.2]: https://github.com/theskumar/python-dotenv/compare/v0.19.1...v0.19.2 +[0.19.1]: https://github.com/theskumar/python-dotenv/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/theskumar/python-dotenv/compare/v0.18.0...v0.19.0 [0.18.0]: https://github.com/theskumar/python-dotenv/compare/v0.17.1...v0.18.0 [0.17.1]: https://github.com/theskumar/python-dotenv/compare/v0.17.0...v0.17.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d989d87f..49840fa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,12 +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 +$ 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/LICENSE b/LICENSE index 39372fee..3a971190 100644 --- a/LICENSE +++ b/LICENSE @@ -1,78 +1,18 @@ -python-dotenv -Copyright (c) 2014, Saurabh Kumar - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of python-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -django-dotenv-rw -Copyright (c) 2013, Ted Tieken - -All rights reserved. +Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss (django-dotenv) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Original django-dotenv -Copyright (c) 2013, Jacob Kaplan-Moss - -All rights reserved. +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. +- Neither the name of django-dotenv nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT diff --git a/MANIFEST.in b/MANIFEST.in index 78e43e9b..bf0d47e6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ -include LICENSE *.md *.yml *.toml +include LICENSE *.md *.yml *.yaml *.toml include tox.ini +recursive-include docs *.md recursive-include tests *.py include .bumpversion.cfg @@ -8,4 +9,5 @@ include .coveragerc include .editorconfig include Makefile include requirements.txt +include requirements-docs.txt include src/dotenv/py.typed 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 9b56b546..7594086b 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![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](http://12factor.net/) principles. +[12-factor](https://12factor.net/) principles. - [Getting Started](#getting-started) - [Other Use Cases](#other-use-cases) @@ -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 @@ -146,6 +150,11 @@ $ dotenv set EMAIL foo@example.org $ dotenv list USER=foo EMAIL=foo@example.org +$ dotenv list --format=json +{ + "USER": "foo", + "EMAIL": "foo@example.org" +} $ dotenv run -- python foo.py ``` @@ -158,7 +167,7 @@ The format is not formally specified and still improves over time. That being s Keys can be unquoted or single-quoted. Values can be unquoted, single- or double-quoted. Spaces before and after keys, equal signs, and values are ignored. Values can be followed -by a comment. Lines can start with the `export` directive, which has no effect on their +by a comment. Lines can start with the `export` directive, which does not affect their interpretation. Allowed escape sequences: @@ -180,9 +189,23 @@ second line" FOO="first line\nsecond line" ``` +### Variable without a value + +A variable can have no value: + +```bash +FOO +``` + +It results in `dotenv_values` associating that variable name with the value `None` (e.g. +`{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores such variables. + +This shouldn't be confused with `FOO=`, in which case the variable is associated with the +empty string. + ### Variable expansion -Python-dotenv can interpolate variables using POSIX variable expansion. +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: @@ -211,6 +234,8 @@ defined in the following list: - [dump-env](https://github.com/sobolevn/dump-env) - [environs](https://github.com/sloria/environs) - [dynaconf](https://github.com/rochacbruno/dynaconf) +- [parse_it](https://github.com/naorlivne/parse_it) +- [python-decouple](https://github.com/HBNetwork/python-decouple) ## Acknowledgements @@ -222,5 +247,5 @@ people](https://github.com/theskumar/python-dotenv/graphs/contributors). [build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg [build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml [pypi_badge]: https://badge.fury.io/py/python-dotenv.svg -[pypi_link]: http://badge.fury.io/py/python-dotenv +[pypi_link]: https://badge.fury.io/py/python-dotenv [python_streams]: https://docs.python.org/3/library/io.html diff --git a/docs/changelog.md b/docs/changelog.md new file mode 120000 index 00000000..04c99a55 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/docs/contributing.md b/docs/contributing.md new file mode 120000 index 00000000..44fcc634 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 120000 index 00000000..32d46ee8 --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/docs/license.md b/docs/license.md new file mode 120000 index 00000000..ea5b6064 --- /dev/null +++ b/docs/license.md @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/docs/reference.md b/docs/reference.md new file mode 100644 index 00000000..8a3762ad --- /dev/null +++ b/docs/reference.md @@ -0,0 +1,2 @@ +# ::: dotenv + diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..ba77fa7f --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,23 @@ +site_name: python-dotenv +repo_url: https://github.com/theskumar/python-dotenv +edit_uri: "" +theme: + name: material + palette: + primary: green + features: + - toc.follow + - navigation.sections + +markdown_extensions: + - mdx_truly_sane_lists + +plugins: + - mkdocstrings + - search +nav: + - Home: index.md + - Changelog: changelog.md + - Contributing: contributing.md + - Reference: reference.md + - License: license.md diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 64b4431f..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,5 +0,0 @@ -[tool.portray] -modules = ["dotenv"] - -[tool.portray.mkdocs] -repo_url = "https://github.com/theskumar/python-dotenv" diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..7f8b71f3 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,5 @@ +mdx_truly_sane_lists~=1.2 +mkdocs-include-markdown-plugin~=3.3.0 +mkdocs-material~=8.2.9 +mkdocstrings[python]~=0.18.1 +mkdocs~=1.3.0 diff --git a/requirements.txt b/requirements.txt index 39302b21..660c5dcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,11 @@ bumpversion click -flake8>=2.2.3 ipython -mock pytest-cov pytest>=3.9 -sh>=1.09 +sh>=2 tox -types-mock -wheel twine -portray +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 a20d2498..60effd2b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,17 +1,13 @@ [bumpversion] -current_version = 0.19.0 +current_version = 1.1.1 commit = True tag = True [bumpversion:file:src/dotenv/version.py] - -[bdist_wheel] -universal = 1 - [flake8] max-line-length = 120 -exclude = .tox,.git,docs,venv,.venv +exclude = .tox,.git,docs,venv,.venv,build [mypy] check_untyped_defs = true @@ -28,14 +24,14 @@ relative_files = True source = dotenv [coverage:paths] -source = - src/dotenv - .tox/*/lib/python*/site-packages/dotenv - .tox/pypy*/site-packages/dotenv +source = + src/dotenv + .tox/*/lib/python*/site-packages/dotenv + .tox/pypy*/site-packages/dotenv [coverage:report] show_missing = True include = */site-packages/dotenv/* -exclude_lines = - if IS_TYPE_CHECKING: - pragma: no cover +exclude_lines = + if IS_TYPE_CHECKING: + pragma: no cover diff --git a/setup.py b/setup.py index 06ad2dd9..f3d43ca1 100644 --- a/setup.py +++ b/setup.py @@ -1,62 +1,71 @@ -import io from setuptools import setup def read_files(files): data = [] for file in files: - with io.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 io.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.5", + python_requires=">=3.9", extras_require={ - 'cli': ['click>=5.0', ], + "cli": [ + "click>=5.0", + ], }, - entry_points=''' - [console_scripts] - dotenv=dotenv.cli:cli - ''', - license='BSD-3-Clause', + entry_points={ + "console_scripts": [ + "dotenv=dotenv.__main__:cli", + ], + }, + license="BSD-3-Clause", classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - '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 3512d101..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('-q %s' % quote) + command.append(f"-q {quote}") if path: - command.append('-f %s' % path) + command.append(f"-f {path}") if action: command.append(action) if key: command.append(key) if value: - if ' ' in value: - command.append('"%s"' % 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/__main__.py b/src/dotenv/__main__.py new file mode 100644 index 00000000..3977f55a --- /dev/null +++ b/src/dotenv/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for cli, enables execution with `python -m dotenv`""" + +from .cli import cli + +if __name__ == "__main__": + cli() diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index b7ae24af..c548aa39 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,117 +1,175 @@ +import json import os +import shlex import sys -from subprocess import Popen -from typing import Any, Dict, List +from contextlib import contextmanager +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, get_key, set_key, unset_key +from .main import dotenv_values, set_key, unset_key from .version import __version__ +def enumerate_env() -> Optional[str]: + """ + Return a path for the ${pwd}/.env file. + + If pwd does not exist, return None. + """ + try: + cwd = os.getcwd() + except FileNotFoundError: + return None + path = os.path.join(cwd, ".env") + return path + + @click.group() -@click.option('-f', '--file', default=os.path.join(os.getcwd(), '.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 = {} - ctx.obj['QUOTE'] = quote - ctx.obj['EXPORT'] = export - ctx.obj['FILE'] = file + """This script is used to set, get or unset values from a .env file.""" + ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file} -@cli.command() +@contextmanager +def stream_file(path: os.PathLike) -> Iterator[IO[str]]: + """ + Open a file and yield the corresponding (decoded) stream. + + Exits with error code 2 if the file cannot be opened. + """ + + try: + with open(path) as stream: + yield stream + except OSError as exc: + print(f"Error opening env file: {exc}", file=sys.stderr) + sys.exit(2) + + +@cli.command(name="list") @click.pass_context -def list(ctx: click.Context) -> None: - '''Display all the stored key/value.''' - file = ctx.obj['FILE'] - if not os.path.isfile(file): - raise click.BadParameter( - 'Path "%s" does not exist.' % (file), - ctx=ctx - ) - dotenv_as_dict = dotenv_values(file) - for k, v in dotenv_as_dict.items(): - click.echo('%s=%s' % (k, v)) +@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"] + with stream_file(file) as stream: + values = dotenv_values(stream=stream) -@cli.command() + if output_format == "json": + click.echo(json.dumps(values, indent=2, sort_keys=True)) + else: + prefix = "export " if output_format == "export" else "" + for k in sorted(values): + v = values[k] + if v is not None: + if output_format in ("export", "shell"): + v = shlex.quote(v) + click.echo(f"{prefix}{k}={v}") + + +@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: - '''Store the given key/value.''' - file = ctx.obj['FILE'] - quote = ctx.obj['QUOTE'] - export = ctx.obj['EXPORT'] +@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"] success, key, value = set_key(file, key, value, quote, export) if success: - click.echo('%s=%s' % (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'] - if not os.path.isfile(file): - raise click.BadParameter( - 'Path "%s" does not exist.' % (file), - ctx=ctx - ) - stored_value = get_key(file, key) + """Retrieve the value for the given key.""" + file = ctx.obj["FILE"] + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + stored_value = values.get(key) 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'] + """Removes the given key.""" + file = ctx.obj["FILE"] + quote = ctx.obj["QUOTE"] success, key = unset_key(file, key, quote) if success: - click.echo("Successfully removed %s" % key) + 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( - 'Invalid value for \'-f\' "%s" does not exist.' % (file), - ctx=ctx + f"Invalid value for '-f' \"{file}\" does not exist.", ctx=ctx ) dotenv_as_dict = { k: v @@ -120,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 @@ -141,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 @@ -150,15 +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() - - return p.returncode + 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() - -if __name__ == "__main__": - cli() + 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 b8d0a4e0..b6de171c 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,67 +1,78 @@ 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 +# A type alias for a string path to be used for the paths in this file. +# These paths may flow to `open()` and `shutil.move()`; `shutil.move()` +# only accepts string paths, not byte paths or file descriptors. See +# https://github.com/python/typeshed/pull/6832. +StrPath = Union[str, "os.PathLike[str]"] + logger = logging.getLogger(__name__) -if sys.version_info >= (3, 6): - _PathLike = os.PathLike -else: - _PathLike = str + +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 -class DotEnv(): +class DotEnv: def __init__( self, - dotenv_path: Optional[Union[str, _PathLike]], + dotenv_path: Optional[StrPath], stream: Optional[IO[str]] = None, verbose: bool = False, - encoding: Union[None, str] = None, + encoding: Optional[str] = None, interpolate: bool = True, override: bool = True, ) -> None: - self.dotenv_path = dotenv_path # type: Optional[Union[str, _PathLike]] - self.stream = stream # type: Optional[IO[str]] - self._dict = None # type: Optional[Dict[str, Optional[str]]] - self.verbose = verbose # type: bool - self.encoding = encoding # type: Union[None, str] - self.interpolate = interpolate # type: bool - self.override = override # type: bool + self.dotenv_path: Optional[StrPath] = dotenv_path + self.stream: Optional[IO[str]] = stream + self._dict: Optional[Dict[str, Optional[str]]] = None + self.verbose: bool = verbose + self.encoding: Optional[str] = encoding + self.interpolate: bool = interpolate + self.override: bool = override @contextmanager def _get_stream(self) -> Iterator[IO[str]]: if self.dotenv_path and os.path.isfile(self.dotenv_path): - with io.open(self.dotenv_path, encoding=self.encoding) as stream: + with open(self.dotenv_path, encoding=self.encoding) as stream: yield stream elif self.stream is not None: yield self.stream 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""" @@ -71,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) @@ -87,6 +100,9 @@ def set_as_environment_variables(self) -> bool: """ Load the current dotenv as system environment variable. """ + if not self.dict(): + return False + for k, v in self.dict().items(): if k in os.environ and not self.override: continue @@ -96,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: @@ -109,38 +124,48 @@ def get(self, key: str) -> Optional[str]: return None -def get_key(dotenv_path: Union[str, _PathLike], key_to_get: str) -> Optional[str]: +def get_key( + dotenv_path: StrPath, + key_to_get: str, + encoding: Optional[str] = "utf-8", +) -> Optional[str]: """ - Gets the value of a given key from the given .env + Get the value of a given key from the given .env. - If the .env path given doesn't exist, fails + Returns `None` if the key isn't found or doesn't have a value. """ - return DotEnv(dotenv_path, verbose=True).get(key_to_get) + return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get) @contextmanager -def rewrite(path: Union[str, _PathLike]) -> Iterator[Tuple[IO[str], IO[str]]]: - try: - if not os.path.isfile(path): - with io.open(path, "w+") as source: - source.write("") - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as dest: - with io.open(path) as source: - yield (source, dest) # type: ignore - except BaseException: - if os.path.isfile(dest.name): - os.unlink(dest.name) - raise - else: +def rewrite( + path: StrPath, + encoding: Optional[str], +) -> Iterator[Tuple[IO[str], IO[str]]]: + 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 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( - dotenv_path: Union[str, _PathLike], + dotenv_path: StrPath, key_to_set: str, value_to_set: str, quote_mode: str = "always", export: bool = False, + encoding: Optional[str] = "utf-8", ) -> Tuple[Optional[bool], str, str]: """ Adds or Updates a key/value to the given .env @@ -149,11 +174,10 @@ def set_key( an orphan .env somewhere in the filesystem """ if quote_mode not in ("always", "auto", "never"): - raise ValueError("Unknown quote_mode: {}".format(quote_mode)) + 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: @@ -161,41 +185,46 @@ def set_key( else: value_out = value_to_set if export: - line_out = 'export {}={}\n'.format(key_to_set, value_out) + line_out = f"export {key_to_set}={value_out}\n" else: - line_out = "{}={}\n".format(key_to_set, value_out) + line_out = f"{key_to_set}={value_out}\n" - with rewrite(dotenv_path) as (source, dest): + with rewrite(dotenv_path, encoding=encoding) as (source, dest): replaced = False + missing_newline = False for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_set: dest.write(line_out) replaced = True else: dest.write(mapping.original.string) + missing_newline = not mapping.original.string.endswith("\n") if not replaced: + if missing_newline: + dest.write("\n") dest.write(line_out) return True, key_to_set, value_to_set def unset_key( - dotenv_path: Union[str, _PathLike], + dotenv_path: StrPath, key_to_unset: str, quote_mode: str = "always", + encoding: Optional[str] = "utf-8", ) -> Tuple[Optional[bool], str]: """ - Removes a given key from the given .env + Removes a given key from the given `.env` file. - If the .env path given doesn't exist, fails - If the given key doesn't exist in the .env, fails + If the .env path given doesn't exist, fails. + If the given key doesn't exist in the .env, fails. """ if not os.path.exists(dotenv_path): logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) return None, key_to_unset removed = False - with rewrite(dotenv_path) as (source, dest): + with rewrite(dotenv_path, encoding=encoding) as (source, dest): for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_unset: removed = True @@ -203,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 @@ -213,14 +244,14 @@ def resolve_variables( values: Iterable[Tuple[str, Optional[str]]], override: bool, ) -> Mapping[str, Optional[str]]: - new_values = {} # type: Dict[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: atoms = parse_variables(value) - env = {} # type: Dict[str, Optional[str]] + env: Dict[str, Optional[str]] = {} if override: env.update(os.environ) # type: ignore env.update(new_values) @@ -239,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) @@ -253,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: @@ -264,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__') - - if usecwd or _is_interactive() or getattr(sys, 'frozen', False): + """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__") + + 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: @@ -276,7 +315,9 @@ def _is_interactive(): frame = sys._getframe() current_file = __file__ - while frame.f_code.co_filename == current_file: + while frame.f_code.co_filename == current_file or not os.path.exists( + frame.f_code.co_filename + ): assert frame.f_back is not None frame = frame.f_back frame_filename = frame.f_code.co_filename @@ -288,13 +329,13 @@ 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( - dotenv_path: Union[str, _PathLike, None] = None, + dotenv_path: Optional[StrPath] = None, stream: Optional[IO[str]] = None, verbose: bool = False, override: bool = False, @@ -303,17 +344,31 @@ def load_dotenv( ) -> bool: """Parse a .env file and then load all the variables found as environment variables. - - *dotenv_path*: absolute or relative path to .env file. - - *stream*: Text stream (such as `io.StringIO`) with .env content, used if - `dotenv_path` is `None`. - - *verbose*: whether to output a warning the .env file is missing. Defaults to - `False`. - - *override*: whether to override the system environment variables with the variables - in `.env` file. Defaults to `False`. - - *encoding*: encoding to be used to read the file. - - If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. + Parameters: + dotenv_path: Absolute or relative path to .env file. + stream: Text stream (such as `io.StringIO`) with .env content, used if + `dotenv_path` is `None`. + verbose: Whether to output a warning the .env file is missing. + override: Whether to override the system environment variables with the variables + from the `.env` file. + encoding: Encoding to be used to read the file. + Returns: + 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 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() @@ -329,7 +384,7 @@ def load_dotenv( def dotenv_values( - dotenv_path: Union[str, _PathLike, None] = None, + dotenv_path: Optional[StrPath] = None, stream: Optional[IO[str]] = None, verbose: bool = False, interpolate: bool = True, @@ -338,14 +393,18 @@ def dotenv_values( """ Parse a .env file and return its content as a dict. - - *dotenv_path*: absolute or relative path to .env file. - - *stream*: `StringIO` object with .env content, used if `dotenv_path` is `None`. - - *verbose*: whether to output a warning the .env file is missing. Defaults to - `False`. - in `.env` file. Defaults to `False`. - - *encoding*: encoding to be used to read the file. + The returned dict will have `None` values for keys without values in the .env file. + For example, `foo=bar` results in `{"foo": "bar"}` whereas `foo` alone results in + `{"foo": None}` + + Parameters: + dotenv_path: Absolute or relative path to the .env file. + stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. + verbose: Whether to output a warning if the .env file is missing. + encoding: Encoding to be used to read the file. - If both `dotenv_path` and `stream`, `find_dotenv()` is used to find the .env file. + If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the + .env file. """ 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 398bd49a..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]: @@ -25,23 +32,16 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _single_quote_escapes = make_regex(r"\\[\\']") -Original = NamedTuple( - "Original", - [ - ("string", str), - ("line", int), - ], -) +class Original(NamedTuple): + string: str + line: int -Binding = NamedTuple( - "Binding", - [ - ("key", Optional[str]), - ("value", Optional[str]), - ("original", Original), - ("error", bool), - ], -) + +class Binding(NamedTuple): + key: Optional[str] + value: Optional[str] + original: Original + error: bool class Position: @@ -80,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) @@ -98,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) @@ -127,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) @@ -155,7 +155,7 @@ def parse_binding(reader: Reader) -> Binding: reader.read_regex(_whitespace) if reader.peek(1) == "=": reader.read_regex(_equal_sign) - value = parse_value(reader) # type: Optional[str] + value: Optional[str] = parse_value(reader) else: value = None reader.read_regex(_comment) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py index d77b700c..667f2f26 100644 --- a/src/dotenv/variables.py +++ b/src/dotenv/variables.py @@ -1,8 +1,8 @@ import re -from abc import ABCMeta +from abc import ABCMeta, abstractmethod from typing import Iterator, Mapping, Optional, Pattern -_posix_variable = re.compile( +_posix_variable: Pattern[str] = re.compile( r""" \$\{ (?P[^\}:]*) @@ -12,20 +12,18 @@ \} """, re.VERBOSE, -) # type: Pattern[str] +) -class Atom(): - __metaclass__ = ABCMeta - +class Atom(metaclass=ABCMeta): def __ne__(self, other: object) -> bool: result = self.__eq__(other) if result is NotImplemented: return NotImplemented return not result - def resolve(self, env: Mapping[str, Optional[str]]) -> str: - raise NotImplementedError + @abstractmethod + def resolve(self, env: Mapping[str, Optional[str]]) -> str: ... class Literal(Atom): @@ -33,7 +31,7 @@ def __init__(self, value: str) -> None: self.value = value def __repr__(self) -> str: - return "Literal(value={})".format(self.value) + return f"Literal(value={self.value})" def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): @@ -53,7 +51,7 @@ def __init__(self, name: str, default: Optional[str]) -> None: self.default = default def __repr__(self) -> str: - return "Variable(name={}, default={})".format(self.name, self.default) + return f"Variable(name={self.name}, default={self.default})" def __eq__(self, other: object) -> bool: if not isinstance(other, self.__class__): @@ -74,8 +72,8 @@ def parse_variables(value: str) -> Iterator[Atom]: for match in _posix_variable.finditer(value): (start, end) = match.span() - name = match.groupdict()["name"] - default = match.groupdict()["default"] + name = match["name"] + default = match["default"] if start > cursor: yield Literal(value=value[cursor:start]) diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 11ac8e1a..a82b376d 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.19.0" +__version__ = "1.1.1" diff --git a/tests/conftest.py b/tests/conftest.py index 24a82528..cc6f0f07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ def cli(): @pytest.fixture -def dotenv_file(tmp_path): - file_ = tmp_path / '.env' - file_.write_bytes(b'') - yield str(file_) +def dotenv_path(tmp_path): + path = tmp_path / ".env" + path.write_bytes(b"") + yield path diff --git a/tests/test_cli.py b/tests/test_cli.py index 223476fe..343fdb23 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,6 @@ import os +from pathlib import Path +from typing import Optional import pytest import sh @@ -8,106 +10,158 @@ from dotenv.version import __version__ -def test_list(cli, dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +@pytest.mark.parametrize( + "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"""), + ("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"""), + ), +) +def test_list( + cli, dotenv_path, output_format: Optional[str], content: str, expected: str +): + dotenv_path.write_text(content + "\n") - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'list']) + args = ["--file", dotenv_path, "list"] + if format is not None: + args.extend(["--format", output_format]) - assert (result.exit_code, result.output) == (0, result.output) + result = cli.invoke(dotenv_cli, args) + + assert (result.exit_code, result.output) == (0, expected) 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 "does not exist" in result.output + assert "Error opening env file" in result.output + + +def test_list_not_a_file(cli): + 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, "") -def test_get_existing_value(cli, dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_get_existing_value(cli, dotenv_path): + dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, '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_file): - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'a']) +def test_get_non_existent_value(cli, dotenv_path): + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) assert (result.exit_code, result.output) == (1, "") -def test_get_no_file(cli): - result = cli.invoke(dotenv_cli, ['--file', 'nx_file', 'get', 'a']) +def test_get_non_existent_file(cli): + 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"]) assert result.exit_code == 2 - assert "does not exist" in result.output + assert "Error opening env file" in result.output -def test_unset_existing_value(cli, dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_unset_existing_value(cli, dotenv_path): + dotenv_path.write_text("a=b") - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'a']) + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) assert (result.exit_code, result.output) == (0, "Successfully removed a\n") - assert open(dotenv_file, "r").read() == "" + assert dotenv_path.read_text() == "" -def test_unset_non_existent_value(cli, dotenv_file): - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'a']) +def test_unset_non_existent_value(cli, dotenv_path): + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) assert (result.exit_code, result.output) == (1, "") - assert open(dotenv_file, "r").read() == "" + assert dotenv_path.read_text() == "" @pytest.mark.parametrize( "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_file, quote_mode, variable, value, expected): +def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_file, "--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)) - assert open(dotenv_file, "r").read() == expected + assert dotenv_path.read_text() == expected @pytest.mark.parametrize( - "dotenv_file,export_mode,variable,value,expected", + "dotenv_path,export_mode,variable,value,expected", ( - (".nx_file", "true", "a", "x", "export a='x'\n"), - (".nx_file", "false", "a", "x", "a='x'\n"), - ) + (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_file, export_mode, variable, value, expected): +def test_set_export(cli, dotenv_path, export_mode, variable, value, expected): result = cli.invoke( dotenv_cli, - ["--file", dotenv_file, "--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)) - assert open(dotenv_file, "r").read() == expected + assert dotenv_path.read_text() == 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, "") @@ -120,88 +174,78 @@ def test_set_no_file(cli): def test_get_default_path(tmp_path): - sh.cd(str(tmp_path)) - with open(str(tmp_path / ".env"), "w") as f: - f.write("a=b") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") - result = sh.dotenv("get", "a") + result = sh.dotenv("get", "a") - assert result == "b\n" + assert result == "b\n" def test_run(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") - result = sh.dotenv("run", "printenv", "a") + result = sh.dotenv("run", "printenv", "a") - assert result == "b\n" + assert result == "b\n" def test_run_with_existing_variable(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv("run", "printenv", "a", _env=env) + result = sh.dotenv("run", "printenv", "a", _env=env) - assert result == "b\n" + assert result == "b\n" def test_run_with_existing_variable_not_overridden(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b") - env = dict(os.environ) - env.update({"LANG": "en_US.UTF-8", "a": "c"}) + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "a": "c"}) - result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) + result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) - assert result == "c\n" + assert result == "c\n" def test_run_with_none_value(tmp_path): - sh.cd(str(tmp_path)) - dotenv_file = str(tmp_path / ".env") - with open(dotenv_file, "w") as f: - f.write("a=b\nc") + with sh.pushd(tmp_path): + (tmp_path / ".env").write_text("a=b\nc") - result = sh.dotenv("run", "printenv", "a") + result = sh.dotenv("run", "printenv", "a") - assert result == "b\n" + assert result == "b\n" -def test_run_with_other_env(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_run_with_other_env(dotenv_path): + dotenv_path.write_text("a=b") - result = sh.dotenv("--file", dotenv_file, "run", "printenv", "a") + result = sh.dotenv("--file", dotenv_path, "run", "printenv", "a") assert result == "b\n" 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 8983bf13..f01b3ad7 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,6 +1,9 @@ import os +from unittest import mock -import mock +import pytest + +pytest.importorskip("IPython") @mock.patch.dict(os.environ, {}, clear=True) @@ -9,12 +12,12 @@ def test_ipython_existing_variable_no_override(tmp_path): dotenv_file = tmp_path / ".env" dotenv_file.write_text("a=b\n") - os.chdir(str(tmp_path)) + os.chdir(tmp_path) os.environ["a"] = "c" ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "c"} @@ -25,12 +28,12 @@ def test_ipython_existing_variable_override(tmp_path): dotenv_file = tmp_path / ".env" dotenv_file.write_text("a=b\n") - os.chdir(str(tmp_path)) + os.chdir(tmp_path) os.environ["a"] = "c" ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv -o") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "-o") assert os.environ == {"a": "b"} @@ -41,10 +44,10 @@ def test_ipython_new_variable(tmp_path): dotenv_file = tmp_path / ".env" dotenv_file.write_text("a=b\n") - os.chdir(str(tmp_path)) + os.chdir(tmp_path) ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") assert os.environ == {"a": "b"} 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 13e2791c..08b41cd3 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,8 +3,8 @@ import os import sys import textwrap +from unittest import mock -import mock import pytest import sh @@ -12,14 +12,14 @@ def test_set_key_no_file(tmp_path): - nx_file = str(tmp_path / "nx") + nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "warning"): - result = dotenv.set_key(nx_file, "foo", "bar") + result = dotenv.set_key(nx_path, "foo", "bar") assert result == (True, "foo", "bar") - assert os.path.exists(nx_file) + assert nx_path.exists() @pytest.mark.parametrize( @@ -28,135 +28,163 @@ 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"), ("a=b\nc=d", "a", "e", (True, "a", "e"), "a='e'\nc=d"), ("a=b\nc=d\ne=f", "c", "g", (True, "c", "g"), "a=b\nc='g'\ne=f"), ("a=b\n", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), + ("a=b", "c", "d", (True, "c", "d"), "a=b\nc='d'\n"), ], ) -def test_set_key(dotenv_file, before, key, value, expected, after): +def test_set_key(dotenv_path, before, key, value, expected, after): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write(before) + dotenv_path.write_text(before) with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.set_key(dotenv_file, key, value) + result = dotenv.set_key(dotenv_path, key, value) assert result == expected - assert open(dotenv_file, "r").read() == after + assert dotenv_path.read_text() == after mock_warning.assert_not_called() -def test_set_key_permission_error(dotenv_file): - os.chmod(dotenv_file, 0o000) +def test_set_key_encoding(dotenv_path): + encoding = "latin-1" - with pytest.raises(Exception): - dotenv.set_key(dotenv_file, "a", "b") + result = dotenv.set_key(dotenv_path, "a", "é", encoding=encoding) - os.chmod(dotenv_file, 0o600) - with open(dotenv_file, "r") as fp: - assert fp.read() == "" + assert result == (True, "a", "é") + assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" + + +def test_set_key_permission_error(dotenv_path): + dotenv_path.chmod(0o000) + + with pytest.raises(PermissionError): + dotenv.set_key(dotenv_path, "a", "b") + + dotenv_path.chmod(0o600) + assert dotenv_path.read_text() == "" def test_get_key_no_file(tmp_path): - nx_file = str(tmp_path / "nx") + 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: - result = dotenv.get_key(nx_file, "foo") + 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_file) + 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_file) - ], + calls=[mock.call("Key %s not found in %s.", "foo", nx_path)], ) -def test_get_key_not_found(dotenv_file): +def test_get_key_not_found(dotenv_path): logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.get_key(dotenv_file, "foo") + result = dotenv.get_key(dotenv_path, "foo") assert result is None - mock_warning.assert_called_once_with("Key %s not found in %s.", "foo", dotenv_file) + mock_warning.assert_called_once_with("Key %s not found in %s.", "foo", dotenv_path) -def test_get_key_ok(dotenv_file): +def test_get_key_ok(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("foo=bar") + dotenv_path.write_text("foo=bar") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.get_key(dotenv_file, "foo") + result = dotenv.get_key(dotenv_path, "foo") assert result == "bar" mock_warning.assert_not_called() -def test_get_key_none(dotenv_file): +def test_get_key_encoding(dotenv_path): + encoding = "latin-1" + dotenv_path.write_text("é=è", encoding=encoding) + + result = dotenv.get_key(dotenv_path, "é", encoding=encoding) + + assert result == "è" + + +def test_get_key_none(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("foo") + dotenv_path.write_text("foo") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.get_key(dotenv_file, "foo") + result = dotenv.get_key(dotenv_path, "foo") assert result is None mock_warning.assert_not_called() -def test_unset_with_value(dotenv_file): +def test_unset_with_value(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("a=b\nc=d") + dotenv_path.write_text("a=b\nc=d") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.unset_key(dotenv_file, "a") + result = dotenv.unset_key(dotenv_path, "a") assert result == (True, "a") - with open(dotenv_file, "r") as f: - assert f.read() == "c=d" + assert dotenv_path.read_text() == "c=d" mock_warning.assert_not_called() -def test_unset_no_value(dotenv_file): +def test_unset_no_value(dotenv_path): logger = logging.getLogger("dotenv.main") - with open(dotenv_file, "w") as f: - f.write("foo") + dotenv_path.write_text("foo") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.unset_key(dotenv_file, "foo") + result = dotenv.unset_key(dotenv_path, "foo") assert result == (True, "foo") - with open(dotenv_file, "r") as f: - assert f.read() == "" + assert dotenv_path.read_text() == "" mock_warning.assert_not_called() +def test_unset_encoding(dotenv_path): + encoding = "latin-1" + dotenv_path.write_text("é=x", encoding=encoding) + + result = dotenv.unset_key(dotenv_path, "é", encoding=encoding) + + assert result == (True, "é") + assert dotenv_path.read_text(encoding=encoding) == "" + + +def test_set_key_unauthorized_file(dotenv_path): + dotenv_path.chmod(0o000) + + with pytest.raises(PermissionError): + dotenv.set_key(dotenv_path, "a", "x") + + def test_unset_non_existent_file(tmp_path): - nx_file = str(tmp_path / "nx") + nx_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") with mock.patch.object(logger, "warning") as mock_warning: - result = dotenv.unset_key(nx_file, "foo") + result = dotenv.unset_key(nx_path, "foo") assert result == (None, "foo") mock_warning.assert_called_once_with( "Can't delete from %s - it doesn't exist.", - nx_file, + nx_path, ) @@ -174,27 +202,22 @@ def prepare_file_hierarchy(path): Then try to automatically `find_dotenv` starting in `child4` """ - curr_dir = path - dirs = [] - for f in ['child1', 'child2', 'child3', 'child4']: - curr_dir /= f - dirs.append(curr_dir) - curr_dir.mkdir() - - return (dirs[0], dirs[-1]) + leaf = path / "child1" / "child2" / "child3" / "child4" + leaf.mkdir(parents=True, exist_ok=True) + return leaf def test_find_dotenv_no_file_raise(tmp_path): - (root, leaf) = prepare_file_hierarchy(tmp_path) - os.chdir(str(leaf)) + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) with pytest.raises(IOError): dotenv.find_dotenv(raise_error_if_not_found=True, usecwd=True) def test_find_dotenv_no_file_no_raise(tmp_path): - (root, leaf) = prepare_file_hierarchy(tmp_path) - os.chdir(str(leaf)) + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) result = dotenv.find_dotenv(usecwd=True) @@ -202,75 +225,197 @@ def test_find_dotenv_no_file_no_raise(tmp_path): def test_find_dotenv_found(tmp_path): - (root, leaf) = prepare_file_hierarchy(tmp_path) - os.chdir(str(leaf)) - dotenv_file = root / ".env" - dotenv_file.write_bytes(b"TEST=test\n") + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) + dotenv_path = tmp_path / ".env" + dotenv_path.write_bytes(b"TEST=test\n") result = dotenv.find_dotenv(usecwd=True) - assert result == str(dotenv_file) + assert result == str(dotenv_path) @mock.patch.dict(os.environ, {}, clear=True) -def test_load_dotenv_existing_file(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_existing_file(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.load_dotenv(dotenv_file) + result = dotenv.load_dotenv(dotenv_path) assert result is True 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: - dotenv.load_dotenv('.does_not_exist', verbose=True) + result = dotenv.load_dotenv(".does_not_exist", verbose=True) - mock_info.assert_called_once_with("Python-dotenv could not find configuration file %s.", ".does_not_exist") + assert result is False + 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) -def test_load_dotenv_existing_variable_no_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_existing_variable_no_override(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.load_dotenv(dotenv_file, override=False) + result = dotenv.load_dotenv(dotenv_path, override=False) assert result is True assert os.environ == {"a": "c"} @mock.patch.dict(os.environ, {"a": "c"}, clear=True) -def test_load_dotenv_existing_variable_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_existing_variable_override(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.load_dotenv(dotenv_file, override=True) + result = dotenv.load_dotenv(dotenv_path, override=True) assert result is True assert os.environ == {"a": "b"} @mock.patch.dict(os.environ, {"a": "c"}, clear=True) -def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write('a=b\nd="${a}"') +def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): + dotenv_path.write_text('a=b\nd="${a}"') - result = dotenv.load_dotenv(dotenv_file) + result = dotenv.load_dotenv(dotenv_path) assert result is True assert os.environ == {"a": "c", "d": "c"} @mock.patch.dict(os.environ, {"a": "c"}, clear=True) -def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_file): - with open(dotenv_file, "w") as f: - f.write('a=b\nd="${a}"') +def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): + dotenv_path.write_text('a=b\nd="${a}"') - result = dotenv.load_dotenv(dotenv_file, override=True) + result = dotenv.load_dotenv(dotenv_path, override=True) assert result is True assert os.environ == {"a": "b", "d": "b"} @@ -287,11 +432,10 @@ def test_load_dotenv_string_io_utf_8(): @mock.patch.dict(os.environ, {}, clear=True) -def test_load_dotenv_file_stream(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_load_dotenv_file_stream(dotenv_path): + dotenv_path.write_text("a=b") - with open(dotenv_file, "r") as f: + with dotenv_path.open() as f: result = dotenv.load_dotenv(stream=f) assert result is True @@ -299,28 +443,29 @@ def test_load_dotenv_file_stream(dotenv_file): 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(str(tmp_path)) + """) + ) + 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_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_dotenv_values_file(dotenv_path): + dotenv_path.write_text("a=b") - result = dotenv.dotenv_values(dotenv_file) + result = dotenv.dotenv_values(dotenv_path) assert result == {"a": "b"} @@ -335,30 +480,23 @@ def test_dotenv_values_file(dotenv_file): ({"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"}), @@ -375,11 +513,10 @@ def test_dotenv_values_string_io(env, string, interpolate, expected): assert result == expected -def test_dotenv_values_file_stream(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b") +def test_dotenv_values_file_stream(dotenv_path): + dotenv_path.write_text("a=b") - with open(dotenv_file, "r") as f: + with dotenv_path.open() as f: result = dotenv.dotenv_values(stream=f) assert result == {"a": "b"} 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 new file mode 100644 index 00000000..5c0fb88d --- /dev/null +++ b/tests/test_zip_imports.py @@ -0,0 +1,102 @@ +import os +import sys +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 + current_dir = path + while last_dir != current_dir: + yield current_dir + (parent_dir, _) = os.path.split(current_dir) + last_dir, current_dir = current_dir, parent_dir + + +class FileToAdd: + def __init__(self, content: str, path: str): + self.content = content + self.path = path + + +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 zipfile: + for f in files: + zipfile.writestr(data=f.content, zinfo_or_arcname=f.path) + for dirname in walk_to_root(os.path.dirname(f.path)): + 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(dirname) + return zip_file_path + + +@mock.patch.object(sys, "path", list(sys.path)) +def test_load_dotenv_gracefully_handles_zip_imports_when_no_env_file(tmp_path): + zip_file_path = setup_zipfile( + tmp_path, + [ + FileToAdd( + content=textwrap.dedent( + """ + from dotenv import load_dotenv + + load_dotenv() + """ + ), + path="child1/child2/test.py", + ), + ], + ) + + # Should run without an error + sys.path.append(str(zip_file_path)) + import child1.child2.test # noqa + + +def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): + zip_file_path = setup_zipfile( + tmp_path, + [ + FileToAdd( + content=textwrap.dedent( + """ + from dotenv import load_dotenv + + load_dotenv() + """ + ), + path="child1/child2/test.py", + ), + ], + ) + dotenv_path = tmp_path / ".env" + dotenv_path.write_bytes(b"a=b") + code_path = tmp_path / "code.py" + code_path.write_text( + textwrap.dedent( + f""" + import os + import sys + + sys.path.append("{zip_file_path}") + + import child1.child2.test + + print(os.environ['a']) + """ + ) + ) + os.chdir(str(tmp_path)) + + result = sh.Command(sys.executable)(code_path) + + assert result == "b\n" diff --git a/tox.ini b/tox.ini index 2cd63024..186b3046 100644 --- a/tox.ini +++ b/tox.ini @@ -1,38 +1,40 @@ [tox] -envlist = lint,py{35,36,37,38,39},pypy3,manifest,coverage-report +envlist = lint,py{39,310,311,312,313},pypy3,manifest,coverage-report [gh-actions] python = - 3.5: py35, coverage-report - 3.6: py36, coverage-report - 3.7: py37, coverage-report - 3.8: py38, coverage-report - 3.9: py39, lint, manifest, coverage-report - pypy3: pypy3, coverage-report + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313, lint, manifest + pypy-3.9: pypy3 [testenv] deps = - mock - pytest - coverage - sh - click - py{35,36,37,38,39,py3}: ipython -commands = coverage run --parallel -m pytest {posargs} + 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{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 - types-mock + ruff + mypy commands = - flake8 src tests - mypy --python-version=3.9 src tests - mypy --python-version=3.8 src tests - mypy --python-version=3.7 src tests - mypy --python-version=3.6 src tests - mypy --python-version=3.5 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 @@ -48,5 +50,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage combine - coverage report + coverage report