diff --git a/.bumpversion.cfg b/.bumpversion.cfg index a72da630..646b9e7b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.1 +current_version = 1.2.2 commit = True tag = True diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c56ca621..dc91d369 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,11 @@ jobs: permissions: # IMPORTANT: this permission is mandatory for trusted publishing id-token: write + # Required for pushing to gh-pages branch + contents: write steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 @@ -32,8 +34,14 @@ jobs: - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - name: Publish Documentation + - name: Build Documentation run: | pip install -r requirements-docs.txt pip install -e . - mkdocs gh-deploy --force + mkdocs build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3151bb9..66056f6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,19 @@ name: Run Tests -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: runs-on: ${{ matrix.os }} + timeout-minutes: 15 strategy: fail-fast: false @@ -13,10 +22,16 @@ jobs: os: - ubuntu-latest python-version: - ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", pypy3.9, pypy3.10] + ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.11] + include: + # Windows: Test lowest and highest supported Python versions + - os: windows-latest + python-version: "3.10" + - os: windows-latest + python-version: "3.14" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b362fdd..ab35b253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ 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). +## [v1.2.2] - 2026-03-01 + +### Added + +- Support for Python 3.14, including the free-threaded (3.14t) build. (#) + +### Changed + +- The `dotenv run` command now forwards flags directly to the specified command by [@bbc2] in [#607] +- Improved documentation clarity regarding override behavior and the reference page. +- Updated PyPy support to version 3.11. +- Documentation for FIFO file support. +- Dropped Support for Python 3.9. + +### Fixed + +- Improved `set_key` and `unset_key` behavior when interacting with symlinks by [@bbc2] in [#790c5](https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311) +- Corrected the license specifier and added missing Python 3.14 classifiers in package metadata by [@JYOuyang] in [#590] + +### Breaking Changes + +- `dotenv.set_key` and `dotenv.unset_key` used to follow symlinks in some + situations. This is no longer the case. For that behavior to be restored in + all cases, `follow_symlinks=True` should be used. + +- In the CLI, `set` and `unset` used to follow symlinks in some situations. This + is no longer the case. + +- `dotenv.set_key`, `dotenv.unset_key` and the CLI commands `set` and `unset` + used to reset the file mode of the modified .env file to `0o600` in some + situations. This is no longer the case: The original mode of the file is now + preserved. Is the file needed to be created or wasn't a regular file, mode + `0o600` is used. + ## [1.2.1] - 2025-10-26 - Move more config to `pyproject.toml`, removed `setup.cfg` @@ -20,9 +54,8 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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) - +- 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 @@ -43,56 +76,56 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **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]) +- 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]) + +- 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]) +- 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]) - +- 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]) +- 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]) +- 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]) +- 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 @@ -124,16 +157,16 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). **Changed** -- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 +- Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). **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]). +os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). - The `stream` argument of `load_dotenv` and `dotenv_values` can now be a text stream (`IO[str]`), which includes values like `io.StringIO("foo")` and `open("file.env", - "r")` (#348 by [@bbc2]). +"r")` (#348 by [@bbc2]). ## [0.18.0] - 2021-06-20 @@ -271,6 +304,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fix Unicode error in Python 2, introduced in 0.10.0. ([@bbc2])([#176]) ## 0.10.1 + - Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158]) ## 0.10.0 @@ -283,7 +317,6 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Drop Python 3.3 support ([@greyli]) - Fix stderr/-out/-in redirection ([@venthur]) - ## 0.9.0 - Add `--version` parameter to cli ([@venthur]) @@ -292,81 +325,82 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 0.8.1 -- Add tests for docs ([@Flimm]) -- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) +- Add tests for docs ([@Flimm]) +- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) ## 0.8.0 -- `set_key` and `unset_key` only modified the affected file instead of - parsing and re-writing file, this causes comments and other file - entact as it is. -- Add support for `export` prefix in the line. -- Internal refractoring ([@theskumar]) -- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) +- `set_key` and `unset_key` only modified the affected file instead of + parsing and re-writing file, this causes comments and other file + entact as it is. +- Add support for `export` prefix in the line. +- Internal refractoring ([@theskumar]) +- Allow `load_dotenv` and `dotenv_values` to work with `StringIO())` ([@alanjds])([@theskumar])([#78]) ## 0.7.1 -- Remove hard dependency on iPython ([@theskumar]) +- Remove hard dependency on iPython ([@theskumar]) ## 0.7.0 -- Add support to override system environment variable via .env. - ([@milonimrod](https://github.com/milonimrod)) - ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) -- Disable ".env not found" warning by default - ([@maxkoryukov](https://github.com/maxkoryukov)) - ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) +- Add support to override system environment variable via .env. + ([@milonimrod](https://github.com/milonimrod)) + ([\#63](https://github.com/theskumar/python-dotenv/issues/63)) +- Disable ".env not found" warning by default + ([@maxkoryukov](https://github.com/maxkoryukov)) + ([\#57](https://github.com/theskumar/python-dotenv/issues/57)) ## 0.6.5 -- Add support for special characters `\`. - ([@pjona](https://github.com/pjona)) - ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) +- Add support for special characters `\`. + ([@pjona](https://github.com/pjona)) + ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) ## 0.6.4 -- Fix issue with single quotes ([@Flimm]) - ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) +- Fix issue with single quotes ([@Flimm]) + ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) ## 0.6.3 -- Handle unicode exception in setup.py - ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) +- Handle unicode exception in setup.py + ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) ## 0.6.2 -- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) -- Add iPython Support - ([@tillahoffmann](https://github.com/tillahoffmann)) +- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) +- Add iPython Support + ([@tillahoffmann](https://github.com/tillahoffmann)) ## 0.6.0 -- Drop support for Python 2.6 -- Handle escaped characters and newlines in quoted values. (Thanks - [@iameugenejo](https://github.com/iameugenejo)) -- Remove any spaces around unquoted key/value. (Thanks - [@paulochf](https://github.com/paulochf)) -- Added POSIX variable expansion. (Thanks - [@hugochinchilla](https://github.com/hugochinchilla)) +- Drop support for Python 2.6 +- Handle escaped characters and newlines in quoted values. (Thanks + [@iameugenejo](https://github.com/iameugenejo)) +- Remove any spaces around unquoted key/value. (Thanks + [@paulochf](https://github.com/paulochf)) +- Added POSIX variable expansion. (Thanks + [@hugochinchilla](https://github.com/hugochinchilla)) ## 0.5.1 -- Fix `find_dotenv` - it now start search from the file where this - function is called from. +- Fix `find_dotenv` - it now start search from the file where this + function is called from. ## 0.5.0 -- Add `find_dotenv` method that will try to find a `.env` file. - (Thanks [@isms](https://github.com/isms)) +- Add `find_dotenv` method that will try to find a `.env` file. + (Thanks [@isms](https://github.com/isms)) ## 0.4.0 -- cli: Added `-q/--quote` option to control the behaviour of quotes - around values in `.env`. (Thanks - [@hugochinchilla](https://github.com/hugochinchilla)). -- Improved test coverage. +- cli: Added `-q/--quote` option to control the behaviour of quotes + around values in `.env`. (Thanks + [@hugochinchilla](https://github.com/hugochinchilla)). +- Improved test coverage. + [#78]: https://github.com/theskumar/python-dotenv/issues/78 [#121]: https://github.com/theskumar/python-dotenv/issues/121 [#148]: https://github.com/theskumar/python-dotenv/issues/148 @@ -386,8 +420,11 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [#569]: https://github.com/theskumar/python-dotenv/issues/569 [#583]: https://github.com/theskumar/python-dotenv/issues/583 [#586]: https://github.com/theskumar/python-dotenv/issues/586 +[#590]: https://github.com/theskumar/python-dotenv/issues/590 +[#607]: https://github.com/theskumar/python-dotenv/issues/607 + [@23f3001135]: https://github.com/23f3001135 [@EpicWink]: https://github.com/EpicWink [@Flimm]: https://github.com/Flimm @@ -437,8 +474,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). [@x-yuri]: https://github.com/x-yuri [@yannham]: https://github.com/yannham [@zueve]: https://github.com/zueve - -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...HEAD +[@JYOuyang]: https://github.com/JYOuyang +[@burnout-projects]: https://github.com/burnout-projects +[@cpackham-atlnz]: https://github.com/cpackham-atlnz +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...HEAD +[1.2.2]: https://github.com/theskumar/python-dotenv/compare/v1.2.1...v1.2.2 +[1.2.1]: https://github.com/theskumar/python-dotenv/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/theskumar/python-dotenv/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/theskumar/python-dotenv/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/theskumar/python-dotenv/compare/v1.0.1...v1.1.0 diff --git a/README.md b/README.md index 6df13fab..a08d6141 100644 --- a/README.md +++ b/README.md @@ -3,19 +3,19 @@ [![Build Status][build_status_badge]][build_status_link] [![PyPI version][pypi_badge]][pypi_link] -python-dotenv reads key-value pairs from a `.env` file and can set them as environment -variables. It helps in the development of applications following the +python-dotenv reads key-value pairs from a `.env` file and can set them as +environment variables. It helps in the development of applications following the [12-factor](https://12factor.net/) principles. - [Getting Started](#getting-started) - [Other Use Cases](#other-use-cases) - * [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) - * [Parse configuration as a stream](#parse-configuration-as-a-stream) - * [Load .env files in IPython](#load-env-files-in-ipython) + - [Load configuration without altering the environment](#load-configuration-without-altering-the-environment) + - [Parse configuration as a stream](#parse-configuration-as-a-stream) + - [Load .env files in IPython](#load-env-files-in-ipython) - [Command-line Interface](#command-line-interface) - [File format](#file-format) - * [Multiline values](#multiline-values) - * [Variable expansion](#variable-expansion) + - [Multiline values](#multiline-values) + - [Variable expansion](#variable-expansion) - [Related Projects](#related-projects) - [Acknowledgements](#acknowledgements) @@ -25,13 +25,13 @@ variables. It helps in the development of applications following the pip install python-dotenv ``` -If your application takes its configuration from environment variables, like a 12-factor -application, launching it in development is not very practical because you have to set -those environment variables yourself. +If your application takes its configuration from environment variables, like a +12-factor application, launching it in development is not very practical because +you have to set those environment variables yourself. -To help you with that, you can add python-dotenv to your application to make it load the -configuration from a `.env` file when it is present (e.g. in development) while remaining -configurable via the environment: +To help you with that, you can add python-dotenv to your application to make it +load the configuration from a `.env` file when it is present (e.g. in +development) while remaining configurable via the environment: ```python from dotenv import load_dotenv @@ -46,10 +46,10 @@ By default, `load_dotenv()` will: - Look for a `.env` file in the same directory as the Python script (or higher up the directory tree). - Read each key-value pair and add it to `os.environ`. -- **Not override** an environment variable that is already set, unless you explicitly pass `override=True`. +- **Not override** existing environment variables (`override=False`). Pass `override=True` to override existing variables. -To configure the development environment, add a `.env` in the root directory of your -project: +To configure the development environment, add a `.env` in the root directory of +your project: ``` . @@ -57,7 +57,8 @@ project: └── foo.py ``` -The syntax of `.env` files supported by python-dotenv is similar to that of Bash: +The syntax of `.env` files supported by python-dotenv is similar to that of +Bash: ```bash # Development settings @@ -66,22 +67,21 @@ ADMIN_EMAIL=admin@${DOMAIN} ROOT_URL=${DOMAIN}/app ``` -If you use variables in values, ensure they are surrounded with `{` and `}`, like -`${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. +If you use variables in values, ensure they are surrounded with `{` and `}`, +like `${DOMAIN}`, as bare variables such as `$DOMAIN` are not expanded. -You will probably want to add `.env` to your `.gitignore`, especially if it contains -secrets like a password. +You will probably want to add `.env` to your `.gitignore`, especially if it +contains secrets like a password. -See the section "File format" below for more information about what you can write in a -`.env` file. +See the section "[File format](#file-format)" below for more information about what you can write in a `.env` file. ## Other Use Cases ### Load configuration without altering the environment -The function `dotenv_values` works more or less the same way as `load_dotenv`, except it -doesn't touch the environment, it just returns a `dict` with the values parsed from the -`.env` file. +The function `dotenv_values` works more or less the same way as `load_dotenv`, +except it doesn't touch the environment, it just returns a `dict` with the +values parsed from the `.env` file. ```python from dotenv import dotenv_values @@ -104,9 +104,9 @@ config = { ### Parse configuration as a stream -`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their `stream` -argument. It is thus possible to load the variables from sources other than the -filesystem (e.g. the network). +`load_dotenv` and `dotenv_values` accept [streams][python_streams] via their +`stream` argument. It is thus possible to load the variables from sources other +than the filesystem (e.g. the network). ```python from io import StringIO @@ -119,7 +119,7 @@ load_dotenv(stream=config) ### Load .env files in IPython -You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a +You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a `.env` file: ```python @@ -140,12 +140,14 @@ Optional flags: ### Disable load_dotenv -Set `PYTHON_DOTENV_DISABLED=1` to disable `load_dotenv()` from loading .env files or streams. Useful when you can't modify third-party package calls or in production. +Set `PYTHON_DOTENV_DISABLED=1` to disable `load_dotenv()` from loading .env +files or streams. Useful when you can't modify third-party package calls or in +production. ## Command-line Interface -A CLI interface `dotenv` is also included, which helps you manipulate the `.env` file -without manually opening it. +A CLI interface `dotenv` is also included, which helps you manipulate the `.env` +file without manually opening it. ```shell $ pip install "python-dotenv[cli]" @@ -166,13 +168,14 @@ Run `dotenv --help` for more information about the options and subcommands. ## File format -The format is not formally specified and still improves over time. That being said, -`.env` files should mostly look like Bash files. +The format is not formally specified and still improves over time. That being +said, `.env` files should mostly look like Bash files. Reading from FIFOs (named +pipes) on Unix systems is also supported. -Keys can be unquoted or single-quoted. Values can be unquoted, single- or double-quoted. -Spaces before and after keys, equal signs, and values are ignored. Values can be followed -by a comment. Lines can start with the `export` directive, which does not affect their -interpretation. +Keys can be unquoted or single-quoted. Values can be unquoted, single- or +double-quoted. Spaces before and after keys, equal signs, and values are +ignored. Values can be followed by a comment. Lines can start with the `export` +directive, which does not affect their interpretation. Allowed escape sequences: @@ -181,8 +184,8 @@ Allowed escape sequences: ### Multiline values -It is possible for single- or double-quoted values to span multiple lines. The following -examples are equivalent: +It is possible for single- or double-quoted values to span multiple lines. The +following examples are equivalent: ```bash FOO="first line @@ -201,26 +204,27 @@ A variable can have no value: FOO ``` -It results in `dotenv_values` associating that variable name with the value `None` (e.g. -`{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores such variables. +It results in `dotenv_values` associating that variable name with the value +`None` (e.g. `{"FOO": None}`. `load_dotenv`, on the other hand, simply ignores +such variables. -This shouldn't be confused with `FOO=`, in which case the variable is associated with the -empty string. +This shouldn't be confused with `FOO=`, in which case the variable is associated +with the empty string. ### Variable expansion python-dotenv can interpolate variables using POSIX variable expansion. -With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable is the -first of the values defined in the following list: +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable +is the first of the values defined in the following list: - Value of that variable in the `.env` file. - Value of that variable in the environment. - Default value, if provided. - Empty string. -With `load_dotenv(override=False)`, the value of a variable is the first of the values -defined in the following list: +With `load_dotenv(override=False)`, the value of a variable is the first of the +values defined in the following list: - Value of that variable in the environment. - Value of that variable in the `.env` file. @@ -229,27 +233,27 @@ defined in the following list: ## Related Projects -- [Honcho](https://github.com/nickstenning/honcho) - For managing - Procfile-based applications. -- [django-dotenv](https://github.com/jpadilla/django-dotenv) -- [django-environ](https://github.com/joke2k/django-environ) -- [django-environ-2](https://github.com/sergeyklay/django-environ-2) -- [django-configuration](https://github.com/jezdez/django-configurations) -- [dump-env](https://github.com/sobolevn/dump-env) -- [environs](https://github.com/sloria/environs) -- [dynaconf](https://github.com/rochacbruno/dynaconf) -- [parse_it](https://github.com/naorlivne/parse_it) -- [python-decouple](https://github.com/HBNetwork/python-decouple) +- [environs](https://github.com/sloria/environs) +- [Honcho](https://github.com/nickstenning/honcho) +- [dump-env](https://github.com/sobolevn/dump-env) +- [dynaconf](https://github.com/dynaconf/dynaconf) +- [parse_it](https://github.com/naorlivne/parse_it) +- [django-dotenv](https://github.com/jpadilla/django-dotenv) +- [django-environ](https://github.com/joke2k/django-environ) +- [python-decouple](https://github.com/HBNetwork/python-decouple) +- [django-configuration](https://github.com/jezdez/django-configurations) ## Acknowledgements -This project is currently maintained by [Saurabh Kumar](https://saurabh-kumar.com) and -[Bertrand Bonnefoy-Claudet](https://github.com/bbc2) and would not have been possible -without the support of these [awesome -people](https://github.com/theskumar/python-dotenv/graphs/contributors). +This project is currently maintained by [Saurabh Kumar][saurabh-homepage] and +[Bertrand Bonnefoy-Claudet][gh-bbc2] and would not have been possible without +the support of these [awesome people][contributors]. -[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg -[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml -[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg +[gh-bbc2]: https://github.com/bbc2 +[saurabh-homepage]: https://saurabh-kumar.com [pypi_link]: https://badge.fury.io/py/python-dotenv +[pypi_badge]: https://badge.fury.io/py/python-dotenv.svg [python_streams]: https://docs.python.org/3/library/io.html +[contributors]: https://github.com/theskumar/python-dotenv/graphs/contributors +[build_status_link]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml +[build_status_badge]: https://github.com/theskumar/python-dotenv/actions/workflows/test.yml/badge.svg diff --git a/mkdocs.yml b/mkdocs.yml index ba77fa7f..3d55d899 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,7 +13,14 @@ markdown_extensions: - mdx_truly_sane_lists plugins: - - mkdocstrings + - mkdocstrings: + handlers: + python: + options: + separate_signature: true + show_root_heading: true + show_symbol_type_heading: true + show_symbol_type_toc: true - search nav: - Home: index.md diff --git a/pyproject.toml b/pyproject.toml index f8baeac1..1753fd89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Read key-value pairs from a .env file and set them as environment authors = [ {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, ] -license = "BSD-3-Clause" +license = { text = "BSD-3-Clause" } keywords = [ "environment variables", "deployments", @@ -22,11 +22,11 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: PyPy", "Intended Audience :: Developers", "Intended Audience :: System Administrators", @@ -36,7 +36,7 @@ classifiers = [ "Environment :: Web Environment", ] -requires-python = ">=3.9" +requires-python = ">=3.10" dynamic = ["version", "readme"] diff --git a/requirements.txt b/requirements.txt index d3d0199f..4a9f28a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ click ipython pytest-cov pytest>=3.9 -sh>=2 tox wheel ruff diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index c548aa39..47eec047 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -114,7 +114,13 @@ def list_values(ctx: click.Context, output_format: str) -> None: @click.argument("key", required=True) @click.argument("value", required=True) def set_value(ctx: click.Context, key: Any, value: Any) -> None: - """Store the given key/value.""" + """ + Store the given key/value. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ + file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] export = ctx.obj["EXPORT"] @@ -146,7 +152,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. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] success, key = unset_key(file, key, quote) @@ -156,7 +167,13 @@ def unset(ctx: click.Context, key: Any) -> None: sys.exit(1) -@cli.command(context_settings={"ignore_unknown_options": True}) +@cli.command( + context_settings={ + "allow_extra_args": True, + "allow_interspersed_args": False, + "ignore_unknown_options": True, + } +) @click.pass_context @click.option( "--override/--no-override", @@ -164,7 +181,7 @@ def unset(ctx: click.Context, key: Any) -> None: help="Override variables from the environment file with those from the .env file.", ) @click.argument("commandline", nargs=-1, type=click.UNPROCESSED) -def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: +def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> None: """Run command with environment variables present.""" file = ctx.obj["FILE"] if not os.path.isfile(file): @@ -180,7 +197,8 @@ def run(ctx: click.Context, override: bool, commandline: List[str]) -> None: if not commandline: click.echo("No command given.") sys.exit(1) - run_command(commandline, dotenv_as_dict) + + run_command([*commandline, *ctx.args], dotenv_as_dict) def run_command(command: List[str], env: Dict[str, str]) -> None: diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 1d6bf0b0..48e5245a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -2,7 +2,6 @@ import logging import os import pathlib -import shutil import stat import sys import tempfile @@ -14,9 +13,7 @@ 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. +# These paths may flow to `open()` and `os.replace()`. StrPath = Union[str, "os.PathLike[str]"] logger = logging.getLogger(__name__) @@ -142,21 +139,54 @@ def get_key( def rewrite( path: StrPath, encoding: Optional[str], + follow_symlinks: bool = False, ) -> Iterator[Tuple[IO[str], IO[str]]]: - pathlib.Path(path).touch() + if follow_symlinks: + path = os.path.realpath(path) - with tempfile.NamedTemporaryFile(mode="w", encoding=encoding, delete=False) as dest: + try: + source: IO[str] = open(path, encoding=encoding) + try: + path_stat = os.lstat(path) + original_mode: Optional[int] = ( + stat.S_IMODE(path_stat.st_mode) + if stat.S_ISREG(path_stat.st_mode) + else None + ) + except BaseException: + source.close() + raise + except FileNotFoundError: + source = io.StringIO("") + original_mode = None + + with tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + delete=False, + prefix=".tmp_", + dir=os.path.dirname(os.path.abspath(path)), + ) as dest: + dest_path = pathlib.Path(dest.name) error = None + try: - with open(path, encoding=encoding) as source: + with source: yield (source, dest) except BaseException as err: error = err if error is None: - shutil.move(dest.name, path) + try: + if original_mode is not None: + os.chmod(dest_path, original_mode) + + os.replace(dest_path, path) + except BaseException: + dest_path.unlink(missing_ok=True) + raise else: - os.unlink(dest.name) + dest_path.unlink(missing_ok=True) raise error from None @@ -167,12 +197,16 @@ def set_key( quote_mode: str = "always", export: bool = False, encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, ) -> Tuple[Optional[bool], str, str]: """ Adds or Updates a key/value to the given .env - If the .env path given doesn't exist, fails instead of risking creating - an orphan .env somewhere in the filesystem + The target .env file is created if it doesn't exist. + + This function doesn't follow symlinks by default, to avoid accidentally + modifying a file at a potentially untrusted path. If you don't need this + protection and need symlinks to be followed, use `follow_symlinks`. """ if quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") @@ -190,7 +224,10 @@ def set_key( else: line_out = f"{key_to_set}={value_out}\n" - with rewrite(dotenv_path, encoding=encoding) as (source, dest): + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): replaced = False missing_newline = False for mapping in with_warn_for_invalid_lines(parse_stream(source)): @@ -213,19 +250,27 @@ def unset_key( key_to_unset: str, quote_mode: str = "always", encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, ) -> Tuple[Optional[bool], str]: """ 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. + + This function doesn't follow symlinks by default, to avoid accidentally + modifying a file at a potentially untrusted path. If you don't need this + protection and need symlinks to be followed, use `follow_symlinks`. """ 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, encoding=encoding) as (source, dest): + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): for mapping in with_warn_for_invalid_lines(parse_stream(source)): if mapping.key == key_to_unset: removed = True diff --git a/src/dotenv/version.py b/src/dotenv/version.py index a955fdae..bc86c944 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.1" +__version__ = "1.2.2" diff --git a/tests/test_cli.py b/tests/test_cli.py index 343fdb23..02bdb764 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,11 +3,11 @@ from typing import Optional import pytest -import sh import dotenv from dotenv.cli import cli as dotenv_cli from dotenv.version import __version__ +from tests.test_lib import check_process, run_dotenv @pytest.mark.parametrize( @@ -174,78 +174,108 @@ def test_set_no_file(cli): def test_get_default_path(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("get", "a") + result = run_dotenv(["get", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") def test_run(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b") + (tmp_path / ".env").write_text("A=x") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") def test_run_with_existing_variable(tmp_path): - 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"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "y"}) - result = sh.dotenv("run", "printenv", "a", _env=env) + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path, env=env) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") def test_run_with_existing_variable_not_overridden(tmp_path): - 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"}) + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "C"}) - result = sh.dotenv("run", "--no-override", "printenv", "a", _env=env) + result = run_dotenv( + ["run", "--no-override", "printenv", "A"], cwd=tmp_path, env=env + ) - assert result == "c\n" + check_process(result, exit_code=0, stdout="C\n") def test_run_with_none_value(tmp_path): - with sh.pushd(tmp_path): - (tmp_path / ".env").write_text("a=b\nc") + (tmp_path / ".env").write_text("A=x\nc") - result = sh.dotenv("run", "printenv", "a") + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -def test_run_with_other_env(dotenv_path): - dotenv_path.write_text("a=b") +def test_run_with_other_env(dotenv_path, tmp_path): + dotenv_path.write_text("A=x") - result = sh.dotenv("--file", dotenv_path, "run", "printenv", "a") + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "A"], + cwd=tmp_path, + ) - assert result == "b\n" + check_process(result, exit_code=0, stdout="x\n") -def test_run_without_cmd(cli): - result = cli.invoke(dotenv_cli, ["run"]) +def test_run_without_cmd(tmp_path): + result = run_dotenv(["run"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_run_with_invalid_cmd(cli): - result = cli.invoke(dotenv_cli, ["run", "i_do_not_exist"]) +def test_run_with_invalid_cmd(tmp_path): + result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path) - assert result.exit_code == 2 - assert "Invalid value for '-f'" in result.output + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr + + +def test_run_with_version(tmp_path): + result = run_dotenv(["--version"], cwd=tmp_path) + + check_process(result, exit_code=0) + assert result.stdout.strip().endswith(__version__) -def test_run_with_version(cli): - result = cli.invoke(dotenv_cli, ["--version"]) +def test_run_with_command_flags(dotenv_path, tmp_path): + """ + Check that command flags passed after `dotenv run` are not interpreted. + + Here, we want to run `printenv --version`, not `dotenv --version`. + """ + + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, + ) + + check_process(result, exit_code=0) + assert result.stdout.strip().startswith("printenv ") + + +def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): + """ + Check that dotenv flags supersede command flags. + """ + + result = run_dotenv( + ["--version", "--file", str(dotenv_path), "run", "printenv", "--version"], + cwd=tmp_path, + ) - assert result.exit_code == 0 - assert result.output.strip().endswith(__version__) + check_process(result, exit_code=0) + assert result.stdout.strip().startswith("dotenv, version") diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py index 4961adce..2aa31779 100644 --- a/tests/test_fifo_dotenv.py +++ b/tests/test_fifo_dotenv.py @@ -7,9 +7,7 @@ from dotenv import load_dotenv -pytestmark = pytest.mark.skipif( - sys.platform.startswith("win"), reason="FIFOs are Unix-only" -) +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="FIFOs are Unix-only") def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch): diff --git a/tests/test_ipython.py b/tests/test_ipython.py index f01b3ad7..6eda086b 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -1,4 +1,5 @@ import os +import sys from unittest import mock import pytest @@ -6,6 +7,9 @@ pytest.importorskip("IPython") +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_no_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -22,6 +26,9 @@ def test_ipython_existing_variable_no_override(tmp_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_existing_variable_override(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed @@ -38,6 +45,9 @@ def test_ipython_existing_variable_override(tmp_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_ipython_new_variable(tmp_path): from IPython.terminal.embed import InteractiveShellEmbed diff --git a/tests/test_lib.py b/tests/test_lib.py new file mode 100644 index 00000000..eb9d5204 --- /dev/null +++ b/tests/test_lib.py @@ -0,0 +1,46 @@ +import subprocess +from pathlib import Path +from typing import Sequence + + +def run_dotenv( + args: Sequence[str], + cwd: str | Path | None = None, + env: dict | None = None, +) -> subprocess.CompletedProcess: + """ + Run the `dotenv` CLI in a subprocess with the given arguments. + """ + + process = subprocess.run( + ["dotenv", *args], + capture_output=True, + text=True, + cwd=cwd, + env=env, + ) + + return process + + +def check_process( + process: subprocess.CompletedProcess, + exit_code: int, + stdout: str | None = None, +): + """ + Check that the process completed with the expected exit code and output. + + This provides better error messages than directly checking the attributes. + """ + + assert process.returncode == exit_code, ( + f"Unexpected exit code {process.returncode} (expected {exit_code})\n" + f"stdout:\n{process.stdout}\n" + f"stderr:\n{process.stderr}" + ) + + if stdout is not None: + assert process.stdout == stdout, ( + f"Unexpected output: {process.stdout.strip()!r} (expected {stdout!r})" + ) diff --git a/tests/test_main.py b/tests/test_main.py index 44961117..50703af0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,12 +1,13 @@ import io import logging import os +import stat +import subprocess import sys import textwrap from unittest import mock import pytest -import sh import dotenv @@ -61,13 +62,106 @@ def test_set_key_encoding(dotenv_path): assert dotenv_path.read_text(encoding=encoding) == "a='é'\n" +@pytest.mark.skipif( + sys.platform == "win32", reason="file mode bits behave differently on Windows" +) +def test_set_key_preserves_file_mode(dotenv_path): + dotenv_path.write_text("a=x\n") + dotenv_path.chmod(0o640) + mode_before = stat.S_IMODE(dotenv_path.stat().st_mode) + + dotenv.set_key(dotenv_path, "a", "y") + + mode_after = stat.S_IMODE(dotenv_path.stat().st_mode) + assert mode_before == mode_after + + +def test_rewrite_closes_file_handle_on_lstat_failure(tmp_path): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("a=x\n") + real_open = open + opened_handles = [] + + def tracking_open(*args, **kwargs): + handle = real_open(*args, **kwargs) + opened_handles.append(handle) + return handle + + with mock.patch("dotenv.main.os.lstat", side_effect=FileNotFoundError): + with mock.patch("dotenv.main.open", side_effect=tracking_open): + dotenv.set_key(dotenv_path, "a", "x") + + assert opened_handles, "expected at least one file to be opened" + assert all(handle.closed for handle in opened_handles) + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_symlink_to_existing_file(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "y") + + assert target.read_text() == "a=x\n" + assert not symlink.is_symlink() + assert "a='y'" in symlink.read_text() + assert stat.S_IMODE(symlink.stat().st_mode) == 0o600 + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_symlink_to_missing_file(tmp_path): + target = tmp_path / "nx" + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "x") + + assert not target.exists() + assert not symlink.is_symlink() + assert symlink.read_text() == "a='x'\n" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_set_key_follow_symlinks(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.set_key(symlink, "a", "y", follow_symlinks=True) + + assert target.read_text() == "a='y'\n" + assert symlink.is_symlink() + + +@pytest.mark.skipif( + sys.platform != "win32" and os.geteuid() == 0, + reason="Root user can access files even with 000 permissions.", +) def test_set_key_permission_error(dotenv_path): - dotenv_path.chmod(0o000) + if sys.platform == "win32": + # On Windows, make file read-only + dotenv_path.chmod(stat.S_IREAD) + else: + # On Unix, remove all permissions + dotenv_path.chmod(0o000) with pytest.raises(PermissionError): dotenv.set_key(dotenv_path, "a", "b") - dotenv_path.chmod(0o600) + # Restore permissions + if sys.platform == "win32": + dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD) + else: + dotenv_path.chmod(0o600) assert dotenv_path.read_text() == "" @@ -167,13 +261,6 @@ def test_unset_encoding(dotenv_path): 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_path = tmp_path / "nx" logger = logging.getLogger("dotenv.main") @@ -188,6 +275,54 @@ def test_unset_non_existent_file(tmp_path): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_symlink_to_existing_file(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=x\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.unset_key(symlink, "a") + + assert target.read_text() == "a=x\n" + assert not symlink.is_symlink() + assert symlink.read_text() == "" + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_symlink_to_missing_file(tmp_path): + target = tmp_path / "nx" + symlink = tmp_path / ".env" + symlink.symlink_to(target) + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(symlink, "a") + + assert result == (None, "a") + assert symlink.is_symlink() + mock_warning.assert_called_once() + + +@pytest.mark.skipif( + sys.platform == "win32", reason="symlinks require elevated privileges on Windows" +) +def test_unset_key_follow_symlinks(tmp_path): + target = tmp_path / "target.env" + target.write_text("a=b\n") + symlink = tmp_path / ".env" + symlink.symlink_to(target) + + dotenv.unset_key(symlink, "a", follow_symlinks=True) + + assert target.read_text() == "" + assert symlink.is_symlink() + + def prepare_file_hierarchy(path): """ Create a temporary folder structure like the following: @@ -235,6 +370,9 @@ def test_find_dotenv_found(tmp_path): assert result == str(dotenv_path) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_existing_file(dotenv_path): dotenv_path.write_text("a=b") @@ -306,6 +444,9 @@ def test_load_dotenv_disabled_notification(dotenv_path, flag_value): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "flag_value", [ @@ -389,6 +530,9 @@ def test_load_dotenv_no_file_verbose(): ) +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_no_override(dotenv_path): dotenv_path.write_text("a=b") @@ -399,6 +543,9 @@ def test_load_dotenv_existing_variable_no_override(dotenv_path): assert os.environ == {"a": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_existing_variable_override(dotenv_path): dotenv_path.write_text("a=b") @@ -409,6 +556,9 @@ def test_load_dotenv_existing_variable_override(dotenv_path): assert os.environ == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -419,6 +569,9 @@ def test_load_dotenv_redefine_var_used_in_file_no_override(dotenv_path): assert os.environ == {"a": "c", "d": "c"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {"a": "c"}, clear=True) def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): dotenv_path.write_text('a=b\nd="${a}"') @@ -429,6 +582,9 @@ def test_load_dotenv_redefine_var_used_in_file_with_override(dotenv_path): assert os.environ == {"a": "b", "d": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_string_io_utf_8(): stream = io.StringIO("a=à") @@ -439,6 +595,9 @@ def test_load_dotenv_string_io_utf_8(): assert os.environ == {"a": "à"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @mock.patch.dict(os.environ, {}, clear=True) def test_load_dotenv_file_stream(dotenv_path): dotenv_path.write_text("a=b") @@ -465,9 +624,14 @@ def test_load_dotenv_in_current_dir(tmp_path): ) os.chdir(tmp_path) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + text=True, + check=True, + ) - assert result == "b\n" + assert result.stdout == "b\n" def test_dotenv_values_file(dotenv_path): @@ -478,6 +642,9 @@ def test_dotenv_values_file(dotenv_path): assert result == {"a": "b"} +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) @pytest.mark.parametrize( "env,string,interpolate,expected", [ diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py index 5c0fb88d..6a263502 100644 --- a/tests/test_zip_imports.py +++ b/tests/test_zip_imports.py @@ -1,19 +1,19 @@ import os +import posixpath +import subprocess 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) + parent_dir = posixpath.dirname(current_dir) last_dir, current_dir = current_dir, parent_dir @@ -29,12 +29,11 @@ def setup_zipfile(path, files: List[FileToAdd]): 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)): + for dirname in walk_to_root(posixpath.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") - ) + init_path = posixpath.join(dirname, "__init__.py") + print(f"setup_zipfile: {init_path}") + zipfile.writestr(data="", zinfo_or_arcname=init_path) dirs_init_py_added_to.add(dirname) return zip_file_path @@ -79,24 +78,32 @@ def test_load_dotenv_outside_zip_file_when_called_in_zipfile(tmp_path): ], ) dotenv_path = tmp_path / ".env" - dotenv_path.write_bytes(b"a=b") + dotenv_path.write_bytes(b"A=x") code_path = tmp_path / "code.py" code_path.write_text( textwrap.dedent( f""" - import os - import sys + import os + import sys - sys.path.append("{zip_file_path}") + sys.path.append({str(zip_file_path)!r}) - import child1.child2.test + import child1.child2.test - print(os.environ['a']) - """ + print(os.environ['A']) + """ ) ) - os.chdir(str(tmp_path)) - result = sh.Command(sys.executable)(code_path) + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + check=True, + cwd=tmp_path, + text=True, + env={ + k: v for k, v in os.environ.items() if k.upper() != "A" + }, # env without 'A' + ) - assert result == "b\n" + assert result.stdout == "x\n" diff --git a/tox.ini b/tox.ini index d5959e1e..6d1f25f8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,26 @@ [tox] -envlist = lint,py{39,310,311,312,313},pypy3,manifest,coverage-report +envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313, lint, manifest 3.14: py314 - pypy-3.9: pypy3 + 3.14t: py314t + pypy-3.11: pypy3 [testenv] deps = pytest pytest-cov - sh >= 2.0.2, <3 click - py{39,310,311,312,313,3.14,pypy3}: ipython + py{310,311,312,313,314,314t,pypy3}: ipython commands = pytest --cov --cov-report=term-missing {posargs} depends = - py{39,310,311,312,313,314},pypy3: coverage-clean - coverage-report: py{39,310,311,312,313,314},pypy3 + py{310,311,312,313,314,314t},pypy3: coverage-clean + coverage-report: py{310,311,312,313,314,314t},pypy3 [testenv:lint] skip_install = true @@ -36,7 +35,7 @@ commands = mypy --python-version=3.12 src tests mypy --python-version=3.11 src tests mypy --python-version=3.10 src tests - mypy --python-version=3.9 src tests + [testenv:format] skip_install = true