Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
build-backend = "hatchling.build"
requires = [
"hatch-vcs>=0.5",
"hatchling>=1.27",
"hatchling>=1.28",
]

[project]
Expand Down Expand Up @@ -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"
Expand Down
40 changes: 40 additions & 0 deletions src/pytest_env/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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 ""
Expand Down
176 changes: 176 additions & 0 deletions tests/test_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down
Loading