diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 646b9e7b..9bb4df0b 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.2 +current_version = 1.2.3 commit = True tag = True diff --git a/.github/dependabot.yml b/.github/dependabot.yml index be006de9..3137ade5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,13 +1,32 @@ -# Keep GitHub Actions up to date with GitHub's Dependabot... # https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot -# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem version: 2 updates: + # 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: - - "*" # Group all Actions updates into a single larger pull request + - "*" + 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 index dc91d369..369ebc8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,13 @@ 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 - # Specifying a GitHub environment is optional, but strongly encouraged environment: release permissions: # IMPORTANT: this permission is mandatory for trusted publishing @@ -16,10 +19,10 @@ jobs: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: "3.x" @@ -32,7 +35,7 @@ jobs: run: make sdist - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - name: Build Documentation run: | @@ -41,7 +44,7 @@ jobs: mkdocs build - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 + 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 index 66056f6c..e8e7fa81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,10 @@ on: - 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 @@ -31,10 +35,10 @@ jobs: python-version: "3.14" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ matrix.python-version }} allow-prereleases: true diff --git a/CHANGELOG.md b/CHANGELOG.md index ab35b253..69b51f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [v1.2.2] - 2026-03-01 +## [Unreleased] + +## [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. (#) +- Support for Python 3.14, including the free-threaded (3.14t) build. (#588) ### Changed @@ -21,7 +32,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed -- Improved `set_key` and `unset_key` behavior when interacting with symlinks by [@bbc2] in [#790c5](https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311) +- 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 @@ -47,40 +58,40 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [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](https://github.com/theskumar/python-dotenv/pull/563) +- 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](https://github.com/theskumar/python-dotenv/pull/563) -- CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566](https://github.com/theskumar/python-dotenv/pull/566) +- CLI: Ensure `find_dotenv` work reliably on python 3.13 by [@theskumar] in [#563] +- CLI: revert the use of execvpe on Windows by [@wrongontheinternet] in [#566] ## [1.1.0] - 2025-03-25 -**Feature** +### Added - Add support for python 3.13 - Enhance `dotenv run`, switch to `execvpe` for better resource management and signal handling ([#523]) by [@eekstunt] -**Fixed** +### Fixed - `find_dotenv` and `load_dotenv` now correctly looks up at the current directory when running in debugger or pdb ([#553] by [@randomseed42]) -**Misc** +### Misc - Drop support for Python 3.8 ## [1.0.1] - 2024-01-23 -**Fixed** +### 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** +### Misc - Use pathlib.Path in tests ([#466] by [@eumiro]) - Fix year in release date in changelog.md ([#454] by [@jankislinger]) @@ -88,14 +99,14 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.0.0] - 2023-02-24 -**Fixed** +### 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** +### Added - Use Python 3.11 non-beta in CI (#438 by [@bbc2]) - Modernize variables code (#434 by [@Nougat-Waffle]) @@ -104,7 +115,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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** +### Fixed - Fix Out-of-scope error when "dest" variable is undefined (#413 by [@theGOTOguy]) - Fix IPython test warning about deprecated `magic` (#440 by [@bbc2]) @@ -112,13 +123,13 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.21.0] - 2022-09-03 -**Added** +### 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** +### Fixed - Drop Python 3.5 and 3.6 and upgrade GA (#393 by [@eggplants]) - Use `open` instead of `io.open`. (#389 by [@rabinadk1]) @@ -129,12 +140,12 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.20.0] - 2022-03-24 -**Added** +### Added - Add `encoding` (`Optional[str]`) parameter to `get_key`, `set_key` and `unset_key`. (#379 by [@bbc2]) -**Fixed** +### Fixed - Use dict to specify the `entry_points` parameter of `setuptools.setup` (#376 by [@mgorny]). @@ -142,25 +153,25 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.19.2] - 2021-11-11 -**Fixed** +### Fixed - In `set_key`, add missing newline character before new entry if necessary. (#361 by [@bbc2]) ## [0.19.1] - 2021-08-09 -**Added** +### Added - Add support for Python 3.10. (#359 by [@theskumar]) ## [0.19.0] - 2021-07-24 -**Changed** +### Changed - Require Python 3.5 or a later version. Python 2 and 3.4 are no longer supported. (#341 by [@bbc2]). -**Added** +### Added - The `dotenv_path` argument of `set_key` and `unset_key` now has a type of `Union[str, os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). @@ -170,7 +181,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.18.0] - 2021-06-20 -**Changed** +### Changed - Raise `ValueError` if `quote_mode` isn't one of `always`, `auto` or `never` in `set_key` (#330 by [@bbc2]). @@ -183,23 +194,23 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.17.1] - 2021-04-29 -**Fixed** +### Fixed - Fixed tests for build environments relying on `PYTHONPATH` (#318 by [@befeleme]). ## [0.17.0] - 2021-04-02 -**Changed** +### Changed - Make `dotenv get ` only show the value, not `key=value` (#313 by [@bbc2]). -**Added** +### Added - Add `--override`/`--no-override` option to `dotenv run` (#312 by [@zueve] and [@bbc2]). ## [0.16.0] - 2021-03-27 -**Changed** +### Changed - The default value of the `encoding` parameter for `load_dotenv` and `dotenv_values` is now `"utf-8"` instead of `None` (#306 by [@bbc2]). @@ -207,17 +218,17 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.15.0] - 2020-10-28 -**Added** +### Added - Add `--export` option to `set` to make it prepend the binding with `export` (#270 by [@jadutter]). -**Changed** +### Changed - Make `set` command create the `.env` file in the current directory if no `.env` file was found (#270 by [@jadutter]). -**Fixed** +### Fixed - Fix potentially empty expanded value for duplicate key (#260 by [@bbc2]). - Fix import error on Python 3.5.0 and 3.5.1 (#267 by [@gongqingkui]). @@ -226,30 +237,30 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.14.0] - 2020-07-03 -**Changed** +### Changed - Privilege definition in file over the environment in variable expansion (#256 by [@elbehery95]). -**Fixed** +### Fixed - Improve error message for when file isn't found (#245 by [@snobu]). - Use HTTPS URL in package meta data (#251 by [@ekohl]). ## [0.13.0] - 2020-04-16 -**Added** +### Added - Add support for a Bash-like default value in variable expansion (#248 by [@bbc2]). ## [0.12.0] - 2020-02-28 -**Changed** +### Changed - Use current working directory to find `.env` when bundled by PyInstaller (#213 by [@gergelyk]). -**Fixed** +### Fixed - Fix escaping of quoted values written by `set_key` (#236 by [@bbc2]). - Fix `dotenv run` crashing on environment variables without values (#237 by [@yannham]). @@ -257,23 +268,23 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.11.0] - 2020-02-07 -**Added** +### Added - Add `interpolate` argument to `load_dotenv` and `dotenv_values` to disable interpolation (#232 by [@ulyssessouza]). -**Changed** +### Changed - Use logging instead of warnings (#231 by [@bbc2]). -**Fixed** +### Fixed - Fix installation in non-UTF-8 environments (#225 by [@altendky]). - Fix PyPI classifiers (#228 by [@bbc2]). ## [0.10.5] - 2020-01-19 -**Fixed** +### Fixed - Fix handling of malformed lines and lines without a value (#222 by [@bbc2]): - Don't print warning when key has no value. @@ -282,7 +293,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). ## [0.10.4] - 2020-01-17 -**Added** +### Added - Make typing optional (#179 by [@techalchemy]). - Print a warning on malformed line (#211 by [@bbc2]). @@ -422,6 +433,17 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#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 +[#680]: https://github.com/theskumar/python-dotenv/pull/680 +[790c5c0]: https://github.com/theskumar/python-dotenv/commit/790c5c02991100aa1bf41ee5330aca75edc51311 @@ -431,6 +453,7 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@Nicals]: https://github.com/Nicals [@Nougat-Waffle]: https://github.com/Nougat-Waffle [@Qwerty-133]: https://github.com/Qwerty-133 +[@ReinerBRO]: https://github.com/ReinerBRO [@alanjds]: https://github.com/alanjds [@altendky]: https://github.com/altendky [@andrewsmith]: https://github.com/andrewsmith @@ -438,10 +461,11 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@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 +[@eggplants]: https://github.com/eggplants [@ekohl]: https://github.com/ekohl [@elbehery95]: https://github.com/elbehery95 [@eumiro]: https://github.com/eumiro @@ -449,19 +473,20 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@gergelyk]: https://github.com/gergelyk [@gongqingkui]: https://github.com/gongqingkui [@greyli]: https://github.com/greyli -[@harveer07]: https://github.com/@harveer07 +[@h1whelan]: https://github.com/h1whelan +[@harveer07]: https://github.com/harveer07 [@jadutter]: https://github.com/jadutter [@jankislinger]: https://github.com/jankislinger [@jctanner]: https://github.com/jctanner -[@larsks]: https://github.com/@larsks +[@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 +[@naorlivne]: https://github.com/naorlivne [@qnighy]: https://github.com/qnighy -[@rabinadk1]: https://github.com/@rabinadk1 -[@randomseed42]: https://github.com/zueve -[@sammck]: https://github.com/@sammck +[@rabinadk1]: https://github.com/rabinadk1 +[@randomseed42]: https://github.com/randomseed42 +[@sammck]: https://github.com/sammck [@samwyma]: https://github.com/samwyma [@sidharth-sudhir]: https://github.com/sidharth-sudhir [@snobu]: https://github.com/snobu @@ -477,7 +502,8 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@JYOuyang]: https://github.com/JYOuyang [@burnout-projects]: https://github.com/burnout-projects [@cpackham-atlnz]: https://github.com/cpackham-atlnz -[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.2...HEAD +[Unreleased]: https://github.com/theskumar/python-dotenv/compare/v1.2.3...HEAD +[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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49840fa7..dd21ae76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ Use of pre-commit is recommended: $ uv run precommit install -Documentation is published with [mkdocs](): +Documentation is published with [mkdocs](https://www.mkdocs.org/): ```shell $ uv pip install -r requirements-docs.txt diff --git a/Makefile b/Makefile index 78866a60..6706fe94 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: clean-pyc clean-build test fmt +.PHONY: clean-pyc clean-build test fmt release + +part ?= patch clean: clean-build clean-pyc @@ -33,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/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/requirements-docs.txt b/requirements-docs.txt index b09a710d..76fa086e 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,5 +1,5 @@ mdx_truly_sane_lists>=1.3 -mkdocs-include-markdown-plugin>=6.0.0 +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 4a9f28a1..8b1177fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ bumpversion click ipython pytest-cov -pytest>=3.9 +pytest>=9.0.3 tox wheel ruff diff --git a/src/dotenv/cli.py b/src/dotenv/cli.py index 47eec047..79613e28 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -228,9 +228,20 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: if sys.platform == "win32": # execvpe on Windows returns control immediately # rather than once the command has finished. - p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) + try: + p = Popen( + command, 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: - os.execvpe(command[0], args=command, env=cmd_env) + 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/main.py b/src/dotenv/main.py index 48e5245a..3123690a 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -74,7 +74,7 @@ def _get_stream(self) -> Iterator[IO[str]]: def dict(self) -> Dict[str, Optional[str]]: """Return dotenv as dict""" - if self._dict: + if self._dict is not None: return self._dict raw_values = self.parse() @@ -216,7 +216,12 @@ def set_key( ) if quote: - value_out = "'{}'".format(value_to_set.replace("'", "\\'")) + # 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: @@ -397,12 +402,13 @@ def load_dotenv( 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 it's default parameters. If you need to change the default parameters + .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`. @@ -447,6 +453,7 @@ def dotenv_values( dotenv_path: Absolute or relative path to the .env file. stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. verbose: Whether to output a warning if the .env file is missing. + 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 diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index eb100b47..02648ab0 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -22,8 +22,10 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _single_quoted_key = make_regex(r"'([^']+)'") _unquoted_key = make_regex(r"([^=\#\s]+)") _equal_sign = make_regex(r"(=[^\S\r\n]*)") -_single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'") -_double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"') +# 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|$)") @@ -68,7 +70,7 @@ class Error(Exception): class Reader: def __init__(self, stream: IO[str]) -> None: - self.string = stream.read() + self.string = stream.read().removeprefix("\ufeff") self.position = Position.start() self.mark = Position.start() diff --git a/src/dotenv/version.py b/src/dotenv/version.py index bc86c944..10aa336c 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.2" +__version__ = "1.2.3" diff --git a/tests/test_cli.py b/tests/test_cli.py index 02bdb764..d4e3ad4d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -237,7 +237,21 @@ def test_run_without_cmd(tmp_path): assert "Invalid value for '-f'" in result.stderr -def test_run_with_invalid_cmd(tmp_path): +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, + ) + + check_process(result, exit_code=1) + assert "Command not found: i_do_not_exist" in result.stderr + + +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. + """ + result = run_dotenv(["run", "i_do_not_exist"], cwd=tmp_path) check_process(result, exit_code=2) diff --git a/tests/test_main.py b/tests/test_main.py index 50703af0..48dd7bf4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -10,6 +10,7 @@ import pytest import dotenv +from dotenv.main import DotEnv def test_set_key_no_file(tmp_path): @@ -39,6 +40,9 @@ def test_set_key_no_file(tmp_path): ("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): @@ -53,6 +57,32 @@ def test_set_key(dotenv_path, before, key, value, expected, 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" @@ -227,6 +257,18 @@ def test_get_key_none(dotenv_path): 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") diff --git a/tests/test_parser.py b/tests/test_parser.py index 43386e5a..dd638d9b 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -295,6 +295,75 @@ ) ], ), + ( + "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=à", [ @@ -545,6 +614,35 @@ ), ], ), + # 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):