From 6a2317f6a41c858f877fb261a41abca1d16dd464 Mon Sep 17 00:00:00 2001 From: zombieli Date: Sun, 25 Oct 2026 13:23:36 +0800 Subject: [PATCH 1/4] Run template tests without symlink privileges --- changelog.d/0.bugfix.md | 1 + tests/conftest.py | 11 +++++++++++ tests/test_env.py | 18 ++++++++++------- tests/test_verbose.py | 43 +++++++++++++++++++++-------------------- 4 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 changelog.d/0.bugfix.md diff --git a/changelog.d/0.bugfix.md b/changelog.d/0.bugfix.md new file mode 100644 index 0000000..5c57901 --- /dev/null +++ b/changelog.d/0.bugfix.md @@ -0,0 +1 @@ +Run environment and verbose-output tests without requiring permission to create symbolic links. diff --git a/tests/conftest.py b/tests/conftest.py index fd9d35f..650f54b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,14 @@ from __future__ import annotations +from pathlib import Path + +import pytest + pytest_plugins = ["pytester"] + + +@pytest.fixture +def env_test_template() -> str: + template = Path(__file__).with_name("template.py") + # Execute the original template so coverage still records its assertions. + return f"from runpy import run_path\n\ntest_env = run_path({str(template)!r})['test_env']\n" diff --git a/tests/test_env.py b/tests/test_env.py index eb883b5..6ca024e 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -117,15 +117,16 @@ ), ], ) -def test_env_via_pytest( +def test_env_via_pytest( # ruff:ignore[too-many-arguments, too-many-positional-arguments] pytester: pytest.Pytester, + env_test_template: str, env: dict[str, str], ini: str, expected_env: dict[str, str], request: pytest.FixtureRequest, ) -> None: test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower() - (pytester.path / f"test_{test_name}.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / f"test_{test_name}.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pytest.ini").write_text(ini, encoding="utf-8") new_env = { @@ -283,6 +284,7 @@ def test_env_via_pytest( ) def test_env_via_toml( # ruff:ignore[too-many-arguments, too-many-positional-arguments] pytester: pytest.Pytester, + env_test_template: str, env: dict[str, str], pyproject_toml: str, pytest_toml: str, @@ -306,7 +308,7 @@ def test_env_via_toml( # ruff:ignore[too-many-arguments, too-many-positional-ar if ini: (pytester.path / "pytest.ini").write_text(ini, encoding="utf-8") - (test_dir / f"test_{test_name}.py").symlink_to(Path(__file__).parent / "template.py") + (test_dir / f"test_{test_name}.py").write_text(env_test_template, encoding="utf-8") new_env = { **env, @@ -480,6 +482,7 @@ def test_env_via_toml( # ruff:ignore[too-many-arguments, too-many-positional-ar ) def test_env_via_env_file( # ruff:ignore[too-many-arguments, too-many-positional-arguments] pytester: pytest.Pytester, + env_test_template: str, env: dict[str, str], env_file_content: str, config: str, @@ -488,7 +491,7 @@ def test_env_via_env_file( # ruff:ignore[too-many-arguments, too-many-positiona request: pytest.FixtureRequest, ) -> None: test_name = re.sub(r"\W|^(?=\d)", "_", request.node.callspec.id).lower() - (pytester.path / f"test_{test_name}.py").symlink_to(Path(__file__).parent / "template.py") + (pytester.path / f"test_{test_name}.py").write_text(env_test_template, encoding="utf-8") if env_file_content: (pytester.path / ".env").write_text(env_file_content, encoding="utf-8") config_file_names = {"pyproject": "pyproject.toml", "pytest.toml": "pytest.toml", "ini": "pytest.ini"} @@ -625,13 +628,14 @@ def test_env_via_pyproject_toml_bad(pytester: pytest.Pytester, toml_name: str) - ) def test_envfile_cli( # ruff:ignore[too-many-arguments, too-many-positional-arguments] pytester: pytest.Pytester, + env_test_template: str, 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") + (pytester.path / "test_cli.py").write_text(env_test_template, encoding="utf-8") if env_file_content: (pytester.path / ".env").write_text(env_file_content, encoding="utf-8") @@ -651,8 +655,8 @@ def test_envfile_cli( # ruff:ignore[too-many-arguments, too-many-positional-arg result.assert_outcomes(passed=1) -def test_envfile_cli_skip_if_set(pytester: pytest.Pytester) -> None: - (pytester.path / "test_cli_skip.py").symlink_to(Path(__file__).parent / "template.py") +def test_envfile_cli_skip_if_set(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_cli_skip.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "cli.env").write_text("MAGIC=from_cli", encoding="utf-8") (pytester.path / "pyproject.toml").write_text( dedent("""\ diff --git a/tests/test_verbose.py b/tests/test_verbose.py index 918061a..eda2ec9 100644 --- a/tests/test_verbose.py +++ b/tests/test_verbose.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING from unittest import mock @@ -10,8 +9,8 @@ import pytest -def test_verbose_shows_set_from_ini(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_set_from_ini(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pytest.ini").write_text("[pytest]\nenv = MAGIC=alpha", encoding="utf-8") new_env = { @@ -26,8 +25,10 @@ def test_verbose_shows_set_from_ini(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*pytest-env:*", "*SET*MAGIC=alpha*(from*pytest.ini*"]) -def test_verbose_shows_ini_source_when_pyproject_lacks_pytest_env(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_ini_source_when_pyproject_lacks_pytest_env( + pytester: pytest.Pytester, env_test_template: str +) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pyproject.toml").write_text( dedent("""\ [build-system] @@ -50,8 +51,8 @@ def test_verbose_shows_ini_source_when_pyproject_lacks_pytest_env(pytester: pyte result.stdout.fnmatch_lines(["*SET*MAGIC=alpha*(from*pytest.ini*"]) -def test_verbose_shows_set_from_toml(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_set_from_toml(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pyproject.toml").write_text( '[tool.pytest_env]\nMY_VAR = "hello"', encoding="utf-8", @@ -69,8 +70,8 @@ def test_verbose_shows_set_from_toml(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*pytest-env:*", "*SET*MY_VAR=hello*(from*pyproject.toml*"]) -def test_verbose_shows_set_from_env_file(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_set_from_env_file(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / ".env").write_text("FROM_FILE=value", encoding="utf-8") (pytester.path / "pyproject.toml").write_text( '[tool.pytest_env]\nenv_files = [".env"]', @@ -89,8 +90,8 @@ def test_verbose_shows_set_from_env_file(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*SET*FROM_FILE=value*(from*.env*"]) -def test_verbose_shows_skip_from_env_file(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_skip_from_env_file(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / ".env").write_text("FROM_FILE=value", encoding="utf-8") (pytester.path / "pyproject.toml").write_text( dedent("""\ @@ -114,8 +115,8 @@ def test_verbose_shows_skip_from_env_file(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*SKIP*FROM_FILE=original*(from*.env*"]) -def test_verbose_shows_skip(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_skip(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pytest.ini").write_text("[pytest]\nenv = D:EXISTING=new", encoding="utf-8") new_env = { @@ -131,8 +132,8 @@ def test_verbose_shows_skip(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*SKIP*EXISTING=original*"]) -def test_verbose_shows_unset(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_unset(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pytest.ini").write_text("[pytest]\nenv = U:TO_REMOVE", encoding="utf-8") new_env = { @@ -148,8 +149,8 @@ def test_verbose_shows_unset(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*UNSET*TO_REMOVE*"]) -def test_verbose_shows_transform(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_shows_transform(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pyproject.toml").write_text( dedent("""\ [tool.pytest_env] @@ -171,8 +172,8 @@ def test_verbose_shows_transform(pytester: pytest.Pytester) -> None: result.stdout.fnmatch_lines(["*SET*GREETING=hello_world*"]) -def test_no_verbose_output_without_flag(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_no_verbose_output_without_flag(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / "pytest.ini").write_text("[pytest]\nenv = MAGIC=alpha", encoding="utf-8") new_env = { @@ -187,8 +188,8 @@ def test_no_verbose_output_without_flag(pytester: pytest.Pytester) -> None: result.stdout.no_fnmatch_line("*pytest-env:*") -def test_verbose_multiple_sources(pytester: pytest.Pytester) -> None: - (pytester.path / "test_it.py").symlink_to(Path(__file__).parent / "template.py") +def test_verbose_multiple_sources(pytester: pytest.Pytester, env_test_template: str) -> None: + (pytester.path / "test_it.py").write_text(env_test_template, encoding="utf-8") (pytester.path / ".env").write_text("FROM_FILE=file_val", encoding="utf-8") (pytester.path / "pyproject.toml").write_text( dedent("""\ From b8a9437a27c17a0c61cc28169212962c7d8d1282 Mon Sep 17 00:00:00 2001 From: zombieli Date: Sun, 25 Oct 2026 13:24:32 +0800 Subject: [PATCH 2/4] Name news fragment for PR 283 --- changelog.d/{0.bugfix.md => 283.bugfix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0.bugfix.md => 283.bugfix.md} (100%) diff --git a/changelog.d/0.bugfix.md b/changelog.d/283.bugfix.md similarity index 100% rename from changelog.d/0.bugfix.md rename to changelog.d/283.bugfix.md From 1979a8e4992c17db4231b684dcc9a063e39ed58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 10:23:45 -0700 Subject: [PATCH 3/4] test: drop news fragment and type path as Final The PR changes only the test suite, so it has no user-facing release note; CONTRIBUTING.md asks for the `skip news` label instead of a fragment. Type the template path Final like the other locals in the test modules. --- changelog.d/283.bugfix.md | 1 - tests/conftest.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 changelog.d/283.bugfix.md diff --git a/changelog.d/283.bugfix.md b/changelog.d/283.bugfix.md deleted file mode 100644 index 5c57901..0000000 --- a/changelog.d/283.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -Run environment and verbose-output tests without requiring permission to create symbolic links. diff --git a/tests/conftest.py b/tests/conftest.py index 650f54b..cc142dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Final import pytest @@ -9,6 +10,6 @@ @pytest.fixture def env_test_template() -> str: - template = Path(__file__).with_name("template.py") + template: Final[Path] = Path(__file__).with_name("template.py") # Execute the original template so coverage still records its assertions. return f"from runpy import run_path\n\ntest_env = run_path({str(template)!r})['test_env']\n" From 689ef0803c4c1a5e8694955b1057a1e08a1a617c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Tue, 27 Oct 2026 10:28:46 -0700 Subject: [PATCH 4/4] test: allow long test signatures in ruff config Pytest injects fixtures and parameters as arguments, so test signatures grow past the argument limits. Ignore the two rules for tests in the config and drop the inline suppressions. --- pyproject.toml | 2 ++ tests/test_env.py | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4f7901..f524db1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,8 @@ lint.per-file-ignores."tests/**/*.py" = [ "implicit-namespace-package", # no implicit namespace "magic-value-comparison", # Magic value used in comparison, consider replacing with a constant variable "subprocess-without-shell-equals-true", # `subprocess` call: check for execution of untrusted input + "too-many-arguments", # pytest injects fixtures and parameters as arguments + "too-many-positional-arguments", # pytest injects fixtures and parameters as arguments ] lint.isort = { known-first-party = [ "pytest_env", diff --git a/tests/test_env.py b/tests/test_env.py index 6ca024e..d7b8795 100644 --- a/tests/test_env.py +++ b/tests/test_env.py @@ -117,7 +117,7 @@ ), ], ) -def test_env_via_pytest( # ruff:ignore[too-many-arguments, too-many-positional-arguments] +def test_env_via_pytest( pytester: pytest.Pytester, env_test_template: str, env: dict[str, str], @@ -282,7 +282,7 @@ def test_env_via_pytest( # ruff:ignore[too-many-arguments, too-many-positional- ), ], ) -def test_env_via_toml( # ruff:ignore[too-many-arguments, too-many-positional-arguments] +def test_env_via_toml( pytester: pytest.Pytester, env_test_template: str, env: dict[str, str], @@ -480,7 +480,7 @@ def test_env_via_toml( # ruff:ignore[too-many-arguments, too-many-positional-ar ), ], ) -def test_env_via_env_file( # ruff:ignore[too-many-arguments, too-many-positional-arguments] +def test_env_via_env_file( pytester: pytest.Pytester, env_test_template: str, env: dict[str, str], @@ -626,7 +626,7 @@ def test_env_via_pyproject_toml_bad(pytester: pytest.Pytester, toml_name: str) - ), ], ) -def test_envfile_cli( # ruff:ignore[too-many-arguments, too-many-positional-arguments] +def test_envfile_cli( pytester: pytest.Pytester, env_test_template: str, env_file_content: str | None,