diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad56192..0294ad3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,14 +13,14 @@ repos: rev: v2.4.1 hooks: - id: codespell - additional_dependencies: ["tomli>=2.2.1"] + additional_dependencies: ["tomli>=2.4"] - repo: https://github.com/tox-dev/tox-ini-fmt rev: "1.7.1" hooks: - id: tox-ini-fmt args: ["-p", "fix"] - repo: https://github.com/tox-dev/pyproject-fmt - rev: "v2.15.0" + rev: "v2.15.2" hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit @@ -34,7 +34,7 @@ repos: hooks: - id: prettier additional_dependencies: - - prettier@3.6.2 + - prettier@3.8.1 - "@prettier/plugin-xml@3.4.2" - repo: meta hooks: diff --git a/README.md b/README.md index 73df965..3336a5a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Downloads](https://static.pepy.tech/badge/pytest-env/month)](https://pepy.tech/project/pytest-env) A `pytest` plugin that sets environment variables from `pyproject.toml`, `pytest.toml`, `.pytest.toml`, or `pytest.ini` -configuration files. +configuration files. It can also load variables from `.env` files. ## Installation @@ -104,6 +104,43 @@ Running `pytest tests_integration/` uses `DB_HOST = "test-db"` from the subdirec If no TOML file with a `pytest_env` section is found, the plugin falls back to the INI-style `env` key. +### Loading `.env` files + +Use `env_files` to load variables from `.env` files. Files are loaded before inline `env` entries, so inline config +takes precedence. Missing files are silently skipped. Paths are relative to the project root. + +```toml +# pyproject.toml +[tool.pytest_env] +env_files = [".env", ".env.test"] +API_KEY = "override_value" +``` + +```toml +# pytest.toml or .pytest.toml +[pytest_env] +env_files = [".env"] +``` + +```ini +# pytest.ini +[pytest] +env_files = + .env + .env.test +``` + +Files are parsed by [python-dotenv](https://github.com/theskumar/python-dotenv), supporting `KEY=VALUE` lines, `#` +comments, `export` prefix, quoted values (with escape sequences in double quotes), and `${VAR:-default}` expansion: + +```shell +# .env +DATABASE_URL=postgres://localhost/mydb +export SECRET_KEY='my-secret-key' +DEBUG="true" +MESSAGE="hello\nworld" +``` + ### Examples **Expanding environment variables** — reference existing variables using `{VAR}` syntax: diff --git a/pyproject.toml b/pyproject.toml index 5e7acbb..db7be9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.5", - "hatchling>=1.27", + "hatchling>=1.28", ] [project] @@ -36,12 +36,13 @@ dynamic = [ "version", ] dependencies = [ - "pytest>=9", - "tomli>=2.2.1; python_version<'3.11'", + "pytest>=9.0.2", + "python-dotenv>=1.2.1", + "tomli>=2.4; python_version<'3.11'", ] optional-dependencies.testing = [ "covdefaults>=2.3", - "coverage>=7.10.7", + "coverage>=7.13.4", "pytest-mock>=3.15.1", ] urls.Homepage = "https://github.com/pytest-dev/pytest-env" diff --git a/src/pytest_env/plugin.py b/src/pytest_env/plugin.py index b66c683..2f38904 100644 --- a/src/pytest_env/plugin.py +++ b/src/pytest_env/plugin.py @@ -9,9 +9,11 @@ from typing import TYPE_CHECKING, Any import pytest +from dotenv import dotenv_values if TYPE_CHECKING: from collections.abc import Generator, Iterator + from pathlib import Path if sys.version_info >= (3, 11): # pragma: >=3.11 cover import tomllib @@ -23,6 +25,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: """Add section to configuration files.""" help_msg = "a line separated list of environment variables of the form (FLAG:)NAME=VALUE" parser.addini("env", type="linelist", help=help_msg, default=[]) + parser.addini("env_files", type="linelist", help="a line separated list of .env files to load", default=[]) @dataclass @@ -43,6 +46,10 @@ def pytest_load_initial_conftests( parser: pytest.Parser, # noqa: ARG001 ) -> None: """Load environment variables from configuration files.""" + for env_file in _load_env_files(early_config): + for key, value in dotenv_values(env_file).items(): + if value is not None: + os.environ[key] = value for entry in _load_values(early_config): if entry.unset: os.environ.pop(entry.key, None) @@ -53,8 +60,41 @@ def pytest_load_initial_conftests( os.environ[entry.key] = entry.value.format(**os.environ) if entry.transform else entry.value +def _env_files_from_toml(early_config: pytest.Config) -> list[str]: + for path in chain.from_iterable([[early_config.rootpath], early_config.rootpath.parents]): + for pytest_toml_name in ("pytest.toml", ".pytest.toml", "pyproject.toml"): + pytest_toml_file = path / pytest_toml_name + if not pytest_toml_file.exists(): + continue + with pytest_toml_file.open("rb") as file_handler: + try: + config = tomllib.load(file_handler) + except tomllib.TOMLDecodeError: + return [] + if pytest_toml_name == "pyproject.toml": + config = config.get("tool", {}) + if ( + (pytest_env := config.get("pytest_env")) + and isinstance(pytest_env, dict) + and (raw := pytest_env.get("env_files")) + ): + return [str(f) for f in (raw if isinstance(raw, list) else [raw])] + return [] + return [] + + +def _load_env_files(early_config: pytest.Config) -> Generator[Path, None, None]: + if not (env_files := _env_files_from_toml(early_config)): + env_files = list(early_config.getini("env_files")) + for env_file_str in env_files: + if (resolved := early_config.rootpath / env_file_str).is_file(): + yield resolved + + def _parse_toml_config(config: dict[str, Any]) -> Generator[Entry, None, None]: for key, entry in config.items(): + if key == "env_files" and isinstance(entry, list): + continue if isinstance(entry, dict): unset = bool(entry.get("unset")) value = str(entry.get("value", "")) if not unset else "" diff --git a/tests/test_env.py b/tests/test_env.py index 1715494..3351fb3 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -3,10 +3,16 @@ import os import re from pathlib import Path +from typing import TYPE_CHECKING from unittest import mock import pytest +from pytest_env.plugin import _env_files_from_toml # noqa: PLC2701 + +if TYPE_CHECKING: + import pytest_mock + @pytest.mark.parametrize( ("env", "ini", "expected_env"), @@ -318,6 +324,176 @@ def test_env_via_toml( # noqa: PLR0913, PLR0917 result.assert_outcomes(passed=1) +@pytest.mark.parametrize( + ("env", "env_file_content", "config", "expected_env", "config_type"), + [ + pytest.param( + {}, + "MAGIC=alpha\nSORCERY=beta", + '[tool.pytest_env]\nenv_files = [".env"]', + {"MAGIC": "alpha", "SORCERY": "beta"}, + "pyproject", + id="basic env file via pyproject toml", + ), + pytest.param( + {}, + "MAGIC=alpha\nSORCERY=beta", + '[pytest_env]\nenv_files = [".env"]', + {"MAGIC": "alpha", "SORCERY": "beta"}, + "pytest.toml", + id="basic env file via pytest toml", + ), + pytest.param( + {}, + "MAGIC=alpha\nSORCERY=beta", + "[pytest]\nenv_files = .env", + {"MAGIC": "alpha", "SORCERY": "beta"}, + "ini", + id="basic env file via ini", + ), + pytest.param( + {}, + "# comment line\n\nMAGIC=alpha\n # indented comment\n", + '[tool.pytest_env]\nenv_files = [".env"]', + {"MAGIC": "alpha"}, + "pyproject", + id="comments and blank lines", + ), + pytest.param( + {}, + "SINGLE='hello world'\nDOUBLE=\"hello world\"", + '[tool.pytest_env]\nenv_files = [".env"]', + {"SINGLE": "hello world", "DOUBLE": "hello world"}, + "pyproject", + id="quoted values", + ), + pytest.param( + {}, + "MAGIC=alpha", + '[tool.pytest_env]\nenv_files = [".env"]\nMAGIC = "beta"', + {"MAGIC": "beta"}, + "pyproject", + id="inline overrides env file", + ), + pytest.param( + {}, + "", + '[tool.pytest_env]\nenv_files = ["missing.env"]', + {}, + "pyproject", + id="missing env file is skipped", + ), + pytest.param( + {}, + "KEY_ONLY\nVALID=yes", + '[tool.pytest_env]\nenv_files = [".env"]', + {"VALID": "yes"}, + "pyproject", + id="line without equals is skipped", + ), + pytest.param( + {}, + "MAGIC=has=equals", + '[tool.pytest_env]\nenv_files = [".env"]', + {"MAGIC": "has=equals"}, + "pyproject", + id="value with equals sign", + ), + pytest.param( + {}, + " MAGIC = alpha ", + '[tool.pytest_env]\nenv_files = [".env"]', + {"MAGIC": "alpha"}, + "pyproject", + id="whitespace around key and value", + ), + pytest.param( + {"MAGIC": "original"}, + "MAGIC=from_file", + '[tool.pytest_env]\nenv_files = [".env"]\nMAGIC = {value = "from_file", skip_if_set = true}', + {"MAGIC": "from_file"}, + "pyproject", + id="skip if set respects env file", + ), + pytest.param( + {}, + "=no_key\nVALID=yes", + '[tool.pytest_env]\nenv_files = [".env"]', + {"VALID": "yes"}, + "pyproject", + id="empty key is skipped", + ), + pytest.param( + {}, + "", + '[tool.pytest_env]\nenv_files = "some_value"', + {"env_files": "some_value"}, + "pyproject", + id="env_files as env var when string", + ), + pytest.param( + {}, + "export MAGIC=alpha", + '[tool.pytest_env]\nenv_files = [".env"]', + {"MAGIC": "alpha"}, + "pyproject", + id="export prefix", + ), + pytest.param( + {}, + 'MAGIC="hello\\nworld"', + '[tool.pytest_env]\nenv_files = [".env"]', + {"MAGIC": "hello\nworld"}, + "pyproject", + id="escape sequences in double quotes", + ), + pytest.param( + {}, + "MAGIC=alpha #comment", + '[tool.pytest_env]\nenv_files = [".env"]', + {"MAGIC": "alpha"}, + "pyproject", + id="inline comment", + ), + ], +) +def test_env_via_env_file( # noqa: PLR0913, PLR0917 + testdir: pytest.Testdir, + env: dict[str, str], + env_file_content: str, + config: str, + expected_env: dict[str, str | None], + config_type: str, + request: pytest.FixtureRequest, +) -> None: + tmp_dir = Path(str(testdir.tmpdir)) + test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower() + Path(str(tmp_dir / f"test_{test_name}.py")).symlink_to(Path(__file__).parent / "template.py") + if env_file_content: + (tmp_dir / ".env").write_text(env_file_content, encoding="utf-8") + config_file_names = {"pyproject": "pyproject.toml", "pytest.toml": "pytest.toml", "ini": "pytest.ini"} + (tmp_dir / config_file_names[config_type]).write_text(config, encoding="utf-8") + + new_env = { + **env, + "_TEST_ENV": repr(expected_env), + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + + with mock.patch.dict(os.environ, new_env, clear=True): + result = testdir.runpytest() + + result.assert_outcomes(passed=1) + + +def test_env_files_from_toml_bad_toml(tmp_path: Path, mocker: pytest_mock.MockerFixture) -> None: + (tmp_path / "pyproject.toml").write_text("bad toml", encoding="utf-8") + config = mocker.MagicMock() + config.rootpath = tmp_path + assert _env_files_from_toml(config) == [] + + @pytest.mark.parametrize("toml_name", ["pytest.toml", ".pytest.toml", "pyproject.toml"]) def test_env_via_pyproject_toml_bad(testdir: pytest.Testdir, toml_name: str) -> None: toml_file = Path(str(testdir.tmpdir)) / toml_name diff --git a/tox.ini b/tox.ini index 6987410..b10d44f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] requires = - tox>=4.30.3 - tox-uv>=1.28 + tox>=4.34.1 + tox-uv>=1.29 env_list = fix 3.14 @@ -37,7 +37,7 @@ commands = description = run static analysis and style check using flake8 skip_install = true deps = - pre-commit-uv>=4.1.5 + pre-commit-uv>=4.2 pass_env = HOMEPATH PROGRAMDATA @@ -47,7 +47,7 @@ commands = [testenv:type] description = run type check on code base deps = - mypy==1.18.2 + mypy==1.19.1 commands = mypy --strict src mypy --strict tests @@ -58,7 +58,7 @@ skip_install = true deps = check-wheel-contents>=0.6.3 twine>=6.2 - uv>=0.8.22 + uv>=0.10.2 commands = uv build --sdist --wheel --out-dir {env_tmp_dir} . twine check {env_tmp_dir}{/}*