diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 0c7713ca..aa28bcfd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,7 +1,6 @@ [bumpversion] -current_version = 0.10.1 +current_version = 1.2.4 commit = True tag = True -[bumpversion:file:dotenv/version.py] - +[bumpversion:file:src/dotenv/version.py] diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 18c6ca7f..00000000 --- a/.coveragerc +++ /dev/null @@ -1,11 +0,0 @@ -[run] -source = dotenv - -[paths] -source = - src/dotenv - .tox/*/lib/python*/site-packages/dotenv - .tox/pypy*/site-packages/dotenv - -[report] -show_missing = True 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..3137ade5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +version: 2 +updates: + # Keep GitHub Actions pinned-by-SHA references up to date. Dependabot + # rewrites the SHA and the trailing `# vX.Y.Z` version comment together. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: ci + include: scope + groups: + github-actions: + patterns: + - "*" + labels: + - dependencies + - github-actions + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + labels: + - dependencies + - python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1e455693 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,50 @@ +name: Upload Python Package + +on: + release: + types: [created] + +# Least-privilege default: jobs are read-only unless they opt into more below. +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: "3.x" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package distributions + run: make sdist + + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + + - name: Build Documentation + run: | + pip install -r requirements-docs.txt + pip install -e . + mkdocs build + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./site diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..7a3c535a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,63 @@ +name: Run Tests + +on: + push: + branches: + - main + pull_request: + +# Least-privilege default token for every job in this workflow. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + + strategy: + fail-fast: false + max-parallel: 8 + matrix: + os: + - ubuntu-latest + python-version: + ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15", "3.15t", pypy3.11] + include: + # Windows: Test lowest and highest supported Python versions + - os: windows-latest + python-version: "3.10" + - os: windows-latest + python-version: "3.15" + + env: + # Select the tox envs for this matrix entry (see `[gh]` in `tox.ini`). + TOX_GH_MAJOR_MINOR: ${{ matrix.python-version }} + # Use uv-managed interpreters everywhere. + UV_PYTHON_PREFERENCE: only-managed + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + version-file: requirements-ci.txt + enable-cache: true + cache-dependency-glob: | + pyproject.toml + tox.ini + requirements-ci.txt + + - name: Install tox + run: uv tool install --python 3.15 --with-requirements requirements-ci.txt tox + + - name: Setup test suite + run: tox run -v --notest --skip-missing-interpreters false + + - name: Run test suite + run: tox run --skip-pkg-install diff --git a/.gitignore b/.gitignore index 172047ac..ba1234ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea +.vscode/ # Created by https://www.gitignore.io/api/python # Edit at https://www.gitignore.io/?templates=python diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..60d0365c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.0 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format diff --git a/.pyup.yml b/.pyup.yml deleted file mode 100644 index 604f69f7..00000000 --- a/.pyup.yml +++ /dev/null @@ -1,4 +0,0 @@ -# autogenerated pyup.io config file -# see https://pyup.io/docs/configuration/ for all available options - -update: insecure diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 29ff965f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,50 +0,0 @@ -language: python -cache: pip -sudo: false -dist: xenial - -matrix: - include: - - python: "3.6" - env: TOXENV=lint - - python: "3.6" - env: TOXENV=manifest - - - python: "2.7" - env: TOXENV=py27 - - python: "3.4" - env: TOXENV=py34 - - python: "3.5" - env: TOXENV=py35 - - python: "3.6" - env: TOXENV=py36 - - python: "3.7" - env: TOXENV=py37 - - python: "pypy" - env: TOXENV=pypy - dist: trusty - - python: "pypy3" - env: TOXENV=pypy3 - dist: trusty - -install: - - pip install tox - -script: - - tox - -before_install: - - pip install python-coveralls - -after_success: - - tox -e coverage-report - - coveralls - -deploy: - provider: pypi - user: theskumar - password: - secure: DXUkl4YSC2RCltChik1csvQulnVMQQpD/4i4u+6pEyUfBMYP65zFYSNwLh+jt+URyX+MpN/Er20+TZ/F/fu7xkru6/KBqKLugeXihNbwGhbHUIkjZT/0dNSo03uAz6s5fWgqr8EJk9Ll71GexAsBPx2yqsjc2BMgOjwcNly40Co= - on: - tags: true - repo: theskumar/python-dotenv diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..71c9740d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,566 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add support for Python 3.15. + +### Fixed + +- Fix a package build deprecation warning caused by a non-string `license` value in `pyproject.toml` by [@kurtmckee] in [#648] +- `set_key`, `unset_key` and the `dotenv set`/`unset` commands now name the `.env` path instead of an internal temporary file when its directory is missing or not writable, and the CLI prints a short error and exits with code 2 instead of a traceback by [@jamalkamaladdin] in [#711] +- `set_key` and `unset_key` no longer leave a `.tmp_*` file behind on Windows when writing a read-only `.env` fails, and the error raised is the one from the failed write rather than from cleaning up the temporary file by [@MohammedAlkindi] in [#686] +- `load_dotenv`, `dotenv_values`, `get_key`, `set_key`, `unset_key` and the CLI `--file` option now expand a leading `~` to the user's home directory by [@veeceey] in [#615] +- `find_dotenv` and the IPython `%dotenv` magic now expand a leading `~` in the file name by [@theskumar] in [#714] + +## [1.2.4] - 2026-10-01 + +### Fixed + +- `dotenv get` no longer exits with code 1 for empty string values (`KEY=`) by [@ShamikOfficial] in [#700] +- An unquoted empty value followed by an inline comment (e.g. `KEY= # comment`) is now parsed as an empty string instead of the comment text by [@Noethix55555] in [#663] +- `dotenv run --no-override` now expands variable references with the same precedence as `load_dotenv(override=False)`, so a value like `${BASE}/suffix` uses the existing `BASE` from the environment instead of the one from the `.env` file by [@ROTl24] in [#698] + +## [1.2.3] - 2026-08-16 + +### Fixed + +- Strip a leading UTF-8 BOM from `.env` file contents so the first variable is no longer silently lost when the file is saved with BOM (e.g. by some JetBrains IDEs on Windows) by [@h1whelan] in [#640] +- `set_key` now escapes backslashes, so values containing them (Windows paths, regular expressions) survive a write/read round-trip. Quoted values ending in an escaped backslash are no longer mis-parsed as an escaped quote, which used to swallow the following lines by [@dchaudhari7177] in [#680] +- `dotenv run` now prints a friendly error instead of a traceback when no command is given by [@bbc2] in [#606] +- Cache the parsed result for empty `.env` files so repeated `dotenv_values`/`load_dotenv` calls no longer re-read the file by [@ReinerBRO] in [#638] + +## [1.2.2] - 2026-03-01 + +### Added + +- Support for Python 3.14, including the free-threaded (3.14t) build. (#588) + +### 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 [790c5c0] +- 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` +- Add support for reading `.env` from FIFOs (Unix) by [@sidharth-sudhir] in [#586] + +## [1.2.0] - 2025-10-26 + +- Upgrade build system to use PEP 517 & PEP 518 to use `build` and `pyproject.toml` by [@EpicWink] in [#583] +- Add support for Python 3.14 by [@23f3001135] in [#579] +- Add support for disabling of `load_dotenv()` using `PYTHON_DOTENV_DISABLED` env var. by [@matthewfranglen] in [#569] + +## [1.1.1] - 2025-06-24 + +### Fixed + +- CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563] +- CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566] + +## [1.1.0] - 2025-03-25 + +### Added + +- 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 + +- 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]). +- 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]). + +## [0.18.0] - 2021-06-20 + +### Changed + +- Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in + `set_key` (#330 by [@bbc2]). +- When writing a value to a .env file with `set_key` or `dotenv set ` (#330 + by [@bbc2]): + - Use single quotes instead of double quotes. + - Don't strip surrounding quotes. + - In `auto` mode, don't add quotes if the value is only made of alphanumeric characters + (as determined by `string.isalnum`). + +## [0.17.1] - 2021-04-29 + +### Fixed + +- Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). + +## [0.17.0] - 2021-04-02 + +### Changed + +- Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). + +### Added + +- Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). + +## [0.16.0] - 2021-03-27 + +### Changed + +- The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is + now `"utf-8"` instead of `None` (#306 by [@bbc2]). +- Fix resolution order in variable expansion with `override=False` (#287 by [@bbc2]). + +## [0.15.0] - 2020-10-28 + +### Added + +- Add `--export` option to `set` to make it prepend the binding with `export` (#270 by + [@jadutter]). + +### Changed + +- Make `set` command create the `.env` file in the current directory if no `.env` file was + found (#270 by [@jadutter]). + +### 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]). +- Fix parsing of unquoted values containing several adjacent space or tab characters + (#277 by [@bbc2], review by [@x-yuri]). + +## [0.14.0] - 2020-07-03 + +### Changed + +- Privilege definition in file over the environment in variable expansion (#256 by + [@elbehery95]). + +### 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 + +- Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). + +## [0.12.0] - 2020-02-28 + +### Changed + +- Use current working directory to find `.env` when bundled by PyInstaller (#213 by + [@gergelyk]). + +### 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]). +- Remove warning when last line is empty (#238 by [@bbc2]). + +## [0.11.0] - 2020-02-07 + +### Added + +- Add `interpolate` argument to `load_dotenv` and `dotenv_values` to disable interpolation + (#232 by [@ulyssessouza]). + +### Changed + +- Use logging instead of warnings (#231 by [@bbc2]). + +### Fixed + +- Fix installation in non-UTF-8 environments (#225 by [@altendky]). +- Fix PyPI classifiers (#228 by [@bbc2]). + +## [0.10.5] - 2020-01-19 + +### Fixed + +- Fix handling of malformed lines and lines without a value (#222 by [@bbc2]): + - Don't print warning when key has no value. + - Reject more malformed lines (e.g. "A: B", "a='b',c"). +- Fix handling of lines with just a comment (#224 by [@bbc2]). + +## [0.10.4] - 2020-01-17 + +### Added + +- Make typing optional (#179 by [@techalchemy]). +- Print a warning on malformed line (#211 by [@bbc2]). +- Support keys without a value (#220 by [@ulyssessouza]). + +## 0.10.3 + +- Improve interactive mode detection ([@andrewsmith])([#183]). +- Refactor parser to fix parsing inconsistencies ([@bbc2])([#170]). + - Interpret escapes as control characters only in double-quoted strings. + - Interpret `#` as start of comment only if preceded by whitespace. + +## 0.10.2 + +- Add type hints and expose them to users ([@qnighy])([#172]) +- `load_dotenv` and `dotenv_values` now accept an `encoding` parameter, defaults to `None` + ([@theskumar])([@earlbread])([#161]) +- Fix `str`/`unicode` inconsistency in Python 2: values are always `str` now. ([@bbc2])([#121]) +- 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 + +- Add support for UTF-8 in unquoted values ([@bbc2])([#148]) +- Add support for trailing comments ([@bbc2])([#148]) +- Add backslashes support in values ([@bbc2])([#148]) +- Add support for newlines in values ([@bbc2])([#148]) +- Force environment variables to str with Python2 on Windows ([@greyli]) +- Drop Python 3.3 support ([@greyli]) +- Fix stderr/-out/-in redirection ([@venthur]) + +## 0.9.0 + +- Add `--version` parameter to cli ([@venthur]) +- Enable loading from current directory ([@cjauvin]) +- Add 'dotenv run' command for calling arbitrary shell script with .env ([@venthur]) + +## 0.8.1 + +- 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]) + +## 0.7.1 + +- 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)) + +## 0.6.5 + +- 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)) + +## 0.6.3 + +- 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)) + +## 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)) + +## 0.5.1 + +- 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)) + +## 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. + + + +[#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 +[#158]: https://github.com/theskumar/python-dotenv/issues/158 +[#170]: https://github.com/theskumar/python-dotenv/issues/170 +[#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 +[#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 +[#588]: https://github.com/theskumar/python-dotenv/issues/588 +[#579]: https://github.com/theskumar/python-dotenv/pull/579 +[#566]: https://github.com/theskumar/python-dotenv/pull/566 +[#563]: https://github.com/theskumar/python-dotenv/pull/563 +[#497]: https://github.com/theskumar/python-dotenv/pull/497 +[#161]: https://github.com/theskumar/python-dotenv/issues/161 +[#606]: https://github.com/theskumar/python-dotenv/pull/606 +[#638]: https://github.com/theskumar/python-dotenv/pull/638 +[#640]: https://github.com/theskumar/python-dotenv/pull/640 +[#615]: https://github.com/theskumar/python-dotenv/pull/615 +[#648]: https://github.com/theskumar/python-dotenv/pull/648 +[#663]: https://github.com/theskumar/python-dotenv/pull/663 +[#680]: https://github.com/theskumar/python-dotenv/pull/680 +[#686]: https://github.com/theskumar/python-dotenv/pull/686 +[#698]: https://github.com/theskumar/python-dotenv/pull/698 +[#700]: https://github.com/theskumar/python-dotenv/pull/700 +[#711]: https://github.com/theskumar/python-dotenv/pull/711 +[#714]: https://github.com/theskumar/python-dotenv/pull/714 +[790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 + + + +[@23f3001135]: https://github.com/23f3001135 +[@EpicWink]: https://github.com/EpicWink +[@Flimm]: https://github.com/Flimm +[@MohammedAlkindi]: https://github.com/MohammedAlkindi +[@Nicals]: https://github.com/Nicals +[@Nougat-Waffle]: https://github.com/Nougat-Waffle +[@Qwerty-133]: https://github.com/Qwerty-133 +[@ReinerBRO]: https://github.com/ReinerBRO +[@ROTl24]: https://github.com/ROTl24 +[@alanjds]: https://github.com/alanjds +[@altendky]: https://github.com/altendky +[@andrewsmith]: https://github.com/andrewsmith +[@asyncee]: https://github.com/asyncee +[@bbc2]: https://github.com/bbc2 +[@befeleme]: https://github.com/befeleme +[@cjauvin]: https://github.com/cjauvin +[@dchaudhari7177]: https://github.com/dchaudhari7177 +[@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 +[@freddyaboulton]: https://github.com/freddyaboulton +[@gergelyk]: https://github.com/gergelyk +[@gongqingkui]: https://github.com/gongqingkui +[@greyli]: https://github.com/greyli +[@h1whelan]: https://github.com/h1whelan +[@harveer07]: https://github.com/harveer07 +[@jadutter]: https://github.com/jadutter +[@jamalkamaladdin]: https://github.com/jamalkamaladdin +[@jankislinger]: https://github.com/jankislinger +[@jctanner]: https://github.com/jctanner +[@kurtmckee]: https://github.com/kurtmckee +[@larsks]: https://github.com/larsks +[@lsmith77]: https://github.com/lsmith77 +[@matthewfranglen]: https://github.com/matthewfranglen +[@mgorny]: https://github.com/mgorny +[@naorlivne]: https://github.com/naorlivne +[@Noethix55555]: https://github.com/Noethix55555 +[@qnighy]: https://github.com/qnighy +[@rabinadk1]: https://github.com/rabinadk1 +[@randomseed42]: https://github.com/randomseed42 +[@sammck]: https://github.com/sammck +[@samwyma]: https://github.com/samwyma +[@ShamikOfficial]: https://github.com/ShamikOfficial +[@sidharth-sudhir]: https://github.com/sidharth-sudhir +[@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 +[@veeceey]: https://github.com/veeceey +[@venthur]: https://github.com/venthur +[@wrongontheinternet]: https://github.com/wrongontheinternet +[@x-yuri]: https://github.com/x-yuri +[@yannham]: https://github.com/yannham +[@zueve]: https://github.com/zueve +[@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.4...HEAD +[1.2.4]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...v1.2.4 +[1.2.3]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3 +[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 +[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 +[0.17.0]: https://github.com/theskumar/python-dotenv/compare/v0.16.0...v0.17.0 +[0.16.0]: https://github.com/theskumar/python-dotenv/compare/v0.15.0...v0.16.0 +[0.15.0]: https://github.com/theskumar/python-dotenv/compare/v0.14.0...v0.15.0 +[0.14.0]: https://github.com/theskumar/python-dotenv/compare/v0.13.0...v0.14.0 +[0.13.0]: https://github.com/theskumar/python-dotenv/compare/v0.12.0...v0.13.0 +[0.12.0]: https://github.com/theskumar/python-dotenv/compare/v0.11.0...v0.12.0 +[0.11.0]: https://github.com/theskumar/python-dotenv/compare/v0.10.5...v0.11.0 +[0.10.5]: https://github.com/theskumar/python-dotenv/compare/v0.10.4...v0.10.5 +[0.10.4]: https://github.com/theskumar/python-dotenv/compare/v0.10.3...v0.10.4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..dd21ae76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +Contributing +============ + +All the contributions are welcome! Please open [an +issue](https://github.com/theskumar/python-dotenv/issues/new) or send us +a pull request. + +Executing the tests: + + $ uv venv + $ uv pip install -r requirements.txt + $ uv pip install -e . + $ uv ruff check . + $ uv format . + $ uv run pytest + +or with [tox](https://pypi.org/project/tox/) installed: + + $ tox + + +Use of pre-commit is recommended: + + $ uv run precommit install + + +Documentation is published with [mkdocs](https://www.mkdocs.org/): + +```shell +$ uv pip install -r requirements-docs.txt +$ uv pip install -e . +$ uv run mkdocs serve +``` + +Open http://127.0.0.1:8000/ to view the documentation locally. diff --git a/LICENSE b/LICENSE index 39372fee..3a971190 100644 --- a/LICENSE +++ b/LICENSE @@ -1,78 +1,18 @@ -python-dotenv -Copyright (c) 2014, Saurabh Kumar - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of python-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - - -django-dotenv-rw -Copyright (c) 2013, Ted Tieken - -All rights reserved. +Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss (django-dotenv) Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, -EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR -PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING -NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Original django-dotenv -Copyright (c) 2013, Jacob Kaplan-Moss - -All rights reserved. +- Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of django-dotenv nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. +- Neither the name of django-dotenv nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT diff --git a/MANIFEST.in b/MANIFEST.in index aba1cd49..afa35896 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,7 @@ -include LICENSE *.md *.yml +include LICENSE *.md *.yml *.yaml *.toml include tox.ini +recursive-include docs *.md recursive-include tests *.py include .bumpversion.cfg @@ -8,4 +9,6 @@ include .coveragerc include .editorconfig include Makefile include requirements.txt +include requirements-ci.txt +include requirements-docs.txt include src/dotenv/py.typed diff --git a/Makefile b/Makefile index 50b80b40..6706fe94 100644 --- a/Makefile +++ b/Makefile @@ -1,29 +1,33 @@ -.PHONY: clean-pyc clean-build test +.PHONY: clean-pyc clean-build test fmt release + +part ?= patch clean: clean-build clean-pyc clean-build: rm -fr build/ + rm -rf .mypy_cache/ + rm -rf .tox/ + rm -rf site/ rm -fr dist/ - rm -fr *.egg-info + rm -fr src/*.egg-info clean-pyc: find . -name '*.pyc' -exec rm -f {} + find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + -release: clean - python setup.py sdist upload - python setup.py bdist_wheel upload - sdist: clean - python setup.py sdist + python -m build -o dist . ls -l dist test: - pip install -e . - flake8 . - py.test tests/ + uv pip install -e . + ruff check . + pytest tests/ + +fmt: + ruff format src tests coverage: coverage run --source=dotenv --omit='*tests*' -m py.test tests/ -v --tb=native @@ -31,3 +35,16 @@ coverage: coverage-html: coverage coverage html + +# Cut a release: verify, bump version, commit and tag. See RELEASING.md. +# Override the bump size with `make release part=minor` (default: patch). +release: + @test "$$(git rev-parse --abbrev-ref HEAD)" = "main" || { echo "Release from main only (currently on $$(git rev-parse --abbrev-ref HEAD))"; exit 1; } + @git diff --quiet && git diff --cached --quiet || { echo "Working tree is dirty; commit the changelog first"; exit 1; } + uv run ruff check . + uv run pytest tests/ + uv run bumpversion $(part) + @echo "" + @echo "Tagged v$$(uv run python -c 'import dotenv.version as v; print(v.__version__)'). Next:" + @echo " git push origin main --follow-tags" + @echo " then create the GitHub Release (see RELEASING.md) to publish to PyPI" diff --git a/README.md b/README.md index 4458cb5f..3400e67c 100644 --- a/README.md +++ b/README.md @@ -1,430 +1,260 @@ -``` - _______ .__ __. ____ ____ - | ____|| \ | | \ \ / / - | |__ | \| | \ \/ / - | __| | . ` | \ / - __ | |____ | |\ | \ / - (__)|_______||__| \__| \__/ -``` -python-dotenv | [![Build Status](https://travis-ci.org/theskumar/python-dotenv.svg?branch=master)](https://travis-ci.org/theskumar/python-dotenv) [![Coverage Status](https://coveralls.io/repos/theskumar/python-dotenv/badge.svg?branch=master)](https://coveralls.io/r/theskumar/python-dotenv?branch=master) [![PyPI version](https://badge.fury.io/py/python-dotenv.svg)](http://badge.fury.io/py/python-dotenv) [![Say Thanks!](https://img.shields.io/badge/Say%20Thanks-!-1EAEDB.svg)](https://saythanks.io/to/theskumar) -=============================================================================== - -Reads the key,value pair from `.env` file and adds them to environment -variable. It is great for managing app settings during development and -in production using [12-factor](http://12factor.net/) principles. - -> Do one thing, do it well! - -- [Usages](#usages) -- [Installation](#installation) -- [Command-line interface](#command-line-interface) -- [iPython Support](#ipython-support) -- [Setting config on remote servers](#setting-config-on-remote-servers) +# python-dotenv + +[![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 +[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) +- [Command-line Interface](#command-line-interface) +- [File format](#file-format) + - [Multiline values](#multiline-values) + - [Variable expansion](#variable-expansion) - [Related Projects](#related-projects) -- [Contributing](#contributing) -- [Changelog](#changelog) - -> Hey just wanted to let you know that since I've started writing 12-factor apps I've found python-dotenv to be invaluable for all my projects. It's super useful and “just works.” --Daniel Fridkin - -Usages -====== - -The easiest and most common usage consists on calling `load_dotenv` when -the application starts, which will load environment variables from a -file named `.env` in the current directory or any of its parents or from -the path specificied; after that, you can just call the -environment-related method you need as provided by `os.getenv`. +- [Acknowledgements](#acknowledgements) -`.env` looks like this: +## Getting Started ```shell -# a comment and that will be ignored. -REDIS_ADDRESS=localhost:6379 -MEANING_OF_LIFE=42 -MULTILINE_VAR="hello\nworld" +pip install python-dotenv ``` -You can optionally prefix each line with the word `export`, which is totally ignore by this library, but might allow you to [`source`](https://bash.cyberciti.biz/guide/Source_command) the file in bash. - -``` -export S3_BUCKET=YOURS3BUCKET -export SECRET_KEY=YOURSECRETKEYGOESHERE -``` - -`.env` can interpolate variables using POSIX variable expansion, -variables are replaced from the environment first or from other values -in the `.env` file if the variable is not present in the environment. -(`Note`: Default Value Expansion is not supported as of yet, see -[\#30](https://github.com/theskumar/python-dotenv/pull/30#issuecomment-244036604).) - -```shell -CONFIG_PATH=${HOME}/.config/foo -DOMAIN=example.org -EMAIL=admin@${DOMAIN} -``` +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. -Getting started -=============== - -Assuming you have created the `.env` file along-side your settings -module. - - . - ├── .env - └── settings.py - -Add the following code to your `settings.py` +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 -# settings.py from dotenv import load_dotenv -load_dotenv() - -# OR, the same with increased verbosity: -load_dotenv(verbose=True) -# OR, explicitly providing path to '.env' -from pathlib import Path # python3 only -env_path = Path('.') / '.env' -load_dotenv(dotenv_path=env_path) -``` - -At this point, parsed key/value from the .env file is now present as -system environment variable and they can be conveniently accessed via -`os.getenv()` +load_dotenv() # reads variables from a .env file and sets them in os.environ -```python -# settings.py -import os -SECRET_KEY = os.getenv("EMAIL") -DATABASE_PASSWORD = os.getenv("DATABASE_PASSWORD") +# Code of your application, which uses environment variables (e.g. from `os.environ` or +# `os.getenv`) as if they came from the actual environment. ``` -`load_dotenv` do not override existing System environment variables. To -override, pass `override=True` to `load_dotenv()`. +By default, `load_dotenv()` will: -`load_dotenv` also accepts `encoding` parameter to open the `.env` file. The default encoding is platform dependent (whatever `locale.getpreferredencoding()` returns), but any encoding supported by Python can be used. See the [codecs](https://docs.python.org/3/library/codecs.html#standard-encodings) module for the list of supported encodings. +- 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** existing environment variables (`override=False`). Pass `override=True` to override existing variables. -You can use `find_dotenv()` method that will try to find a `.env` file -by (a) guessing where to start using `__file__` or the working directory --- allowing this to work in non-file contexts such as IPython notebooks -and the REPL, and then (b) walking up the directory tree looking for the -specified file -- called `.env` by default. +To configure the development environment, add a `.env` in the root directory of +your project: -```python -from dotenv import load_dotenv, find_dotenv -load_dotenv(find_dotenv()) ``` - -In-memory filelikes -------------------- - -It is possible to not rely on the filesystem to parse filelikes from -other sources (e.g. from a network storage). `load_dotenv` and -`dotenv_values` accepts a filelike `stream`. Just be sure to rewind it -before passing. - -```python ->>> from io import StringIO # Python2: from StringIO import StringIO ->>> from dotenv import dotenv_values ->>> filelike = StringIO('SPAM=EGGS\n') ->>> filelike.seek(0) ->>> parsed = dotenv_values(stream=filelike) ->>> parsed['SPAM'] -'EGGS' +. +├── .env +└── foo.py ``` -The returned value is dictionary with key value pair. +The syntax of `.env` files supported by python-dotenv is similar to that of +Bash: -`dotenv_values` could be useful if you need to *consume* the envfile but -not *apply* it directly into the system environment. +```bash +# Development settings +DOMAIN=example.org +ADMIN_EMAIL=admin@${DOMAIN} +ROOT_URL=${DOMAIN}/app +``` -Django ------- +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 are using django you should add the above loader script at the -top of `wsgi.py` and `manage.py`. +You will probably want to add `.env` to your `.gitignore`, especially if it +contains secrets like a password. -Installation -============ +See the section "[File format](#file-format)" below for more information about what you can write in a `.env` file. - pip install -U python-dotenv +## Other Use Cases -iPython Support ---------------- +### Load configuration without altering the environment -You can use dotenv with iPython. You can either let the dotenv search -for .env with %dotenv or provide the path to .env file explicitly, see -below for usages. +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. - %load_ext dotenv +```python +from dotenv import dotenv_values - # Use find_dotenv to locate the file - %dotenv +config = dotenv_values(".env") # config = {"USER": "foo", "EMAIL": "foo@example.org"} +``` - # Specify a particular file - %dotenv relative/or/absolute/path/to/.env +This notably enables advanced configuration management: - # Use '-o' to indicate override of existing variables - %dotenv -o +```python +import os +from dotenv import dotenv_values - # Use '-v' to turn verbose mode on - %dotenv -v +config = { + **dotenv_values(".env.shared"), # load shared development variables + **dotenv_values(".env.secret"), # load sensitive variables + **os.environ, # override loaded values with environment variables +} +``` -Command-line interface -====================== +### Parse configuration as a stream -For commandline support, use the cli option during installation: +`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). - pip install -U "python-dotenv[cli]" +```python +from io import StringIO -A cli interface `dotenv` is also included, which helps you manipulate -the `.env` file without manually opening it. The same cli installed on -remote machine combined with fabric (discussed later) will enable you to -update your settings on remote server, handy isn't it! +from dotenv import load_dotenv -``` -Usage: dotenv [OPTIONS] COMMAND [ARGS]... - - This script is used to set, get or unset values from a .env file. - -Options: - -f, --file PATH Location of the .env file, defaults to .env - file in current working directory. - -q, --quote [always|never|auto] - Whether to quote or not the variable values. - Default mode is always. This does not affect - parsing. - --help Show this message and exit. - -Commands: - get Retrive the value for the given key. - list Display all the stored key/value. - run Run command with environment variables from .env file present - set Store the given key/value. - unset Removes the given key. +config = StringIO("USER=foo\nEMAIL=foo@example.org") +load_dotenv(stream=config) ``` -Setting config on remote servers --------------------------------- +### Load .env files in IPython -We make use of excellent [Fabric](http://www.fabfile.org/) to acomplish -this. Add a config task to your local fabfile, `dotenv_path` is the -location of the absolute path of `.env` file on the remote server. +You can use dotenv in IPython. By default, it will use `find_dotenv` to search for a +`.env` file: ```python -# fabfile.py - -import dotenv -from fabric.api import task, run, env - -# absolute path to the location of .env on remote server. -env.dotenv_path = '/opt/myapp/.env' - -@task -def config(action=None, key=None, value=None): - '''Manage project configuration via .env - - e.g: fab config:set,, - fab config:get, - fab config:unset, - fab config:list - ''' - run('touch %(dotenv_path)s' % env) - command = dotenv.get_cli_string(env.dotenv_path, action, key, value) - run(command) +%load_ext dotenv +%dotenv ``` -Usage is designed to mirror the heroku config api very closely. - -Get all your remote config info with `fab config` - - $ fab config - foo="bar" - -Set remote config variables with `fab config:set,,` - - $ fab config:set,hello,world - -Get a single remote config variables with `fab config:get,` - - $ fab config:get,hello - -Delete a remote config variables with `fab config:unset,` +You can also specify a path: - $ fab config:unset,hello - -Thanks entirely to fabric and not one bit to this project, you can chain -commands like so -`fab config:set,, config:set,,` - - $ fab config:set,hello,world config:set,foo,bar config:set,fizz=buzz - -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-configuration](https://github.com/jezdez/django-configurations) -- [dump-env](https://github.com/sobolevn/dump-env) -- [environs](https://github.com/sloria/environs) - -Contributing -============ - -All the contributions are welcome! Please open [an -issue](https://github.com/theskumar/python-dotenv/issues/new) or send us -a pull request. - -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). - -Executing the tests: +```python +%dotenv relative/or/absolute/path/to/.env +%dotenv ~/path/to/.env +``` - $ pip install -r requirements.txt - $ pip install -e . - $ flake8 - $ pytest +Optional flags: -or with [tox](https://pypi.org/project/tox/) installed: +- `-o` to override existing variables. +- `-v` for increased verbosity. - $ tox +### Disable load_dotenv -Changelog -========= +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. -Unreleased ------ +## Command-line Interface -- Add type hints and expose them to users ([@qnighy])([#172]) -- `load_dotenv` and `dotenv_values` now accepts `encoding` paramater, defaults to `None` ([@theskumar])([@earlbread]) (#161) +A CLI interface `dotenv` is also included, which helps you manipulate the `.env` +file without manually opening it. -0.10.1 ------ -- Fix parsing of variable without a value ([@asyncee])([@bbc2])([#158]) +```shell +$ pip install "python-dotenv[cli]" +$ dotenv set USER foo +$ 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 +``` -0.10.0 ------ +Run `dotenv --help` for more information about the options and subcommands. -- Add support for UTF-8 in unquoted values ([@bbc2])([#148]) -- Add support for trailing comments ([@bbc2])([#148]) -- Add backslashes support in values ([@bbc2])([#148]) -- Add support for newlines in values ([@bbc2])([#148]) -- Force environment variables to str with Python2 on Windows ([@greyli]) -- Drop Python 3.3 support ([@greyli]) -- Fix stderr/-out/-in redirection ([@venthur]) +## File format +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. -0.9.0 ------ -- Add `--version` parameter to cli ([@venthur]) -- Enable loading from current directory ([@cjauvin]) -- Add 'dotenv run' command for calling arbitrary shell script with .env ([@venthur]) +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. -0.8.1 ------ +Allowed escape sequences: -- Add tests for docs ([@Flimm]) -- Make 'cli' support optional. Use `pip install python-dotenv[cli]`. ([@theskumar]) +- in single-quoted values: `\\`, `\'` +- in double-quoted values: `\\`, `\'`, `\"`, `\a`, `\b`, `\f`, `\n`, `\r`, `\t`, `\v` -0.8.0 ------ +### Multiline values -- `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]) +It is possible for single- or double-quoted values to span multiple lines. The +following examples are equivalent: -0.7.1 ------ +```bash +FOO="first line +second line" +``` -- Remove hard dependency on iPython ([@theskumar]) +```bash +FOO="first line\nsecond line" +``` -0.7.0 ------ +### Variable without a value -- 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)) +A variable can have no value: -0.6.5 ------ +```bash +FOO +``` -- Add support for special characters `\`. - ([@pjona](https://github.com/pjona)) - ([\#60](https://github.com/theskumar/python-dotenv/issues/60)) +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. -0.6.4 ------ +This shouldn't be confused with `FOO=`, in which case the variable is associated +with the empty string. -- Fix issue with single quotes ([@Flimm]) - ([\#52](https://github.com/theskumar/python-dotenv/issues/52)) +### Variable expansion -0.6.3 ------ +python-dotenv can interpolate variables using POSIX variable expansion. -- Handle unicode exception in setup.py - ([\#46](https://github.com/theskumar/python-dotenv/issues/46)) +With `load_dotenv(override=True)` or `dotenv_values()`, the value of a variable +is the first of the values defined in the following list: -0.6.2 ------ +- Value of that variable in the `.env` file. +- Value of that variable in the environment. +- Default value, if provided. +- Empty string. -- Fix dotenv list command ([@ticosax](https://github.com/ticosax)) -- Add iPython Suport - ([@tillahoffmann](https://github.com/tillahoffmann)) +With `load_dotenv(override=False)`, the value of a variable is the first of the +values defined in the following list: -0.6.0 ------ +- Value of that variable in the environment. +- Value of that variable in the `.env` file. +- Default value, if provided. +- Empty string. -- Drop support for Python 2.6 -- Handle escaped charaters 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)) +## Related Projects -0.5.1 ------ +- [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) -- Fix find\_dotenv - it now start search from the file where this - function is called from. +## Acknowledgements -0.5.0 ------ - -- 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. - -[#161]: https://github.com/theskumar/python-dotenv/issues/161 -[#78]: https://github.com/theskumar/python-dotenv/issues/78 -[#148]: https://github.com/theskumar/python-dotenv/issues/148 -[#158]: https://github.com/theskumar/python-dotenv/issues/158 -[#172]: https://github.com/theskumar/python-dotenv/issues/172 +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]. -[@asyncee]: https://github.com/asyncee -[@greyli]: https://github.com/greyli -[@venthur]: https://github.com/venthur -[@Flimm]: https://github.com/Flimm -[@theskumar]: https://github.com/theskumar -[@alanjds]: https://github.com/alanjds -[@cjauvin]: https://github.com/cjauvin -[@bbc2]: https://github.com/bbc2 -[@qnighy]: https://github.com/qnighy -[@earlbread]: https://github.com/earlbread +[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/RELEASING.md b/RELEASING.md new file mode 100644 index 00000000..9d88c343 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,78 @@ +# Releasing + +python-dotenv follows [Semantic Versioning](https://semver.org/). Bug fixes are +patch releases, backward-compatible features are minor releases, and breaking +changes are major releases. + +Publishing is automated: creating a GitHub **Release** triggers +[`.github/workflows/release.yml`](.github/workflows/release.yml), which builds +the package, publishes it to PyPI via Trusted Publishing (OIDC, no tokens), and +deploys the docs to GitHub Pages. You never run `twine` by hand. + +## Prerequisites + +- Push access to `main` and permission to create releases. +- A clean local checkout: `git switch main && git pull --ff-only`. +- Dev tools installed: `uv pip install -r requirements.txt && uv pip install -e .`. + +## Steps + +1. **Write the changelog.** In `CHANGELOG.md`, rename the `## [Unreleased]` + section to `## [X.Y.Z] - YYYY-MM-DD`, and add a fresh empty `## [Unreleased]` + above it. Every entry credits the author and links the PR, for example: + + ``` + - Short description of the change by [@handle] in [#123] + ``` + + Add the matching link definitions at the bottom of the file (`[#123]: ...` + and, for a first-time contributor, `[@handle]: https://github.com/handle`). + + Every version header is a reference link, so also maintain the compare links + at the bottom: point `[Unreleased]` at the new tag and add the new version: + + ``` + [Unreleased]: https://github.com/theskumar/python-dotenv/compare/vX.Y.Z...HEAD + [X.Y.Z]: https://github.com/theskumar/python-dotenv/compare/vPREV...vX.Y.Z + ``` + +2. **Commit the notes** on `main`: + + ``` + git commit -am "docs: add X.Y.Z release notes" + ``` + +3. **Bump the version.** This runs the checks, updates `src/dotenv/version.py` + and `.bumpversion.cfg`, commits `Bump version: ... → X.Y.Z`, and tags + `vX.Y.Z`: + + ``` + make release # patch (default) + make release part=minor + make release part=major + ``` + +4. **Push** the release commit and tag: + + ``` + git push origin main --follow-tags + ``` + +5. **Publish.** Create the GitHub Release from the new tag. This starts the + PyPI upload and docs deploy: + + ``` + VERSION=$(uv run python -c "import dotenv.version as v; print(v.__version__)") + gh release create "v$VERSION" --title "v$VERSION" \ + --notes "$(awk "/^## \\[$VERSION\\]/{f=1;next} /^## \\[/{f=0} f" CHANGELOG.md)" + ``` + +6. **Verify.** Watch the workflow (`gh run watch`), then confirm the new version + on [PyPI](https://pypi.org/project/python-dotenv/) and that the + [docs](https://saurabh-kumar.com/python-dotenv/) rebuilt. + +## Rollback + +The version bump is a normal commit plus tag. Before you push, undo with +`git reset --hard HEAD~1 && git tag -d vX.Y.Z`. After a bad publish, yank the +release on PyPI and ship a follow-up patch; PyPI versions cannot be reused. diff --git a/_config.yml b/_config.yml deleted file mode 100644 index 2f7efbea..00000000 --- a/_config.yml +++ /dev/null @@ -1 +0,0 @@ -theme: jekyll-theme-minimal \ No newline at end of file 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..3d55d899 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,30 @@ +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: + 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 + - Changelog: changelog.md + - Contributing: contributing.md + - Reference: reference.md + - License: license.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..7dc2a711 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["setuptools >= 77.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-dotenv" +description = "Read key-value pairs from a .env file and set them as environment variables" +authors = [ + {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, +] +license = "BSD-3-Clause" +keywords = [ + "environment variables", + "deployments", + "settings", + "env", + "dotenv", + "configurations", + "python", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "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 :: 3.15", + "Programming Language :: Python :: Implementation :: PyPy", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Operating System :: OS Independent", + "Topic :: System :: Systems Administration", + "Topic :: Utilities", + "Environment :: Web Environment", +] + +requires-python = ">=3.10" + +dynamic = ["version", "readme"] + +[project.urls] +Source = "https://github.com/theskumar/python-dotenv" + +[project.optional-dependencies] +cli = [ + "click>=5.0", +] + +[project.scripts] +dotenv = "dotenv.__main__:cli" + +[tool.setuptools] +packages = ["dotenv"] +package-dir = {"" = "src"} +package-data = {dotenv = ["py.typed"]} + +[tool.setuptools.dynamic] +version = {attr = "dotenv.version.__version__"} +readme = {file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown"} + +[tool.pytest.ini_options] +testpaths = [ + "tests", +] + +[tool.coverage.run] +relative_files = true +source = ["dotenv"] + +[tool.coverage.paths] +source = [ + "src/dotenv", + ".tox/*/lib/python*/site-packages/dotenv", + ".tox/pypy*/site-packages/dotenv", +] + +[tool.coverage.report] +show_missing = true +omit = ["*/tests/*"] +exclude_lines = [ + "if IS_TYPE_CHECKING:", + "pragma: no cover", +] + +[tool.mypy] +check_untyped_defs = true +ignore_missing_imports = true diff --git a/requirements-ci.txt b/requirements-ci.txt new file mode 100644 index 00000000..002cc7aa --- /dev/null +++ b/requirements-ci.txt @@ -0,0 +1,5 @@ +# Tooling installed by `.github/workflows/test.yml`. +tox==4.67.4 +tox-gh==1.9.1 +tox-uv==1.37.2 +uv==0.13.6 diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..76fa086e --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,5 @@ +mdx_truly_sane_lists>=1.3 +mkdocs-include-markdown-plugin>=7.3.0 +mkdocs-material>=9.5.0 +mkdocstrings[python]>=0.24.0 +mkdocs>=1.5.0 diff --git a/requirements.txt b/requirements.txt index bd49777d..8b1177fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,10 @@ bumpversion -typing; python_version<"3.5" click -flake8>=2.2.3 ipython -pypandoc pytest-cov -pytest>=3.9 -sh>=1.09 +pytest>=9.0.3 tox wheel +ruff +build +pre-commit diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 00000000..a2e0feca --- /dev/null +++ b/ruff.toml @@ -0,0 +1,19 @@ +[lint] +select = [ + # pycodestyle + "E4", + "E7", + "E9", + + # Pyflakes + "F", + + # flake8-bugbear + "B", + + # iSort + "I", + + # flake8-builtins + "A", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7f784591..00000000 --- a/setup.cfg +++ /dev/null @@ -1,12 +0,0 @@ -[bdist_wheel] -universal = 1 - -[flake8] -max-line-length = 120 -exclude = .tox,.git,docs,venv,.venv - -[metadata] -description-file = README.rst - -[tool:pytest] -testpaths = tests diff --git a/setup.py b/setup.py deleted file mode 100644 index 8f601724..00000000 --- a/setup.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -from setuptools import setup - -# https://github.com/theskumar/python-dotenv/issues/45#issuecomment-277135416 -try: - import pypandoc - long_description = pypandoc.convert('README.md', 'rst') - long_description = long_description.replace("\r", "") # YOU NEED THIS LINE -except (OSError, ImportError): - print("Pandoc not found. Long_description conversion failure.") - import io - # pandoc is not installed, fallback to using raw contents - with io.open('README.md', encoding="utf-8") as f: - long_description = f.read() - -meta = {} -with open('./src/dotenv/version.py') as f: - exec(f.read(), meta) - -setup( - name="python-dotenv", - description="Add .env support to your django/flask apps in development and deployments", - long_description=long_description, - version=meta['__version__'], - author="Saurabh Kumar", - author_email="me+github@saurabh-kumar.com", - url="http://github.com/theskumar/python-dotenv", - keywords=['environment variables', 'deployments', 'settings', 'env', 'dotenv', - 'configurations', 'python'], - packages=['dotenv'], - package_dir={'': 'src'}, - package_data={ - 'dotenv': ['py.typed'], - }, - install_requires=[ - "typing; python_version<'3.5'", - ], - extras_require={ - 'cli': ['click>=5.0', ], - }, - entry_points=''' - [console_scripts] - dotenv=dotenv.cli:cli - ''', - classifiers=[ - # As from https://pypi.python.org/pypi?%3Aaction=list_classifiers - # 'Development Status :: 1 - Planning', - # 'Development Status :: 2 - Pre-Alpha', - # 'Development Status :: 3 - Alpha', - # 'Development Status :: 4 - Beta', - 'Development Status :: 5 - Production/Stable', - # 'Development Status :: 6 - Mature', - # 'Development Status :: 7 - Inactive', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - # 'Programming Language :: Python :: 2.3', - # 'Programming Language :: Python :: 2.4', - # 'Programming Language :: Python :: 2.5', - # 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.0', - 'Programming Language :: Python :: 3.1', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - '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', - ] -) - -# (*) Please direct queries to the discussion group, rather than to me directly -# Doing so helps ensure your question is helpful to other users. -# Queries directly to my email are likely to receive a canned response. -# -# Many thanks for your understanding. diff --git a/src/dotenv/__init__.py b/src/dotenv/__init__.py index 1867868f..dde24a01 100644 --- a/src/dotenv/__init__.py +++ b/src/dotenv/__init__.py @@ -1,43 +1,51 @@ from typing import Any, Optional -from .main import load_dotenv, get_key, set_key, unset_key, find_dotenv, dotenv_values +from .main import dotenv_values, find_dotenv, get_key, load_dotenv, set_key, unset_key -def load_ipython_extension(ipython): - # type: (Any) -> None + +def load_ipython_extension(ipython: Any) -> None: from .ipython import load_ipython_extension + load_ipython_extension(ipython) -def get_cli_string(path=None, action=None, key=None, value=None, quote=None): - # type: (Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]) -> str +def get_cli_string( + path: Optional[str] = None, + action: Optional[str] = None, + key: Optional[str] = None, + value: Optional[str] = None, + quote: Optional[str] = None, +): """Returns a string suitable for running as a shell script. Useful for converting a arguments passed to a fabric task to be passed to a `local` or `run` command. """ - command = ['dotenv'] + command = ["dotenv"] if quote: - command.append('-q %s' % quote) + command.append(f"-q {quote}") if path: - command.append('-f %s' % path) + command.append(f"-f {path}") if action: command.append(action) if key: command.append(key) if value: - if ' ' in value: - command.append('"%s"' % value) + if " " in value: + command.append(f'"{value}"') else: command.append(value) - return ' '.join(command).strip() + return " ".join(command).strip() -__all__ = ['get_cli_string', - 'load_dotenv', - 'dotenv_values', - 'get_key', - 'set_key', - 'unset_key', - 'find_dotenv', - 'load_ipython_extension'] +__all__ = [ + "get_cli_string", + "load_dotenv", + "dotenv_values", + "get_key", + "set_key", + "unset_key", + "find_dotenv", + "load_ipython_extension", +] diff --git a/src/dotenv/__main__.py b/src/dotenv/__main__.py new file mode 100644 index 00000000..3977f55a --- /dev/null +++ b/src/dotenv/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for cli, enables execution with `python -m dotenv`""" + +from .cli import cli + +if __name__ == "__main__": + cli() diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 45f4b765..83849690 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,105 +1,264 @@ +import json import os +import shlex +import shutil import sys -from typing import Any, List +from contextlib import contextmanager +from typing import IO, Any, Dict, Iterator, List, Optional + +if sys.platform == "win32": + from subprocess import Popen try: import click except ImportError: - sys.stderr.write('It seems python-dotenv is not installed with cli option. \n' - 'Run pip install "python-dotenv[cli]" to fix this.') + sys.stderr.write( + "It seems python-dotenv is not installed with cli option. \n" + 'Run pip install "python-dotenv[cli]" to fix this.' + ) sys.exit(1) -from .main import dotenv_values, get_key, set_key, unset_key, run_command +from .main import DotEnv, dotenv_values, set_key, unset_key from .version import __version__ +def enumerate_env() -> Optional[str]: + """ + Return a path for the ${pwd}/.env file. + + If pwd does not exist, return None. + """ + try: + cwd = os.getcwd() + except FileNotFoundError: + return None + path = os.path.join(cwd, ".env") + return path + + @click.group() -@click.option('-f', '--file', default=os.path.join(os.getcwd(), '.env'), - type=click.Path(exists=True), - help="Location of the .env file, defaults to .env file in current working directory.") -@click.option('-q', '--quote', default='always', - type=click.Choice(['always', 'never', 'auto']), - help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.") +@click.option( + "-f", + "--file", + default=enumerate_env(), + type=click.Path(file_okay=True), + help="Location of the .env file, defaults to .env file in current working directory.", +) +@click.option( + "-q", + "--quote", + default="always", + type=click.Choice(["always", "never", "auto"]), + help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.", +) +@click.option( + "-e", + "--export", + default=False, + type=click.BOOL, + help="Whether to write the dot file as an executable bash script.", +) @click.version_option(version=__version__) @click.pass_context -def cli(ctx, file, quote): - # type: (click.Context, Any, Any) -> None - '''This script is used to set, get or unset values from a .env file.''' - ctx.obj = {} - ctx.obj['FILE'] = file - ctx.obj['QUOTE'] = quote +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.""" + if file is not None: + file = os.path.expanduser(file) + ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file} -@cli.command() +@contextmanager +def stream_file(path: os.PathLike) -> Iterator[IO[str]]: + """ + Open a file and yield the corresponding (decoded) stream. + + Exits with error code 2 if the file cannot be opened. + """ + + try: + with open(path) as stream: + yield stream + except OSError as exc: + print(f"Error opening env file: {exc}", file=sys.stderr) + sys.exit(2) + + +@cli.command(name="list") @click.pass_context -def list(ctx): - # type: (click.Context) -> None - '''Display all the stored key/value.''' - file = ctx.obj['FILE'] - dotenv_as_dict = dotenv_values(file) - for k, v in dotenv_as_dict.items(): - click.echo('%s=%s' % (k, v)) +@click.option( + "--format", + "output_format", + default="simple", + type=click.Choice(["simple", "json", "shell", "export"]), + help="The format in which to display the list. Default format is simple, " + "which displays name=value without quotes.", +) +def list_values(ctx: click.Context, output_format: str) -> None: + """Display all the stored key/value.""" + file = ctx.obj["FILE"] + with stream_file(file) as stream: + values = dotenv_values(stream=stream) -@cli.command() + if output_format == "json": + click.echo(json.dumps(values, indent=2, sort_keys=True)) + else: + prefix = "export " if output_format == "export" else "" + for k in sorted(values): + v = values[k] + if v is not None: + if output_format in ("export", "shell"): + v = shlex.quote(v) + click.echo(f"{prefix}{k}={v}") + + +@cli.command(name="set") @click.pass_context -@click.argument('key', required=True) -@click.argument('value', required=True) -def set(ctx, key, value): - # type: (click.Context, Any, Any) -> None - '''Store the given key/value.''' - file = ctx.obj['FILE'] - quote = ctx.obj['QUOTE'] - success, key, value = set_key(file, key, value, quote) +@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. + + 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"] + try: + success, key, value = set_key(file, key, value, quote, export) + except OSError as exc: + print(f"Error writing env file: {exc}", file=sys.stderr) + sys.exit(2) if success: - click.echo('%s=%s' % (key, value)) + click.echo(f"{key}={value}") else: - exit(1) + sys.exit(1) @cli.command() @click.pass_context -@click.argument('key', required=True) -def get(ctx, key): - # type: (click.Context, Any) -> None - '''Retrieve the value for the given key.''' - file = ctx.obj['FILE'] - stored_value = get_key(file, key) - if stored_value: - click.echo('%s=%s' % (key, stored_value)) - else: - exit(1) +@click.argument("key", required=True) +def get(ctx: click.Context, key: Any) -> None: + """Retrieve the value for the given key.""" + file = ctx.obj["FILE"] + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + # Empty strings are valid values; only missing keys / bare keys (None) fail. + if key not in values or values[key] is None: + sys.exit(1) + click.echo(values[key]) @cli.command() @click.pass_context -@click.argument('key', required=True) -def unset(ctx, key): - # type: (click.Context, Any) -> None - '''Removes the given key.''' - file = ctx.obj['FILE'] - quote = ctx.obj['QUOTE'] - success, key = unset_key(file, key, quote) +@click.argument("key", required=True) +def unset(ctx: click.Context, key: Any) -> None: + """ + 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"] + try: + success, key = unset_key(file, key, quote) + except OSError as exc: + print(f"Error writing env file: {exc}", file=sys.stderr) + sys.exit(2) if success: - click.echo("Successfully removed %s" % key) + click.echo(f"Successfully removed {key}") else: - exit(1) + sys.exit(1) -@cli.command(context_settings={'ignore_unknown_options': True}) +@cli.command( + context_settings={ + "allow_extra_args": True, + "allow_interspersed_args": False, + "ignore_unknown_options": True, + } +) @click.pass_context -@click.argument('commandline', nargs=-1, type=click.UNPROCESSED) -def run(ctx, commandline): - # type: (click.Context, List[str]) -> None +@click.option( + "--override/--no-override", + default=True, + help="Override variables from the environment file with those from the .env file.", +) +@click.argument("commandline", nargs=-1, type=click.UNPROCESSED) +def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> None: """Run command with environment variables present.""" - file = ctx.obj['FILE'] - dotenv_as_dict = dotenv_values(file) + file = ctx.obj["FILE"] + if not os.path.isfile(file): + raise click.BadParameter( + f"Invalid value for '-f' \"{file}\" does not exist.", ctx=ctx + ) + dotenv_as_dict = { + k: v + for (k, v) in DotEnv(file, override=override, encoding="utf-8").dict().items() + if v is not None and (override or k not in os.environ) + } + if not commandline: - click.echo('No command given.') - exit(1) - ret = run_command(commandline, dotenv_as_dict) # type: ignore - exit(ret) + click.echo("No command given.") + sys.exit(1) + + run_command([*commandline, *ctx.args], dotenv_as_dict) + +def run_command(command: List[str], env: Dict[str, str]) -> None: + """Replace the current process with the specified command. -if __name__ == "__main__": - cli() + Replaces the current process with the specified command and the variables from `env` + added in the current environment variables. + + Parameters + ---------- + command: List[str] + The command and it's parameters + env: Dict + The additional environment variables + + Returns + ------- + 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 + # `env` + cmd_env = os.environ.copy() + cmd_env.update(env) + + if sys.platform == "win32": + # Resolve the command as suggested in Python's docs + # (https://docs.python.org/3/library/subprocess.html#popen-constructor). + executable = shutil.which(command[0], path=cmd_env.get("PATH")) + try: + # `execvpe` on Windows returns control immediately. + p = Popen( + command, + executable=executable, + universal_newlines=True, + bufsize=0, + shell=False, + env=cmd_env, + ) + except FileNotFoundError: + print(f"Command not found: {command[0]}", file=sys.stderr) + sys.exit(1) + + _, _ = p.communicate() + + sys.exit(p.returncode) + else: + try: + os.execvpe(command[0], args=command, env=cmd_env) + except FileNotFoundError: + print(f"Command not found: {command[0]}", file=sys.stderr) + sys.exit(1) diff --git a/src/dotenv/compat.py b/src/dotenv/compat.py deleted file mode 100644 index a0339303..00000000 --- a/src/dotenv/compat.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Text, Type -import sys -if sys.version_info >= (3, 0): - from io import StringIO # noqa -else: - from StringIO import StringIO # noqa - -PY2 = sys.version_info[0] == 2 # type: bool -WIN = sys.platform.startswith('win') # type: bool -text_type = Text # type: Type[Text] diff --git a/src/dotenv/ipython.py b/src/dotenv/ipython.py index 7f1b13d6..4e7edbbf 100644 --- a/src/dotenv/ipython.py +++ b/src/dotenv/ipython.py @@ -1,26 +1,35 @@ -from __future__ import print_function - from IPython.core.magic import Magics, line_magic, magics_class # type: ignore -from IPython.core.magic_arguments import (argument, magic_arguments, # type: ignore - parse_argstring) # type: ignore +from IPython.core.magic_arguments import ( + argument, + magic_arguments, + parse_argstring, +) # type: ignore from .main import find_dotenv, load_dotenv @magics_class class IPythonDotEnv(Magics): - @magic_arguments() @argument( - '-o', '--override', action='store_true', - help="Indicate to override existing variables" + "-o", + "--override", + action="store_true", + help="Indicate to override existing variables", + ) + @argument( + "-v", + "--verbose", + action="store_true", + help="Indicate function calls to be verbose", ) @argument( - '-v', '--verbose', action='store_true', - help="Indicate function calls to be verbose" + "dotenv_path", + nargs="?", + type=str, + default=".env", + help="Search in increasingly higher folders for the `dotenv_path`", ) - @argument('dotenv_path', nargs='?', type=str, default='.env', - help='Search in increasingly higher folders for the `dotenv_path`') @line_magic def dotenv(self, line): args = parse_argstring(self.dotenv, line) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 715a08e2..5faa7f0e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -1,306 +1,366 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import, print_function, unicode_literals - -import codecs import io +import logging import os -import re -import shutil +import pathlib +import stat import sys -from subprocess import Popen import tempfile -from typing import (Any, Dict, Iterator, List, Match, NamedTuple, Optional, # noqa - Pattern, Union, TYPE_CHECKING, Text, IO, Tuple) # noqa -import warnings from collections import OrderedDict from contextlib import contextmanager +from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union -from .compat import StringIO, PY2, WIN, text_type - -if TYPE_CHECKING: # pragma: no cover - if sys.version_info >= (3, 6): - _PathLike = os.PathLike - else: - _PathLike = Text - - if sys.version_info >= (3, 0): - _StringIO = StringIO - else: - _StringIO = StringIO[Text] - -__posix_variable = re.compile(r'\$\{[^\}]*\}') # type: Pattern[Text] +from .parser import Binding, parse_stream +from .variables import parse_variables -_binding = re.compile( - r""" - ( - \s* # leading whitespace - (?:export{0}+)? # export +# A type alias for a string path to be used for the paths in this file. +# These paths may flow to `open()` and `os.replace()`. +StrPath = Union[str, "os.PathLike[str]"] - ( '[^']+' # single-quoted key - | [^=\#\s]+ # or unquoted key - )? - - (?: - (?:{0}*={0}*) # equal sign - - ( '(?:\\'|[^'])*' # single-quoted value - | "(?:\\"|[^"])*" # or double-quoted value - | [^\#\r\n]* # or unquoted value - ) - )? - - \s* # trailing whitespace - (?:\#[^\r\n]*)? # comment - (?:\r|\n|\r\n)? # newline - ) - """.format(r'[^\S\r\n]'), - re.MULTILINE | re.VERBOSE, -) # type: Pattern[Text] +logger = logging.getLogger(__name__) -_escape_sequence = re.compile(r"\\[\\'\"abfnrtv]") # type: Pattern[Text] - -Binding = NamedTuple("Binding", [("key", Optional[Text]), - ("value", Optional[Text]), - ("original", Text)]) - - -def decode_escapes(string): - # type: (Text) -> Text - def decode_match(match): - # type: (Match[Text]) -> Text - return codecs.decode(match.group(0), 'unicode-escape') # type: ignore - - return _escape_sequence.sub(decode_match, string) - - -def is_surrounded_by(string, char): - # type: (Text, Text) -> bool - return ( - len(string) > 1 - and string[0] == string[-1] == char - ) - - -def parse_binding(string, position): - # type: (Text, int) -> Tuple[Binding, int] - match = _binding.match(string, position) - assert match is not None - (matched, key, value) = match.groups() - if key is None or value is None: - key = None - value = None - else: - value_quoted = is_surrounded_by(value, "'") or is_surrounded_by(value, '"') - if value_quoted: - value = decode_escapes(value[1:-1]) - else: - value = value.strip() - return (Binding(key=key, value=value, original=matched), match.end()) - - -def parse_stream(stream): - # type:(IO[Text]) -> Iterator[Binding] - string = stream.read() - position = 0 - length = len(string) - while position < length: - (binding, position) = parse_binding(string, position) - yield binding - - -class DotEnv(): - - def __init__(self, dotenv_path, verbose=False, encoding=None): - # type: (Union[Text, _PathLike, _StringIO], bool, Union[None, Text]) -> None - self.dotenv_path = dotenv_path # type: Union[Text,_PathLike, _StringIO] - self._dict = None # type: Optional[Dict[Text, Text]] - self.verbose = verbose # type: bool - self.encoding = encoding # type: Union[None, Text] +def _load_dotenv_disabled() -> bool: + """ + Determine if dotenv loading has been disabled. + """ + if "PYTHON_DOTENV_DISABLED" not in os.environ: + return False + value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() + return value in {"1", "true", "t", "yes", "y"} + + +def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: + for mapping in mappings: + if mapping.error: + logger.warning( + "python-dotenv could not parse statement starting at line %s", + mapping.original.line, + ) + yield mapping + + +class DotEnv: + def __init__( + self, + dotenv_path: Optional[StrPath], + stream: Optional[IO[str]] = None, + verbose: bool = False, + encoding: Optional[str] = None, + interpolate: bool = True, + override: bool = True, + ) -> None: + self.dotenv_path: Optional[StrPath] = dotenv_path + if isinstance(dotenv_path, str): + self.dotenv_path = os.path.expanduser(dotenv_path) + elif dotenv_path is not None: + self.dotenv_path = pathlib.Path(dotenv_path).expanduser() + 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): - # type: () -> Iterator[IO[Text]] - if isinstance(self.dotenv_path, StringIO): - yield self.dotenv_path - elif os.path.isfile(self.dotenv_path): - with io.open(self.dotenv_path, encoding=self.encoding) as stream: + def _get_stream(self) -> Iterator[IO[str]]: + if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): + 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: - warnings.warn("File doesn't exist {}".format(self.dotenv_path)) # type: ignore - yield StringIO('') + logger.info( + "python-dotenv could not find configuration file %s.", + self.dotenv_path or ".env", + ) + yield io.StringIO("") - def dict(self): - # type: () -> Dict[Text, Text] + def dict(self) -> Dict[str, Optional[str]]: """Return dotenv as dict""" - if self._dict: + if self._dict is not None: return self._dict - values = OrderedDict(self.parse()) - self._dict = resolve_nested_variables(values) + raw_values = self.parse() + + if self.interpolate: + self._dict = OrderedDict( + resolve_variables(raw_values, override=self.override) + ) + else: + self._dict = OrderedDict(raw_values) + return self._dict - def parse(self): - # type: () -> Iterator[Tuple[Text, Text]] + def parse(self) -> Iterator[Tuple[str, Optional[str]]]: with self._get_stream() as stream: - for mapping in parse_stream(stream): - if mapping.key is not None and mapping.value is not None: + for mapping in with_warn_for_invalid_lines(parse_stream(stream)): + if mapping.key is not None: yield mapping.key, mapping.value - def set_as_environment_variables(self, override=False): - # type: (bool) -> bool + def set_as_environment_variables(self) -> bool: """ - Load the current dotenv as system environemt variable. + 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 override: + if k in os.environ and not self.override: continue - # With Python2 on Windows, force environment variables to str to avoid - # "TypeError: environment can only contain strings" in Python's subprocess.py. - if PY2 and WIN: - if isinstance(k, text_type) or isinstance(v, text_type): - k = k.encode('ascii') - v = v.encode('ascii') - os.environ[k] = v # type: ignore + if v is not None: + os.environ[k] = v return True - def get(self, key): - # type: (Text) -> Optional[Text] - """ - """ + def get(self, key: str) -> Optional[str]: + """ """ data = self.dict() if key in data: return data[key] if self.verbose: - warnings.warn("key %s not found in %s." % (key, self.dotenv_path)) # type: ignore + logger.warning("Key %s not found in %s.", key, self.dotenv_path) return None -def get_key(dotenv_path, key_to_get): - # type: (Union[Text, _PathLike], Text) -> Optional[Text] +def get_key( + dotenv_path: StrPath, + key_to_get: str, + encoding: Optional[str] = "utf-8", +) -> Optional[str]: + """ + Get the value of a given key from the given .env. + + Returns `None` if the key isn't found or doesn't have a value. + """ + return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get) + + +def _discard_temp_file(path: pathlib.Path) -> None: """ - Gets the value of a given key from the given .env + Delete `rewrite`'s temporary file, ignoring any failure to do so. - If the .env path given doesn't exist, fails + This runs while another exception is propagating, so it must not raise: + that error is the one worth reporting. On Windows, a file whose mode has + no owner-write bit carries the read-only attribute and can't be unlinked, + so the mode is reset before a second attempt. """ - return DotEnv(dotenv_path, verbose=True).get(key_to_get) + try: + path.unlink(missing_ok=True) + except OSError: + try: + path.chmod(stat.S_IWRITE | stat.S_IREAD) + path.unlink(missing_ok=True) + except OSError: + logger.warning("python-dotenv could not remove the temporary file %s", path) @contextmanager -def rewrite(path): - # type: (_PathLike) -> Iterator[Tuple[IO[Text], IO[Text]]] +def rewrite( + path: StrPath, + encoding: Optional[str], + follow_symlinks: bool = False, +) -> Iterator[Tuple[IO[str], IO[str]]]: + if follow_symlinks: + path = os.path.realpath(path) + try: - 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: - shutil.move(dest.name, path) + 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 + try: + temp_file = tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + delete=False, + prefix=".tmp_", + dir=os.path.dirname(os.path.abspath(path)), + ) + except OSError as err: + source.close() + # Report the target path, not the name of the temporary file. + err.filename = os.fspath(path) + raise -def set_key(dotenv_path, key_to_set, value_to_set, quote_mode="always"): - # type: (_PathLike, Text, Text, Text) -> Tuple[Optional[bool], Text, Text] + with temp_file as dest: + dest_path = pathlib.Path(dest.name) + error = None + + try: + with source: + yield (source, dest) + except BaseException as err: + error = err + + if error is None: + try: + if original_mode is not None: + os.chmod(dest_path, original_mode) + + os.replace(dest_path, path) + except BaseException: + _discard_temp_file(dest_path) + raise + else: + _discard_temp_file(dest_path) + raise error from None + + +def set_key( + dotenv_path: StrPath, + key_to_set: str, + value_to_set: str, + 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`. """ - value_to_set = value_to_set.strip("'").strip('"') - if not os.path.exists(dotenv_path): - warnings.warn("can't write to %s - it doesn't exist." % dotenv_path) # type: ignore - return None, key_to_set, value_to_set + if isinstance(dotenv_path, str): + dotenv_path = os.path.expanduser(dotenv_path) + else: + dotenv_path = pathlib.Path(dotenv_path).expanduser() + if quote_mode not in ("always", "auto", "never"): + raise ValueError(f"Unknown quote_mode: {quote_mode}") - if " " in value_to_set: - quote_mode = "always" + quote = quote_mode == "always" or ( + quote_mode == "auto" and not value_to_set.isalnum() + ) - line_template = '{}="{}"\n' if quote_mode == "always" else '{}={}\n' - line_out = line_template.format(key_to_set, value_to_set) + if quote: + # The single-quoted-value parser decodes `\\` and `\'`, so both have to + # be escaped here for the value to survive a write/read round-trip. + # Backslashes first, otherwise the backslash added by the quote + # escaping would be escaped in turn. + escaped = value_to_set.replace("\\", "\\\\").replace("'", "\\'") + value_out = f"'{escaped}'" + else: + value_out = value_to_set + if export: + line_out = f"export {key_to_set}={value_out}\n" + else: + line_out = f"{key_to_set}={value_out}\n" - with rewrite(dotenv_path) as (source, dest): + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): replaced = False - for mapping in parse_stream(source): + 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) + 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, key_to_unset, quote_mode="always"): - # type: (_PathLike, Text, Text) -> Tuple[Optional[bool], Text] +def unset_key( + dotenv_path: StrPath, + 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 + 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. + + 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 isinstance(dotenv_path, str): + dotenv_path = os.path.expanduser(dotenv_path) + else: + dotenv_path = pathlib.Path(dotenv_path).expanduser() if not os.path.exists(dotenv_path): - warnings.warn("can't delete from %s - it doesn't exist." % dotenv_path) # type: ignore + 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): - for mapping in parse_stream(source): + 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 else: - dest.write(mapping.original) + dest.write(mapping.original.string) if not removed: - warnings.warn("key %s not removed from %s - key doesn't exist." % (key_to_unset, dotenv_path)) # type: ignore + 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 -def resolve_nested_variables(values): - # type: (Dict[Text, Text]) -> Dict[Text, Text] - def _replacement(name): - # type: (Text) -> Text - """ - get appropriate value for a variable name. - first search in environ, if not found, - then look into the dotenv variables - """ - ret = os.getenv(name, new_values.get(name, "")) - return ret +def resolve_variables( + values: Iterable[Tuple[str, Optional[str]]], + override: bool, +) -> Mapping[str, Optional[str]]: + new_values: Dict[str, Optional[str]] = {} - def _re_sub_callback(match_object): - # type: (Match[Text]) -> Text - """ - From a match object gets the variable name and returns - the correct replacement - """ - return _replacement(match_object.group()[2:-1]) - - new_values = {} + for name, value in values: + if value is None: + result = None + else: + atoms = parse_variables(value) + env: Dict[str, Optional[str]] = {} + if override: + env.update(os.environ) # type: ignore + env.update(new_values) + else: + env.update(new_values) + env.update(os.environ) # type: ignore + result = "".join(atom.resolve(env) for atom in atoms) - for k, v in values.items(): - new_values[k] = __posix_variable.sub(_re_sub_callback, v) + new_values[name] = result return new_values -def _walk_to_root(path): - # type: (Text) -> Iterator[Text] +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) @@ -313,85 +373,160 @@ def _walk_to_root(path): last_dir, current_dir = current_dir, parent_dir -def find_dotenv(filename='.env', raise_error_if_not_found=False, usecwd=False): - # type: (Text, bool, bool) -> Text +def find_dotenv( + filename: str = ".env", + raise_error_if_not_found: bool = False, + usecwd: bool = False, +) -> str: """ Search in increasingly higher folders for the given file + A leading `~` in `filename` is expanded to the user's home directory, and + an absolute `filename` is returned as is if it exists. + Returns path to the file if found, or an empty string otherwise """ - if usecwd or '__file__' not in globals(): - # should work without __file__, e.g. in REPL or IPython notebook + filename = os.path.expanduser(filename) + + def _is_interactive(): + """Decide whether this is running in a REPL or IPython notebook""" + if hasattr(sys, "ps1") or hasattr(sys, "ps2"): + return True + try: + main = __import__("__main__", None, None, fromlist=["__file__"]) + except ModuleNotFoundError: + return False + return not hasattr(main, "__file__") + + def _is_debugger(): + return sys.gettrace() is not None + + if usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False): + # Should work without __file__, e.g. in REPL or IPython notebook. path = os.getcwd() else: # will work for .py files frame = sys._getframe() - # find first frame that is outside of this file - if PY2 and not __file__.endswith('.py'): - # in Python2 __file__ extension could be .pyc or .pyo (this doesn't account - # for edge case of Python compiled for non-standard extension) - current_file = __file__.rsplit('.', 1)[0] + '.py' - else: - current_file = __file__ + 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 path = os.path.dirname(os.path.abspath(frame_filename)) for dirname in _walk_to_root(path): check_path = os.path.join(dirname, filename) - if os.path.isfile(check_path): + if _is_file_or_fifo(check_path): return check_path if raise_error_if_not_found: - raise IOError('File not found') - - return '' - - -def load_dotenv(dotenv_path=None, stream=None, verbose=False, override=False, **kwargs): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, bool, Union[None, Text]) -> bool - f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, **kwargs).set_as_environment_variables(override=override) - + raise IOError("File not found") + + return "" + + +def load_dotenv( + dotenv_path: Optional[StrPath] = None, + stream: Optional[IO[str]] = None, + verbose: bool = False, + override: bool = False, + interpolate: bool = True, + encoding: Optional[str] = "utf-8", +) -> bool: + """Parse a .env file and then load all the variables found as environment variables. + + Parameters: + dotenv_path: Absolute or relative path to .env file. A leading `~` is + expanded to the user's home directory. + 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. + interpolate: Whether to interpolate variables using POSIX variable expansion. + 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 its default parameters. If you need to change the default parameters + of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result + to this function as `dotenv_path`. + + If the environment variable `PYTHON_DOTENV_DISABLED` is set to a truthy value, + .env loading is disabled. + """ + if _load_dotenv_disabled(): + logger.debug( + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) + return False + + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + + dotenv = DotEnv( + dotenv_path=dotenv_path, + stream=stream, + verbose=verbose, + interpolate=interpolate, + override=override, + encoding=encoding, + ) + return dotenv.set_as_environment_variables() -def dotenv_values(dotenv_path=None, stream=None, verbose=False, **kwargs): - # type: (Union[Text, _PathLike, None], Optional[_StringIO], bool, Union[None, Text]) -> Dict[Text, Text] - f = dotenv_path or stream or find_dotenv() - return DotEnv(f, verbose=verbose, **kwargs).dict() +def dotenv_values( + dotenv_path: Optional[StrPath] = None, + stream: Optional[IO[str]] = None, + verbose: bool = False, + interpolate: bool = True, + encoding: Optional[str] = "utf-8", +) -> Dict[str, Optional[str]]: + """ + Parse a .env file and return its content as a dict. + + 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. A leading `~` is + expanded to the user's home directory. + stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. + verbose: Whether to output a warning if the .env file is missing. + interpolate: Whether to interpolate variables using POSIX variable expansion. + encoding: Encoding to be used to read the 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() -def run_command(command, env): - # type: (List[str], Dict[str, str]) -> int - """Run command in sub process. + return DotEnv( + dotenv_path=dotenv_path, + stream=stream, + verbose=verbose, + interpolate=interpolate, + override=True, + encoding=encoding, + ).dict() - Runs the command in a sub process with the variables from `env` - added in the current environment variables. - Parameters - ---------- - command: List[str] - The command and it's parameters - env: Dict - The additional environment variables +def _is_file_or_fifo(path: StrPath) -> bool: + """ + Return True if `path` exists and is either a regular file or a FIFO. + """ + if os.path.isfile(path): + return True - Returns - ------- - int - The return code of the command + try: + st = os.stat(path) + except (FileNotFoundError, OSError): + return False - """ - # copy the current environment variables and add the vales from - # `env` - 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 + return stat.S_ISFIFO(st.st_mode) diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py new file mode 100644 index 00000000..b3b55070 --- /dev/null +++ b/src/dotenv/parser.py @@ -0,0 +1,191 @@ +import codecs +import re +from typing import ( + IO, + Iterator, + Match, + NamedTuple, + Optional, + Pattern, + Sequence, +) + + +def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: + return re.compile(string, re.UNICODE | extra_flags) + + +_newline = make_regex(r"(\r\n|\n|\r)") +_multiline_whitespace = make_regex(r"\s*", extra_flags=re.MULTILINE) +_whitespace = make_regex(r"[^\S\r\n]*") +_export = make_regex(r"(?:export[^\S\r\n]+)?") +_single_quoted_key = make_regex(r"'([^']+)'") +_unquoted_key = make_regex(r"([^=\#\s]+)") +_equal_sign = make_regex(r"(=[^\S\r\n]*)") +# A backslash always escapes the character after it, so that an escaped +# backslash (`\\`) is not mistaken for the start of an escaped quote. +_single_quoted_value = make_regex(r"'((?:\\.|[^'\\])*)'", extra_flags=re.DOTALL) +_double_quoted_value = make_regex(r'"((?:\\.|[^"\\])*)"', extra_flags=re.DOTALL) +_unquoted_value = make_regex(r"([^\r\n]*)") +_comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?") +_end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r|$)") +_rest_of_line = make_regex(r"[^\r\n]*(?:\r\n|\n|\r)?") +_double_quote_escapes = make_regex(r"\\[\\'\"abfnrtv]") +_single_quote_escapes = make_regex(r"\\[\\']") + + +class Original(NamedTuple): + string: str + line: int + + +class Binding(NamedTuple): + key: Optional[str] + value: Optional[str] + original: Original + error: bool + + +class Position: + def __init__(self, chars: int, line: int) -> None: + self.chars = chars + self.line = line + + @classmethod + def start(cls) -> "Position": + return cls(chars=0, line=1) + + def set(self, other: "Position") -> None: + self.chars = other.chars + self.line = other.line + + def advance(self, string: str) -> None: + self.chars += len(string) + self.line += len(re.findall(_newline, string)) + + +class Error(Exception): + pass + + +class Reader: + def __init__(self, stream: IO[str]) -> None: + self.string = stream.read().removeprefix("\ufeff") + self.position = Position.start() + self.mark = Position.start() + + def has_next(self) -> bool: + return self.position.chars < len(self.string) + + def set_mark(self) -> None: + self.mark.set(self.position) + + def get_marked(self) -> Original: + return Original( + string=self.string[self.mark.chars : self.position.chars], + line=self.mark.line, + ) + + def peek(self, count: int) -> str: + return self.string[self.position.chars : self.position.chars + count] + + def read(self, count: int) -> str: + result = self.string[self.position.chars : self.position.chars + count] + if len(result) < count: + raise Error("read: End of string") + self.position.advance(result) + return result + + def read_regex(self, regex: Pattern[str]) -> Sequence[str]: + match = regex.match(self.string, self.position.chars) + if match is None: + raise Error("read_regex: Pattern not found") + self.position.advance(self.string[match.start() : match.end()]) + return match.groups() + + +def decode_escapes(regex: Pattern[str], string: str) -> str: + def decode_match(match: Match[str]) -> str: + return codecs.decode(match.group(0), "unicode-escape") # type: ignore + + return regex.sub(decode_match, string) + + +def parse_key(reader: Reader) -> Optional[str]: + char = reader.peek(1) + if char == "#": + return None + elif char == "'": + (key,) = reader.read_regex(_single_quoted_key) + else: + (key,) = reader.read_regex(_unquoted_key) + return key + + +def parse_unquoted_value(reader: Reader) -> str: + (part,) = reader.read_regex(_unquoted_value) + return re.sub(r"\s+#.*", "", part).rstrip() + + +def parse_value(reader: Reader) -> str: + char = reader.peek(1) + if char == "'": + (value,) = reader.read_regex(_single_quoted_value) + return decode_escapes(_single_quote_escapes, value) + elif char == '"': + (value,) = reader.read_regex(_double_quoted_value) + return decode_escapes(_double_quote_escapes, value) + elif char in ("", "\n", "\r"): + return "" + else: + return parse_unquoted_value(reader) + + +def parse_binding(reader: Reader) -> Binding: + reader.set_mark() + try: + reader.read_regex(_multiline_whitespace) + if not reader.has_next(): + return Binding( + key=None, + value=None, + original=reader.get_marked(), + error=False, + ) + reader.read_regex(_export) + key = parse_key(reader) + reader.read_regex(_whitespace) + if reader.peek(1) == "=": + (equal_sign,) = reader.read_regex(_equal_sign) + # If there is whitespace after `=` and the value starts with `#`, + # the value is empty and the rest of the line is an inline comment + # (e.g. `KEY= # comment`). Without whitespace (`KEY=#novalue`) the + # `#` is part of the unquoted value. + if len(equal_sign) > 1 and reader.peek(1) == "#": + value: Optional[str] = "" + else: + value = parse_value(reader) + else: + value = None + reader.read_regex(_comment) + reader.read_regex(_end_of_line) + return Binding( + key=key, + value=value, + original=reader.get_marked(), + error=False, + ) + except Error: + reader.read_regex(_rest_of_line) + return Binding( + key=None, + value=None, + original=reader.get_marked(), + error=True, + ) + + +def parse_stream(stream: IO[str]) -> Iterator[Binding]: + reader = Reader(stream) + while reader.has_next(): + yield parse_binding(reader) diff --git a/src/dotenv/variables.py b/src/dotenv/variables.py new file mode 100644 index 00000000..667f2f26 --- /dev/null +++ b/src/dotenv/variables.py @@ -0,0 +1,86 @@ +import re +from abc import ABCMeta, abstractmethod +from typing import Iterator, Mapping, Optional, Pattern + +_posix_variable: Pattern[str] = re.compile( + r""" + \$\{ + (?P[^\}:]*) + (?::- + (?P[^\}]*) + )? + \} + """, + re.VERBOSE, +) + + +class Atom(metaclass=ABCMeta): + def __ne__(self, other: object) -> bool: + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + return not result + + @abstractmethod + def resolve(self, env: Mapping[str, Optional[str]]) -> str: ... + + +class Literal(Atom): + def __init__(self, value: str) -> None: + self.value = value + + def __repr__(self) -> str: + return f"Literal(value={self.value})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return NotImplemented + return self.value == other.value + + def __hash__(self) -> int: + return hash((self.__class__, self.value)) + + def resolve(self, env: Mapping[str, Optional[str]]) -> str: + return self.value + + +class Variable(Atom): + def __init__(self, name: str, default: Optional[str]) -> None: + self.name = name + self.default = default + + def __repr__(self) -> str: + return f"Variable(name={self.name}, default={self.default})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name, self.default) == (other.name, other.default) + + def __hash__(self) -> int: + return hash((self.__class__, self.name, self.default)) + + def resolve(self, env: Mapping[str, Optional[str]]) -> str: + default = self.default if self.default is not None else "" + result = env.get(self.name, default) + return result if result is not None else "" + + +def parse_variables(value: str) -> Iterator[Atom]: + cursor = 0 + + for match in _posix_variable.finditer(value): + (start, end) = match.span() + name = match["name"] + default = match["default"] + + if start > cursor: + yield Literal(value=value[cursor:start]) + + yield Variable(name=name, default=default) + cursor = end + + length = len(value) + if cursor < length: + yield Literal(value=value[cursor:length]) diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 1f4c4d43..b3f9ac7f 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "0.10.1" +__version__ = "1.2.4" diff --git a/tests/conftest.py b/tests/conftest.py index 7a9ed7e5..cc6f0f07 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,11 +4,13 @@ @pytest.fixture def cli(): - yield CliRunner() + runner = CliRunner() + with runner.isolated_filesystem(): + yield runner @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 91f967a3..a719e195 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,266 +1,479 @@ -# -*- coding: utf-8 -*- import os +import sys +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__ +from tests.test_lib import check_process, run_dotenv -def test_get_key(dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - stored_value = dotenv.get_key(dotenv_file, 'HELLO') - assert stored_value == 'WORLD' - sh.rm(dotenv_file) - assert dotenv.get_key(dotenv_file, 'HELLO') is None - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - assert success is None +@pytest.mark.parametrize( + "output_format,content,expected", + ( + (None, "x='a b c'", """x=a b c\n"""), + ("simple", "x='a b c'", """x=a b c\n"""), + ("simple", """x='"a b c"'""", """x="a b c"\n"""), + ("simple", '''x="'a b c'"''', """x='a b c'\n"""), + ("json", "x='a b c'", """{\n "x": "a b c"\n}\n"""), + ("shell", "x='a b c'", "x='a b c'\n"), + ("shell", """x='"a b c"'""", """x='"a b c"'\n"""), + ("shell", '''x="'a b c'"''', """x=''"'"'a b c'"'"''\n"""), + ("shell", "x='a\nb\nc'", "x='a\nb\nc'\n"), + ("export", "x='a b c'", """export x='a b c'\n"""), + ), +) +def test_list( + cli, dotenv_path, output_format: Optional[str], content: str, expected: str +): + dotenv_path.write_text(content + "\n") + + args = ["--file", dotenv_path, "list"] + if format is not None: + args.extend(["--format", output_format]) + result = cli.invoke(dotenv_cli, args) -def test_set_key(dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'foo', 'bar') - assert dotenv.get_key(dotenv_file, 'HELLO') == 'WORLD' + assert (result.exit_code, result.output) == (0, expected) - with open(dotenv_file, 'r') as fp: - assert 'HELLO="WORLD"\nfoo="bar"' == fp.read().strip() - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD 2') - assert dotenv.get_key(dotenv_file, 'HELLO') == 'WORLD 2' - assert dotenv.get_key(dotenv_file, 'foo') == 'bar' +def test_list_non_existent_file(cli): + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "list"]) - with open(dotenv_file, 'r') as fp: - assert 'HELLO="WORLD 2"\nfoo="bar"' == fp.read().strip() + assert result.exit_code == 2, result.output + assert "Error opening env file" in result.output - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, "HELLO", "WORLD\n3") - with open(dotenv_file, "r") as fp: - assert 'HELLO="WORLD\n3"\nfoo="bar"' == fp.read().strip() +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_set_key_permission_error(dotenv_file): - os.chmod(dotenv_file, 0o000) - with pytest.raises(Exception): - dotenv.set_key(dotenv_file, "HELLO", "WORLD") +def test_list_no_file(cli): + result = cli.invoke(dotenv.cli.list_values, []) - os.chmod(dotenv_file, 0o600) - with open(dotenv_file, "r") as fp: - assert fp.read() == "" + assert (result.exit_code, result.output) == (1, "") -def test_list(cli, dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'list']) - assert result.exit_code == 0, result.output - assert result.output == 'HELLO=WORLD\n' +def test_get_existing_value(cli, dotenv_path): + dotenv_path.write_text("a=b") + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) -def test_get_cli(cli, dotenv_file): - cli.invoke(dotenv_cli, ['--file', dotenv_file, 'set', 'HELLO', "WORLD 1"]) - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'get', 'HELLO']) - assert result.exit_code == 0, result.output - assert result.output == 'HELLO=WORLD 1\n' + assert (result.exit_code, result.output) == (0, "b\n") -def test_list_wo_file(cli): - result = cli.invoke(dotenv_cli, ['--file', 'doesnotexists', 'list']) - assert result.exit_code == 2, result.output - assert 'Invalid value for "-f"' in result.output +def test_get_empty_string_value(cli, dotenv_path): + """Empty string values must not be treated as missing (truthiness trap).""" + dotenv_path.write_text("a=\n") + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) -def test_empty_value(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("TEST=") - assert dotenv.get_key(dotenv_file, "TEST") == "" + assert (result.exit_code, result.output) == (0, "\n") -def test_key_value_without_quotes(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write("TEST = value \n") - assert dotenv.get_key(dotenv_file, 'TEST') == "value" +def test_get_value_without_equals(cli, dotenv_path): + dotenv_path.write_text("a") + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) -def test_key_value_without_quotes_with_spaces(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write('TEST = " with spaces " \n') - assert dotenv.get_key(dotenv_file, 'TEST') == " with spaces " + assert (result.exit_code, result.output) == (1, "") -def test_value_with_double_quotes(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write('TEST="two words"\n') - assert dotenv.get_key(dotenv_file, 'TEST') == 'two words' +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_value_with_simple_quotes(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write("TEST='two words'\n") - assert dotenv.get_key(dotenv_file, 'TEST') == 'two words' +def test_get_non_existent_file(cli): + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "get", "a"]) -def test_value_with_special_characters(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write(r'TEST="}=&~{,(\5%{&;"') - assert dotenv.get_key(dotenv_file, 'TEST') == r'}=&~{,(\5%{&;' + assert result.exit_code == 2 + assert "Error opening env file" in result.output -def test_value_with_new_lines(dotenv_file): - with open(dotenv_file, 'w') as f: - f.write('TEST="a\nb"') - assert dotenv.get_key(dotenv_file, 'TEST') == "a\nb" +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_value_after_comment(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("# comment\nTEST=a") - assert dotenv.get_key(dotenv_file, "TEST") == "a" +def test_unset_existing_value(cli, dotenv_path): + dotenv_path.write_text("a=b") -def test_unset_ok(dotenv_file): - with open(dotenv_file, "w") as f: - f.write("a=b\nc=d") + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) - success, key_to_unset = dotenv.unset_key(dotenv_file, "a") + assert (result.exit_code, result.output) == (0, "Successfully removed a\n") + assert dotenv_path.read_text() == "" - assert success is True - assert key_to_unset == "a" - with open(dotenv_file, "r") as f: - assert f.read() == "c=d" +def test_unset_non_existent_value(cli, dotenv_path): + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) -def test_unset_non_existing_file(): - success, key_to_unset = dotenv.unset_key('/non-existing', 'HELLO') + assert (result.exit_code, result.output) == (1, "") + assert dotenv_path.read_text() == "" - assert success is None +@pytest.mark.parametrize( + "quote_mode,variable,value,expected", + ( + ("always", "a", "x", "a='x'\n"), + ("never", "a", "x", "a=x\n"), + ("auto", "a", "x", "a=x\n"), + ("auto", "a", "x y", "a='x y'\n"), + ("auto", "a", "$", "a='$'\n"), + ), +) +def test_set_quote_options(cli, dotenv_path, quote_mode, variable, value, expected): + result = cli.invoke( + dotenv_cli, + [ + "--file", + dotenv_path, + "--export", + "false", + "--quote", + quote_mode, + "set", + variable, + value, + ], + ) -def test_unset_cli(cli, dotenv_file): - success, key_to_set, value_to_set = dotenv.set_key(dotenv_file, 'TESTHELLO', 'WORLD') - dotenv.get_key(dotenv_file, 'TESTHELLO') == 'WORLD' - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'TESTHELLO']) - assert result.exit_code == 0, result.output - assert result.output == 'Successfully removed TESTHELLO\n' - dotenv.get_key(dotenv_file, 'TESTHELLO') is None - result = cli.invoke(dotenv_cli, ['--file', dotenv_file, 'unset', 'TESTHELLO']) - assert result.exit_code == 1, result.output + assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) + assert dotenv_path.read_text() == expected @pytest.mark.parametrize( - "quote_mode,variable,value,expected", + "dotenv_path,export_mode,variable,value,expected", ( - ("always", "HELLO", "WORLD", 'HELLO="WORLD"\n'), - ("never", "HELLO", "WORLD", 'HELLO=WORLD\n'), - ("auto", "HELLO", "WORLD", 'HELLO=WORLD\n'), - ("auto", "HELLO", "HELLO WORLD", 'HELLO="HELLO WORLD"\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_path, export_mode, variable, value, expected): + result = cli.invoke( + dotenv_cli, + [ + "--file", + dotenv_path, + "--quote", + "always", + "--export", + export_mode, + "set", + variable, + value, + ], + ) + + assert (result.exit_code, result.output) == (0, "{}={}\n".format(variable, value)) + assert dotenv_path.read_text() == expected + + +def test_set_non_existent_file(cli): + result = cli.invoke(dotenv.cli.set_value, ["a", "b"]) + + assert (result.exit_code, result.output) == (1, "") + + +def test_set_no_file(cli): + result = cli.invoke(dotenv_cli, ["--file", "nx_file", "set"]) + + assert result.exit_code == 2 + assert "Missing argument" in result.output + + +def test_set_missing_directory(cli, tmp_path): + dotenv_path = tmp_path / "nx_dir" / ".env" + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "set", "a", "b"]) + + assert (result.exit_code, result.output) == ( + 2, + f"Error writing env file: [Errno 2] No such file or directory: {str(dotenv_path)!r}\n", + ) + + +@pytest.mark.skipif( + sys.platform == "win32" or os.geteuid() == 0, + reason="Directory permissions are not enforced on Windows or for root.", +) +def test_unset_read_only_directory(cli, tmp_path): + directory = tmp_path / "ro" + directory.mkdir() + dotenv_path = directory / ".env" + dotenv_path.write_text("a=b\n") + directory.chmod(0o555) + + try: + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "unset", "a"]) + finally: + directory.chmod(0o755) + + assert (result.exit_code, result.output) == ( + 2, + f"Error writing env file: [Errno 13] Permission denied: {str(dotenv_path)!r}\n", + ) + assert dotenv_path.read_text() == "a=b\n" + + +def test_get_default_path(tmp_path): + (tmp_path / ".env").write_text("A=x") + + result = run_dotenv(["get", "A"], cwd=tmp_path) + + check_process(result, exit_code=0, stdout="x\n") + + +def test_run(tmp_path): + (tmp_path / ".env").write_text("A=x") + + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) + + check_process(result, exit_code=0, stdout="x\n") + + +def test_run_with_existing_variable(tmp_path): + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "y"}) + + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path, env=env) + + check_process(result, exit_code=0, stdout="x\n") + + +def test_run_with_existing_variable_not_overridden(tmp_path): + (tmp_path / ".env").write_text("A=x") + env = dict(os.environ) + env.update({"LANG": "en_US.UTF-8", "A": "C"}) + + result = run_dotenv( + ["run", "--no-override", "printenv", "A"], cwd=tmp_path, env=env ) + + check_process(result, exit_code=0, stdout="C\n") + + +@pytest.mark.parametrize( + "option,existing_value,expected", + [ + ("--override", "environment", "file\nfile/suffix\n"), + ("--no-override", "environment", "environment\nenvironment/suffix\n"), + ("--no-override", "", "\n/suffix\n"), + ], ) -def test_console_script(quote_mode, variable, value, expected, dotenv_file): - sh.dotenv('-f', dotenv_file, '-q', quote_mode, 'set', variable, value) +def test_run_interpolation_respects_override( + tmp_path, option, existing_value, expected +): + (tmp_path / ".env").write_text( + "DOTENV_TEST_BASE=file\nDOTENV_TEST_DERIVED=${DOTENV_TEST_BASE}/suffix\n" + ) + env = dict(os.environ) + env.pop("DOTENV_TEST_DERIVED", None) + env["DOTENV_TEST_BASE"] = existing_value + + result = run_dotenv( + [ + "run", + option, + sys.executable, + "-c", + "import os; print(os.environ['DOTENV_TEST_BASE']); " + "print(os.environ['DOTENV_TEST_DERIVED'])", + ], + cwd=tmp_path, + env=env, + ) + + check_process(result, exit_code=0, stdout=expected) + + +def test_run_with_none_value(tmp_path): + (tmp_path / ".env").write_text("A=x\nc") - result = sh.cat(dotenv_file) + result = run_dotenv(["run", "printenv", "A"], cwd=tmp_path) - assert result == expected + check_process(result, exit_code=0, stdout="x\n") -def test_set_non_existing_file(cli): - result = cli.invoke(dotenv.cli.set, ['my_key', 'my_value']) +def test_run_with_other_env(dotenv_path, tmp_path): + dotenv_path.write_text("A=x") - assert result.exit_code != 0 + result = run_dotenv( + ["--file", str(dotenv_path), "run", "printenv", "A"], + cwd=tmp_path, + ) + check_process(result, exit_code=0, stdout="x\n") -def test_get_non_existing_file(cli): - result = cli.invoke(dotenv.cli.get, ['my_key']) - assert result.exit_code != 0 +@pytest.mark.parametrize( + "args,expected_stdout,expected_content", + [ + (["list"], "a=x\n", "a=x\n"), + (["get", "a"], "x\n", "a=x\n"), + (["set", "b", "y"], "b=y\n", "a=x\nb='y'\n"), + (["unset", "a"], "Successfully removed a\n", ""), + ( + ["run", sys.executable, "-c", "import os; print(os.environ['a'])"], + "x\n", + "a=x\n", + ), + ], + ids=["list", "get", "set", "unset", "run"], +) +def test_file_option_expands_user(tmp_path, args, expected_stdout, expected_content): + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("a=x\n") + env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home)} + + result = run_dotenv(["--file", "~/.env", *args], cwd=tmp_path, env=env) + + check_process(result, exit_code=0, stdout=expected_stdout) + assert (home / ".env").read_text() == expected_content + assert sorted(tmp_path.iterdir()) == [home] + + +def make_command(directory: Path, name: str, output: str) -> None: + """ + Create a command named `name` in `directory` which prints `output`. + """ + + if sys.platform == "win32": + (directory / f"{name}.cmd").write_text(f"@echo {output}\n") + else: + path = directory / name + path.write_text(f"#!/bin/sh\necho {output}\n") + path.chmod(0o755) + + +def test_run_with_path_from_env_file(tmp_path): + """ + Check that the command is looked up in the `PATH` set by the .env file. + """ + + # We use two commands (one prints "x", the other "y"). + dotenv_bin_dir = tmp_path / "dotenv-bin" + dotenv_bin_dir.mkdir() + make_command(directory=dotenv_bin_dir, name="dotenv-test-command", output="x") + start_bin_dir = tmp_path / "start-bin" + start_bin_dir.mkdir() + make_command(directory=start_bin_dir, name="dotenv-test-command", output="y") + start_env = { + **os.environ, + "PATH": os.pathsep.join([str(start_bin_dir), os.environ["PATH"]]), + } + # Unquoted, so that a Windows `PATH` ending with `\` isn't read as an escape. + (tmp_path / ".env").write_text(f"PATH={dotenv_bin_dir}{os.pathsep}${{PATH}}") + + result = run_dotenv(["run", "dotenv-test-command"], cwd=tmp_path, env=start_env) + + check_process(result, exit_code=0, stdout="x\n") + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific lookup") +def test_run_python_uses_path_on_windows(dotenv_path, tmp_path): + """ + Check that `dotenv run python` runs the first `python` in `PATH`, as with an + activated virtual environment (#373). + + A fake `python` placed first in `PATH` prints "x", while the real interpreter + would run the `-c` code and print "y" instead. Without resolving the command + against `PATH`, Windows picks the real interpreter. + """ + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + make_command(directory=bin_dir, name="python", output="x") # Creates `python.cmd`. + env = {**os.environ, "PATH": os.pathsep.join([str(bin_dir), os.environ["PATH"]])} + + result = run_dotenv( + ["--file", str(dotenv_path), "run", "python", "-c", "print('y')"], + cwd=tmp_path, + env=env, + ) + check_process(result, exit_code=0, stdout="x\n") -def test_list_non_existing_file(cli): - result = cli.invoke(dotenv.cli.set, []) - assert result.exit_code != 0 +def test_run_without_cmd(tmp_path): + result = run_dotenv(["run"], cwd=tmp_path) + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_default_path(tmp_path): - sh.cd(str(tmp_path)) - sh.touch(tmp_path / '.env') - sh.dotenv('set', 'HELLO', 'WORLD') - result = sh.dotenv('get', 'HELLO') +def test_run_with_invalid_cmd(dotenv_path, tmp_path): + result = run_dotenv( + ["--file", str(dotenv_path), "run", "i_do_not_exist"], + cwd=tmp_path, + ) - assert result == 'HELLO=WORLD\n' + check_process(result, exit_code=1) + assert "Command not found: i_do_not_exist" in result.stderr -def test_get_key_with_interpolation(dotenv_file): - sh.touch(dotenv_file) - dotenv.set_key(dotenv_file, 'HELLO', 'WORLD') - dotenv.set_key(dotenv_file, 'FOO', '${HELLO}') - dotenv.set_key(dotenv_file, 'BAR', 'CONCATENATED_${HELLO}_POSIX_VAR') +def test_run_with_env_missing_and_invalid_cmd(tmp_path): + """ + Check that an .env file missing takes precedence over a command not found error. + """ - with open(dotenv_file) as f: - lines = f.readlines() - assert lines == [ - 'HELLO="WORLD"\n', - 'FOO="${HELLO}"\n', - 'BAR="CONCATENATED_${HELLO}_POSIX_VAR"\n', - ] + result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path) - # test replace from variable in file - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == 'WORLD' - stored_value = dotenv.get_key(dotenv_file, 'BAR') - assert stored_value == 'CONCATENATED_WORLD_POSIX_VAR' - # test replace from environ taking precedence over file - os.environ["HELLO"] = "TAKES_PRECEDENCE" - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == "TAKES_PRECEDENCE" + check_process(result, exit_code=2) + assert "Invalid value for '-f'" in result.stderr -def test_get_key_with_interpolation_of_unset_variable(dotenv_file): - dotenv.set_key(dotenv_file, 'FOO', '${NOT_SET}') - # test unavailable replacement returns empty string - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == '' - # unless present in environment - os.environ['NOT_SET'] = 'BAR' - stored_value = dotenv.get_key(dotenv_file, 'FOO') - assert stored_value == 'BAR' - del(os.environ['NOT_SET']) +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(tmp_path): - dotenv_file = tmp_path / '.env' - dotenv_file.touch() - sh.cd(str(tmp_path)) - dotenv.set_key(str(dotenv_file), 'FOO', 'BAR') - result = sh.dotenv('run', 'printenv', 'FOO').strip() - assert result == 'BAR' +def test_run_with_command_flags(dotenv_path, tmp_path): + """ + Check that command flags passed after `dotenv run` are not interpreted. -def test_run_with_other_env(tmp_path): - dotenv_name = 'dotenv' - dotenv_file = tmp_path / dotenv_name - dotenv_file.touch() - sh.cd(str(tmp_path)) - sh.dotenv('--file', dotenv_name, 'set', 'FOO', 'BAR') - result = sh.dotenv('--file', dotenv_name, 'run', 'printenv', 'FOO').strip() - assert result == 'BAR' + Here, we want the command to receive `--help`, not `dotenv run --help`. + `sys.executable` is used instead of a system tool for portability. + """ + result = run_dotenv( + [ + "--file", + str(dotenv_path), + "run", + sys.executable, + "-c", + "import sys; print(sys.argv[1:])", + "--help", + ], + cwd=tmp_path, + ) -def test_run_without_cmd(cli): - result = cli.invoke(dotenv_cli, ['run']) - assert result.exit_code != 0 + check_process(result, exit_code=0, stdout="['--help']\n") -def test_run_with_invalid_cmd(cli): - result = cli.invoke(dotenv_cli, ['run', 'i_do_not_exist']) - assert result.exit_code != 0 +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, + ) -def test_run_with_version(cli): - result = cli.invoke(dotenv_cli, ['--version']) - print(vars(result)) - 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_core.py b/tests/test_core.py deleted file mode 100644 index eed46f97..00000000 --- a/tests/test_core.py +++ /dev/null @@ -1,247 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import contextlib -import os -import sys -import textwrap -import warnings - -import pytest -import sh -from IPython.terminal.embed import InteractiveShellEmbed - -from dotenv import dotenv_values, find_dotenv, load_dotenv, set_key -from dotenv.compat import StringIO -from dotenv.main import Binding, parse_stream - - -@contextlib.contextmanager -def restore_os_environ(): - environ = dict(os.environ) - - try: - yield - finally: - os.environ.update(environ) - - -@pytest.mark.parametrize("test_input,expected", [ - ("", []), - ("a=b", [Binding(key="a", value="b", original="a=b")]), - ("'a'=b", [Binding(key="'a'", value="b", original="'a'=b")]), - ("[=b", [Binding(key="[", value="b", original="[=b")]), - (" a = b ", [Binding(key="a", value="b", original=" a = b ")]), - ("export a=b", [Binding(key="a", value="b", original="export a=b")]), - (" export 'a'=b", [Binding(key="'a'", value="b", original=" export 'a'=b")]), - (" export 'a'=b", [Binding(key="'a'", value="b", original=" export 'a'=b")]), - ("# a=b", [Binding(key=None, value=None, original="# a=b")]), - ('a=b # comment', [Binding(key="a", value="b", original="a=b # comment")]), - ("a=b space ", [Binding(key="a", value="b space", original="a=b space ")]), - ("a='b space '", [Binding(key="a", value="b space ", original="a='b space '")]), - ('a="b space "', [Binding(key="a", value="b space ", original='a="b space "')]), - ("export export_a=1", [Binding(key="export_a", value="1", original="export export_a=1")]), - ("export port=8000", [Binding(key="port", value="8000", original="export port=8000")]), - ('a="b\nc"', [Binding(key="a", value="b\nc", original='a="b\nc"')]), - ("a='b\nc'", [Binding(key="a", value="b\nc", original="a='b\nc'")]), - ('a="b\nc"', [Binding(key="a", value="b\nc", original='a="b\nc"')]), - ('a="b\\nc"', [Binding(key="a", value='b\nc', original='a="b\\nc"')]), - ('a="b\\"c"', [Binding(key="a", value='b"c', original='a="b\\"c"')]), - ("a='b\\'c'", [Binding(key="a", value="b'c", original="a='b\\'c'")]), - ("a=à", [Binding(key="a", value="à", original="a=à")]), - ('a="à"', [Binding(key="a", value="à", original='a="à"')]), - ('garbage', [Binding(key=None, value=None, original="garbage")]), - ( - "a=b\nc=d", - [ - Binding(key="a", value="b", original="a=b\n"), - Binding(key="c", value="d", original="c=d"), - ], - ), - ( - "a=b\r\nc=d", - [ - Binding(key="a", value="b", original="a=b\r\n"), - Binding(key="c", value="d", original="c=d"), - ], - ), - ( - 'a=\nb=c', - [ - Binding(key="a", value='', original='a=\n'), - Binding(key="b", value='c', original="b=c"), - ] - ), - ( - 'a="\nb=c', - [ - Binding(key="a", value='"', original='a="\n'), - Binding(key="b", value='c', original="b=c"), - ] - ), - ( - '# comment\na="b\nc"\nd=e\n', - [ - Binding(key=None, value=None, original="# comment\n"), - Binding(key="a", value="b\nc", original='a="b\nc"\n'), - Binding(key="d", value="e", original="d=e\n"), - ], - ), - ( - 'garbage[%$#\na=b', - [ - Binding(key=None, value=None, original="garbage[%$#\n"), - Binding(key="a", value="b", original='a=b'), - ], - ), -]) -def test_parse_stream(test_input, expected): - result = parse_stream(StringIO(test_input)) - - assert list(result) == expected - - -def test_warns_if_file_does_not_exist(): - with warnings.catch_warnings(record=True) as w: - load_dotenv('.does_not_exist', verbose=True) - - assert len(w) == 1 - assert w[0].category is UserWarning - assert str(w[0].message) == "File doesn't exist .does_not_exist" - - -def test_find_dotenv(tmp_path): - """ - Create a temporary folder structure like the following: - - test_find_dotenv0/ - └── child1 - ├── child2 - │   └── child3 - │   └── child4 - └── .env - - Then try to automatically `find_dotenv` starting in `child4` - """ - - curr_dir = tmp_path - dirs = [] - for f in ['child1', 'child2', 'child3', 'child4']: - curr_dir /= f - dirs.append(curr_dir) - curr_dir.mkdir() - - child1, child4 = dirs[0], dirs[-1] - - # change the working directory for testing - os.chdir(str(child4)) - - # try without a .env file and force error - with pytest.raises(IOError): - find_dotenv(raise_error_if_not_found=True, usecwd=True) - - # try without a .env file and fail silently - assert find_dotenv(usecwd=True) == '' - - # now place a .env file a few levels up and make sure it's found - dotenv_file = child1 / '.env' - dotenv_file.write_bytes(b"TEST=test\n") - assert find_dotenv(usecwd=True) == str(dotenv_file) - - -def test_load_dotenv(tmp_path): - os.chdir(str(tmp_path)) - dotenv_path = '.test_load_dotenv' - sh.touch(dotenv_path) - set_key(dotenv_path, 'DOTENV', 'WORKS') - assert 'DOTENV' not in os.environ - success = load_dotenv(dotenv_path) - assert success - assert 'DOTENV' in os.environ - assert os.environ['DOTENV'] == 'WORKS' - - -def test_load_dotenv_override(tmp_path): - os.chdir(str(tmp_path)) - dotenv_path = '.test_load_dotenv_override' - key_name = "DOTENV_OVER" - sh.touch(dotenv_path) - os.environ[key_name] = "OVERRIDE" - set_key(dotenv_path, key_name, 'WORKS') - success = load_dotenv(dotenv_path, override=True) - assert success - assert key_name in os.environ - assert os.environ[key_name] == 'WORKS' - - -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(""" - import dotenv - import os - - dotenv.load_dotenv(verbose=True) - print(os.environ['a']) - """)) - os.chdir(str(tmp_path)) - - result = sh.Command(sys.executable)(code_path) - - assert result == 'b\n' - - -def test_ipython(tmp_path): - os.chdir(str(tmp_path)) - dotenv_file = tmp_path / '.env' - dotenv_file.write_text("MYNEWVALUE=q1w2e3\n") - ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv") - assert os.environ["MYNEWVALUE"] == 'q1w2e3' - - -def test_ipython_override(tmp_path): - os.chdir(str(tmp_path)) - dotenv_file = tmp_path / '.env' - os.environ["MYNEWVALUE"] = "OVERRIDE" - dotenv_file.write_text("MYNEWVALUE=q1w2e3\n") - ipshell = InteractiveShellEmbed() - ipshell.magic("load_ext dotenv") - ipshell.magic("dotenv -o") - assert os.environ["MYNEWVALUE"] == 'q1w2e3' - - -def test_dotenv_values_stream(): - stream = StringIO(u'hello="it works!😃"\nDOTENV=${hello}\n') - stream.seek(0) - parsed_dict = dotenv_values(stream=stream) - assert 'DOTENV' in parsed_dict - assert parsed_dict['DOTENV'] == u'it works!😃' - - -def test_dotenv_values_export(): - stream = StringIO('export foo=bar\n') - stream.seek(0) - load_dotenv(stream=stream) - assert 'foo' in os.environ - assert os.environ['foo'] == 'bar' - - -def test_dotenv_empty_selfreferential_interpolation(): - stream = StringIO(u'some_path="${some_path}:a/b/c"\n') - stream.seek(0) - assert u'some_path' not in os.environ - parsed_dict = dotenv_values(stream=stream) - assert {u'some_path': u':a/b/c'} == parsed_dict - - -def test_dotenv_nonempty_selfreferential_interpolation(): - stream = StringIO(u'some_path="${some_path}:a/b/c"\n') - stream.seek(0) - assert u'some_path' not in os.environ - with restore_os_environ(): - os.environ[u'some_path'] = u'x/y/z' - parsed_dict = dotenv_values(stream=stream) - assert {u'some_path': u'x/y/z:a/b/c'} == parsed_dict diff --git a/tests/test_fifo_dotenv.py b/tests/test_fifo_dotenv.py new file mode 100644 index 00000000..2aa31779 --- /dev/null +++ b/tests/test_fifo_dotenv.py @@ -0,0 +1,31 @@ +import os +import pathlib +import sys +import threading + +import pytest + +from dotenv import load_dotenv + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="FIFOs are Unix-only") + + +def test_load_dotenv_from_fifo(tmp_path: pathlib.Path, monkeypatch): + fifo = tmp_path / ".env" + os.mkfifo(fifo) # create named pipe + + def writer(): + with open(fifo, "w", encoding="utf-8") as w: + w.write("MY_PASSWORD=pipe-secret\n") + + t = threading.Thread(target=writer) + t.start() + + # Ensure env is clean + monkeypatch.delenv("MY_PASSWORD", raising=False) + + ok = load_dotenv(dotenv_path=str(fifo), override=True) + t.join(timeout=2) + + assert ok is True + assert os.getenv("MY_PASSWORD") == "pipe-secret" diff --git a/tests/test_ipython.py b/tests/test_ipython.py new file mode 100644 index 00000000..93bffd66 --- /dev/null +++ b/tests/test_ipython.py @@ -0,0 +1,78 @@ +import os +import sys +from unittest import mock + +import pytest + +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 + + dotenv_file = tmp_path / ".env" + dotenv_file.write_text("a=b\n") + os.chdir(tmp_path) + os.environ["a"] = "c" + + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") + + 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 + + dotenv_file = tmp_path / ".env" + dotenv_file.write_text("a=b\n") + os.chdir(tmp_path) + os.environ["a"] = "c" + + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "-o") + + 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 + + dotenv_file = tmp_path / ".env" + dotenv_file.write_text("a=b\n") + os.chdir(tmp_path) + + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "") + + assert os.environ == {"a": "b"} + + +@mock.patch.dict(os.environ, {}, clear=False) +def test_ipython_path_expands_user(tmp_path, monkeypatch): + from IPython.terminal.embed import InteractiveShellEmbed + + (tmp_path / ".env").write_text("dotenv_ipython_home=b\n") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + ipshell = InteractiveShellEmbed() + ipshell.run_line_magic("load_ext", "dotenv") + ipshell.run_line_magic("dotenv", "~/.env") + + assert os.environ["dotenv_ipython_home"] == "b" diff --git a/tests/test_is_interactive.py b/tests/test_is_interactive.py new file mode 100644 index 00000000..1c073471 --- /dev/null +++ b/tests/test_is_interactive.py @@ -0,0 +1,232 @@ +import builtins +import sys +from unittest import mock + +from dotenv.main import find_dotenv + + +class TestIsInteractive: + """Tests for the _is_interactive helper function within find_dotenv. + + The _is_interactive function is used by find_dotenv to determine if the code + is running in an interactive environment (like a REPL, IPython notebook, etc.) + versus a normal script execution. + + Interactive environments include: + - Python REPL (has sys.ps1 or sys.ps2) + - IPython notebooks (no __file__ in __main__) + - Interactive shells + + Non-interactive environments include: + - Normal script execution (has __file__ in __main__) + - Module imports + + Examples of the behavior: + >>> import sys + >>> # In a REPL: + >>> hasattr(sys, 'ps1') # True + >>> # In a script: + >>> hasattr(sys, 'ps1') # False + """ + + def _create_dotenv_file(self, tmp_path): + """Helper to create a test .env file.""" + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("TEST=value") + return dotenv_path + + def _setup_subdir_and_chdir(self, tmp_path, monkeypatch): + """Helper to create subdirectory and change to it.""" + test_dir = tmp_path / "subdir" + test_dir.mkdir() + monkeypatch.chdir(test_dir) + return test_dir + + def _remove_ps_attributes(self, monkeypatch): + """Helper to remove ps1/ps2 attributes if they exist.""" + if hasattr(sys, "ps1"): + monkeypatch.delattr(sys, "ps1") + if hasattr(sys, "ps2"): + monkeypatch.delattr(sys, "ps2") + + def _mock_main_import(self, monkeypatch, mock_main_module): + """Helper to mock __main__ module import.""" + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "__main__": + return mock_main_module + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + def _mock_main_import_error(self, monkeypatch): + """Helper to mock __main__ module import that raises ModuleNotFoundError.""" + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "__main__": + raise ModuleNotFoundError("No module named '__main__'") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + def test_is_interactive_with_ps1(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when sys.ps1 exists.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock sys.ps1 to simulate interactive shell + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_with_ps2(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when sys.ps2 exists.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock sys.ps2 to simulate multi-line interactive input + monkeypatch.setattr(sys, "ps2", "... ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_module_not_found(self, tmp_path, monkeypatch): + """Test that _is_interactive returns False when __main__ module import fails.""" + self._remove_ps_attributes(monkeypatch) + self._mock_main_import_error(monkeypatch) + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # Since _is_interactive() returns False, find_dotenv should not find anything + # without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_main_without_file(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when __main__ has no __file__ attribute.""" + self._remove_ps_attributes(monkeypatch) + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock __main__ module without __file__ attribute + mock_main = mock.MagicMock() + del mock_main.__file__ # Remove __file__ attribute + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # When _is_interactive() returns True, find_dotenv should search from cwd + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_with_file(self, tmp_path, monkeypatch): + """Test that _is_interactive returns False when __main__ has __file__ attribute.""" + self._remove_ps_attributes(monkeypatch) + + # Mock __main__ module with __file__ attribute + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # Since _is_interactive() returns False, find_dotenv should not find anything + # without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_precedence_ps1_over_main(self, tmp_path, monkeypatch): + """Test that ps1/ps2 attributes take precedence over __main__ module check.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Set ps1 attribute + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + + # Mock __main__ module with __file__ attribute (which would normally return False) + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # ps1 should take precedence, so _is_interactive() returns True + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_ps1_and_ps2_both_exist(self, tmp_path, monkeypatch): + """Test that _is_interactive returns True when both ps1 and ps2 exist.""" + dotenv_path = self._create_dotenv_file(tmp_path) + + # Set both ps1 and ps2 attributes + monkeypatch.setattr(sys, "ps1", ">>> ", raising=False) + monkeypatch.setattr(sys, "ps2", "... ", raising=False) + + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # Should return True with either attribute present + result = find_dotenv() + assert result == str(dotenv_path) + + def test_is_interactive_main_module_with_file_attribute_none( + self, tmp_path, monkeypatch + ): + """Test _is_interactive when __main__ has __file__ attribute set to None.""" + self._remove_ps_attributes(monkeypatch) + + # Mock __main__ module with __file__ = None + mock_main = mock.MagicMock() + mock_main.__file__ = None + + self._mock_main_import(monkeypatch, mock_main) + + # Mock sys.gettrace to ensure debugger detection returns False + monkeypatch.setattr("sys.gettrace", lambda: None) + + monkeypatch.chdir(tmp_path) + + # __file__ = None should still be considered non-interactive + # and with no debugger, find_dotenv should not search from cwd + result = find_dotenv() + assert result == "" + + def test_is_interactive_no_ps_attributes_and_normal_execution( + self, tmp_path, monkeypatch + ): + """Test normal script execution scenario where _is_interactive should return False.""" + self._remove_ps_attributes(monkeypatch) + + # Don't mock anything - let it use the real __main__ module + # which should have a __file__ attribute in normal execution + + # Change to directory and test + monkeypatch.chdir(tmp_path) + + # In normal execution, _is_interactive() should return False + # so find_dotenv should not find anything without usecwd=True + result = find_dotenv() + assert result == "" + + def test_is_interactive_with_usecwd_override(self, tmp_path, monkeypatch): + """Test that usecwd=True overrides _is_interactive behavior.""" + self._remove_ps_attributes(monkeypatch) + dotenv_path = self._create_dotenv_file(tmp_path) + + # Mock __main__ module with __file__ attribute (non-interactive) + mock_main = mock.MagicMock() + mock_main.__file__ = "/path/to/script.py" + + self._mock_main_import(monkeypatch, mock_main) + self._setup_subdir_and_chdir(tmp_path, monkeypatch) + + # Even though _is_interactive() returns False, usecwd=True should find the file + result = find_dotenv(usecwd=True) + assert result == str(dotenv_path) diff --git a/tests/test_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 new file mode 100644 index 00000000..930ab171 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,950 @@ +import io +import logging +import os +import pathlib +import stat +import subprocess +import sys +import textwrap +from unittest import mock + +import pytest + +import dotenv +from dotenv.main import DotEnv + + +def test_set_key_no_file(tmp_path): + nx_path = tmp_path / "nx" + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning"): + result = dotenv.set_key(nx_path, "foo", "bar") + + assert result == (True, "foo", "bar") + assert nx_path.exists() + + +@pytest.mark.parametrize( + "before,key,value,expected,after", + [ + ("", "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'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"), + ("", "a", "b\\c", (True, "a", "b\\c"), "a='b\\\\c'\n"), + ("", "a", "b\\", (True, "a", "b\\"), "a='b\\\\'\n"), + ("", "a", "b\\'c", (True, "a", "b\\'c"), "a='b\\\\\\'c'\n"), + ], +) +def test_set_key(dotenv_path, before, key, value, expected, after): + logger = logging.getLogger("dotenv.main") + dotenv_path.write_text(before) + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.set_key(dotenv_path, key, value) + + assert result == expected + assert dotenv_path.read_text() == after + mock_warning.assert_not_called() + + +@pytest.mark.parametrize( + "value", + [ + "C:\\Users", + "C:\\Users\\", + "\\d+", + "back\\", + "a\\'b", + "it's", + 'say "hi"', + "a\\nb", + "plain", + "", + ], +) +def test_set_key_round_trips(dotenv_path, value): + dotenv_path.write_text("") + + dotenv.set_key(dotenv_path, "a", value) + dotenv.set_key(dotenv_path, "b", "sentinel") + + assert dotenv.get_key(dotenv_path, "a") == value + # A value that is mis-tokenized can swallow the lines that follow it. + assert dotenv.get_key(dotenv_path, "b") == "sentinel" + + +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" + + +@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): + 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") + + # 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() == "" + + +@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_leaves_no_temp_file(dotenv_path): + 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) + + try: + with pytest.raises(PermissionError): + dotenv.set_key(dotenv_path, "a", "b") + + assert list(dotenv_path.parent.glob(".tmp_*")) == [] + finally: + # Restore permissions + if sys.platform == "win32": + dotenv_path.chmod(stat.S_IWRITE | stat.S_IREAD) + else: + dotenv_path.chmod(0o600) + + +def test_rewrite_reports_original_error_when_cleanup_fails(dotenv_path, caplog): + replace_error = OSError("replace failed") + + with mock.patch("dotenv.main.os.replace", side_effect=replace_error): + with mock.patch.object( + pathlib.Path, "unlink", side_effect=OSError("unlink failed") + ): + with pytest.raises(OSError) as excinfo: + dotenv.set_key(dotenv_path, "a", "b") + + assert excinfo.value is replace_error + [temp_file] = dotenv_path.parent.glob(".tmp_*") + assert caplog.messages == [ + f"python-dotenv could not remove the temporary file {temp_file}" + ] + + +def test_set_key_missing_directory(tmp_path): + dotenv_path = tmp_path / "nx_dir" / ".env" + + with pytest.raises(FileNotFoundError) as exc_info: + dotenv.set_key(dotenv_path, "a", "b") + + assert exc_info.value.filename == str(dotenv_path) + assert not dotenv_path.parent.exists() + + +@pytest.mark.skipif( + sys.platform == "win32" or os.geteuid() == 0, + reason="Directory permissions are not enforced on Windows or for root.", +) +def test_set_key_read_only_directory(tmp_path): + directory = tmp_path / "ro" + directory.mkdir() + dotenv_path = directory / ".env" + dotenv_path.write_text("a=x\n") + directory.chmod(0o555) + + try: + with pytest.raises(PermissionError) as exc_info: + dotenv.set_key(dotenv_path, "a", "y") + finally: + directory.chmod(0o755) + + assert exc_info.value.filename == str(dotenv_path) + assert dotenv_path.read_text() == "a=x\n" + assert list(directory.iterdir()) == [dotenv_path] + + +def windows_read_only_semantics(path): + # On Windows, a file without the owner-write bit can't be replaced or deleted. + return path.exists() and not path.stat().st_mode & stat.S_IWUSR + + +@pytest.mark.parametrize( + "rewrite", + [ + lambda path: dotenv.set_key(path, "a", "y"), + lambda path: dotenv.unset_key(path, "a"), + ], + ids=["set_key", "unset_key"], +) +def test_rewrite_read_only_file_leaves_no_temp_file(tmp_path, rewrite): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("a=x\n") + dotenv_path.chmod(stat.S_IREAD) + real_replace = os.replace + real_unlink = pathlib.Path.unlink + + def replace(src, dst): + if windows_read_only_semantics(pathlib.Path(dst)): + raise PermissionError( + 13, "Access is denied", os.fspath(src), None, os.fspath(dst) + ) + real_replace(src, dst) + + def unlink(self, missing_ok=False): + if windows_read_only_semantics(self): + raise PermissionError(13, "Access is denied", str(self)) + real_unlink(self, missing_ok=missing_ok) + + with mock.patch("dotenv.main.os.replace", side_effect=replace): + with mock.patch.object(pathlib.Path, "unlink", unlink): + with pytest.raises(PermissionError) as exc_info: + rewrite(dotenv_path) + + dotenv_path.chmod(stat.S_IREAD | stat.S_IWRITE) + assert exc_info.value.filename2 == str(dotenv_path) + assert dotenv_path.read_text() == "a=x\n" + assert list(tmp_path.iterdir()) == [dotenv_path] + + +@pytest.mark.parametrize("path_type", [str, pathlib.Path]) +@pytest.mark.parametrize( + "call,expected_result,expected_content", + [ + (lambda path: dotenv.dotenv_values(path), {"a": "x"}, "a=x\n"), + (lambda path: dotenv.get_key(path, "a"), "x", "a=x\n"), + (lambda path: dotenv.set_key(path, "b", "y"), (True, "b", "y"), "a=x\nb='y'\n"), + (lambda path: dotenv.unset_key(path, "a"), (True, "a"), ""), + ], + ids=["dotenv_values", "get_key", "set_key", "unset_key"], +) +def test_dotenv_path_expands_user( + tmp_path, monkeypatch, path_type, call, expected_result, expected_content +): + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("a=x\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.chdir(tmp_path) + + result = call(path_type("~/.env")) + + assert result == expected_result + assert (home / ".env").read_text() == expected_content + assert sorted(tmp_path.iterdir()) == [home] + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_expands_user(tmp_path, monkeypatch): + (tmp_path / ".env").write_text("a=x\n") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + + result = dotenv.load_dotenv("~/.env") + + assert result is True + assert os.environ["a"] == "x" + + +@pytest.mark.parametrize( + "path,expected", + [ + ("~/.env", "{home}/.env"), + (pathlib.Path("~/.env"), pathlib.Path("{home}/.env")), + (".env", ".env"), + (None, None), + ], +) +def test_dotenv_path_keeps_type(tmp_path, monkeypatch, path, expected): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + if expected is not None: + expected = type(expected)(str(expected).format(home=tmp_path)) + + result = DotEnv(path).dotenv_path + + assert result == expected + assert type(result) is type(expected) + + +def test_get_key_no_file(tmp_path): + nx_path = tmp_path / "nx" + logger = logging.getLogger("dotenv.main") + + with ( + mock.patch.object(logger, "info") as mock_info, + mock.patch.object(logger, "warning") as mock_warning, + ): + result = dotenv.get_key(nx_path, "foo") + + assert result is None + mock_info.assert_has_calls( + calls=[ + mock.call("python-dotenv could not find configuration file %s.", nx_path) + ], + ) + mock_warning.assert_has_calls( + calls=[mock.call("Key %s not found in %s.", "foo", nx_path)], + ) + + +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_path, "foo") + + assert result is None + mock_warning.assert_called_once_with("Key %s not found in %s.", "foo", dotenv_path) + + +def test_get_key_ok(dotenv_path): + logger = logging.getLogger("dotenv.main") + dotenv_path.write_text("foo=bar") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.get_key(dotenv_path, "foo") + + assert result == "bar" + mock_warning.assert_not_called() + + +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") + dotenv_path.write_text("foo") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.get_key(dotenv_path, "foo") + + assert result is None + mock_warning.assert_not_called() + + +def test_empty_dotenv_dict_is_cached(tmp_path): + dotenv_path = tmp_path / ".env" + dotenv_path.write_text("") + dotenv_obj = DotEnv(dotenv_path) + + with mock.patch.object(dotenv_obj, "parse", wraps=dotenv_obj.parse) as mock_parse: + assert dotenv_obj.dict() == {} + assert dotenv_obj.dict() == {} + + assert mock_parse.call_count == 1 + + +def test_unset_with_value(dotenv_path): + logger = logging.getLogger("dotenv.main") + dotenv_path.write_text("a=b\nc=d") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(dotenv_path, "a") + + assert result == (True, "a") + assert dotenv_path.read_text() == "c=d" + mock_warning.assert_not_called() + + +def test_unset_no_value(dotenv_path): + logger = logging.getLogger("dotenv.main") + dotenv_path.write_text("foo") + + with mock.patch.object(logger, "warning") as mock_warning: + result = dotenv.unset_key(dotenv_path, "foo") + + assert result == (True, "foo") + 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_unset_non_existent_file(tmp_path): + nx_path = tmp_path / "nx" + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "warning") as mock_warning: + 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_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: + + test_find_dotenv0/ + └── child1 + ├── child2 + │   └── child3 + │   └── child4 + └── .env + + Then try to automatically `find_dotenv` starting in `child4` + """ + + leaf = path / "child1" / "child2" / "child3" / "child4" + leaf.mkdir(parents=True, exist_ok=True) + return leaf + + +def test_find_dotenv_no_file_raise(tmp_path): + 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): + leaf = prepare_file_hierarchy(tmp_path) + os.chdir(leaf) + + result = dotenv.find_dotenv(usecwd=True) + + assert result == "" + + +def test_find_dotenv_found(tmp_path): + 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_path) + + +def test_find_dotenv_expands_user(tmp_path, monkeypatch): + home = tmp_path / "home" + home.mkdir() + (home / ".env").write_text("a=x\n") + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + monkeypatch.chdir(tmp_path) + + result = dotenv.find_dotenv("~/.env", usecwd=True) + + assert result == f"{home}/.env" + + +@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") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + assert os.environ == {"a": "b"} + + +@pytest.mark.parametrize( + "flag_value", + [ + "true", + "yes", + "1", + "t", + "y", + "True", + "Yes", + "TRUE", + "YES", + "T", + "Y", + ], +) +def test_load_dotenv_disabled(dotenv_path, flag_value): + expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value} + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): + dotenv_path.write_text("a=b") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is False + assert os.environ == expected_environ + + +@pytest.mark.parametrize( + "flag_value", + [ + "true", + "yes", + "1", + "t", + "y", + "True", + "Yes", + "TRUE", + "YES", + "T", + "Y", + ], +) +def test_load_dotenv_disabled_notification(dotenv_path, flag_value): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): + dotenv_path.write_text("a=b") + + logger = logging.getLogger("dotenv.main") + with mock.patch.object(logger, "debug") as mock_debug: + result = dotenv.load_dotenv(dotenv_path) + + assert result is False + mock_debug.assert_called_once_with( + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) + + +@pytest.mark.skipif( + sys.platform == "win32", reason="This test assumes case-sensitive variable names" +) +@pytest.mark.parametrize( + "flag_value", + [ + "", + "false", + "no", + "0", + "f", + "n", + "False", + "No", + "FALSE", + "NO", + "F", + "N", + ], +) +def test_load_dotenv_enabled(dotenv_path, flag_value): + expected_environ = {"PYTHON_DOTENV_DISABLED": flag_value, "a": "b"} + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): + dotenv_path.write_text("a=b") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + assert os.environ == expected_environ + + +@pytest.mark.parametrize( + "flag_value", + [ + "", + "false", + "no", + "0", + "f", + "n", + "False", + "No", + "FALSE", + "NO", + "F", + "N", + ], +) +def test_load_dotenv_enabled_no_notification(dotenv_path, flag_value): + with mock.patch.dict( + os.environ, {"PYTHON_DOTENV_DISABLED": flag_value}, clear=True + ): + dotenv_path.write_text("a=b") + + logger = logging.getLogger("dotenv.main") + with mock.patch.object(logger, "debug") as mock_debug: + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + mock_debug.assert_not_called() + + +@mock.patch.dict(os.environ, {}, clear=True) +def test_load_dotenv_doesnt_disable_itself(dotenv_path): + dotenv_path.write_text("PYTHON_DOTENV_DISABLED=true") + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + assert os.environ == {"PYTHON_DOTENV_DISABLED": "true"} + + +def test_load_dotenv_no_file_verbose(): + logger = logging.getLogger("dotenv.main") + + with mock.patch.object(logger, "info") as mock_info: + result = dotenv.load_dotenv(".does_not_exist", verbose=True) + + assert result is False + mock_info.assert_called_once_with( + "python-dotenv could not find configuration file %s.", ".does_not_exist" + ) + + +@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") + + result = dotenv.load_dotenv(dotenv_path, override=False) + + assert result is True + 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") + + result = dotenv.load_dotenv(dotenv_path, override=True) + + assert result is True + 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}"') + + result = dotenv.load_dotenv(dotenv_path) + + assert result is True + 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}"') + + result = dotenv.load_dotenv(dotenv_path, override=True) + + assert result is True + 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=à") + + result = dotenv.load_dotenv(stream=stream) + + assert result is True + 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") + + with dotenv_path.open() as f: + result = dotenv.load_dotenv(stream=f) + + assert result is True + assert os.environ == {"a": "b"} + + +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(""" + import dotenv + import os + + dotenv.load_dotenv(verbose=True) + print(os.environ['a']) + """) + ) + os.chdir(tmp_path) + + result = subprocess.run( + [sys.executable, str(code_path)], + capture_output=True, + text=True, + check=True, + ) + + assert result.stdout == "b\n" + + +def test_dotenv_values_file(dotenv_path): + dotenv_path.write_text("a=b") + + result = dotenv.dotenv_values(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", + [ + # Defined in environment, with and without interpolation + ({"b": "c"}, "a=$b", False, {"a": "$b"}), + ({"b": "c"}, "a=$b", True, {"a": "$b"}), + ({"b": "c"}, "a=${b}", False, {"a": "${b}"}), + ({"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"}), + ({}, "a=b\nc=${a}\nd=e\nc=${d}", True, {"a": "b", "c": "e", "d": "e"}), + ], +) +def test_dotenv_values_string_io(env, string, interpolate, expected): + with mock.patch.dict(os.environ, env, clear=True): + stream = io.StringIO(string) + stream.seek(0) + + result = dotenv.dotenv_values(stream=stream, interpolate=interpolate) + + assert result == expected + + +def test_dotenv_values_file_stream(dotenv_path): + dotenv_path.write_text("a=b") + + with dotenv_path.open() as f: + result = dotenv.dotenv_values(stream=f) + + assert result == {"a": "b"} + + +@pytest.mark.parametrize( + "string,expected", + [ + ("KEY= # comment", {"KEY": ""}), + ("KEY= # comment", {"KEY": ""}), + ("KEY=val # comment", {"KEY": "val"}), + ("KEY=a#b", {"KEY": "a#b"}), + ("KEY=#novalue", {"KEY": "#novalue"}), + ("KEY=# # comment", {"KEY": "#"}), + ("KEY=\t# comment", {"KEY": ""}), + ("KEY= # comment\nOTHER=val", {"KEY": "", "OTHER": "val"}), + ], +) +def test_dotenv_values_empty_value_with_inline_comment(string, expected): + result = dotenv.dotenv_values(stream=io.StringIO(string)) + + assert result == expected diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 00000000..982305a3 --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,763 @@ +import io + +import pytest + +from dotenv.parser import Binding, Original, parse_stream + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("", []), + ( + "a=b", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b", line=1), + error=False, + ) + ], + ), + ( + "'a'=b", + [ + Binding( + key="a", + value="b", + original=Original(string="'a'=b", line=1), + error=False, + ) + ], + ), + ( + "[=b", + [ + Binding( + key="[", + value="b", + original=Original(string="[=b", line=1), + error=False, + ) + ], + ), + ( + " a = b ", + [ + Binding( + key="a", + value="b", + original=Original(string=" a = b ", line=1), + error=False, + ) + ], + ), + ( + "export a=b", + [ + Binding( + key="a", + value="b", + original=Original(string="export a=b", line=1), + error=False, + ) + ], + ), + ( + " export 'a'=b", + [ + Binding( + key="a", + value="b", + original=Original(string=" export 'a'=b", line=1), + error=False, + ) + ], + ), + ( + "# a=b", + [ + Binding( + key=None, + value=None, + original=Original(string="# a=b", line=1), + error=False, + ) + ], + ), + ( + "a=b#c", + [ + Binding( + key="a", + value="b#c", + original=Original(string="a=b#c", line=1), + error=False, + ) + ], + ), + ( + "a=b #c", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b #c", line=1), + error=False, + ) + ], + ), + ( + "a=b\t#c", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\t#c", line=1), + error=False, + ) + ], + ), + ( + "a= #b", + [ + Binding( + key="a", + value="", + original=Original(string="a= #b", line=1), + error=False, + ) + ], + ), + ( + "a= # b", + [ + Binding( + key="a", + value="", + original=Original(string="a= # b", line=1), + error=False, + ) + ], + ), + ( + "a=#b", + [ + Binding( + key="a", + value="#b", + original=Original(string="a=#b", line=1), + error=False, + ) + ], + ), + ( + "a=# # comment", + [ + Binding( + key="a", + value="#", + original=Original(string="a=# # comment", line=1), + error=False, + ) + ], + ), + ( + "a=\t#c", + [ + Binding( + key="a", + value="", + original=Original(string="a=\t#c", line=1), + error=False, + ) + ], + ), + ( + "a= #c\nd=e", + [ + Binding( + key="a", + value="", + original=Original(string="a= #c\n", line=1), + error=False, + ), + Binding( + key="d", + value="e", + original=Original(string="d=e", line=2), + error=False, + ), + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\tc", + [ + Binding( + key="a", + value="b\tc", + original=Original(string="a=b\tc", line=1), + error=False, + ) + ], + ), + ( + "a=b c", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c", line=1), + error=False, + ) + ], + ), + ( + "a=b\u00a0 c", + [ + Binding( + key="a", + value="b\u00a0 c", + original=Original(string="a=b\u00a0 c", line=1), + error=False, + ) + ], + ), + ( + "a=b c ", + [ + Binding( + key="a", + value="b c", + original=Original(string="a=b c ", line=1), + error=False, + ) + ], + ), + ( + "a='b c '", + [ + Binding( + key="a", + value="b c ", + original=Original(string="a='b c '", line=1), + error=False, + ) + ], + ), + ( + 'a="b c "', + [ + Binding( + key="a", + value="b c ", + original=Original(string='a="b c "', line=1), + error=False, + ) + ], + ), + ( + "export export_a=1", + [ + Binding( + key="export_a", + value="1", + original=Original(string="export export_a=1", line=1), + error=False, + ) + ], + ), + ( + "export port=8000", + [ + Binding( + key="port", + value="8000", + original=Original(string="export port=8000", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\nc'", + [ + Binding( + key="a", + value="b\nc", + original=Original(string="a='b\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"', line=1), + error=False, + ) + ], + ), + ( + 'a="b\\nc"', + [ + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\\nc"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\nc'", + [ + Binding( + key="a", + value="b\\nc", + original=Original(string="a='b\\nc'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\\"c"', + [ + Binding( + key="a", + value='b"c', + original=Original(string='a="b\\"c"', line=1), + error=False, + ) + ], + ), + ( + "a='b\\'c'", + [ + Binding( + key="a", + value="b'c", + original=Original(string="a='b\\'c'", line=1), + error=False, + ) + ], + ), + ( + "a='b\\\\c'", + [ + Binding( + key="a", + value="b\\c", + original=Original(string="a='b\\\\c'", line=1), + error=False, + ) + ], + ), + ( + 'a="b\\\\c"', + [ + Binding( + key="a", + value="b\\c", + original=Original(string='a="b\\\\c"', line=1), + error=False, + ) + ], + ), + # An escaped backslash at the end of the value must not be read as the + # start of an escaped quote, which would swallow the following lines. + ( + "a='b\\\\'\nc='d'", + [ + Binding( + key="a", + value="b\\", + original=Original(string="a='b\\\\'\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c='d'", line=2), + error=False, + ), + ], + ), + ( + 'a="b\\\\"\nc="d"', + [ + Binding( + key="a", + value="b\\", + original=Original(string='a="b\\\\"\n', line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string='c="d"', line=2), + error=False, + ), + ], + ), + ( + "a='b\\\\\\'c'", + [ + Binding( + key="a", + value="b\\'c", + original=Original(string="a='b\\\\\\'c'", line=1), + error=False, + ) + ], + ), + ( + "a=à", + [ + Binding( + key="a", + value="à", + original=Original(string="a=à", line=1), + error=False, + ) + ], + ), + ( + 'a="à"', + [ + Binding( + key="a", + value="à", + original=Original(string='a="à"', line=1), + error=False, + ) + ], + ), + ( + "no_value_var", + [ + Binding( + key="no_value_var", + value=None, + original=Original(string="no_value_var", line=1), + error=False, + ) + ], + ), + ( + "a: b", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b", line=1), + error=True, + ) + ], + ), + ( + "a: b\r\nc=d", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b\r\n", line=1), + error=True, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a: b\r\nc=d\r\ne: f", + [ + Binding( + key=None, + value=None, + original=Original(string="a: b\r\n", line=1), + error=True, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d\r\n", line=2), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="e: f", line=3), + error=True, + ), + ], + ), + ( + "a=b\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\rc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\r", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=b\r\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\r\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ( + "a=\nb=c", + [ + Binding( + key="a", + value="", + original=Original(string="a=\n", line=1), + error=False, + ), + Binding( + key="b", + value="c", + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + "\n\n", + [ + Binding( + key=None, + value=None, + original=Original(string="\n\n", line=1), + error=False, + ), + ], + ), + ( + "a=b\n\n", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="\n", line=2), + error=False, + ), + ], + ), + ( + "a=b\n\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="\nc=d", line=2), + error=False, + ), + ], + ), + ( + 'a="\nb=c', + [ + Binding( + key=None, + value=None, + original=Original(string='a="\n', line=1), + error=True, + ), + Binding( + key="b", + value="c", + original=Original(string="b=c", line=2), + error=False, + ), + ], + ), + ( + '# comment\na="b\nc"\nd=e\n', + [ + Binding( + key=None, + value=None, + original=Original(string="# comment\n", line=1), + error=False, + ), + Binding( + key="a", + value="b\nc", + original=Original(string='a="b\nc"\n', line=2), + error=False, + ), + Binding( + key="d", + value="e", + original=Original(string="d=e\n", line=4), + error=False, + ), + ], + ), + ( + "a=b\n# comment 1", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="# comment 1", line=2), + error=False, + ), + ], + ), + ( + "# comment 1\n# comment 2", + [ + Binding( + key=None, + value=None, + original=Original(string="# comment 1\n", line=1), + error=False, + ), + Binding( + key=None, + value=None, + original=Original(string="# comment 2", line=2), + error=False, + ), + ], + ), + ( + 'uglyKey[%$="S3cr3t_P4ssw#rD" #\na=b', + [ + Binding( + key="uglyKey[%$", + value="S3cr3t_P4ssw#rD", + original=Original( + string='uglyKey[%$="S3cr3t_P4ssw#rD" #\n', line=1 + ), + error=False, + ), + Binding( + key="a", + value="b", + original=Original(string="a=b", line=2), + error=False, + ), + ], + ), + # UTF-8 BOM at the start of the file should be stripped + ( + "\ufeffa=b", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b", line=1), + error=False, + ) + ], + ), + ( + "\ufeffa=b\nc=d", + [ + Binding( + key="a", + value="b", + original=Original(string="a=b\n", line=1), + error=False, + ), + Binding( + key="c", + value="d", + original=Original(string="c=d", line=2), + error=False, + ), + ], + ), + ], +) +def test_parse_stream(test_input, expected): + result = parse_stream(io.StringIO(test_input)) + + assert list(result) == expected diff --git a/tests/test_utils.py b/tests/test_utils.py index d691f0e7..93b8bae2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,12 +2,18 @@ def test_to_cli_string(): - assert c() == 'dotenv' - assert c(path='/etc/.env') == 'dotenv -f /etc/.env' - assert c(path='/etc/.env', action='list') == 'dotenv -f /etc/.env list' - assert c(action='list') == 'dotenv list' - assert c(action='get', key='DEBUG') == 'dotenv get DEBUG' - assert c(action='set', key='DEBUG', value='True') == 'dotenv set DEBUG True' - assert c(action='set', key='SECRET', value='=@asdfasf') == 'dotenv set SECRET =@asdfasf' - assert c(action='set', key='SECRET', value='a b') == 'dotenv set SECRET "a b"' - assert c(action='set', key='SECRET', value='a b', quote="always") == 'dotenv -q always set SECRET "a b"' + assert c() == "dotenv" + assert c(path="/etc/.env") == "dotenv -f /etc/.env" + assert c(path="/etc/.env", action="list") == "dotenv -f /etc/.env list" + assert c(action="list") == "dotenv list" + assert c(action="get", key="DEBUG") == "dotenv get DEBUG" + assert c(action="set", key="DEBUG", value="True") == "dotenv set DEBUG True" + assert ( + c(action="set", key="SECRET", value="=@asdfasf") + == "dotenv set SECRET =@asdfasf" + ) + assert c(action="set", key="SECRET", value="a b") == 'dotenv set SECRET "a b"' + assert ( + c(action="set", key="SECRET", value="a b", quote="always") + == 'dotenv -q always set SECRET "a b"' + ) diff --git a/tests/test_variables.py b/tests/test_variables.py new file mode 100644 index 00000000..6f2b2203 --- /dev/null +++ b/tests/test_variables.py @@ -0,0 +1,35 @@ +import pytest + +from dotenv.variables import Literal, Variable, parse_variables + + +@pytest.mark.parametrize( + "value,expected", + [ + ("", []), + ("a", [Literal(value="a")]), + ("${a}", [Variable(name="a", default=None)]), + ("${a:-b}", [Variable(name="a", default="b")]), + ( + "${a}${b}", + [ + Variable(name="a", default=None), + Variable(name="b", default=None), + ], + ), + ( + "a${b}c${d}e", + [ + Literal(value="a"), + Variable(name="b", default=None), + Literal(value="c"), + Variable(name="d", default=None), + Literal(value="e"), + ], + ), + ], +) +def test_parse_variables(value, expected): + result = parse_variables(value) + + assert list(result) == expected diff --git a/tests/test_zip_imports.py b/tests/test_zip_imports.py new file mode 100644 index 00000000..6a263502 --- /dev/null +++ b/tests/test_zip_imports.py @@ -0,0 +1,109 @@ +import os +import posixpath +import subprocess +import sys +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 = posixpath.dirname(current_dir) + last_dir, current_dir = current_dir, parent_dir + + +class FileToAdd: + def __init__(self, content: str, path: str): + self.content = content + self.path = path + + +def setup_zipfile(path, files: List[FileToAdd]): + zip_file_path = path / "test.zip" + dirs_init_py_added_to = set() + with ZipFile(zip_file_path, "w") as zipfile: + for f in files: + zipfile.writestr(data=f.content, zinfo_or_arcname=f.path) + for dirname in walk_to_root(posixpath.dirname(f.path)): + if dirname not in dirs_init_py_added_to: + 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 + + +@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=x") + code_path = tmp_path / "code.py" + code_path.write_text( + textwrap.dedent( + f""" + import os + import sys + + sys.path.append({str(zip_file_path)!r}) + + import child1.child2.test + + print(os.environ['A']) + """ + ) + ) + + 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.stdout == "x\n" diff --git a/tox.ini b/tox.ini index 56c8732a..1f49dc26 100644 --- a/tox.ini +++ b/tox.ini @@ -1,29 +1,51 @@ [tox] -envlist = lint,py{27,34,35,36,37},pypy,pypy3,manifest,coverage-report +envlist = lint,py{310,311,312,313,314,315,315t},pypy3,manifest,coverage-report + +[gh] +python = + 3.10 = py310 + 3.11 = py311 + 3.12 = py312 + 3.13 = py313, lint, manifest + 3.14 = py314 + 3.15 = py315 + 3.15t = py315t + pypy3.11 = pypy3 [testenv] -deps = - pytest - coverage - sh - click - py{27,py}: ipython<6.0.0 - py34: ipython<7.0.0 - py{35,36,37,py3}: ipython -commands = coverage run --parallel -m pytest {posargs} +deps = + pytest + pytest-cov + click + py{310,311,312,313,314,315,315t},pypy3: ipython +commands = pytest --cov --cov-report=term-missing {posargs} +depends = + py{310,311,312,313,314,315,315t},pypy3: coverage-clean + coverage-report: py{310,311,312,313,314,315,315t},pypy3 + +[testenv:pypy3] +base_python = pypy3.11 [testenv:lint] skip_install = true deps = - flake8 - mypy + ruff + mypy commands = - flake8 src tests - mypy --python-version=3.7 src - mypy --python-version=3.6 src - mypy --python-version=3.5 src - mypy --python-version=3.4 src - mypy --python-version=2.7 src + ruff check src tests + ruff format --check src tests + mypy --python-version=3.15 src tests + mypy --python-version=3.14 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 + + +[testenv:format] +skip_install = true +deps = ruff +commands = ruff format src tests [testenv:manifest] deps = check-manifest @@ -39,5 +61,4 @@ commands = coverage erase deps = coverage skip_install = true commands = - coverage combine - coverage report + coverage report