diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f5dab88..eaf6302 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,11 +14,10 @@ repos: hooks: - id: codespell additional_dependencies: ["tomli>=2.4"] - - repo: https://github.com/tox-dev/tox-ini-fmt - rev: "1.7.1" + - repo: https://github.com/tox-dev/tox-toml-fmt + rev: "v1.6.0" hooks: - - id: tox-ini-fmt - args: ["-p", "fix"] + - id: tox-toml-fmt - repo: https://github.com/tox-dev/pyproject-fmt rev: "v2.16.0" hooks: diff --git a/README.md b/README.md index 3336a5a..593749d 100644 --- a/README.md +++ b/README.md @@ -14,46 +14,140 @@ configuration files. It can also load variables from `.env` files. pip install pytest-env ``` -## Usage +## Quick start -### TOML configuration (native form) +Add environment variables to your `pyproject.toml`: -Define environment variables under `[tool.pytest_env]` in `pyproject.toml`, or `[pytest_env]` in `pytest.toml` / -`.pytest.toml`: +```toml +[tool.pytest_env] +DATABASE_URL = "postgresql://localhost/test_db" +DEBUG = "true" +``` + +Run your tests. The environment variables are now available: + +```python +import os + + +def test_database_connection(): + assert os.environ["DATABASE_URL"] == "postgresql://localhost/test_db" + assert os.environ["DEBUG"] == "true" +``` + +## How-to guides + +### Set different environments for test suites + +Create a subdirectory config to override parent settings: + +``` +project/ +├── pyproject.toml # [tool.pytest_env] DB_HOST = "prod-db" +└── tests_integration/ + ├── pytest.toml # [pytest_env] DB_HOST = "test-db" + └── test_api.py +``` + +Running `pytest tests_integration/` uses the subdirectory configuration. + +### Switch environments at runtime + +Use the `--envfile` CLI option to override or extend your configuration: + +```shell +# Override all configured env files with a different one. +pytest --envfile .env.local + +# Add an additional env file to those already configured. +pytest --envfile +.env.override +``` + +Override mode loads only the specified file. Extend mode (prefix with `+`) loads configuration files first, then the CLI +file. Variables in the CLI file take precedence. + +### Load variables from `.env` files + +Specify `.env` files in your configuration: ```toml -# pyproject.toml [tool.pytest_env] -HOME = "~/tmp" -RUN_ENV = 1 -TRANSFORMED = { value = "{USER}/alpha", transform = true } -SKIP_IF_SET = { value = "on", skip_if_set = true } -DATABASE_URL = { unset = true } +env_files = [".env", ".env.test"] +``` + +Create your `.env` file: + +```shell +DATABASE_URL=postgres://localhost/mydb +SECRET_KEY='my-secret-key' +DEBUG="true" +``` + +Files are loaded before inline variables, so inline configuration takes precedence. + +### Expand variables using other environment variables + +Reference existing environment variables in values: + +```toml +[tool.pytest_env] +RUN_PATH = { value = "/run/path/{USER}", transform = true } +``` + +The `{USER}` placeholder expands to the current user's name. + +### Set conditional defaults + +Only set a variable if it does not already exist: + +```toml +[tool.pytest_env] +HOME = { value = "~/tmp", skip_if_set = true } ``` +This leaves `HOME` unchanged if already set, otherwise sets it to `~/tmp`. + +### Remove variables from the environment + +Unset a variable completely (different from setting to empty string): + ```toml -# pytest.toml or .pytest.toml -[pytest_env] -HOME = "~/tmp" -RUN_ENV = 1 -TRANSFORMED = { value = "{USER}/alpha", transform = true } -SKIP_IF_SET = { value = "on", skip_if_set = true } +[tool.pytest_env] DATABASE_URL = { unset = true } ``` -Each key is the environment variable name. The value is either a plain value (cast to string) or an inline table with -the following keys: +## Reference + +### TOML configuration format + +Define environment variables under `[tool.pytest_env]` in `pyproject.toml`, or `[pytest_env]` in `pytest.toml` or +`.pytest.toml`: + +```toml +# pyproject.toml +[tool.pytest_env] +SIMPLE_VAR = "value" +NUMBER_VAR = 42 +EXPANDED = { value = "{HOME}/path", transform = true } +CONDITIONAL = { value = "default", skip_if_set = true } +REMOVED = { unset = true } +``` + +Each key is the environment variable name. Values can be: -| Key | Type | Description | -| ------------- | ------ | --------------------------------------------------------------------------- | -| `value` | string | The value to set | -| `transform` | bool | Expand `{VAR}` references in the value using existing environment variables | -| `skip_if_set` | bool | Only set the variable if it is not already defined | -| `unset` | bool | Remove the variable from the environment (ignores `value`) | +- **Plain values**: Cast to string and set directly. +- **Inline tables**: Objects with the following keys: -### INI configuration +| Key | Type | Description | +| ------------- | ------ | ---------------------------------------------------------------------------- | +| `value` | string | The value to set. | +| `transform` | bool | Expand `{VAR}` references in the value using existing environment variables. | +| `skip_if_set` | bool | Only set the variable if it is not already defined. | +| `unset` | bool | Remove the variable from the environment (ignores `value`). | -Define environment variables as a line-separated list of `KEY=VALUE` entries under the `env` key: +### INI configuration format + +Define environment variables as line-separated `KEY=VALUE` entries: ```ini # pytest.ini @@ -61,10 +155,13 @@ Define environment variables as a line-separated list of `KEY=VALUE` entries und env = HOME=~/tmp RUN_ENV=test + D:CONDITIONAL=value + R:RAW_VALUE={USER} + U:REMOVED_VAR ``` ```toml -# pyproject.toml +# pyproject.toml (INI-style) [tool.pytest] env = [ "HOME=~/tmp", @@ -74,133 +171,125 @@ env = [ Prefix flags modify behavior. Flags are case-insensitive and can be combined in any order (e.g., `R:D:KEY=VALUE`): -| Flag | Description | -| ---- | ------------------------------------------------------------------ | -| `D:` | Default — only set if the variable is not already defined | -| `R:` | Raw — skip `{VAR}` expansion (INI expands by default, unlike TOML) | -| `U:` | Unset — remove the variable from the environment entirely | - -### Precedence +| Flag | Description | +| ---- | ------------------------------------------------------------------- | +| `D:` | Default — only set if the variable is not already defined. | +| `R:` | Raw — skip `{VAR}` expansion (INI expands by default, unlike TOML). | +| `U:` | Unset — remove the variable from the environment entirely. | -When multiple configuration sources are present, the native TOML form takes precedence over the INI form. Within the -TOML form, files are checked in this order: `pytest.toml`, `.pytest.toml`, `pyproject.toml`. The first file containing a -`pytest_env` section wins. +**Note**: In INI format, variable expansion is enabled by default. In TOML format, it requires `transform = true`. -### Configuration file discovery +### `.env` file format -The plugin walks the directory tree starting from the directory containing the configuration file pytest resolved -(`inipath`). For each directory it checks `pytest.toml`, `.pytest.toml`, and `pyproject.toml` in order, stopping at the -first file with a `pytest_env` section. This means a subdirectory config takes precedence over a parent config: - -``` -project/ -├── pyproject.toml # [tool.pytest_env] DB_HOST = "prod-db" -└── tests_integration/ - ├── pytest.toml # [pytest_env] DB_HOST = "test-db" - └── test_api.py -``` - -Running `pytest tests_integration/` uses `DB_HOST = "test-db"` from the subdirectory. - -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. +Specify `.env` files using the `env_files` configuration option: ```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: +Files are parsed by [python-dotenv](https://github.com/theskumar/python-dotenv) and support: + +- `KEY=VALUE` lines +- `#` comments +- `export` prefix +- Quoted values with escape sequences in double quotes +- `${VAR:-default}` expansion + +Example `.env` file: ```shell -# .env DATABASE_URL=postgres://localhost/mydb export SECRET_KEY='my-secret-key' DEBUG="true" MESSAGE="hello\nworld" +API_KEY=${FALLBACK_KEY:-default_key} ``` -### Examples +Missing `.env` files are silently skipped. Paths are resolved relative to the project root. -**Expanding environment variables** — reference existing variables using `{VAR}` syntax: +### CLI option: `--envfile` -```toml -[pytest_env] -RUN_PATH = { value = "/run/path/{USER}", transform = true } -``` +Override or extend configuration-based `env_files` at runtime: -```ini -[pytest] -env = - RUN_PATH=/run/path/{USER} +```shell +pytest --envfile PATH # Override mode +pytest --envfile +PATH # Extend mode ``` -In TOML, expansion requires `transform = true`. In INI, expansion is the default; use the `R:` flag to disable it. +**Override mode** (`--envfile PATH`): Loads only the specified file, ignoring all `env_files` from configuration. -**Keeping raw values** — prevent `{VAR}` expansion: +**Extend mode** (`--envfile +PATH`): Loads configuration files first in their normal order, then loads the CLI file. +Variables from the CLI file override those from configuration files. -```toml -[pytest_env] -PATTERN = { value = "/run/path/{USER}" } -``` +Unlike configuration-based `env_files`, CLI-specified files must exist. Missing files raise `FileNotFoundError`. Paths +are resolved relative to the project root. -```ini -[pytest] -env = - R:PATTERN=/run/path/{USER} -``` +## Explanation -**Conditional defaults** — only set when not already defined: +### Configuration precedence -```toml -[pytest_env] -HOME = { value = "~/tmp", skip_if_set = true } -``` +When multiple configuration sources define the same variable, the following precedence rules apply (highest to lowest): -```ini -[pytest] -env = - D:HOME=~/tmp -``` +1. Inline variables in configuration files (TOML or INI format) +1. Variables from `.env` files loaded via `env_files` +1. Variables already present in the environment (unless `skip_if_set = false` or no `D:` flag) -**Unsetting variables** — completely remove a variable from `os.environ` (not the same as setting to empty string): +When using `--envfile`, CLI files take precedence over configuration-based `env_files`, but inline variables still win. -```toml -[pytest_env] -DATABASE_URL = { unset = true } -``` +### Configuration format precedence -```ini -[pytest] -env = - U:DATABASE_URL -``` +When multiple configuration formats are present: -**Combining flags** — flags can be combined in any order: +1. TOML native format (`[pytest_env]` or `[tool.pytest_env]`) takes precedence over INI format. +1. Among TOML files, the first file with a `pytest_env` section is used, checked in order: `pytest.toml`, + `.pytest.toml`, `pyproject.toml`. +1. If no TOML file contains `pytest_env`, the plugin falls back to INI-style `env` configuration. -```ini -[pytest] -env = - R:D:TEMPLATE=/path/{placeholder} -``` +### File discovery + +The plugin walks up the directory tree starting from pytest's resolved configuration directory. For each directory, it +checks `pytest.toml`, `.pytest.toml`, and `pyproject.toml` in order, stopping at the first file containing a +`pytest_env` section. + +This means subdirectory configurations take precedence over parent configurations, allowing you to have different +settings for integration tests versus unit tests. + +### When to use TOML vs INI format + +Use the **TOML native format** (`[pytest_env]`) when: + +- You need fine-grained control over expansion and conditional setting. +- Your configuration is complex with multiple inline tables. +- You prefer explicit `transform = true` for variable expansion. + +Use the **INI format** (`env` key) when: + +- You want simple `KEY=VALUE` pairs with minimal syntax. +- You prefer expansion by default (add `R:` to disable). +- You are migrating from an existing INI-based setup. + +Both formats are fully supported and can coexist (TOML takes precedence if both are present). + +### When to use `.env` files vs inline configuration + +Use **`.env` files** when: + +- You have many environment variables that would clutter your config file. +- You want to share environment configuration with other tools (e.g., Docker, shell scripts). +- You need different `.env` files for different environments (dev, staging, prod). + +Use **inline configuration** when: + +- You have a small number of test-specific variables. +- You want variables to be version-controlled alongside test configuration. +- You need features like `transform`, `skip_if_set`, or `unset` that `.env` files do not support. + +You can combine both approaches. Inline variables always take precedence over `.env` files. diff --git a/pyproject.toml b/pyproject.toml index 6454829..bea77bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ lint.ignore = [ "D212", # `multi-line-summary-first-line` (D212) and `multi-line-summary-second-line` (D213) are incompatible "DOC201", # `return` is not documented in docstring (we prefer minimal docs) "DOC402", # `yield` is not documented in docstring (we prefer minimal docs) + "DOC501", # `raises` is not documented in docstring (we prefer minimal docs) "ISC001", # Conflict with formatter "S104", # Possible binding to all interface ] diff --git a/src/pytest_env/plugin.py b/src/pytest_env/plugin.py index e6a06bd..baa67e8 100644 --- a/src/pytest_env/plugin.py +++ b/src/pytest_env/plugin.py @@ -25,6 +25,13 @@ def pytest_addoption(parser: pytest.Parser) -> None: 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=[]) + parser.addoption( + "--envfile", + action="store", + dest="envfile", + default=None, + help="path to .env file to load (prefix with + to extend config files, otherwise replaces them)", + ) @dataclass @@ -100,10 +107,24 @@ def _load_toml_config(config_path: Path) -> tuple[list[str], list[Entry]]: def _load_env_files(early_config: pytest.Config, env_files: list[str]) -> Generator[Path, None, None]: - """Resolve and yield existing env files.""" - if not env_files: - env_files = list(early_config.getini("env_files")) - for env_file_str in env_files: + """Resolve and yield existing env files, with CLI option taking precedence.""" + if cli_envfile := getattr(early_config.known_args_namespace, "envfile", None): + if cli_envfile.startswith("+"): + if not (resolved := early_config.rootpath / cli_envfile[1:]).is_file(): + msg = f"Environment file not found: {cli_envfile[1:]}" + raise FileNotFoundError(msg) + for env_file_str in env_files or list(early_config.getini("env_files")): + if (config_resolved := early_config.rootpath / env_file_str).is_file(): + yield config_resolved + yield resolved + else: + if not (resolved := early_config.rootpath / cli_envfile).is_file(): + msg = f"Environment file not found: {cli_envfile}" + raise FileNotFoundError(msg) + yield resolved + return + + for env_file_str in env_files or list(early_config.getini("env_files")): if (resolved := early_config.rootpath / env_file_str).is_file(): yield resolved diff --git a/tests/test_env.py b/tests/test_env.py index 7f78a5c..afcdfbc 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -548,3 +548,99 @@ def test_env_via_pyproject_toml_bad(pytester: pytest.Pytester, toml_name: str) - f"ERROR: {toml_file}: Expected '=' after a key in a key/value pair (at line 1, column 5)", "", ] + + +@pytest.mark.parametrize( + ("env_file_content", "cli_file_content", "config", "cli_arg", "expected_env"), + [ + pytest.param( + "MAGIC=from_env", + "MAGIC=from_cli", + '[tool.pytest_env]\nenv_files = [".env"]', + "cli.env", + {"MAGIC": "from_cli"}, + id="override mode", + ), + pytest.param( + "MAGIC=from_env\nEXTRA=config", + "MAGIC=from_cli", + '[tool.pytest_env]\nenv_files = [".env"]', + "+cli.env", + {"MAGIC": "from_cli", "EXTRA": "config"}, + id="extend mode", + ), + pytest.param( + None, + "MAGIC=from_cli", + None, + "cli.env", + {"MAGIC": "from_cli"}, + id="no config files override", + ), + pytest.param( + None, + "MAGIC=from_cli", + None, + "+cli.env", + {"MAGIC": "from_cli"}, + id="no config files extend", + ), + pytest.param( + "MAGIC=from_env", + "EXTRA=from_cli", + '[tool.pytest_env]\nenv_files = [".env", "missing.env"]', + "+cli.env", + {"MAGIC": "from_env", "EXTRA": "from_cli"}, + id="extend with missing config file", + ), + ], +) +def test_envfile_cli( # noqa: PLR0913, PLR0917 + pytester: pytest.Pytester, + env_file_content: str | None, + cli_file_content: str, + config: str | None, + cli_arg: str, + expected_env: dict[str, str], +) -> None: + (pytester.path / "test_cli.py").symlink_to(Path(__file__).parent / "template.py") + + if env_file_content: + (pytester.path / ".env").write_text(env_file_content, encoding="utf-8") + (pytester.path / "cli.env").write_text(cli_file_content, encoding="utf-8") + if config: + (pytester.path / "pyproject.toml").write_text(config, encoding="utf-8") + + new_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 = pytester.runpytest("--envfile", cli_arg) + + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + "cli_arg", + [ + pytest.param("nonexistent.env", id="missing file override"), + pytest.param("+nonexistent.env", id="missing file extend"), + ], +) +def test_envfile_cli_missing_file(pytester: pytest.Pytester, cli_arg: str) -> None: + pytester.makepyfile(test_it="def test_it() -> None:\n pass") + + new_env = { + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_env.plugin", + } + + with mock.patch.dict(os.environ, new_env, clear=True): + result = pytester.runpytest("--envfile", cli_arg) + + assert result.ret != 0 + error_file = cli_arg.lstrip("+") + assert any(f"Environment file not found: {error_file}" in line for line in result.errlines) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 897ec6d..0000000 --- a/tox.ini +++ /dev/null @@ -1,73 +0,0 @@ -[tox] -requires = - tox>=4.34.1 - tox-uv>=1.29 -env_list = - fix - 3.14 - 3.13 - 3.12 - 3.11 - 3.10 - type - pkg_meta -skip_missing_interpreters = true - -[testenv] -description = run the tests with pytest -package = wheel -wheel_build_env = .pkg -extras = - testing -pass_env = - DIFF_AGAINST - PYTEST_* -set_env = - COVERAGE_FILE = {env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}} -commands = - coverage erase - coverage run -m pytest {tty:--color=yes} \ - --junitxml {toxworkdir}{/}junit.{envname}.xml \ - {posargs:tests} - coverage combine - coverage report - coverage html -d {envtmpdir}{/}htmlcov - -[testenv:fix] -description = run static analysis and style check using flake8 -skip_install = true -deps = - pre-commit-uv>=4.2 -pass_env = - HOMEPATH - PROGRAMDATA -commands = - pre-commit run --all-files --show-diff-on-failure - -[testenv:type] -description = run type check on code base -deps = - ty==0.0.16 -commands = - ty check --output-format concise --error-on-warning . - -[testenv:pkg_meta] -description = check that the long description is valid -skip_install = true -deps = - check-wheel-contents>=0.6.3 - twine>=6.2 - uv>=0.10.2 -commands = - uv build --sdist --wheel --out-dir {env_tmp_dir} . - twine check {env_tmp_dir}{/}* - check-wheel-contents --no-config {env_tmp_dir} - -[testenv:dev] -description = generate a DEV environment -package = editable -extras = - testing -commands = - uv pip tree - python -c 'import sys; print(sys.executable)' diff --git a/tox.toml b/tox.toml new file mode 100644 index 0000000..fe2a872 --- /dev/null +++ b/tox.toml @@ -0,0 +1,75 @@ +[env_run_base] +description = "run the tests with pytest" +package = "wheel" +wheel_build_env = ".pkg" +extras = [ "testing" ] +pass_env = [ "DIFF_AGAINST", "PYTEST_*" ] +set_env.COVERAGE_FILE = "{env:COVERAGE_FILE:{toxworkdir}{/}.coverage.{envname}}" +commands = [ + [ "coverage", "erase" ], + [ + "coverage", + "run", + "-m", + "pytest", + "{tty:--color=yes}", + "--junitxml", + "{toxworkdir}{/}junit.{envname}.xml", + "{posargs:tests}", + ], + [ "coverage", "combine" ], + [ "coverage", "report" ], + [ "coverage", "html", "-d", "{envtmpdir}{/}htmlcov" ], +] + +[env.dev] +description = "generate a DEV environment" +package = "editable" +extras = [ "testing" ] +commands = [ + [ "uv", "pip", "tree" ], + [ "python", "-c", "import sys; print(sys.executable)" ], +] + +[env.fix] +description = "run static analysis and style check using flake8" +skip_install = true +deps = [ "pre-commit-uv>=4.2" ] +pass_env = [ "HOMEPATH", "PROGRAMDATA" ] +commands = [ [ "pre-commit", "run", "--all-files", "--show-diff-on-failure" ] ] + +[env.pkg_meta] +description = "check that the long description is valid" +skip_install = true +deps = [ + "check-wheel-contents>=0.6.3", + "twine>=6.2", + "uv>=0.10.3", +] +commands = [ + [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ], + [ "twine", "check", "{env_tmp_dir}{/}*" ], + [ "check-wheel-contents", "--no-config", "{env_tmp_dir}" ], +] + +[env.type] +description = "run type check on code base" +deps = [ "ty==0.0.17" ] +commands = [ [ "ty", "check", "--output-format", "concise", "--error-on-warning", "." ] ] + +[tox] +requires = [ + "tox>=4.36.1", + "tox-uv>=1.29", +] +env_list = [ + "fix", + "3.14", + "3.13", + "3.12", + "3.11", + "3.10", + "type", + "pkg_meta", +] +skip_missing_interpreters = true