diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 646b9e7b..aa28bcfd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.2 +current_version = 1.2.4 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..1e455693 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 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..7a3c535a 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 @@ -22,28 +26,38 @@ jobs: os: - ubuntu-latest python-version: - ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", pypy3.11] + ["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.14" + 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@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true + version-file: requirements-ci.txt + enable-cache: true + cache-dependency-glob: | + pyproject.toml + tox.ini + requirements-ci.txt - - name: Upgrade pip - run: python -m pip install --upgrade pip + - name: Install tox + run: uv tool install --python 3.15 --with-requirements requirements-ci.txt tox - - name: Install dependencies - run: pip install tox tox-gh-actions + - name: Setup test suite + run: tox run -v --notest --skip-missing-interpreters false - - name: Test with tox - run: tox + - name: Run test suite + run: tox run --skip-pkg-install diff --git a/CHANGELOG.md b/CHANGELOG.md index ee732346..71c9740d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,34 @@ 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 @@ -432,6 +459,18 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#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 @@ -439,9 +478,12 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@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 @@ -449,6 +491,7 @@ 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 @@ -460,26 +503,32 @@ 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 +[@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 @@ -488,7 +537,9 @@ 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.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 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/MANIFEST.in b/MANIFEST.in index bf0d47e6..afa35896 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,5 +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 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/README.md b/README.md index a08d6141..3400e67c 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ You can also specify a path: ```python %dotenv relative/or/absolute/path/to/.env +%dotenv ~/path/to/.env ``` Optional flags: 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/pyproject.toml b/pyproject.toml index 1753fd89..7dc2a711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ description = "Read key-value pairs from a .env file and set them as environment authors = [ {name = "Saurabh Kumar", email = "me+github@saurabh-kumar.com"}, ] -license = { text = "BSD-3-Clause" } +license = "BSD-3-Clause" keywords = [ "environment variables", "deployments", @@ -27,6 +27,7 @@ classifiers = [ "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", 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 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 79613e28..83849690 100644 --- a/src/dotenv/cli.py +++ b/src/dotenv/cli.py @@ -1,6 +1,7 @@ import json import os import shlex +import shutil import sys from contextlib import contextmanager from typing import IO, Any, Dict, Iterator, List, Optional @@ -17,7 +18,7 @@ ) sys.exit(1) -from .main import dotenv_values, set_key, unset_key +from .main import DotEnv, dotenv_values, set_key, unset_key from .version import __version__ @@ -61,6 +62,8 @@ def enumerate_env() -> Optional[str]: @click.pass_context def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: """This script is used to set, get or unset values from a .env file.""" + if file is not None: + file = os.path.expanduser(file) ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file} @@ -124,7 +127,11 @@ def set_value(ctx: click.Context, key: Any, value: Any) -> None: file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] export = ctx.obj["EXPORT"] - success, key, value = set_key(file, key, value, quote, 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(f"{key}={value}") else: @@ -141,11 +148,10 @@ def get(ctx: click.Context, key: Any) -> None: with stream_file(file) as stream: values = dotenv_values(stream=stream) - stored_value = values.get(key) - if stored_value: - click.echo(stored_value) - else: + # 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() @@ -160,7 +166,11 @@ def unset(ctx: click.Context, key: Any) -> None: """ file = ctx.obj["FILE"] quote = ctx.obj["QUOTE"] - success, key = unset_key(file, key, 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(f"Successfully removed {key}") else: @@ -190,7 +200,7 @@ def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> Non ) dotenv_as_dict = { k: v - for (k, v) in dotenv_values(file).items() + 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) } @@ -226,11 +236,18 @@ def run_command(command: List[str], env: Dict[str, str]) -> None: cmd_env.update(env) if sys.platform == "win32": - # execvpe on Windows returns control immediately - # rather than once the command has finished. + # 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, universal_newlines=True, bufsize=0, shell=False, env=cmd_env + 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) diff --git a/src/dotenv/main.py b/src/dotenv/main.py index 491634d9..5faa7f0e 100644 --- a/src/dotenv/main.py +++ b/src/dotenv/main.py @@ -50,6 +50,10 @@ def __init__( 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 @@ -74,7 +78,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() @@ -135,6 +139,25 @@ def get_key( return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get) +def _discard_temp_file(path: pathlib.Path) -> None: + """ + Delete `rewrite`'s temporary file, ignoring any failure to do so. + + 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. + """ + 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: StrPath, @@ -160,13 +183,21 @@ def rewrite( source = io.StringIO("") original_mode = None - with tempfile.NamedTemporaryFile( - mode="w", - encoding=encoding, - delete=False, - prefix=".tmp_", - dir=os.path.dirname(os.path.abspath(path)), - ) as dest: + 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 + + with temp_file as dest: dest_path = pathlib.Path(dest.name) error = None @@ -183,10 +214,10 @@ def rewrite( os.replace(dest_path, path) except BaseException: - dest_path.unlink(missing_ok=True) + _discard_temp_file(dest_path) raise else: - dest_path.unlink(missing_ok=True) + _discard_temp_file(dest_path) raise error from None @@ -208,6 +239,10 @@ def set_key( 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 quote_mode not in ("always", "auto", "never"): raise ValueError(f"Unknown quote_mode: {quote_mode}") @@ -216,7 +251,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: @@ -262,6 +302,10 @@ def unset_key( 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): logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) return None, key_to_unset @@ -337,8 +381,12 @@ def find_dotenv( """ 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 """ + filename = os.path.expanduser(filename) def _is_interactive(): """Decide whether this is running in a REPL or IPython notebook""" @@ -391,7 +439,8 @@ def load_dotenv( """Parse a .env file and then load all the variables found as environment variables. Parameters: - dotenv_path: Absolute or relative path to .env file. + 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. @@ -403,7 +452,7 @@ def load_dotenv( 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`. @@ -445,7 +494,8 @@ def dotenv_values( `{"foo": None}` Parameters: - dotenv_path: Absolute or relative path to the .env file. + 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. diff --git a/src/dotenv/parser.py b/src/dotenv/parser.py index eb100b47..b3b55070 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -22,12 +22,14 @@ 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|$)") -_rest_of_line = make_regex(r"[^\r\n]*(?:\r|\n|\r\n)?") +_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"\\[\\']") @@ -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() @@ -154,8 +156,15 @@ def parse_binding(reader: Reader) -> Binding: key = parse_key(reader) reader.read_regex(_whitespace) if reader.peek(1) == "=": - reader.read_regex(_equal_sign) - value: Optional[str] = parse_value(reader) + (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) diff --git a/src/dotenv/version.py b/src/dotenv/version.py index bc86c944..b3f9ac7f 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.2" +__version__ = "1.2.4" diff --git a/tests/test_cli.py b/tests/test_cli.py index d4e3ad4d..a719e195 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path from typing import Optional @@ -67,6 +68,23 @@ def test_get_existing_value(cli, dotenv_path): assert (result.exit_code, result.output) == (0, "b\n") +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"]) + + assert (result.exit_code, result.output) == (0, "\n") + + +def test_get_value_without_equals(cli, dotenv_path): + dotenv_path.write_text("a") + + result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) + + assert (result.exit_code, result.output) == (1, "") + + def test_get_non_existent_value(cli, dotenv_path): result = cli.invoke(dotenv_cli, ["--file", dotenv_path, "get", "a"]) @@ -173,6 +191,40 @@ def test_set_no_file(cli): 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") @@ -211,6 +263,40 @@ def test_run_with_existing_variable_not_overridden(tmp_path): 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_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") @@ -230,6 +316,96 @@ def test_run_with_other_env(dotenv_path, tmp_path): check_process(result, exit_code=0, stdout="x\n") +@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_run_without_cmd(tmp_path): result = run_dotenv(["run"], cwd=tmp_path) @@ -269,16 +445,24 @@ def test_run_with_command_flags(dotenv_path, tmp_path): """ Check that command flags passed after `dotenv run` are not interpreted. - Here, we want to run `printenv --version`, not `dotenv --version`. + 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", "printenv", "--version"], + [ + "--file", + str(dotenv_path), + "run", + sys.executable, + "-c", + "import sys; print(sys.argv[1:])", + "--help", + ], cwd=tmp_path, ) - check_process(result, exit_code=0) - assert result.stdout.strip().startswith("printenv ") + check_process(result, exit_code=0, stdout="['--help']\n") def test_run_with_dotenv_and_command_flags(dotenv_path, tmp_path): diff --git a/tests/test_ipython.py b/tests/test_ipython.py index 6eda086b..93bffd66 100644 --- a/tests/test_ipython.py +++ b/tests/test_ipython.py @@ -61,3 +61,18 @@ def test_ipython_new_variable(tmp_path): 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_main.py b/tests/test_main.py index 50703af0..930ab171 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,6 +1,7 @@ import io import logging import os +import pathlib import stat import subprocess import sys @@ -10,6 +11,7 @@ import pytest import dotenv +from dotenv.main import DotEnv def test_set_key_no_file(tmp_path): @@ -39,6 +41,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 +58,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" @@ -165,6 +196,184 @@ def test_set_key_permission_error(dotenv_path): 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") @@ -227,6 +436,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") @@ -370,6 +591,19 @@ def test_find_dotenv_found(tmp_path): 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" ) @@ -695,3 +929,22 @@ def test_dotenv_values_file_stream(dotenv_path): 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 index 43386e5a..982305a3 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -119,6 +119,78 @@ ) ], ), + ( + "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", [ @@ -295,6 +367,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=à", [ @@ -339,6 +480,46 @@ ) ], ), + ( + "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", [ @@ -545,6 +726,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): diff --git a/tox.ini b/tox.ini index 6d1f25f8..1f49dc26 100644 --- a/tox.ini +++ b/tox.ini @@ -1,26 +1,30 @@ [tox] -envlist = lint,py{310,311,312,313,314,314t},pypy3,manifest,coverage-report +envlist = lint,py{310,311,312,313,314,315,315t},pypy3,manifest,coverage-report -[gh-actions] +[gh] python = - 3.10: py310 - 3.11: py311 - 3.12: py312 - 3.13: py313, lint, manifest - 3.14: py314 - 3.14t: py314t - pypy-3.11: pypy3 + 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 pytest-cov click - py{310,311,312,313,314,314t,pypy3}: ipython + 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,314t},pypy3: coverage-clean - coverage-report: py{310,311,312,313,314,314t},pypy3 + 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 @@ -30,6 +34,7 @@ deps = commands = 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