diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9bb4df0b..aa28bcfd 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.2.3 +current_version = 1.2.4 commit = True tag = True diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 369ebc8e..1e455693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,10 @@ jobs: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: "3.x" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8e7fa81..7a3c535a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # 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 02bad5aa..71c9740d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,23 @@ 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] @@ -447,9 +462,15 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [#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 @@ -457,6 +478,7 @@ 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 @@ -484,8 +506,10 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@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 @@ -497,12 +521,14 @@ os.PathLike]` instead of just `os.PathLike` (#347 by [@bbc2]). [@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 @@ -511,7 +537,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.3...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 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/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/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/src/dotenv/cli.py b/src/dotenv/cli.py index 72c81816..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 @@ -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: @@ -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 3123690a..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 @@ -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}") @@ -267,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 @@ -342,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""" @@ -396,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. @@ -450,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 d9d47583..b3b55070 100644 --- a/src/dotenv/parser.py +++ b/src/dotenv/parser.py @@ -29,7 +29,7 @@ def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: _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"\\[\\']") diff --git a/src/dotenv/version.py b/src/dotenv/version.py index 10aa336c..b3f9ac7f 100644 --- a/src/dotenv/version.py +++ b/src/dotenv/version.py @@ -1 +1 @@ -__version__ = "1.2.3" +__version__ = "1.2.4" diff --git a/tests/test_cli.py b/tests/test_cli.py index a6d3daf5..a719e195 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -68,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"]) @@ -174,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") @@ -265,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) 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 6f9d4c5c..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 @@ -195,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") @@ -412,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" ) diff --git a/tests/test_parser.py b/tests/test_parser.py index 667eae15..982305a3 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -480,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", [ 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