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/CHANGELOG.md b/CHANGELOG.md index d1305894..f1afd06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,111 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.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 +119,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 +132,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 +156,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 +175,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 +206,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 +231,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 +333,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 +356,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 +372,53 @@ 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 + +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 +[1.0.1]: https://github.com/theskumar/python-dotenv/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v1.0.0 +[0.21.1]: https://github.com/theskumar/python-dotenv/compare/v0.21.0...v0.21.1 +[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..fac71bff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,3 +16,14 @@ or with [tox](https://pypi.org/project/tox/) installed: $ tox + +Documentation is published with [mkdocs](): + +```shell +$ pip install -r requirements-docs.txt +$ pip install -e . +$ 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..9c457e66 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ include LICENSE *.md *.yml *.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/README.md b/README.md index 9b56b546..e92949ef 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: @@ -146,6 +146,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 +163,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 +185,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 +230,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 +243,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..af7e1bc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,11 @@ +black~=22.3.0 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 diff --git a/setup.cfg b/setup.cfg index a20d2498..02dc0695 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,17 +1,13 @@ [bumpversion] -current_version = 0.19.0 +current_version = 1.1.0 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..7f4c631b 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -23,16 +23,16 @@ def get_cli_string( """ 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) + command.append(f'"{value}"') else: command.append(value) 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..33ae1485 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,7 +1,9 @@ +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 Any, Dict, IO, Iterator, List, Optional try: import click @@ -10,12 +12,26 @@ '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'), +@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', @@ -27,26 +43,49 @@ @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} + + +@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) + exit(2) @cli.command() @click.pass_context -def list(ctx: click.Context) -> None: - '''Display all the stored key/value.''' +@click.option('--format', default='simple', + type=click.Choice(['simple', 'json', 'shell', 'export']), + help="The format in which to display the list. Default format is simple, " + "which displays name=value without quotes.") +def list(ctx: click.Context, format: bool) -> None: + """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)) + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + if format == 'json': + click.echo(json.dumps(values, indent=2, sort_keys=True)) + else: + prefix = 'export ' if format == 'export' else '' + for k in sorted(values): + v = values[k] + if v is not None: + if format in ('export', 'shell'): + v = shlex.quote(v) + click.echo(f'{prefix}{k}={v}') @cli.command() @@ -54,13 +93,13 @@ def list(ctx: click.Context) -> None: @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.''' + """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) @@ -69,14 +108,13 @@ def set(ctx: click.Context, key: Any, value: Any) -> None: @click.pass_context @click.argument('key', required=True) def get(ctx: click.Context, key: Any) -> None: - '''Retrieve the value for the given key.''' + """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) + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + stored_value = values.get(key) if stored_value: click.echo(stored_value) else: @@ -87,12 +125,12 @@ def get(ctx: click.Context, key: Any) -> None: @click.pass_context @click.argument('key', required=True) def unset(ctx: click.Context, key: Any) -> None: - '''Removes the given key.''' + """Removes the given key.""" 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) @@ -110,7 +148,7 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: file = ctx.obj['FILE'] if not os.path.isfile(file): raise click.BadParameter( - 'Invalid value for \'-f\' "%s" does not exist.' % (file), + f'Invalid value for \'-f\' "{file}" does not exist.', ctx=ctx ) dotenv_as_dict = { @@ -122,14 +160,13 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo('No command given.') exit(1) - ret = run_command(commandline, dotenv_as_dict) - exit(ret) + run_command(commandline, dotenv_as_dict) -def run_command(command: List[str], env: Dict[str, str]) -> int: - """Run command in sub process. +def run_command(command: List[str], env: Dict[str, str]) -> None: + """Replace the current process with the specified command. - Runs the command in a sub process with the variables from `env` + Replaces the current process with the specified command and the variables from `env` added in the current environment variables. Parameters @@ -141,8 +178,8 @@ def run_command(command: List[str], env: Dict[str, str]) -> int: Returns ------- - int - The return code of the command + None + This function does not return any value. It replaces the current process with the new one. """ # copy the current environment variables and add the vales from @@ -150,15 +187,4 @@ def run_command(command: List[str], env: Dict[str, str]) -> int: cmd_env = os.environ.copy() cmd_env.update(env) - p = Popen(command, - universal_newlines=True, - bufsize=0, - shell=False, - env=cmd_env) - _, _ = p.communicate() - - return p.returncode - - -if __name__ == "__main__": - cli() + os.execvpe(command[0], args=command, env=cmd_env) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index b8d0a4e0..1848d602 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,67 +1,68 @@ 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 -logger = logging.getLogger(__name__) +# 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]"] -if sys.version_info >= (3, 6): - _PathLike = os.PathLike -else: - _PathLike = str +logger = logging.getLogger(__name__) 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 +72,9 @@ def dict(self) -> Dict[str, Optional[str]]: raw_values = self.parse() if self.interpolate: - self._dict = OrderedDict(resolve_variables(raw_values, override=self.override)) + self._dict = OrderedDict( + resolve_variables(raw_values, override=self.override) + ) else: self._dict = OrderedDict(raw_values) @@ -87,6 +90,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 +102,7 @@ def set_as_environment_variables(self) -> bool: return True def get(self, key: str) -> Optional[str]: - """ - """ + """ """ data = self.dict() if key in data: @@ -109,38 +114,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 +164,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 +175,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 +222,9 @@ def unset_key( dest.write(mapping.original.string) if not removed: - logger.warning("Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path) + logger.warning( + "Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path + ) return None, key_to_unset return removed, key_to_unset @@ -213,14 +234,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 +260,7 @@ def _walk_to_root(path: str) -> Iterator[str]: Yield directories starting from the given directory up to the root """ if not os.path.exists(path): - raise IOError('Starting path not found') + raise IOError("Starting path not found") if os.path.isfile(path): path = os.path.dirname(path) @@ -253,7 +274,7 @@ def _walk_to_root(path: str) -> Iterator[str]: def find_dotenv( - filename: str = '.env', + filename: str = ".env", raise_error_if_not_found: bool = False, usecwd: bool = False, ) -> str: @@ -264,11 +285,17 @@ def find_dotenv( """ def _is_interactive(): - """ Decide whether this is running in a REPL or IPython notebook """ - main = __import__('__main__', None, None, fromlist=['__file__']) - return not hasattr(main, '__file__') + """Decide whether this is running in a REPL or IPython notebook""" + try: + main = __import__("__main__", None, None, fromlist=["__file__"]) + except ModuleNotFoundError: + return False + return not hasattr(main, "__file__") - if usecwd or _is_interactive() or getattr(sys, 'frozen', False): + def _is_debugger(): + return sys.gettrace() is not None + + if usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False): # Should work without __file__, e.g. in REPL or IPython notebook. path = os.getcwd() else: @@ -276,7 +303,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 +317,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,16 +332,21 @@ 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 dotenv_path is None and stream is None: dotenv_path = find_dotenv() @@ -329,7 +363,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 +372,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..735f14a3 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -25,23 +25,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), - ], -) - -Binding = NamedTuple( - "Binding", - [ - ("key", Optional[str]), - ("value", Optional[str]), - ("original", Original), - ("error", bool), - ], -) +class Original(NamedTuple): + string: str + line: int + + +class Binding(NamedTuple): + key: Optional[str] + value: Optional[str] + original: Original + error: bool class Position: @@ -155,7 +148,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..6849410a 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.19.0" +__version__ = "1.1.0" diff --git a/tests/conftest.py b/tests/conftest.py index 24a82528..69193de0 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..fc309b48 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,27 +1,54 @@ import os +import sh +from pathlib import Path +from typing import Optional import pytest -import sh import dotenv from dotenv.cli import cli as dotenv_cli 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( + "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, format: Optional[str], content: str, expected: str): + dotenv_path.write_text(content + '\n') + + args = ['--file', dotenv_path, 'list'] + if format is not None: + args.extend(['--format', format]) - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'list']) + result = cli.invoke(dotenv_cli, args) - assert (result.exit_code, result.output) == (0, result.output) + assert (result.exit_code, result.output) == (0, expected) def test_list_non_existent_file(cli): 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): @@ -30,43 +57,48 @@ def test_list_no_file(cli): 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): +def test_get_non_existent_file(cli): result = cli.invoke(dotenv_cli, ['--file', 'nx_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_get_not_a_file(cli): + result = cli.invoke(dotenv_cli, ['--file', '.', 'get', 'a']) + + assert result.exit_code == 2 + 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( @@ -79,31 +111,31 @@ def test_unset_non_existent_value(cli, dotenv_file): ("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): @@ -120,68 +152,58 @@ 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" diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 8983bf13..960479ba 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,6 +1,10 @@ import os +from unittest import mock -import mock +import pytest + + +pytest.importorskip("IPython") @mock.patch.dict(os.environ, {}, clear=True) @@ -9,12 +13,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 +29,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 +45,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_main.py b/tests/test_main.py index 13e2791c..2d63eec1 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" + + result = dotenv.set_key(dotenv_path, "a", "é", encoding=encoding) + + 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(Exception): - dotenv.set_key(dotenv_file, "a", "b") + dotenv.set_key(dotenv_path, "a", "b") - os.chmod(dotenv_file, 0o600) - with open(dotenv_file, "r") as fp: - assert fp.read() == "" + 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,22 +225,21 @@ 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"} @@ -227,50 +249,49 @@ 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 +308,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 +319,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 +356,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 +389,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_zip_imports.py b/tests/test_zip_imports.py new file mode 100644 index 00000000..46d3c02e --- /dev/null +++ b/tests/test_zip_imports.py @@ -0,0 +1,101 @@ +import os +import sys +import sh +import textwrap +from typing import List +from unittest import mock +from zipfile import ZipFile + + +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 zip: + for f in files: + zip.writestr(data=f.content, zinfo_or_arcname=f.path) + for dir in walk_to_root(os.path.dirname(f.path)): + if dir not in dirs_init_py_added_to: + print(os.path.join(dir, "__init__.py")) + zip.writestr( + data="", zinfo_or_arcname=os.path.join(dir, "__init__.py") + ) + dirs_init_py_added_to.add(dir) + 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..057a1ae9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,38 +1,39 @@ [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 + flake8 + 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 + flake8 src tests + mypy --python-version=3.13 src tests + mypy --python-version=3.12 src tests + mypy --python-version=3.11 src tests + mypy --python-version=3.10 src tests + mypy --python-version=3.9 src tests [testenv:manifest] deps = check-manifest @@ -48,5 +49,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage combine - coverage report + coverage report