From 3c93ed5f99656993f59aea07293aa5476ea9ebbb Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:10:13 +0200 Subject: [PATCH 01/20] DX: avoid using IO in tests directory * DX: increase minimal test coverage to 39% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/.gitignore | 2 - tests/cspell/.pre-commit-config-bad.yaml | 5 - tests/cspell/.pre-commit-config-good.yaml | 5 - .../editorconfig/.pre-commit-config-bad.yaml | 5 - .../editorconfig/.pre-commit-config-good.yaml | 11 -- tests/editorconfig/test_editorconfig.py | 34 ++-- tests/pyright/.pre-commit-config-bad.yaml | 4 - tests/pyright/.pre-commit-config-good.yaml | 9 - tests/pyright/pyproject-bad.toml | 3 - tests/pyright/test_pyright.py | 41 +++-- tests/readthedocs/extend/.readthedocs-bad.yml | 16 -- .../readthedocs/extend/.readthedocs-good.yml | 17 -- .../overwrite/.readthedocs-bad1.yml | 10 -- .../overwrite/.readthedocs-bad2.yml | 7 - .../overwrite/.readthedocs-good.yml | 11 -- tests/readthedocs/test_readthedocs.py | 162 ++++++++++++------ tests/test_cspell.py | 39 ++--- 19 files changed, 177 insertions(+), 208 deletions(-) delete mode 100644 tests/.gitignore delete mode 100644 tests/cspell/.pre-commit-config-bad.yaml delete mode 100644 tests/cspell/.pre-commit-config-good.yaml delete mode 100644 tests/editorconfig/.pre-commit-config-bad.yaml delete mode 100644 tests/editorconfig/.pre-commit-config-good.yaml delete mode 100644 tests/pyright/.pre-commit-config-bad.yaml delete mode 100644 tests/pyright/.pre-commit-config-good.yaml delete mode 100644 tests/pyright/pyproject-bad.toml delete mode 100644 tests/readthedocs/extend/.readthedocs-bad.yml delete mode 100644 tests/readthedocs/extend/.readthedocs-good.yml delete mode 100644 tests/readthedocs/overwrite/.readthedocs-bad1.yml delete mode 100644 tests/readthedocs/overwrite/.readthedocs-bad2.yml delete mode 100644 tests/readthedocs/overwrite/.readthedocs-good.yml diff --git a/codecov.yml b/codecov.yml index a3ff615b..99268bd0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 33% + target: 39% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 52e3005e..5d792509 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=33 \ + --cov-fail-under=39 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/.gitignore b/tests/.gitignore deleted file mode 100644 index d68a746d..00000000 --- a/tests/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -!*.yaml -!*.yml diff --git a/tests/cspell/.pre-commit-config-bad.yaml b/tests/cspell/.pre-commit-config-bad.yaml deleted file mode 100644 index b220a87f..00000000 --- a/tests/cspell/.pre-commit-config-bad.yaml +++ /dev/null @@ -1,5 +0,0 @@ -repos: - - repo: https://github.com/ComPWA/mirrors-cspell - rev: v5.10.1 - hooks: - - id: cspell diff --git a/tests/cspell/.pre-commit-config-good.yaml b/tests/cspell/.pre-commit-config-good.yaml deleted file mode 100644 index f687029b..00000000 --- a/tests/cspell/.pre-commit-config-good.yaml +++ /dev/null @@ -1,5 +0,0 @@ -repos: - - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v5.6.10 - hooks: - - id: cspell diff --git a/tests/editorconfig/.pre-commit-config-bad.yaml b/tests/editorconfig/.pre-commit-config-bad.yaml deleted file mode 100644 index d1e01790..00000000 --- a/tests/editorconfig/.pre-commit-config-bad.yaml +++ /dev/null @@ -1,5 +0,0 @@ -repos: - - repo: https://github.com/editorconfig-checker/editorconfig-checker.python - rev: 2.7.3 - hooks: - - id: editorconfig-checker diff --git a/tests/editorconfig/.pre-commit-config-good.yaml b/tests/editorconfig/.pre-commit-config-good.yaml deleted file mode 100644 index 0f28f1e4..00000000 --- a/tests/editorconfig/.pre-commit-config-good.yaml +++ /dev/null @@ -1,11 +0,0 @@ -repos: - - repo: https://github.com/editorconfig-checker/editorconfig-checker.python - rev: 2.7.3 - hooks: - - id: editorconfig-checker - name: editorconfig - alias: ec - exclude: >- - (?x)^( - .*\.py - )$ diff --git a/tests/editorconfig/test_editorconfig.py b/tests/editorconfig/test_editorconfig.py index 27568dfc..73c63fc2 100644 --- a/tests/editorconfig/test_editorconfig.py +++ b/tests/editorconfig/test_editorconfig.py @@ -1,5 +1,5 @@ import io -from pathlib import Path +from textwrap import dedent import pytest @@ -9,18 +9,30 @@ def test_update_precommit_config(): - this_dir = Path(__file__).parent - with open(this_dir / ".pre-commit-config-bad.yaml") as file: - src = file.read() - - stream = io.StringIO(src) + bad_config = dedent(""" + repos: + - repo: https://github.com/editorconfig-checker/editorconfig-checker.python + rev: 2.7.3 + hooks: + - id: editorconfig-checker + """).lstrip() with ( pytest.raises(PrecommitError, match=r"Updated editorconfig-checker hook"), - ModifiablePrecommit.load(stream) as precommit, + ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, ): _update_precommit_config(precommit) - result = precommit.dumps() - with open(this_dir / ".pre-commit-config-good.yaml") as file: - expected = file.read() - assert result == expected + expected = dedent(r""" + repos: + - repo: https://github.com/editorconfig-checker/editorconfig-checker.python + rev: 2.7.3 + hooks: + - id: editorconfig-checker + name: editorconfig + alias: ec + exclude: >- + (?x)^( + .*\.py + )$ + """).lstrip() + assert precommit.dumps() == expected diff --git a/tests/pyright/.pre-commit-config-bad.yaml b/tests/pyright/.pre-commit-config-bad.yaml deleted file mode 100644 index 824fae7f..00000000 --- a/tests/pyright/.pre-commit-config-bad.yaml +++ /dev/null @@ -1,4 +0,0 @@ -repos: - - repo: meta - hooks: - - id: check-hooks-apply diff --git a/tests/pyright/.pre-commit-config-good.yaml b/tests/pyright/.pre-commit-config-good.yaml deleted file mode 100644 index f3c7f1d0..00000000 --- a/tests/pyright/.pre-commit-config-good.yaml +++ /dev/null @@ -1,9 +0,0 @@ -repos: - - repo: meta - hooks: - - id: check-hooks-apply - - - repo: https://github.com/ComPWA/pyright-pre-commit - rev: PLEASE-UPDATE - hooks: - - id: pyright diff --git a/tests/pyright/pyproject-bad.toml b/tests/pyright/pyproject-bad.toml deleted file mode 100644 index af0ff64e..00000000 --- a/tests/pyright/pyproject-bad.toml +++ /dev/null @@ -1,3 +0,0 @@ -[tool.pyright] -include = ["**/*.py"] -reportUnusedImport = true diff --git a/tests/pyright/test_pyright.py b/tests/pyright/test_pyright.py index c1298222..40d7db88 100644 --- a/tests/pyright/test_pyright.py +++ b/tests/pyright/test_pyright.py @@ -44,31 +44,46 @@ def test_merge_config_into_pyproject(this_dir: Path): assert result.strip() == expected_result.strip() -def test_update_precommit(this_dir: Path): - with open(this_dir / ".pre-commit-config-bad.yaml") as stream: - input_stream = io.StringIO(stream.read()) +def test_update_precommit(): + bad_config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() with ( pytest.raises(PrecommitError), - ModifiablePrecommit.load(input_stream) as precommit, + ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, ): _update_precommit(precommit) - result = input_stream.getvalue() - with open(this_dir / ".pre-commit-config-good.yaml") as stream: - expected_result = stream.read() - assert result.strip() == expected_result.strip() + expected = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + - repo: https://github.com/ComPWA/pyright-pre-commit + rev: PLEASE-UPDATE + hooks: + - id: pyright + """).lstrip() + assert precommit.dumps() == expected -def test_update_settings(this_dir: Path): - with open(this_dir / "pyproject-bad.toml") as stream: - input_stream = io.StringIO(stream.read()) + +def test_update_settings(): + bad_config = dedent(""" + [tool.pyright] + include = ["**/*.py"] + reportUnusedImport = true + """).lstrip() with ( pytest.raises(PrecommitError, match=r"Updated pyright configuration"), - ModifiablePyproject.load(input_stream) as pyproject, + ModifiablePyproject.load(io.StringIO(bad_config)) as pyproject, ): _update_settings(pyproject) - result = input_stream.getvalue() + result = pyproject.dumps() expected_result = dedent(""" [tool.pyright] include = ["**/*.py"] diff --git a/tests/readthedocs/extend/.readthedocs-bad.yml b/tests/readthedocs/extend/.readthedocs-bad.yml deleted file mode 100644 index e5fba45c..00000000 --- a/tests/readthedocs/extend/.readthedocs-bad.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: 2 -build: - os: ubuntu-20.04 - tools: - python: "3.10" - jobs: - post_install: - - pip install -e .[doc] - - | - wget https://julialang-s3.julialang.org/bin/linux/x64/1.9/julia-1.9.2-linux-x86_64.tar.gz - - tar xzf julia-1.9.2-linux-x86_64.tar.gz - - mkdir bin - - ln -s $PWD/julia-1.9.2/bin/julia bin/julia - - ./bin/julia docs/InstallIJulia.jl -sphinx: - configuration: docs/conf.py diff --git a/tests/readthedocs/extend/.readthedocs-good.yml b/tests/readthedocs/extend/.readthedocs-good.yml deleted file mode 100644 index 285710a0..00000000 --- a/tests/readthedocs/extend/.readthedocs-good.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 2 -build: - os: ubuntu-24.04 - tools: - python: "3.13" - jobs: - post_install: - - python -m pip install 'uv>=0.2.0' - - python -m uv pip install -e .[doc] - - | - wget https://julialang-s3.julialang.org/bin/linux/x64/1.9/julia-1.9.2-linux-x86_64.tar.gz - - tar xzf julia-1.9.2-linux-x86_64.tar.gz - - mkdir bin - - ln -s $PWD/julia-1.9.2/bin/julia bin/julia - - ./bin/julia docs/InstallIJulia.jl -sphinx: - configuration: docs/conf.py diff --git a/tests/readthedocs/overwrite/.readthedocs-bad1.yml b/tests/readthedocs/overwrite/.readthedocs-bad1.yml deleted file mode 100644 index c6a7c8d0..00000000 --- a/tests/readthedocs/overwrite/.readthedocs-bad1.yml +++ /dev/null @@ -1,10 +0,0 @@ -version: 2 -build: - os: ubuntu-20.04 - tools: - python: "3.7" - jobs: - post_install: - - pip install -e .[doc] -sphinx: - configuration: docs/conf.py diff --git a/tests/readthedocs/overwrite/.readthedocs-bad2.yml b/tests/readthedocs/overwrite/.readthedocs-bad2.yml deleted file mode 100644 index 68ea541b..00000000 --- a/tests/readthedocs/overwrite/.readthedocs-bad2.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -build: - os: ubuntu-20.04 - tools: - python: "3.7" -sphinx: - configuration: docs/conf.py diff --git a/tests/readthedocs/overwrite/.readthedocs-good.yml b/tests/readthedocs/overwrite/.readthedocs-good.yml deleted file mode 100644 index 351eec23..00000000 --- a/tests/readthedocs/overwrite/.readthedocs-good.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -build: - os: ubuntu-24.04 - tools: - python: "3.13" - jobs: - post_install: - - python -m pip install 'uv>=0.2.0' - - python -m uv pip install -e .[doc] -sphinx: - configuration: docs/conf.py diff --git a/tests/readthedocs/test_readthedocs.py b/tests/readthedocs/test_readthedocs.py index 2146112d..f1965cfc 100644 --- a/tests/readthedocs/test_readthedocs.py +++ b/tests/readthedocs/test_readthedocs.py @@ -1,7 +1,6 @@ from __future__ import annotations import io -from pathlib import Path from textwrap import dedent from typing import TYPE_CHECKING @@ -14,77 +13,136 @@ if TYPE_CHECKING: from compwa_policy.utilities.pyproject.getters import PythonVersion - -@pytest.fixture -def this_dir() -> Path: - return Path(__file__).parent - - -def test_update_readthedocs_extend(this_dir: Path): - with open(this_dir / "extend" / ".readthedocs-bad.yml") as f: - input_stream = io.StringIO(f.read()) +BAD_OVERWRITE_WITH_JOBS = dedent(""" + version: 2 + build: + os: ubuntu-20.04 + tools: + python: "3.7" + jobs: + post_install: + - pip install -e .[doc] + sphinx: + configuration: docs/conf.py +""").lstrip() + +BAD_OVERWRITE_WITHOUT_JOBS = dedent(""" + version: 2 + build: + os: ubuntu-20.04 + tools: + python: "3.7" + sphinx: + configuration: docs/conf.py +""").lstrip() + + +def _good_extend() -> str: + return dedent(f""" + version: 2 + build: + os: ubuntu-24.04 + tools: + python: "{DEFAULT_DEV_PYTHON_VERSION}" + jobs: + post_install: + - python -m pip install 'uv>=0.2.0' + - python -m uv pip install -e .[doc] + - | + wget https://julialang-s3.julialang.org/bin/linux/x64/1.9/julia-1.9.2-linux-x86_64.tar.gz + - tar xzf julia-1.9.2-linux-x86_64.tar.gz + - mkdir bin + - ln -s $PWD/julia-1.9.2/bin/julia bin/julia + - ./bin/julia docs/InstallIJulia.jl + sphinx: + configuration: docs/conf.py + """).lstrip() + + +def _good_overwrite(python_version: str) -> str: + return dedent(f""" + version: 2 + build: + os: ubuntu-24.04 + tools: + python: "{python_version}" + jobs: + post_install: + - python -m pip install 'uv>=0.2.0' + - python -m uv pip install -e .[doc] + sphinx: + configuration: docs/conf.py + """).lstrip() + + +def _expected_message(python_version: str) -> str: + return dedent(f""" + Updated .readthedocs.yml: + - Set build.os to ubuntu-24.04 + - Set build.tools.python to {python_version!r} + - Updated pip install steps + """).strip() + + +def test_update_readthedocs_extend(): + bad_config = dedent(""" + version: 2 + build: + os: ubuntu-20.04 + tools: + python: "3.10" + jobs: + post_install: + - pip install -e .[doc] + - | + wget https://julialang-s3.julialang.org/bin/linux/x64/1.9/julia-1.9.2-linux-x86_64.tar.gz + - tar xzf julia-1.9.2-linux-x86_64.tar.gz + - mkdir bin + - ln -s $PWD/julia-1.9.2/bin/julia bin/julia + - ./bin/julia docs/InstallIJulia.jl + sphinx: + configuration: docs/conf.py + """).lstrip() + input_stream = io.StringIO(bad_config) with pytest.raises(PrecommitError) as exception: readthedocs.main( "conda", python_version=DEFAULT_DEV_PYTHON_VERSION, source=input_stream, ) + assert str(exception.value).strip() == _expected_message(DEFAULT_DEV_PYTHON_VERSION) - exception_msg = dedent(f""" - Updated .readthedocs.yml: - - Set build.os to ubuntu-24.04 - - Set build.tools.python to {DEFAULT_DEV_PYTHON_VERSION!r} - - Updated pip install steps - """) - assert str(exception.value).strip() == exception_msg.strip() - - with open(this_dir / "extend" / ".readthedocs-good.yml") as f: - expected_output = f.read() input_stream.seek(0) - result = input_stream.read() - assert result.strip() == expected_output.strip() + assert input_stream.read().strip() == _good_extend().strip() -@pytest.mark.parametrize("example", ["extend", "overwrite"]) -def test_update_readthedocs_good(this_dir: Path, example: str): - with open(this_dir / example / ".readthedocs-good.yml") as f: - input_stream = io.StringIO(f.read()) +@pytest.mark.parametrize( + "good_config", + [_good_extend(), _good_overwrite(DEFAULT_DEV_PYTHON_VERSION)], + ids=["extend", "overwrite"], +) +def test_update_readthedocs_good(good_config: str): + input_stream = io.StringIO(good_config) readthedocs.main( "conda", python_version=DEFAULT_DEV_PYTHON_VERSION, source=input_stream, ) - - with open(this_dir / example / ".readthedocs-good.yml") as f: - expected_output = f.read() input_stream.seek(0) - result = input_stream.read() - assert result.strip() == expected_output.strip() + assert input_stream.read().strip() == good_config.strip() +@pytest.mark.parametrize( + "bad_config", + [BAD_OVERWRITE_WITH_JOBS, BAD_OVERWRITE_WITHOUT_JOBS], + ids=["with-jobs", "without-jobs"], +) @pytest.mark.parametrize("python_version", ["3.9", "3.10"]) -@pytest.mark.parametrize("suffix", ["bad1", "bad2"]) -def test_update_readthedocs_overwrite( - this_dir: Path, python_version: PythonVersion, suffix: str -): - with open(this_dir / "overwrite" / f".readthedocs-{suffix}.yml") as f: - input_stream = io.StringIO(f.read()) +def test_update_readthedocs_overwrite(python_version: PythonVersion, bad_config: str): + input_stream = io.StringIO(bad_config) with pytest.raises(PrecommitError) as exception: readthedocs.main("conda", python_version, source=input_stream) + assert str(exception.value).strip() == _expected_message(python_version) - exception_msg = dedent(f""" - Updated .readthedocs.yml: - - Set build.os to ubuntu-24.04 - - Set build.tools.python to {python_version!r} - - Updated pip install steps - """) - assert str(exception.value).strip() == exception_msg.strip() - - with open(this_dir / "overwrite" / ".readthedocs-good.yml") as f: - expected_output = f.read() - expected_output = expected_output.replace( - DEFAULT_DEV_PYTHON_VERSION, python_version - ) input_stream.seek(0) - result = input_stream.read() - assert result.strip() == expected_output.strip() + assert input_stream.read().strip() == _good_overwrite(python_version).strip() diff --git a/tests/test_cspell.py b/tests/test_cspell.py index b3ae23f2..451ae4cd 100644 --- a/tests/test_cspell.py +++ b/tests/test_cspell.py @@ -1,37 +1,26 @@ import io -from pathlib import Path +from textwrap import dedent import pytest from compwa_policy.errors import PrecommitError from compwa_policy.format.cspell import _update_cspell_repo_url -from compwa_policy.utilities.precommit import ModifiablePrecommit, Precommit +from compwa_policy.utilities.precommit import ModifiablePrecommit -def test_update_cspell_repo_url(bad_yaml: io.StringIO, good_yaml: io.StringIO): +def test_update_cspell_repo_url(): + bad_config = dedent(""" + repos: + - repo: https://github.com/ComPWA/mirrors-cspell + rev: v5.10.1 + hooks: + - id: cspell + """).lstrip() with ( pytest.raises(PrecommitError, match=r"Updated cSpell pre-commit repo URL"), - ModifiablePrecommit.load(bad_yaml) as bad, + ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, ): - _update_cspell_repo_url(bad) + _update_cspell_repo_url(precommit) - good = Precommit.load(good_yaml) - imported = good.document["repos"][0]["repo"] - expected = bad.document["repos"][0]["repo"] - assert imported == expected - - -@pytest.fixture(scope="module") -def bad_yaml() -> io.StringIO: - return load_config(".pre-commit-config-bad.yaml") - - -@pytest.fixture(scope="module") -def good_yaml() -> io.StringIO: - return load_config(".pre-commit-config-good.yaml") - - -def load_config(filename: str) -> io.StringIO: - path = Path(__file__).parent / "cspell" / filename - with path.open() as file: - return io.StringIO(file.read()) + repo_url = precommit.document["repos"][0]["repo"] + assert repo_url == "https://github.com/streetsidesoftware/cspell-cli" From b74c7fd66ba45290c1d7a4e689c691a10b0ae7c3 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:15:56 +0200 Subject: [PATCH 02/20] DX: increase test coverage to 46% --- .cspell.json | 1 + codecov.yml | 2 +- pyproject.toml | 2 +- tests/conftest.py | 11 ++ tests/format/test_prettier.py | 124 +++++++++++++++++++++++ tests/format/test_toml.py | 180 +++++++++++++++++++++++++++++++++ tests/python/test_black.py | 155 ++++++++++++++++++++++++++++ tests/python/test_mypy.py | 143 ++++++++++++++++++++++++++ tests/python/test_pyupgrade.py | 84 +++++++++++++++ tests/repo/test_commitlint.py | 20 ++++ tests/repo/test_deprecated.py | 63 ++++++++++++ tests/test_cli.py | 128 +++++++++++++++++++++++ 12 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 tests/format/test_prettier.py create mode 100644 tests/format/test_toml.py create mode 100644 tests/python/test_black.py create mode 100644 tests/python/test_mypy.py create mode 100644 tests/python/test_pyupgrade.py create mode 100644 tests/repo/test_commitlint.py create mode 100644 tests/repo/test_deprecated.py diff --git a/.cspell.json b/.cspell.json index 342b6000..a5949869 100644 --- a/.cspell.json +++ b/.cspell.json @@ -102,6 +102,7 @@ "tomlkit", "tomlsort", "unittests", + "usefixtures", "venv" ], "language": "en-US", diff --git a/codecov.yml b/codecov.yml index 99268bd0..320eb669 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 39% + target: 46% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 5d792509..917bb0d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=39 \ + --cov-fail-under=46 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/conftest.py b/tests/conftest.py index b71a1830..8051b2c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import pytest +from compwa_policy.utilities import match from compwa_policy.utilities.precommit import getters @@ -10,6 +11,16 @@ def test_dir() -> Path: return Path(__file__).parent +@pytest.fixture(autouse=True) # noqa: RUF076 +def _clear_git_ls_files_cache() -> None: + """Reset the ``git ls-files`` cache, which does not account for the cwd. + + Tests that build a repository in a ``tmp_path`` would otherwise see a stale file + listing cached by an earlier test running in a different working directory. + """ + match._git_ls_files_cmd.cache_clear() + + @pytest.fixture(autouse=True) # noqa: RUF076 def _offline_git_ls_remote(monkeypatch: pytest.MonkeyPatch) -> None: """Keep the test suite offline: pretend ``git ls-remote`` finds no tags. diff --git a/tests/format/test_prettier.py b/tests/format/test_prettier.py new file mode 100644 index 00000000..ae34c7dc --- /dev/null +++ b/tests/format/test_prettier.py @@ -0,0 +1,124 @@ +import io +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.format.prettier import ( + _remove_configuration, + _update_prettier_hook, + _update_prettier_ignore, + main, +) +from compwa_policy.utilities.precommit import ModifiablePrecommit + +_META_ONLY = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply +""").lstrip() + +_WITH_MIRROR = dedent(""" + repos: + - repo: https://github.com/prettier/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier +""").lstrip() + +_WITH_PRETTIER = dedent(""" + repos: + - repo: https://github.com/ComPWA/prettier-pre-commit + rev: v3.4.2 + hooks: + - id: prettier +""").lstrip() + + +def test_update_prettier_hook_renames_mirror(): + with ( + pytest.raises(PrecommitError, match=r"Updated URL for Prettier"), + ModifiablePrecommit.load(io.StringIO(_WITH_MIRROR)) as precommit, + ): + _update_prettier_hook(precommit) + assert "https://github.com/ComPWA/prettier-pre-commit" in precommit.dumps() + + +def test_update_prettier_hook_without_mirror(): + with ModifiablePrecommit.load(io.StringIO(_WITH_PRETTIER)) as precommit: + _update_prettier_hook(precommit) # already migrated -> no change + + +def test_remove_configuration_removes_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / ".prettierrc.json").write_text("{}") + (tmp_path / ".prettierrc").write_text("{}") + with pytest.raises(PrecommitError, match=r"Removed redundant configuration files"): + _remove_configuration() + assert not (tmp_path / ".prettierrc.json").exists() + assert not (tmp_path / ".prettierrc").exists() + + +def test_remove_configuration_without_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + _remove_configuration() # no config files and no badge to remove + + +def test_update_prettier_ignore_removes_forbidden( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / ".prettierignore").write_text(".cspell.json\nbuild/\n") + with pytest.raises(PrecommitError, match=r"Removed forbidden paths"): + _update_prettier_ignore() + assert ".cspell.json" not in (tmp_path / ".prettierignore").read_text() + + +def test_update_prettier_ignore_inserts_obligatory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "LICENSE").touch() + (tmp_path / ".prettierignore").write_text("build/\n") + with pytest.raises(PrecommitError, match=r"Added paths"): + _update_prettier_ignore() + assert "LICENSE" in (tmp_path / ".prettierignore").read_text() + + +def test_update_prettier_ignore_removes_empty_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / ".prettierignore").write_text("") + with pytest.raises(PrecommitError, match=r"is not needed"): + _update_prettier_ignore() + assert not (tmp_path / ".prettierignore").exists() + + +def test_main_with_prettier_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + with ( + ModifiablePrecommit.load(io.StringIO(_WITH_PRETTIER)) as precommit, + pytest.raises(PrecommitError), + ): + main(precommit) + assert "prettier" in (tmp_path / "README.md").read_text() + + +def test_main_without_prettier_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / ".prettierrc.json").write_text("{}") + with ( + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + pytest.raises(PrecommitError, match=r"Removed redundant configuration"), + ): + main(precommit) diff --git a/tests/format/test_toml.py b/tests/format/test_toml.py new file mode 100644 index 00000000..cd5da375 --- /dev/null +++ b/tests/format/test_toml.py @@ -0,0 +1,180 @@ +import io +import subprocess # noqa: S404 +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.format.toml import ( + _rename_precommit_url, + _rename_taplo_config, + _update_precommit_repo, + _update_taplo_config, + _update_tomlsort_config, + _update_tomlsort_hook, + _update_vscode_extensions, + main, +) +from compwa_policy.utilities.precommit import ModifiablePrecommit + +_META_ONLY = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply +""").lstrip() + +_WITH_MIRRORS_TAPLO = dedent(""" + repos: + - repo: https://github.com/ComPWA/mirrors-taplo + rev: v0.8.1 + hooks: + - id: taplo +""").lstrip() + + +def _git_init(directory: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=directory, check=True) # noqa: S607 + + +def test_rename_precommit_url_migrates_mirror(): + with ( + pytest.raises(PrecommitError, match=r"Renamed mirrors-taplo"), + ModifiablePrecommit.load(io.StringIO(_WITH_MIRRORS_TAPLO)) as precommit, + ): + _rename_precommit_url(precommit) + result = precommit.dumps() + assert "mirrors-taplo" not in result + assert "https://github.com/ComPWA/taplo-pre-commit" in result + assert "rev: v0.8.1" in result # preserves the pinned revision + + +def test_update_precommit_repo_adds_taplo(): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _update_precommit_repo(precommit) + result = precommit.dumps() + assert "https://github.com/ComPWA/taplo-pre-commit" in result + assert "id: taplo-format" in result + + +def test_update_tomlsort_hook_without_excludes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _update_tomlsort_hook(precommit) + result = precommit.dumps() + assert "https://github.com/pappasam/toml-sort" in result + assert "exclude" not in result + + +def test_update_tomlsort_hook_with_excludes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _git_init(tmp_path) + (tmp_path / "labels.toml").touch() + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _update_tomlsort_hook(precommit) + assert "exclude" in precommit.dumps() + + +def test_rename_taplo_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "taplo.toml").write_text("include = []\n") + with pytest.raises(PrecommitError, match=r"Renamed taplo\.toml"): + _rename_taplo_config() + assert not (tmp_path / "taplo.toml").exists() + assert (tmp_path / ".taplo.toml").exists() + + +def test_rename_precommit_url_without_mirror(): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _rename_precommit_url(precommit) + assert "https://github.com/ComPWA/taplo-pre-commit" in precommit.dumps() + + +def test_update_precommit_repo_migrates_mirror(): + with ( + pytest.raises(PrecommitError, match=r"Renamed mirrors-taplo"), + ModifiablePrecommit.load(io.StringIO(_WITH_MIRRORS_TAPLO)) as precommit, + ): + _update_precommit_repo(precommit) + result = precommit.dumps() + assert "mirrors-taplo" not in result + assert "https://github.com/ComPWA/taplo-pre-commit" in result + + +def test_update_tomlsort_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + with pytest.raises(PrecommitError, match=r"Updated toml-sort configuration"): + _update_tomlsort_config() + result = (tmp_path / "pyproject.toml").read_text() + assert "[tool.tomlsort]" in result + assert 'sort_first = ["project"]' in result + + +def test_update_tomlsort_config_without_known_tables_is_idempotent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.other]\nkey = 1\n") + with pytest.raises(PrecommitError, match=r"Updated toml-sort configuration"): + _update_tomlsort_config() + assert "sort_first" not in (tmp_path / "pyproject.toml").read_text() + _update_tomlsort_config() # second run is a no-op + + +def test_update_taplo_config_creates_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError, match=r"Added .*\.taplo\.toml"): + _update_taplo_config() + assert (tmp_path / ".taplo.toml").exists() + + +def test_update_vscode_extensions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # cspell:ignore tamasfe + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_extensions() + extensions = (tmp_path / ".vscode" / "extensions.json").read_text() + assert "tamasfe.even-better-toml" in extensions + + +def test_main_runs_when_triggered(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + main(precommit) + result = precommit.dumps() + assert "https://github.com/ComPWA/taplo-pre-commit" in result + assert "https://github.com/pappasam/toml-sort" in result + + +def test_main_skips_without_trigger_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + with ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit: + main(precommit) # no pyproject.toml or taplo config -> no-op diff --git a/tests/python/test_black.py b/tests/python/test_black.py new file mode 100644 index 00000000..f41436fe --- /dev/null +++ b/tests/python/test_black.py @@ -0,0 +1,155 @@ +import io +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.python.black import ( + _remove_outdated_settings, + _update_black_settings, + _update_precommit_repo, + main, +) +from compwa_policy.utilities.precommit import ModifiablePrecommit +from compwa_policy.utilities.pyproject import ModifiablePyproject + + +def test_remove_outdated_settings(): + config = dedent(""" + [tool.black] + line-length = 88 + preview = true + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Removed line-length"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _remove_outdated_settings(pyproject) + assert "line-length" not in pyproject.dumps() + + +def test_remove_outdated_settings_keeps_other_options(): + config = dedent(""" + [tool.black] + preview = true + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _remove_outdated_settings(pyproject) + assert "preview = true" in pyproject.dumps() + + +def test_update_black_settings_with_requires_python(): + config = dedent(""" + [project] + requires-python = ">=3.10" + + [tool.black] + target-version = ["py39"] + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_black_settings(pyproject) + result = pyproject.dumps() + assert "target-version" not in result + assert "preview = true" in result + + +def test_update_black_settings_without_target_version(): + config = dedent(""" + [project] + requires-python = ">=3.10" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated black configuration"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_black_settings(pyproject) + result = pyproject.dumps() + assert "preview = true" in result + assert "target-version" not in result + + +def test_update_black_settings_derives_target_version_from_classifiers(): + config = dedent(""" + [project] + classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated black configuration"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_black_settings(pyproject) + result = pyproject.dumps() + assert '"py310"' in result + assert '"py311"' in result + + +def test_update_black_settings_already_compliant(): + config = dedent(""" + [project] + requires-python = ">=3.10" + + [tool.black] + preview = true + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_black_settings(pyproject) # already compliant -> no change + assert "preview = true" in pyproject.dumps() + + +@pytest.mark.parametrize("has_notebooks", [False, True]) +def test_update_precommit_repo(has_notebooks: bool): + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_repo(precommit, has_notebooks) + result = precommit.dumps() + assert "https://github.com/psf/black-pre-commit-mirror" in result + assert ("black-jupyter" in result) is has_notebooks + + +def test_main_without_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: + main(precommit, has_notebooks=False) # no pyproject.toml -> no-op + + +def test_main_replaces_black_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + requires-python = ">=3.10" + + [tool.black] + line-length = 88 + """).lstrip() + ) + config = dedent(""" + repos: + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, has_notebooks=False) + result = precommit.dumps() + assert "https://github.com/psf/black\n" not in result + assert "https://github.com/psf/black-pre-commit-mirror" in result diff --git a/tests/python/test_mypy.py b/tests/python/test_mypy.py new file mode 100644 index 00000000..799f61f5 --- /dev/null +++ b/tests/python/test_mypy.py @@ -0,0 +1,143 @@ +import io +import json +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.python.mypy import ( + _merge_mypy_into_pyproject, + _remove_mypy, + _update_precommit_config, + _update_vscode_settings, + main, +) +from compwa_policy.utilities.precommit import ModifiablePrecommit +from compwa_policy.utilities.pyproject import ModifiablePyproject + +_PRECOMMIT_WITH_MYPY = dedent(""" + repos: + - repo: local + hooks: + - id: mypy + name: mypy + entry: mypy + language: system + types: [python] +""").lstrip() + + +def test_merge_mypy_into_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".mypy.ini").write_text( + dedent(""" + [mypy] + ignore_missing_imports = True + """).lstrip() + ) + with ( + pytest.raises(PrecommitError, match=r"Imported mypy configuration"), + ModifiablePyproject.load(io.StringIO("")) as pyproject, + ): + _merge_mypy_into_pyproject(pyproject) + assert "[tool.mypy]" in pyproject.dumps() + assert not (tmp_path / ".mypy.ini").exists() + + +def test_merge_mypy_into_pyproject_without_ini( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + with ModifiablePyproject.load(io.StringIO("")) as pyproject: + _merge_mypy_into_pyproject(pyproject) # nothing to import + + +def test_update_precommit_config(): + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_config(precommit) + result = precommit.dumps() + assert "id: mypy" in result + assert "entry: mypy" in result + + +def test_remove_mypy(): + pyproject_config = dedent(""" + [dependency-groups] + style = ["mypy"] + + [tool.mypy] + strict = true + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_WITH_MYPY)) as precommit, + ModifiablePyproject.load(io.StringIO(pyproject_config)) as pyproject, + ): + _remove_mypy(precommit, pyproject) + assert "mypy" not in precommit.dumps() + assert "tool.mypy" not in pyproject.dumps() + + +def test_update_vscode_settings_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_settings(mypy=True) + settings = json.loads((tmp_path / ".vscode" / "settings.json").read_text()) + assert "mypy-type-checker.args" in settings + + +def test_update_vscode_settings_inactive( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_settings(mypy=False) + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "ms-python.mypy-type-checker" in extensions["unwantedRecommendations"] + + +def test_remove_mypy_without_configuration_table(): + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ModifiablePyproject.load(io.StringIO("")) as pyproject, + ): + _remove_mypy(precommit, pyproject) # no tool.mypy table to remove + + +def test_main_activates_mypy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + (tmp_path / "README.md").write_text("# My Package\n\nSome text.\n") + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(active=True, precommit=precommit) + assert "id: mypy" in precommit.dumps() + + +def test_main_deactivates_mypy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.mypy]\nstrict = true\n") + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_WITH_MYPY)) as precommit, + ): + main(active=False, precommit=precommit) + assert "mypy" not in precommit.dumps() diff --git a/tests/python/test_pyupgrade.py b/tests/python/test_pyupgrade.py new file mode 100644 index 00000000..8d294515 --- /dev/null +++ b/tests/python/test_pyupgrade.py @@ -0,0 +1,84 @@ +import io +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.python.pyupgrade import _remove_pyupgrade, main +from compwa_policy.utilities.precommit import ModifiablePrecommit + + +@pytest.fixture +def _project_with_classifiers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ] + """).lstrip() + ) + + +@pytest.mark.usefixtures("_project_with_classifiers") +def test_main_installs_pyupgrade(): + config = dedent(""" + repos: + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.8.5 + hooks: + - id: nbqa-isort + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, no_ruff=True) + + result = precommit.dumps() + assert "https://github.com/asottile/pyupgrade" in result + assert "--py310-plus" in result + assert "nbqa-pyupgrade" in result + + +def test_main_removes_pyupgrade_when_ruff_is_used(): + config = dedent(""" + repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py310-plus] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"pyupgrade"), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, no_ruff=False) + + assert "pyupgrade" not in precommit.dumps() + + +def test_remove_pyupgrade_also_removes_nbqa_hook(): + config = dedent(""" + repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.8.5 + hooks: + - id: nbqa-pyupgrade + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _remove_pyupgrade(precommit) + + assert "pyupgrade" not in precommit.dumps() diff --git a/tests/repo/test_commitlint.py b/tests/repo/test_commitlint.py new file mode 100644 index 00000000..cd0e7b37 --- /dev/null +++ b/tests/repo/test_commitlint.py @@ -0,0 +1,20 @@ +from pathlib import Path + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.repo.commitlint import main + + +def test_main_without_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + main() # no error and nothing to remove + + +def test_main_removes_outdated_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + config = tmp_path / "commitlint.config.js" + config.touch() + with pytest.raises(PrecommitError, match=r"Remove outdated commitlint\.config\.js"): + main() + assert not config.exists() diff --git a/tests/repo/test_deprecated.py b/tests/repo/test_deprecated.py new file mode 100644 index 00000000..4b45232e --- /dev/null +++ b/tests/repo/test_deprecated.py @@ -0,0 +1,63 @@ +import io +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.repo.deprecated import ( + _remove_relink_references, + remove_deprecated_tools, +) +from compwa_policy.utilities.precommit import ModifiablePrecommit + + +def test_remove_relink_references_without_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + _remove_relink_references("docs") # nothing to remove + + +def test_remove_relink_references_raises( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + docs.mkdir() + (docs / "_relink_references.py").touch() + with pytest.raises(PrecommitError, match=r"sphinx-api-relink"): + _remove_relink_references("docs") + + +def test_remove_deprecated_tools_removes_markdownlint( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + config = dedent(""" + repos: + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.41.0 + hooks: + - id: markdownlint + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"markdownlint"), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + remove_deprecated_tools(precommit, keep_issue_templates=True) + + +def test_remove_deprecated_tools_removes_issue_templates( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + template_dir = tmp_path / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True) + (template_dir / "bug_report.md").touch() + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError, match=r"Removed \.github/ISSUE_TEMPLATE"), + ): + remove_deprecated_tools(precommit, keep_issue_templates=False) + assert not template_dir.exists() diff --git a/tests/test_cli.py b/tests/test_cli.py index f3d5e2fb..913de602 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -253,3 +253,131 @@ def test_dry_run_does_not_modify(self, tmp_path: Path) -> None: with pytest.raises(typer.Exit): migrate(config_file=config, dry_run=True) assert config.read_text() == _CONFIG_WITH_NOTEBOOK_HOOK + + +class TestMigrate: + def _write( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config: str + ) -> Path: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + config_file = tmp_path / ".pre-commit-config.yaml" + config_file.write_text(dedent(config).lstrip()) + return config_file + + def test_missing_config_file_exits( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + with pytest.raises(typer.Exit) as exc: + migrate(config_file=tmp_path / "does-not-exist.yaml") + assert exc.value.exit_code == 1 + + def test_missing_pyproject_exits( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.chdir(tmp_path) + config = tmp_path / ".pre-commit-config.yaml" + config.write_text("repos: []\n") + with pytest.raises(typer.Exit) as exc: + migrate(config_file=config) + assert exc.value.exit_code == 1 + + def test_no_hook_found_exits( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = self._write( + tmp_path, + monkeypatch, + """ + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """, + ) + with pytest.raises(typer.Exit) as exc: + migrate(config_file=config) + assert exc.value.exit_code == 0 + + def test_nothing_to_migrate_exits( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = self._write( + tmp_path, + monkeypatch, + """ + repos: + - repo: local + hooks: + - id: check-dev-files + """, + ) + with pytest.raises(typer.Exit) as exc: + migrate(config_file=config) + assert exc.value.exit_code == 0 + + def test_unknown_args_exit( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = self._write( + tmp_path, + monkeypatch, + """ + repos: + - repo: https://github.com/ComPWA/policy + rev: 0.1.0 + hooks: + - id: check-dev-files + args: [--does-not-exist] + """, + ) + with pytest.raises(typer.Exit) as exc: + migrate(config_file=config) + assert exc.value.exit_code == 1 + + def test_migrates_args_into_pyproject( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = self._write( + tmp_path, + monkeypatch, + """ + repos: + - repo: https://github.com/ComPWA/policy + rev: 0.1.0 + hooks: + - id: check-dev-files + args: [--no-pypi, --repo-name=demo, --type-checker=ty] + """, + ) + migrate(config_file=config, dry_run=False) + + pyproject = (tmp_path / "pyproject.toml").read_text() + assert "no-pypi = true" in pyproject + assert 'repo-name = "demo"' in pyproject + hooks = yaml.safe_load(config.read_text())["repos"][0]["hooks"] + assert "args" not in hooks[0], "args must be stripped after migration" + + def test_migrates_environment_variables_into_nested_table( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config = self._write( + tmp_path, + monkeypatch, + """ + repos: + - repo: https://github.com/ComPWA/policy + rev: 0.1.0 + hooks: + - id: check-dev-files + args: ["--environment-variables=A=1,B=2"] + """, + ) + migrate(config_file=config, dry_run=False) + + pyproject = (tmp_path / "pyproject.toml").read_text() + assert "[tool.compwa.policy.setup.env]" in pyproject + assert 'A = "1"' in pyproject + assert 'B = "2"' in pyproject From 776d083b25a0953eb9b3ad92aad66a0a6e95d74f Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:24:52 +0200 Subject: [PATCH 03/20] DX: test `ruff` module * DX: increase test coverage to 52% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/python/test_ruff.py | 200 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 tests/python/test_ruff.py diff --git a/codecov.yml b/codecov.yml index 320eb669..24386302 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 46% + target: 52% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 917bb0d9..4b96fee3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=46 \ + --cov-fail-under=52 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/python/test_ruff.py b/tests/python/test_ruff.py new file mode 100644 index 00000000..db480fb7 --- /dev/null +++ b/tests/python/test_ruff.py @@ -0,0 +1,200 @@ +import io +import subprocess # noqa: S404 +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.python.ruff import ( + _move_ruff_lint_config, + _update_lint_dependencies, + main, +) +from compwa_policy.utilities.precommit import ModifiablePrecommit +from compwa_policy.utilities.pyproject import ModifiablePyproject + +_PRECOMMIT_TO_CLEAN = dedent(""" + repos: + - repo: https://github.com/psf/black + rev: 24.1.0 + hooks: + - id: black + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.8.0 + hooks: + - id: nbqa-isort +""").lstrip() + + +@pytest.fixture +def ruff_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + package = tmp_path / "src" / "my_package" + package.mkdir(parents=True) + (package / "__init__.py").touch() + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "conf.py").touch() + (tmp_path / "tests").mkdir() + (tmp_path / "README.md").write_text("# My Package\n\nText.\n") + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + classifiers = [ + "Programming Language :: Python :: 3.10", + ] + + [tool.black] + line-length = 88 + + [tool.ruff] + select = ["E"] + ignore = ["D203"] + + [tool.nbqa.addopts] + ruff = ["--extend-ignore=E501"] + """).lstrip() + ) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_main_with_notebooks(ruff_repo: Path): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_TO_CLEAN)) as precommit, + ): + main(precommit, has_notebooks=True, imports_on_top=True) + + pyproject = (ruff_repo / "pyproject.toml").read_text() + assert "[tool.black]" not in pyproject # black settings removed + assert "[tool.ruff.lint]" in pyproject # linting config migrated + assert 'select = ["ALL"]' in pyproject + assert '"*.ipynb"' in pyproject # per-file-ignores for notebooks + + config = precommit.dumps() + assert "flake8" not in config # flake8 hook removed + assert "https://github.com/astral-sh/ruff-pre-commit" in config + + +@pytest.mark.usefixtures("ruff_repo") +def test_main_without_notebooks(): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_TO_CLEAN)) as precommit, + ): + main(precommit, has_notebooks=False, imports_on_top=False) + + config = precommit.dumps() + assert "https://github.com/astral-sh/ruff-pre-commit" in config + assert "ruff-format" in config + + +def test_main_migrates_legacy_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + requires-python = ">=3.10" + + [tool.ruff] + target-version = "py39" + + [tool.ruff.lint] + extend-select = ["C90"] + ignore = ["ANN101", "D203"] + + [tool.nbqa.addopts] + black = ["--line-length=85"] + flake8 = ["--ignore=E501"] + isort = ["--profile=black"] + """).lstrip() + ) + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + main(precommit, has_notebooks=True, imports_on_top=False) + + pyproject = (tmp_path / "pyproject.toml").read_text() + assert "target-version" not in pyproject # dropped in favor of requires-python + assert "extend-select" not in pyproject # folded into select + assert "ANN101" not in pyproject # deprecated rule removed + + +def test_move_ruff_lint_config(): + config = dedent(""" + [tool.ruff] + select = ["E", "F"] + ignore = ["D203"] + + [tool.ruff.isort] + known-first-party = ["my_package"] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Moved linting configuration"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _move_ruff_lint_config(pyproject) + result = pyproject.dumps() + assert "[tool.ruff.lint]" in result + assert "[tool.ruff.lint.isort]" in result + + +def test_update_lint_dependencies_adds_ruff( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + config = dedent(""" + [project] + name = "my-package" + classifiers = ["Programming Language :: Python :: 3.10"] + """).lstrip() + (tmp_path / "pyproject.toml").write_text(config) + with ( + pytest.raises(PrecommitError), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_lint_dependencies(pyproject) + assert "ruff" in pyproject.dumps() + + +def test_update_lint_dependencies_legacy_python( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + config = dedent(""" + [project] + name = "my-package" + classifiers = ["Programming Language :: Python :: 3.6"] + """).lstrip() + (tmp_path / "pyproject.toml").write_text(config) + with ( + pytest.raises(PrecommitError), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_lint_dependencies(pyproject) + result = pyproject.dumps() + assert "python_version" in result + assert "3.7.0" in result + + +def test_update_lint_dependencies_without_package_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.foo]\nx = 1\n") + config = dedent(""" + [project] + classifiers = ["Programming Language :: Python :: 3.10"] + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_lint_dependencies(pyproject) # no package name -> no-op From f265b3d42c1e9978aae0fec9d62ed9e98664e38b Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:45:18 +0200 Subject: [PATCH 04/20] DX: test `poe` module * DX: increase test coverage to 56% --- codecov.yml | 2 +- pyproject.toml | 2 +- src/compwa_policy/repo/poe.py | 9 ++ tests/conftest.py | 10 ++- tests/repo/test_poe.py | 154 ++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 tests/repo/test_poe.py diff --git a/codecov.yml b/codecov.yml index 24386302..d2c539d3 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 52% + target: 56% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 4b96fee3..dee95727 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=52 \ + --cov-fail-under=56 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/src/compwa_policy/repo/poe.py b/src/compwa_policy/repo/poe.py index b2aa2cb7..55554df0 100644 --- a/src/compwa_policy/repo/poe.py +++ b/src/compwa_policy/repo/poe.py @@ -216,6 +216,15 @@ def _check_no_uv_run(pyproject: Pyproject) -> None: def __has_uv_run(cmd: str | Sequence) -> bool: + """Check whether a Poe task command shells out to :code:`uv run`. + + >>> __has_uv_run("uv run pytest") + True + >>> __has_uv_run(["python", "-m", "pytest"]) + False + >>> __has_uv_run(["uv run pytest", "coverage report"]) + True + """ if isinstance(cmd, str): return "uv run" in cmd if isinstance(cmd, Sequence): diff --git a/tests/conftest.py b/tests/conftest.py index 8051b2c2..df337d1c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,7 @@ import pytest +from compwa_policy import _characterization from compwa_policy.utilities import match from compwa_policy.utilities.precommit import getters @@ -13,12 +14,15 @@ def test_dir() -> Path: @pytest.fixture(autouse=True) # noqa: RUF076 def _clear_git_ls_files_cache() -> None: - """Reset the ``git ls-files`` cache, which does not account for the cwd. + """Reset caches that depend on the working directory but do not key on it. - Tests that build a repository in a ``tmp_path`` would otherwise see a stale file - listing cached by an earlier test running in a different working directory. + ``git ls-files`` and the repository characterization helpers are cached, so a test + that builds a repository in a ``tmp_path`` would otherwise see a stale result cached + by an earlier test running in a different working directory. """ match._git_ls_files_cmd.cache_clear() + _characterization.has_documentation.cache_clear() + _characterization.has_python_code.cache_clear() @pytest.fixture(autouse=True) # noqa: RUF076 diff --git a/tests/repo/test_poe.py b/tests/repo/test_poe.py new file mode 100644 index 00000000..df1f6258 --- /dev/null +++ b/tests/repo/test_poe.py @@ -0,0 +1,154 @@ +import io +import subprocess # noqa: S404 +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.repo.poe import ( + _check_expected_sections, + _check_no_uv_run, + _set_upgrade_task, + _update_doclive, + main, +) +from compwa_policy.utilities.pyproject import ModifiablePyproject, Pyproject + +# cspell:ignore nbmake +_PYPROJECT = dedent(""" + [project] + name = "my-package" + classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ] + + [dependency-groups] + dev = ["my-package"] + doc = ["myst-nb", "sphinx-autobuild"] + notebooks = ["jupyterlab"] + + [tool.poe.tasks.doc] + cmd = "sphinx-build -b html docs docs/_build/html" + + [tool.poe.tasks.doclive] + cmd = "sphinx-autobuild docs docs/_build/html" + + [tool.poe.tasks.docnb] + cmd = "sphinx-build -b html docs docs/_build/html" + + [tool.poe.tasks.docnblive] + cmd = "sphinx-autobuild docs docs/_build/html" + + [tool.poe.tasks.test] + cmd = "pytest" + + [tool.poe.tasks.nb] + cmd = "pytest --nbmake" + + [tool.poe.tasks.all] + sequence = ["test", "doc"] + + [tool.tox] + legacy_tox_ini = "" +""").lstrip() + + +@pytest.fixture +def poe_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "conf.py").touch() + (tmp_path / "docs" / "index.ipynb").touch() + (tmp_path / "tests").mkdir() + (tmp_path / "pyproject.toml").write_text(_PYPROJECT) + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + return tmp_path + + +def test_main_configures_groups_and_tasks(poe_repo: Path): + with pytest.raises(PrecommitError): + main(has_notebooks=True, package_manager="uv") + + pyproject = (poe_repo / "pyproject.toml").read_text() + assert "[tool.poe.executor]" in pyproject # uv executor configured + assert "[tool.poe.groups.doc.tasks.doc]" in pyproject # doc task migrated to group + assert "[tool.poe.groups.test.tasks.test]" in pyproject + assert "test-py310" in pyproject # multi-version test-all tasks generated + assert "test-py311" in pyproject + assert "[tool.poe.tasks.upgrade]" in pyproject # upgrade task added + + +def test_main_with_pixi_package_manager(poe_repo: Path): + with pytest.raises(PrecommitError): + main(has_notebooks=True, package_manager="pixi") + + pyproject = (poe_repo / "pyproject.toml").read_text() + assert "pixi upgrade" in pyproject # pixi-specific upgrade command + + +def test_update_doclive_adds_executor(): + # cspell:ignore autobuild + config = dedent(""" + [dependency-groups] + doc = ["sphinx-autobuild"] + + [tool.poe.groups.doc.tasks.doclive] + cmd = "sphinx-autobuild docs docs/_build/html" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated Poe the Poet doclive task"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_doclive(pyproject) + result = pyproject.dumps() + assert "sphinx-autobuild" in result + assert "executor" in result + + +def test_main_without_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + main(has_notebooks=False, package_manager="uv") # no pyproject.toml -> no-op + + +def test_check_expected_sections_reports_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "conf.py").touch() + (tmp_path / "pyproject.toml").write_text("[tool.poe.tasks]\n") + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + pyproject = Pyproject.load() + with pytest.raises(PrecommitError, match=r"missing task definitions: doc, doclive"): + _check_expected_sections(pyproject, has_notebooks=False) + + +def test_check_no_uv_run_rejects_uv_run(): + config = dedent(""" + [tool.poe.tasks.test] + cmd = "uv run pytest" + """).lstrip() + pyproject = Pyproject.load(io.StringIO(config)) + with pytest.raises(PrecommitError, match=r"should not use 'uv run'"): + _check_no_uv_run(pyproject) + + +def test_set_upgrade_task_removes_task_when_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + config = dedent(""" + [tool.poe.tasks.upgrade] + cmd = "outdated" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Removed Poe the Poet upgrade task"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _set_upgrade_task(pyproject, package_manager="conda") + assert "upgrade" not in pyproject.dumps() From ae66883b3e6948e7d3771548d8ef6fcfdb1b61d5 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:14:02 +0200 Subject: [PATCH 05/20] DX: test `workflows` module * DX: increase test coverage to 59% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/github/test_workflows.py | 151 +++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/github/test_workflows.py diff --git a/codecov.yml b/codecov.yml index d2c539d3..d16ba43d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 56% + target: 59% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index dee95727..49c02859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=56 \ + --cov-fail-under=59 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/github/test_workflows.py b/tests/github/test_workflows.py new file mode 100644 index 00000000..d5d4fb7e --- /dev/null +++ b/tests/github/test_workflows.py @@ -0,0 +1,151 @@ +import io +import subprocess # noqa: S404 +from pathlib import Path + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.github.workflows import main, remove_workflow +from compwa_policy.utilities.precommit import Precommit +from compwa_policy.utilities.pyproject import PythonVersion + +_WORKFLOW_DIR = Path(".github/workflows") + + +def _precommit(content: str = "repos: []\n") -> Precommit: + return Precommit.load(io.StringIO(content)) + + +@pytest.fixture +def workflows_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "tests").mkdir() + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "conf.py").touch() + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "my-package"\nrequires-python = ">=3.10"\n' + ) + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _run_main( + *, + doc_apt_packages: list[str] | None = None, + environment_variables: dict[str, str] | None = None, + github_pages: bool = False, + macos_python_version: PythonVersion | None = None, + no_cd: bool = False, + no_milestones: bool = False, + no_pypi: bool = False, + no_version_branches: bool = False, + precommit_content: str = "repos: []\n", + python_version: PythonVersion = "3.13", + single_threaded: bool = False, + skip_tests: list[str] | None = None, +) -> None: + main( + _precommit(precommit_content), + allow_deprecated=False, + doc_apt_packages=doc_apt_packages or [], + environment_variables=environment_variables or {}, + github_pages=github_pages, + keep_pr_linting=False, + macos_python_version=macos_python_version, + no_cd=no_cd, + no_milestones=no_milestones, + no_pypi=no_pypi, + no_version_branches=no_version_branches, + python_version=python_version, + single_threaded=single_threaded, + skip_tests=skip_tests or [], + ) + + +def test_main_creates_workflows(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main() + + assert (workflows_repo / _WORKFLOW_DIR / "cd.yml").exists() + assert (workflows_repo / _WORKFLOW_DIR / "ci.yml").exists() + assert (workflows_repo / _WORKFLOW_DIR / "pr-linting.yml").exists() + assert (workflows_repo / _WORKFLOW_DIR / "clean-caches.yml").exists() + + +def test_main_with_options(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main( + doc_apt_packages=["graphviz"], + environment_variables={"PYTHONHASHSEED": "0"}, + github_pages=True, + macos_python_version="3.12", + python_version="3.12", + single_threaded=True, + skip_tests=["3.10"], + ) + + ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() + assert "graphviz" in ci + assert "PYTHONHASHSEED" in ci + + +def test_main_no_cd(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main(no_cd=True) + assert not (workflows_repo / _WORKFLOW_DIR / "cd.yml").exists() + + +def test_main_bans_cd_jobs(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main(no_pypi=True, no_milestones=True, no_version_branches=True) + cd_path = workflows_repo / _WORKFLOW_DIR / "cd.yml" + if cd_path.exists(): + assert "pypi" not in cd_path.read_text() + + +def test_main_with_codecov(workflows_repo: Path): + (workflows_repo / "codecov.yml").touch() + (workflows_repo / ".python-version").write_text("3.11\n") + with pytest.raises(PrecommitError): + _run_main() + ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() + assert "CODECOV_TOKEN" in ci + assert "3.11" in ci # coverage python version from .python-version + + +def test_main_removes_style_job_when_outsourced(workflows_repo: Path): + precommit = "ci:\n autofix_prs: true\nrepos: []\n" + with pytest.raises(PrecommitError): + _run_main(precommit_content=precommit) + ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() + assert "style:" not in ci # style job outsourced to pre-commit.ci + + +def test_main_removes_doc_and_test_jobs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "pyproject.toml").write_text('[project]\nname = "my-package"\n') + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _run_main() + ci = (tmp_path / _WORKFLOW_DIR / "ci.yml").read_text() + assert "doc:" not in ci # no documentation -> doc job removed + assert "test:" not in ci # no tests directory -> test job removed + + +def test_remove_workflow_absent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + remove_workflow("ci-tests.yml") # nothing to remove + + +def test_remove_workflow_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + workflow = tmp_path / _WORKFLOW_DIR / "ci-tests.yml" + workflow.parent.mkdir(parents=True) + workflow.touch() + with pytest.raises(PrecommitError, match=r"Removed deprecated ci-tests.yml"): + remove_workflow("ci-tests.yml") + assert not workflow.exists() From ec9c627ad6805fc6976e2bf43d33d537c3b8cbf9 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:17:29 +0200 Subject: [PATCH 06/20] DX: test `pixi` module * DX: increase test coverage to 62% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/test_pixi.py | 161 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 3 deletions(-) diff --git a/codecov.yml b/codecov.yml index d16ba43d..cad4385f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 59% + target: 62% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 49c02859..f0ce1fd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=59 \ + --cov-fail-under=62 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/test_pixi.py b/tests/test_pixi.py index 88741e79..37ceb4be 100644 --- a/tests/test_pixi.py +++ b/tests/test_pixi.py @@ -1,8 +1,17 @@ +import subprocess # noqa: S404 +from pathlib import Path from textwrap import dedent import pytest -from compwa_policy.env.pixi._update import _update_docnb_and_doclive +from compwa_policy.env.pixi._update import ( + _clean_up_task_env, + _define_combined_ci_job, + _set_dev_python_version, + _update_dev_environment, + _update_docnb_and_doclive, + update_pixi_configuration, +) from compwa_policy.errors import PrecommitError from compwa_policy.utilities.pyproject import ModifiablePyproject @@ -42,3 +51,153 @@ def test_update_docnb_and_doclive(table_key: str): cmd = "should not change" """) assert new_content.strip() == expected.strip() + + +_ENVIRONMENT_YML = dedent(""" + dependencies: + - python==3.12.* + - pip + - graphviz + variables: + MY_VARIABLE: "1" +""").lstrip() + + +def test_update_pixi_configuration_skips_non_pixi( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + update_pixi_configuration( + is_python_package=True, + dev_python_version="3.12", + package_manager="uv", # not a pixi manager -> no-op + ) + assert not (tmp_path / "pixi.toml").exists() + + +def test_update_pixi_configuration_for_pyproject( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "environment.yml").write_text(_ENVIRONMENT_YML) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + + [tool.pixi.project] + channels = ["conda-forge"] + + [tool.pixi.feature.dev.tasks.docnb] + cmd = "outdated" + """).lstrip() + ) + with pytest.raises(PrecommitError): + update_pixi_configuration( + is_python_package=True, + dev_python_version="3.12", + package_manager="pixi", + ) + + pyproject = (tmp_path / "pyproject.toml").read_text() + assert "[tool.pixi.workspace]" in pyproject # project table renamed + assert "graphviz" in pyproject # conda dependency imported + assert "MY_VARIABLE" in pyproject # conda variable imported + assert 'python = "3.12.*"' in pyproject # dev Python version set + assert "my-package" in pyproject # installed as editable pypi-dependency + assert 'cmd = "pixi run doc"' in pyproject # docnb task outsourced + + +def test_update_pixi_configuration_for_pixi_uv( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pixi.toml").write_text( + dedent(""" + [feature.dev.tasks.sty] + cmd = "pre-commit run -a" + + [feature.dev.tasks.docnb] + cmd = "build docs" + """).lstrip() + ) + with pytest.raises(PrecommitError): + update_pixi_configuration( + is_python_package=True, + dev_python_version="3.12", + package_manager="pixi+uv", + ) + + pixi = (tmp_path / "pixi.toml").read_text() + assert "[feature.dev.tasks.ci]" in pixi # combined CI job defined + assert "depends_on" in pixi + + +def test_define_combined_ci_job_selects_tests_and_doc(): + content = dedent(""" + [feature.dev.tasks.tests] + cmd = "pytest" + + [feature.dev.tasks.doc] + cmd = "build docs" + """) + with ( + pytest.raises(PrecommitError, match=r"Updated combined CI job"), + ModifiablePyproject.load(content) as config, + ): + _define_combined_ci_job(config) + result = config.dumps() + assert "tests" in result + assert "doc" in result + + +def test_clean_up_task_env_removes_redundant_variables(): + content = dedent(""" + [activation.env] + SHARED = "global" + + [feature.dev.tasks.test.env] + SHARED = "global" + LOCAL = "value" + """) + with ( + pytest.raises(PrecommitError, match=r"Removed redundant environment variables"), + ModifiablePyproject.load(content) as config, + ): + _clean_up_task_env(config) + result = config.dumps() + assert "LOCAL" in result + assert result.count("SHARED") == 1 # only the global activation entry remains + + +def test_update_dev_environment_lists_optional_dependency_features(): + content = dedent(""" + [project.optional-dependencies] + dev = ["pytest"] + doc = ["sphinx"] + """) + with ( + pytest.raises(PrecommitError, match=r"Updated Pixi developer environment"), + ModifiablePyproject.load(content) as config, + ): + _update_dev_environment(config) + result = config.dumps() + assert "features" in result + assert "doc" in result + + +def test_set_dev_python_version(): + content = dedent(""" + [dependencies] + python = "3.10.*" + """) + with ( + pytest.raises(PrecommitError, match=r"Set Python version"), + ModifiablePyproject.load(content) as config, + ): + _set_dev_python_version(config, "3.12") + assert 'python = "3.12.*"' in config.dumps() From 7b06325525c1de34dbad4c1fedc66f36c1ecc3b7 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:12:55 +0200 Subject: [PATCH 07/20] DX: test `precommit` module * DX: increase test coverage to 65% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/format/test_precommit.py | 227 ++++++++++++++++++++++++++++++++- 3 files changed, 228 insertions(+), 3 deletions(-) diff --git a/codecov.yml b/codecov.yml index cad4385f..052fcc8e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 62% + target: 65% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index f0ce1fd6..301eb228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=62 \ + --cov-fail-under=65 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/format/test_precommit.py b/tests/format/test_precommit.py index 14dd532b..03b3a1f5 100644 --- a/tests/format/test_precommit.py +++ b/tests/format/test_precommit.py @@ -1,12 +1,23 @@ from __future__ import annotations import io +from textwrap import dedent +from typing import TYPE_CHECKING +import pytest import yaml from compwa_policy.errors import PrecommitError from compwa_policy.format import precommit -from compwa_policy.utilities.precommit import ModifiablePrecommit +from compwa_policy.utilities.precommit import ModifiablePrecommit, Precommit + +if TYPE_CHECKING: + from pathlib import Path + + +def _load(content: str) -> ModifiablePrecommit: + return ModifiablePrecommit.load(io.StringIO(dedent(content).lstrip())) + _CONFIG_WITH_NOTEBOOK_HOOK = """\ repos: @@ -71,3 +82,217 @@ def test_no_notebooks_only_migrates_existing_hooks(): repos = {repo["repo"]: repo for repo in yaml.safe_load(result)["repos"]} nbhooks_ids = {hook["id"] for hook in repos[_NBHOOKS_URL]["hooks"]} assert nbhooks_ids == {"set-nb-cells"}, "no defaults added without notebooks" + + +def test_sort_hooks(): + with ( + pytest.raises(PrecommitError, match=r"Sorted all pre-commit hooks"), + _load(""" + repos: + - repo: https://github.com/psf/black + hooks: + - id: black + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit._sort_hooks(pc) + result = pc.dumps() + assert result.index("meta") < result.index("psf/black") + + +def test_sort_hooks_orders_all_categories(): + with ( + pytest.raises(PrecommitError, match=r"Sorted all pre-commit hooks"), + _load(""" + repos: + - repo: https://github.com/some/other + hooks: + - id: some-hook + - repo: https://github.com/x/prettier + hooks: + - id: prettier + - repo: https://github.com/multi/repo + hooks: + - id: hook-a + - id: hook-b + - repo: https://github.com/nbqa-dev/nbQA + hooks: + - id: nbqa-isort + - repo: https://github.com/kynan/nbstripout + hooks: + - id: nbstripout + - repo: https://github.com/ComPWA/policy + hooks: + - id: check-dev-files + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit._sort_hooks(pc) + result = pc.dumps() + expected_order = [ + "meta", + "ComPWA/policy", + "nbstripout", + "nbqa-isort", + "multi/repo", + "x/prettier", + "some/other", + ] + positions = [result.index(token) for token in expected_order] + assert positions == sorted(positions) + + +def test_ci_updates_skip_without_ci_section_are_noops(): + with _load(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc: + precommit._update_precommit_ci_autofix_commit_msg(pc) + precommit._update_precommit_ci_autoupdate_commit_msg(pc) + precommit._update_precommit_ci_skip(pc) # no ci section -> nothing to do + + +def test_update_precommit_ci_autofix_commit_msg(): + with ( + pytest.raises(PrecommitError, match=r"autofix_commit_msg"), + _load(""" + ci: + autofix_prs: true + repos: [] + """) as pc, + ): + precommit._update_precommit_ci_autofix_commit_msg(pc) + assert "MAINT: implement pre-commit autofixes" in pc.dumps() + + +def test_update_precommit_ci_skip_collects_local_and_non_functional_hooks(): + with ( + pytest.raises(PrecommitError, match=r"Updated ci.skip"), + _load(""" + ci: + autofix_prs: true + repos: + - repo: local + hooks: + - id: my-local-hook + - repo: https://github.com/astral-sh/ty-pre-commit + rev: v0.0.1 + hooks: + - id: ty + """) as pc, + ): + precommit._update_precommit_ci_skip(pc) + result = pc.dumps() + assert "my-local-hook" in result + assert "ty" in result + + +def test_update_precommit_ci_skip_removes_redundant_section(): + with ( + pytest.raises(PrecommitError, match=r"Removed redundant ci.skip"), + _load(""" + ci: + skip: + - some-hook + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit._update_precommit_ci_skip(pc) + assert "skip" not in pc.dumps() + + +def test_update_repo_urls(): + with ( + pytest.raises(PrecommitError, match=r"Updated repo URLs"), + _load(""" + repos: + - repo: https://github.com/ComPWA/repo-maintenance + rev: "1.0" + hooks: + - id: check-dev-files + """) as pc, + ): + precommit._update_repo_urls(pc) + assert _POLICY_URL in pc.dumps() + + +def test_get_local_and_non_functional_hooks(): + config = Precommit.load( + io.StringIO( + dedent(""" + repos: + - repo: local + hooks: + - id: my-local-hook + - repo: https://github.com/astral-sh/ty-pre-commit + rev: v0.0.1 + hooks: + - id: ty + """).lstrip() + ) + ).document + assert precommit.get_local_hooks(config) == ["my-local-hook"] + assert precommit.get_non_functional_hooks(config) == ["ty"] + + +def test_update_conda_environment_sets_legacy_flag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "environment.yml").write_text("dependencies:\n - python\n") + config = Precommit.load( + io.StringIO( + dedent(""" + repos: + - repo: https://github.com/ComPWA/prettier-pre-commit + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + """).lstrip() + ) + ) + with pytest.raises(PrecommitError, match=r"Set PRETTIER_LEGACY_CLI"): + precommit._update_conda_environment(config) + assert "PRETTIER_LEGACY_CLI" in (tmp_path / "environment.yml").read_text() + + +def test_update_conda_environment_removes_legacy_flag( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "environment.yml").write_text("variables:\n PRETTIER_LEGACY_CLI: 1\n") + config = Precommit.load(io.StringIO("repos: []\n")) + with pytest.raises(PrecommitError, match=r"Removed PRETTIER_LEGACY_CLI"): + precommit._update_conda_environment(config) + assert "PRETTIER_LEGACY_CLI" not in (tmp_path / "environment.yml").read_text() + + +def test_main_sorts_and_updates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[dependency-groups]\ndev = []\n") + with ( + pytest.raises(PrecommitError), + _load(""" + ci: + autofix_prs: true + repos: + - repo: https://github.com/psf/black + hooks: + - id: black + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit.main(pc, has_notebooks=False) + result = pc.dumps() + assert result.index("meta") < result.index("psf/black") # hooks sorted From d1821ba96d1b559abfbfd0b5c7dc66b975e4b35a Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:44:39 +0200 Subject: [PATCH 08/20] DX: test `uv` module * DX: increase test coverage to 67% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/env/test_uv.py | 190 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/env/test_uv.py diff --git a/codecov.yml b/codecov.yml index 052fcc8e..039ef0a8 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 65% + target: 67% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 301eb228..0d9efb2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=65 \ + --cov-fail-under=67 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/env/test_uv.py b/tests/env/test_uv.py new file mode 100644 index 00000000..f2d53eda --- /dev/null +++ b/tests/env/test_uv.py @@ -0,0 +1,190 @@ +import io +import subprocess # noqa: S404 +from pathlib import Path + +import pytest + +from compwa_policy.env.uv import ( + _remove_pip_constraint_files, + _remove_uv_configuration, + _remove_uv_lock, + _update_contributing_file, + _update_editor_config, + _update_python_version_file, + _update_uv_lock_hook, + main, +) +from compwa_policy.errors import PrecommitError +from compwa_policy.utilities.precommit import ModifiablePrecommit + + +def _git_init(directory: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=directory, check=True) # noqa: S607 + + +def _git_add(directory: Path) -> None: + subprocess.run(["git", "add", "-A"], cwd=directory, check=True) # noqa: S607 + + +def test_remove_uv_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "uv.lock").write_text("# lock\n") + with pytest.raises(PrecommitError, match=r"Removed uv.lock"): + _remove_uv_lock() + assert not (tmp_path / "uv.lock").exists() + + +def test_remove_uv_configuration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[tool.uv]\nmanaged = true\n' + ) + with pytest.raises(PrecommitError, match=r"Removed uv configuration"): + _remove_uv_configuration() + assert "[tool.uv]" not in (tmp_path / "pyproject.toml").read_text() + + +def test_remove_pip_constraint_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + constraints = tmp_path / ".constraints" + constraints.mkdir() + (constraints / "py3.10.txt").write_text("numpy==1.0\n") + with pytest.raises(PrecommitError, match=r"Removed deprecated"): + _remove_pip_constraint_files() + assert not constraints.exists() + + +def test_update_uv_lock_hook_adds_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + (tmp_path / "uv.lock").write_text("# lock\n") + _git_add(tmp_path) + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + _update_uv_lock_hook(precommit) + assert "uv-lock" in precommit.dumps() + + +def test_update_uv_lock_hook_removes_hook( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + config = ( + "repos:\n" + " - repo: https://github.com/astral-sh/uv-pre-commit\n" + " rev: 0.4.20\n" + " hooks:\n" + " - id: uv-lock\n" + ) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_uv_lock_hook(precommit) + assert "uv-lock" not in precommit.dumps() + + +def test_update_python_version_file_writes_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = ">=3.10"\n' + ) + with pytest.raises(PrecommitError, match=r"Updated .python-version"): + _update_python_version_file("3.12") + assert (tmp_path / ".python-version").read_text().strip() == "3.12" + + +def test_update_python_version_file_removed_when_pinned( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = "==3.12.*"\n' + ) + (tmp_path / ".python-version").write_text("3.12\n") + with pytest.raises(PrecommitError, match=r"Removed .python-version"): + _update_python_version_file("3.12") + assert not (tmp_path / ".python-version").exists() + + +def test_update_editor_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + (tmp_path / "uv.lock").write_text("# lock\n") + (tmp_path / ".editorconfig").write_text("root = true\n") + _git_add(tmp_path) + monkeypatch.chdir(tmp_path) + _update_editor_config() # appends a [uv.lock] section, no error + assert "[uv.lock]" in (tmp_path / ".editorconfig").read_text() + + +def test_update_python_version_file_is_idempotent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = ">=3.10"\n' + ) + (tmp_path / ".python-version").write_text("3.12\n") + _update_python_version_file("3.12") # already up to date -> no error + + +def test_update_contributing_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[tool.poe.tasks.style]\ncmd = "check"\n') + (tmp_path / "CONTRIBUTING.md").write_text("outdated\n") + with pytest.raises(PrecommitError, match=r"Updated CONTRIBUTING.md"): + _update_contributing_file("ComPWA", "policy") + result = (tmp_path / "CONTRIBUTING.md").read_text() + assert "policy" in result + assert "Poe the Poet" in result # runner instructions selected from tool.poe.tasks + + +def test_main_uv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = ">=3.10"\n' + ) + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError), + ): + main( + precommit, + dev_python_version="3.12", + keep_contributing_md=True, + package_manager="uv", + organization="ComPWA", + repo_name="policy", + ) + assert (tmp_path / ".python-version").read_text().strip() == "3.12" + + +def test_main_without_uv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[tool.uv]\nmanaged = true\n' + ) + (tmp_path / "uv.lock").write_text("# lock\n") + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError), + ): + main( + precommit, + dev_python_version="3.12", + keep_contributing_md=True, + package_manager="pixi", + organization="ComPWA", + repo_name="policy", + ) + assert not (tmp_path / "uv.lock").exists() + assert "[tool.uv]" not in (tmp_path / "pyproject.toml").read_text() From 82b41439f9f7e9eeb605dda0e5ea11b196bb47a7 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:01:14 +0200 Subject: [PATCH 09/20] DX: test `cspell` module * DX: increase test coverage to 68% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/test_cspell.py | 116 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/codecov.yml b/codecov.yml index 039ef0a8..705154e8 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 67% + target: 68% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 0d9efb2c..2ca772d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=67 \ + --cov-fail-under=68 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/test_cspell.py b/tests/test_cspell.py index 451ae4cd..4c8c0fe3 100644 --- a/tests/test_cspell.py +++ b/tests/test_cspell.py @@ -1,13 +1,28 @@ import io +import json +import subprocess # noqa: S404 +from pathlib import Path from textwrap import dedent import pytest from compwa_policy.errors import PrecommitError -from compwa_policy.format.cspell import _update_cspell_repo_url +from compwa_policy.format.cspell import ( + _remove_configuration, + _sort_config_entries, + _update_config_content, + _update_cspell_repo_url, + _update_precommit_repo, + main, +) +from compwa_policy.utilities import COMPWA_POLICY_DIR, CONFIG_PATH from compwa_policy.utilities.precommit import ModifiablePrecommit +def _git_init(directory: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=directory, check=True) # noqa: S607 + + def test_update_cspell_repo_url(): bad_config = dedent(""" repos: @@ -24,3 +39,102 @@ def test_update_cspell_repo_url(): repo_url = precommit.document["repos"][0]["repo"] assert repo_url == "https://github.com/streetsidesoftware/cspell-cli" + + +def test_remove_configuration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text("{}") + with pytest.raises(PrecommitError, match=r"no longer required"): + _remove_configuration() + assert not (tmp_path / ".cspell.json").exists() + + +def test_remove_configuration_cleans_editorconfig( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / ".editorconfig").write_text(".cspell.json\nother-entry\n") + with pytest.raises(PrecommitError, match=r"no longer"): + _remove_configuration() + assert ".cspell.json" not in (tmp_path / ".editorconfig").read_text() + + +def test_update_precommit_repo(): + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_repo(precommit) + result = precommit.dumps() + assert "https://github.com/streetsidesoftware/cspell-cli" in result + assert "id: cspell" in result + + +def test_update_config_content_fixes_value( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + # Start from the full template so every expected section already exists, then break + # one value. (Starting from `{}` hits a KeyError bug in _update_config_content; see + # the follow-up issue.) + template = json.loads( + (COMPWA_POLICY_DIR / ".template" / CONFIG_PATH.cspell).read_text() + ) + template["language"] = "xx-XX" + (tmp_path / ".cspell.json").write_text(json.dumps(template)) + with pytest.raises(PrecommitError, match=r"has been updated"): + _update_config_content() + config = json.loads((tmp_path / ".cspell.json").read_text()) + assert config["language"] == "en-US" + + +def test_sort_config_entries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text( + json.dumps({"words": ["zebra", "apple", "mango"]}) + ) + with pytest.raises(PrecommitError, match=r"sorted alphabetically"): + _sort_config_entries() + config = json.loads((tmp_path / ".cspell.json").read_text()) + assert config["words"] == ["apple", "mango", "zebra"] + + +def test_main_updates_existing_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / ".cspell.json").write_text('{"words": ["zebra", "apple"]}') + config = dedent(""" + repos: + - repo: https://github.com/streetsidesoftware/cspell-cli + rev: v8.0.0 + hooks: + - id: cspell + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, no_cspell_update=True) + result = json.loads((tmp_path / ".cspell.json").read_text()) + assert result["words"] == ["apple", "zebra"] # sorted + + +def test_main_removes_config_without_hook( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text("{}") + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError, match=r"no longer required"), + ): + main(precommit, no_cspell_update=False) + assert not (tmp_path / ".cspell.json").exists() From 40fd5b0d745d4cef4260af3be3708f7d45d949ef Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:05:43 +0200 Subject: [PATCH 10/20] FIX: handle new sections in `_update_config_content` The `fixed_sections` generator indexed `original_config[section_name]`, which raised `KeyError` when the template adds a section absent from the original config (e.g. starting from an empty `.cspell.json`). --- src/compwa_policy/format/cspell.py | 2 +- tests/test_cspell.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/compwa_policy/format/cspell.py b/src/compwa_policy/format/cspell.py index 8da750df..f5358e0d 100644 --- a/src/compwa_policy/format/cspell.py +++ b/src/compwa_policy/format/cspell.py @@ -129,7 +129,7 @@ def _update_config_content() -> None: fixed_sections = sorted( section_name for section_name, section in config.items() - if section != original_config[section_name] + if section != original_config.get(section_name) ) error_message = __express_list_of_sections(fixed_sections) error_message += f" in {CONFIG_PATH.cspell} has been updated." diff --git a/tests/test_cspell.py b/tests/test_cspell.py index 4c8c0fe3..df3068a9 100644 --- a/tests/test_cspell.py +++ b/tests/test_cspell.py @@ -81,9 +81,6 @@ def test_update_config_content_fixes_value( ): _git_init(tmp_path) monkeypatch.chdir(tmp_path) - # Start from the full template so every expected section already exists, then break - # one value. (Starting from `{}` hits a KeyError bug in _update_config_content; see - # the follow-up issue.) template = json.loads( (COMPWA_POLICY_DIR / ".template" / CONFIG_PATH.cspell).read_text() ) @@ -95,6 +92,18 @@ def test_update_config_content_fixes_value( assert config["language"] == "en-US" +def test_update_config_content_from_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text("{}") + with pytest.raises(PrecommitError, match=r"has been updated"): + _update_config_content() + config = json.loads((tmp_path / ".cspell.json").read_text()) + assert config["language"] == "en-US" + + def test_sort_config_entries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.chdir(tmp_path) (tmp_path / ".cspell.json").write_text( From 4694f2f802939261e4d5cf216c8e106372688ff7 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:10:03 +0200 Subject: [PATCH 11/20] DX: test `pytest` module * DX: increase test coverage to 70% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/python/test_pytest.py | 207 ++++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 tests/python/test_pytest.py diff --git a/codecov.yml b/codecov.yml index 705154e8..8ccd3fc4 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 68% + target: 70% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 2ca772d5..29faf8b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -205,7 +205,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=68 \ + --cov-fail-under=70 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/python/test_pytest.py b/tests/python/test_pytest.py new file mode 100644 index 00000000..2150c75a --- /dev/null +++ b/tests/python/test_pytest.py @@ -0,0 +1,207 @@ +import io +import json +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.python.pytest import ( + _deny_ini_options, + _merge_coverage_into_pyproject, + _merge_pytest_into_pyproject, + _update_codecov_settings, + _update_settings, + _update_vscode_settings, + main, +) +from compwa_policy.utilities.pyproject import ModifiablePyproject, Pyproject + +# cspell:ignore addopts importmode minversion numprocesses ryanluker xdist + + +def test_deny_ini_options_raises(): + config = dedent(""" + [tool.pytest.ini_options] + addopts = "--color=yes" + """).lstrip() + with ( + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + pytest.raises(PrecommitError, match=r"migrate to a native TOML"), + ): + _deny_ini_options(pyproject) + + +def test_deny_ini_options_sets_minversion(): + config = dedent(""" + [tool.pytest] + addopts = "--color=yes" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"minimum pytest version"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _deny_ini_options(pyproject) + assert pyproject.get_table("tool.pytest")["minversion"] == "9.0" + + +def test_deny_ini_options_noop_with_minversion(): + config = dedent(""" + [tool.pytest] + minversion = "8.0" + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _deny_ini_options(pyproject) # minversion present -> no-op + + +def test_update_settings_from_string(): + config = dedent(""" + [tool.pytest] + addopts = "--color=no -ra" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated \[tool.pytest\]"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_settings(pyproject) + addopts = list(pyproject.get_table("tool.pytest")["addopts"]) + assert "--color=yes" in addopts + assert "--import-mode=importlib" in addopts + assert "-ra" in addopts + assert "--color=no" not in addopts + + +def test_update_settings_is_idempotent(): + config = dedent(""" + [tool.pytest] + addopts = ["--color=yes", "--import-mode=importlib"] + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_settings(pyproject) # already up to date -> no error + + +def test_update_settings_noop_without_table(): + with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: + _update_settings(pyproject) # no [tool.pytest] -> no-op + + +def test_merge_pytest_into_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pytest.ini").write_text("[pytest]\nminversion = 7.0\n") + with ( + pytest.raises(PrecommitError, match=r"Imported pytest configuration"), + ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject, + ): + _merge_pytest_into_pyproject(pyproject) + assert not (tmp_path / "pytest.ini").exists() + assert "ini_options" in pyproject.dumps() + + +def test_merge_pytest_into_pyproject_noop( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: + _merge_pytest_into_pyproject(pyproject) # no pytest.ini -> no-op + + +def test_merge_coverage_into_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pytest.ini").write_text( + "[coverage:run]\nbranch = True\nsource = my_pkg\n" + ) + with ( + pytest.raises(PrecommitError, match=r"Imported Coverage.py configuration"), + ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject, + ): + _merge_coverage_into_pyproject(pyproject) + coverage = pyproject.get_table("tool.coverage.run") + assert coverage["source"] == ["my_pkg"] + + +def test_merge_coverage_into_pyproject_noop( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.chdir(tmp_path) + (tmp_path / "pytest.ini").write_text("[pytest]\nminversion = 7.0\n") + with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: + _merge_coverage_into_pyproject(pyproject) # no [coverage:run] -> no-op + + +def test_update_codecov_settings(): + config = dedent(""" + [project] + name = "x" + + [dependency-groups] + test = ["pytest-cov"] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated pytest coverage settings"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_codecov_settings(pyproject) + coverage = pyproject.get_table("tool.coverage.run") + assert coverage["branch"] is True + assert coverage["source"] == ["src"] + + +def test_update_codecov_settings_noop_without_coverage(): + with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: + _update_codecov_settings(pyproject) # no coverage dependency -> no-op + + +def test_update_vscode_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + pyproject = Pyproject.load(io.StringIO('[project]\nname = "my-package"\n')) + with pytest.raises(PrecommitError): + _update_vscode_settings(pyproject, coverage_gutters=True, single_threaded=False) + settings = json.loads((tmp_path / ".vscode" / "settings.json").read_text()) + assert settings["testing.showCoverageInExplorer"] is True + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "ryanluker.vscode-coverage-gutters" in extensions["recommendations"] + + +def test_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + + [dependency-groups] + test = ["pytest", "pytest-cov"] + + [tool.pytest] + addopts = "--color=no" + """).lstrip() + ) + with pytest.raises(PrecommitError): + main(coverage_gutters=False, single_threaded=True) + result = (tmp_path / "pyproject.toml").read_text() + assert "[tool.coverage.run]" in result + + +def test_main_multithreaded_adds_xdist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + + [dependency-groups] + test = ["pytest"] + + [tool.pytest] + addopts = ["--color=yes", "--import-mode=importlib"] + """).lstrip() + ) + with pytest.raises(PrecommitError): + main(coverage_gutters=True, single_threaded=False) + assert "pytest-xdist" in (tmp_path / "pyproject.toml").read_text() + + +def test_main_without_pytest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "my-package"\n') + main(coverage_gutters=False, single_threaded=True) # no pytest dependency -> no-op From 4261b89ea5e01d3e1ab19c03e454dd93f623be87 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:12:17 +0200 Subject: [PATCH 12/20] DX: rewrite tests to use `pytest-describe` --- pyproject.toml | 3 + tests/editorconfig/test_editorconfig.py | 55 +-- tests/env/test_uv.py | 318 +++++++------- tests/format/test_precommit.py | 480 +++++++++++----------- tests/format/test_prettier.py | 161 ++++---- tests/format/test_toml.py | 273 ++++++------ tests/github/test_workflows.py | 165 ++++---- tests/pyright/test_pyright.py | 135 +++--- tests/python/test_black.py | 273 ++++++------ tests/python/test_mypy.py | 221 +++++----- tests/python/test_pytest.py | 372 +++++++++-------- tests/python/test_pyupgrade.py | 105 ++--- tests/python/test_ruff.py | 259 ++++++------ tests/readthedocs/test_readthedocs.py | 113 ++--- tests/repo/test_citation.py | 377 +++++++++-------- tests/repo/test_commitlint.py | 24 +- tests/repo/test_deprecated.py | 90 ++-- tests/repo/test_poe.py | 169 ++++---- tests/test_cli.py | 237 ++++++----- tests/test_cspell.py | 243 ++++++----- tests/test_gitpod.py | 45 +- tests/test_pixi.py | 339 ++++++++------- tests/utilities/precommit/test_class.py | 12 +- tests/utilities/precommit/test_getters.py | 96 ++--- tests/utilities/pyproject/test_getters.py | 256 ++++++------ tests/utilities/pyproject/test_setters.py | 312 +++++++------- tests/utilities/test_cfg.py | 179 ++++---- tests/utilities/test_executor.py | 4 +- tests/utilities/test_pyproject.py | 73 ++-- tests/utilities/test_toml.py | 115 +++--- 30 files changed, 2743 insertions(+), 2761 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 29faf8b6..f1c23b3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ style = [ test = [ "pytest", "pytest-cov", + "pytest-describe", ] types = [ "pytest", @@ -378,9 +379,11 @@ split-on-trailing-comma = false "src/compwa_policy/config.py" = ["D100"] "tests/*" = [ "ANN", + "C901", "D", "INP001", "PLC2701", + "PLR0915", "PLR2004", "PLR6301", "RUF069", diff --git a/tests/editorconfig/test_editorconfig.py b/tests/editorconfig/test_editorconfig.py index 73c63fc2..ad1d7613 100644 --- a/tests/editorconfig/test_editorconfig.py +++ b/tests/editorconfig/test_editorconfig.py @@ -8,31 +8,32 @@ from compwa_policy.utilities.precommit import ModifiablePrecommit -def test_update_precommit_config(): - bad_config = dedent(""" - repos: - - repo: https://github.com/editorconfig-checker/editorconfig-checker.python - rev: 2.7.3 - hooks: - - id: editorconfig-checker - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated editorconfig-checker hook"), - ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, - ): - _update_precommit_config(precommit) +def describe_update_precommit_config(): + def configures_editorconfig_checker_hook(): + bad_config = dedent(""" + repos: + - repo: https://github.com/editorconfig-checker/editorconfig-checker.python + rev: 2.7.3 + hooks: + - id: editorconfig-checker + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated editorconfig-checker hook"), + ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, + ): + _update_precommit_config(precommit) - expected = dedent(r""" - repos: - - repo: https://github.com/editorconfig-checker/editorconfig-checker.python - rev: 2.7.3 - hooks: - - id: editorconfig-checker - name: editorconfig - alias: ec - exclude: >- - (?x)^( - .*\.py - )$ - """).lstrip() - assert precommit.dumps() == expected + expected = dedent(r""" + repos: + - repo: https://github.com/editorconfig-checker/editorconfig-checker.python + rev: 2.7.3 + hooks: + - id: editorconfig-checker + name: editorconfig + alias: ec + exclude: >- + (?x)^( + .*\.py + )$ + """).lstrip() + assert precommit.dumps() == expected diff --git a/tests/env/test_uv.py b/tests/env/test_uv.py index f2d53eda..bbae372e 100644 --- a/tests/env/test_uv.py +++ b/tests/env/test_uv.py @@ -26,165 +26,165 @@ def _git_add(directory: Path) -> None: subprocess.run(["git", "add", "-A"], cwd=directory, check=True) # noqa: S607 -def test_remove_uv_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "uv.lock").write_text("# lock\n") - with pytest.raises(PrecommitError, match=r"Removed uv.lock"): - _remove_uv_lock() - assert not (tmp_path / "uv.lock").exists() - - -def test_remove_uv_configuration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "x"\n\n[tool.uv]\nmanaged = true\n' - ) - with pytest.raises(PrecommitError, match=r"Removed uv configuration"): - _remove_uv_configuration() - assert "[tool.uv]" not in (tmp_path / "pyproject.toml").read_text() - - -def test_remove_pip_constraint_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - constraints = tmp_path / ".constraints" - constraints.mkdir() - (constraints / "py3.10.txt").write_text("numpy==1.0\n") - with pytest.raises(PrecommitError, match=r"Removed deprecated"): - _remove_pip_constraint_files() - assert not constraints.exists() - - -def test_update_uv_lock_hook_adds_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - _git_init(tmp_path) - (tmp_path / "uv.lock").write_text("# lock\n") - _git_add(tmp_path) - monkeypatch.chdir(tmp_path) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - ): - _update_uv_lock_hook(precommit) - assert "uv-lock" in precommit.dumps() - - -def test_update_uv_lock_hook_removes_hook( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - config = ( - "repos:\n" - " - repo: https://github.com/astral-sh/uv-pre-commit\n" - " rev: 0.4.20\n" - " hooks:\n" - " - id: uv-lock\n" - ) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - _update_uv_lock_hook(precommit) - assert "uv-lock" not in precommit.dumps() - - -def test_update_python_version_file_writes_version( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "x"\nrequires-python = ">=3.10"\n' - ) - with pytest.raises(PrecommitError, match=r"Updated .python-version"): - _update_python_version_file("3.12") - assert (tmp_path / ".python-version").read_text().strip() == "3.12" - - -def test_update_python_version_file_removed_when_pinned( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "x"\nrequires-python = "==3.12.*"\n' - ) - (tmp_path / ".python-version").write_text("3.12\n") - with pytest.raises(PrecommitError, match=r"Removed .python-version"): - _update_python_version_file("3.12") - assert not (tmp_path / ".python-version").exists() - - -def test_update_editor_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - _git_init(tmp_path) - (tmp_path / "uv.lock").write_text("# lock\n") - (tmp_path / ".editorconfig").write_text("root = true\n") - _git_add(tmp_path) - monkeypatch.chdir(tmp_path) - _update_editor_config() # appends a [uv.lock] section, no error - assert "[uv.lock]" in (tmp_path / ".editorconfig").read_text() - - -def test_update_python_version_file_is_idempotent( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "x"\nrequires-python = ">=3.10"\n' - ) - (tmp_path / ".python-version").write_text("3.12\n") - _update_python_version_file("3.12") # already up to date -> no error - - -def test_update_contributing_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text('[tool.poe.tasks.style]\ncmd = "check"\n') - (tmp_path / "CONTRIBUTING.md").write_text("outdated\n") - with pytest.raises(PrecommitError, match=r"Updated CONTRIBUTING.md"): - _update_contributing_file("ComPWA", "policy") - result = (tmp_path / "CONTRIBUTING.md").read_text() - assert "policy" in result - assert "Poe the Poet" in result # runner instructions selected from tool.poe.tasks - - -def test_main_uv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "x"\nrequires-python = ">=3.10"\n' - ) - with ( - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - pytest.raises(PrecommitError), - ): - main( - precommit, - dev_python_version="3.12", - keep_contributing_md=True, - package_manager="uv", - organization="ComPWA", - repo_name="policy", +def describe_remove_uv_lock(): + def removes_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "uv.lock").write_text("# lock\n") + with pytest.raises(PrecommitError, match=r"Removed uv.lock"): + _remove_uv_lock() + assert not (tmp_path / "uv.lock").exists() + + +def describe_remove_uv_configuration(): + def removes_tool_uv_table(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[tool.uv]\nmanaged = true\n' + ) + with pytest.raises(PrecommitError, match=r"Removed uv configuration"): + _remove_uv_configuration() + assert "[tool.uv]" not in (tmp_path / "pyproject.toml").read_text() + + +def describe_remove_pip_constraint_files(): + def removes_constraints_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + constraints = tmp_path / ".constraints" + constraints.mkdir() + (constraints / "py3.10.txt").write_text("numpy==1.0\n") + with pytest.raises(PrecommitError, match=r"Removed deprecated"): + _remove_pip_constraint_files() + assert not constraints.exists() + + +def describe_update_uv_lock_hook(): + def adds_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + (tmp_path / "uv.lock").write_text("# lock\n") + _git_add(tmp_path) + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + _update_uv_lock_hook(precommit) + assert "uv-lock" in precommit.dumps() + + def removes_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + config = ( + "repos:\n" + " - repo: https://github.com/astral-sh/uv-pre-commit\n" + " rev: 0.4.20\n" + " hooks:\n" + " - id: uv-lock\n" + ) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_uv_lock_hook(precommit) + assert "uv-lock" not in precommit.dumps() + + +def describe_update_python_version_file(): + def writes_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = ">=3.10"\n' + ) + with pytest.raises(PrecommitError, match=r"Updated .python-version"): + _update_python_version_file("3.12") + assert (tmp_path / ".python-version").read_text().strip() == "3.12" + + def removes_file_when_pinned(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = "==3.12.*"\n' + ) + (tmp_path / ".python-version").write_text("3.12\n") + with pytest.raises(PrecommitError, match=r"Removed .python-version"): + _update_python_version_file("3.12") + assert not (tmp_path / ".python-version").exists() + + def is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = ">=3.10"\n' + ) + (tmp_path / ".python-version").write_text("3.12\n") + _update_python_version_file("3.12") # already up to date -> no error + + +def describe_update_editor_config(): + def appends_uv_lock_section(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + (tmp_path / "uv.lock").write_text("# lock\n") + (tmp_path / ".editorconfig").write_text("root = true\n") + _git_add(tmp_path) + monkeypatch.chdir(tmp_path) + _update_editor_config() # appends a [uv.lock] section, no error + assert "[uv.lock]" in (tmp_path / ".editorconfig").read_text() + + +def describe_update_contributing_file(): + def selects_poe_instructions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[tool.poe.tasks.style]\ncmd = "check"\n' + ) + (tmp_path / "CONTRIBUTING.md").write_text("outdated\n") + with pytest.raises(PrecommitError, match=r"Updated CONTRIBUTING.md"): + _update_contributing_file("ComPWA", "policy") + result = (tmp_path / "CONTRIBUTING.md").read_text() + assert "policy" in result + assert "Poe the Poet" in result # runner instructions from tool.poe.tasks + + +def describe_main(): + def configures_uv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = ">=3.10"\n' ) - assert (tmp_path / ".python-version").read_text().strip() == "3.12" - - -def test_main_without_uv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - (tmp_path / "pyproject.toml").write_text( - '[project]\nname = "x"\n\n[tool.uv]\nmanaged = true\n' - ) - (tmp_path / "uv.lock").write_text("# lock\n") - with ( - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - pytest.raises(PrecommitError), + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError), + ): + main( + precommit, + dev_python_version="3.12", + keep_contributing_md=True, + package_manager="uv", + organization="ComPWA", + repo_name="policy", + ) + assert (tmp_path / ".python-version").read_text().strip() == "3.12" + + def removes_uv_for_other_package_manager( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - main( - precommit, - dev_python_version="3.12", - keep_contributing_md=True, - package_manager="pixi", - organization="ComPWA", - repo_name="policy", + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[tool.uv]\nmanaged = true\n' ) - assert not (tmp_path / "uv.lock").exists() - assert "[tool.uv]" not in (tmp_path / "pyproject.toml").read_text() + (tmp_path / "uv.lock").write_text("# lock\n") + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError), + ): + main( + precommit, + dev_python_version="3.12", + keep_contributing_md=True, + package_manager="pixi", + organization="ComPWA", + repo_name="policy", + ) + assert not (tmp_path / "uv.lock").exists() + assert "[tool.uv]" not in (tmp_path / "pyproject.toml").read_text() diff --git a/tests/format/test_precommit.py b/tests/format/test_precommit.py index 03b3a1f5..77fbf86e 100644 --- a/tests/format/test_precommit.py +++ b/tests/format/test_precommit.py @@ -49,250 +49,248 @@ def _run(config: str, *, has_notebooks: bool) -> tuple[bool, str]: return changed, stream.getvalue() -def test_migrates_notebook_hooks_to_nbhooks(): - changed, result = _run(_CONFIG_WITH_NOTEBOOK_HOOK, has_notebooks=True) - assert changed - repos = {repo["repo"]: repo for repo in yaml.safe_load(result)["repos"]} - - policy_hook_ids = {hook["id"] for hook in repos[_POLICY_URL]["hooks"]} - assert policy_hook_ids == {"check-dev-files"} - - nbhooks = repos[_NBHOOKS_URL] - assert nbhooks["rev"] == "PLEASE-UPDATE" - nbhooks_ids = {hook["id"] for hook in nbhooks["hooks"]} - assert nbhooks_ids == { - "remove-empty-tags", - "set-nb-cells", - "set-nb-display-name", - "strip-nb-whitespace", - } - set_nb_cells = next(h for h in nbhooks["hooks"] if h["id"] == "set-nb-cells") - assert set_nb_cells["args"] == ["--add-install-cell"], "args must be preserved" - - -def test_migration_is_idempotent(): - _, migrated = _run(_CONFIG_WITH_NOTEBOOK_HOOK, has_notebooks=True) - changed, _ = _run(migrated, has_notebooks=True) - assert not changed - - -def test_no_notebooks_only_migrates_existing_hooks(): - changed, result = _run(_CONFIG_WITH_NOTEBOOK_HOOK, has_notebooks=False) - assert changed - repos = {repo["repo"]: repo for repo in yaml.safe_load(result)["repos"]} - nbhooks_ids = {hook["id"] for hook in repos[_NBHOOKS_URL]["hooks"]} - assert nbhooks_ids == {"set-nb-cells"}, "no defaults added without notebooks" - - -def test_sort_hooks(): - with ( - pytest.raises(PrecommitError, match=r"Sorted all pre-commit hooks"), - _load(""" - repos: - - repo: https://github.com/psf/black - hooks: - - id: black - - repo: meta - hooks: - - id: check-hooks-apply - """) as pc, - ): - precommit._sort_hooks(pc) - result = pc.dumps() - assert result.index("meta") < result.index("psf/black") - - -def test_sort_hooks_orders_all_categories(): - with ( - pytest.raises(PrecommitError, match=r"Sorted all pre-commit hooks"), - _load(""" - repos: - - repo: https://github.com/some/other - hooks: - - id: some-hook - - repo: https://github.com/x/prettier - hooks: - - id: prettier - - repo: https://github.com/multi/repo - hooks: - - id: hook-a - - id: hook-b - - repo: https://github.com/nbqa-dev/nbQA - hooks: - - id: nbqa-isort - - repo: https://github.com/kynan/nbstripout - hooks: - - id: nbstripout - - repo: https://github.com/ComPWA/policy - hooks: - - id: check-dev-files - - repo: meta - hooks: - - id: check-hooks-apply - """) as pc, - ): - precommit._sort_hooks(pc) - result = pc.dumps() - expected_order = [ - "meta", - "ComPWA/policy", - "nbstripout", - "nbqa-isort", - "multi/repo", - "x/prettier", - "some/other", - ] - positions = [result.index(token) for token in expected_order] - assert positions == sorted(positions) - - -def test_ci_updates_skip_without_ci_section_are_noops(): - with _load(""" - repos: - - repo: meta - hooks: - - id: check-hooks-apply - """) as pc: - precommit._update_precommit_ci_autofix_commit_msg(pc) - precommit._update_precommit_ci_autoupdate_commit_msg(pc) - precommit._update_precommit_ci_skip(pc) # no ci section -> nothing to do - - -def test_update_precommit_ci_autofix_commit_msg(): - with ( - pytest.raises(PrecommitError, match=r"autofix_commit_msg"), - _load(""" - ci: - autofix_prs: true - repos: [] - """) as pc, - ): - precommit._update_precommit_ci_autofix_commit_msg(pc) - assert "MAINT: implement pre-commit autofixes" in pc.dumps() - - -def test_update_precommit_ci_skip_collects_local_and_non_functional_hooks(): - with ( - pytest.raises(PrecommitError, match=r"Updated ci.skip"), - _load(""" - ci: - autofix_prs: true - repos: - - repo: local - hooks: - - id: my-local-hook - - repo: https://github.com/astral-sh/ty-pre-commit - rev: v0.0.1 - hooks: - - id: ty - """) as pc, - ): - precommit._update_precommit_ci_skip(pc) - result = pc.dumps() - assert "my-local-hook" in result - assert "ty" in result - - -def test_update_precommit_ci_skip_removes_redundant_section(): - with ( - pytest.raises(PrecommitError, match=r"Removed redundant ci.skip"), - _load(""" - ci: - skip: - - some-hook +def describe_update_notebook_hooks(): + def migrates_notebook_hooks_to_nbhooks(): + changed, result = _run(_CONFIG_WITH_NOTEBOOK_HOOK, has_notebooks=True) + assert changed + repos = {repo["repo"]: repo for repo in yaml.safe_load(result)["repos"]} + + policy_hook_ids = {hook["id"] for hook in repos[_POLICY_URL]["hooks"]} + assert policy_hook_ids == {"check-dev-files"} + + nbhooks = repos[_NBHOOKS_URL] + assert nbhooks["rev"] == "PLEASE-UPDATE" + nbhooks_ids = {hook["id"] for hook in nbhooks["hooks"]} + assert nbhooks_ids == { + "remove-empty-tags", + "set-nb-cells", + "set-nb-display-name", + "strip-nb-whitespace", + } + set_nb_cells = next(h for h in nbhooks["hooks"] if h["id"] == "set-nb-cells") + assert set_nb_cells["args"] == ["--add-install-cell"], "args must be preserved" + + def is_idempotent(): + _, migrated = _run(_CONFIG_WITH_NOTEBOOK_HOOK, has_notebooks=True) + changed, _ = _run(migrated, has_notebooks=True) + assert not changed + + def only_migrates_existing_hooks_without_notebooks(): + changed, result = _run(_CONFIG_WITH_NOTEBOOK_HOOK, has_notebooks=False) + assert changed + repos = {repo["repo"]: repo for repo in yaml.safe_load(result)["repos"]} + nbhooks_ids = {hook["id"] for hook in repos[_NBHOOKS_URL]["hooks"]} + assert nbhooks_ids == {"set-nb-cells"}, "no defaults added without notebooks" + + +def describe_sort_hooks(): + def sorts_meta_before_repos(): + with ( + pytest.raises(PrecommitError, match=r"Sorted all pre-commit hooks"), + _load(""" + repos: + - repo: https://github.com/psf/black + hooks: + - id: black + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit._sort_hooks(pc) + result = pc.dumps() + assert result.index("meta") < result.index("psf/black") + + def orders_all_categories(): + with ( + pytest.raises(PrecommitError, match=r"Sorted all pre-commit hooks"), + _load(""" + repos: + - repo: https://github.com/some/other + hooks: + - id: some-hook + - repo: https://github.com/x/prettier + hooks: + - id: prettier + - repo: https://github.com/multi/repo + hooks: + - id: hook-a + - id: hook-b + - repo: https://github.com/nbqa-dev/nbQA + hooks: + - id: nbqa-isort + - repo: https://github.com/kynan/nbstripout + hooks: + - id: nbstripout + - repo: https://github.com/ComPWA/policy + hooks: + - id: check-dev-files + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit._sort_hooks(pc) + result = pc.dumps() + expected_order = [ + "meta", + "ComPWA/policy", + "nbstripout", + "nbqa-isort", + "multi/repo", + "x/prettier", + "some/other", + ] + positions = [result.index(token) for token in expected_order] + assert positions == sorted(positions) + + +def describe_update_precommit_ci(): + def is_noop_without_ci_section(): + with _load(""" repos: - repo: meta hooks: - id: check-hooks-apply - """) as pc, - ): - precommit._update_precommit_ci_skip(pc) - assert "skip" not in pc.dumps() - - -def test_update_repo_urls(): - with ( - pytest.raises(PrecommitError, match=r"Updated repo URLs"), - _load(""" - repos: - - repo: https://github.com/ComPWA/repo-maintenance - rev: "1.0" - hooks: - - id: check-dev-files - """) as pc, - ): - precommit._update_repo_urls(pc) - assert _POLICY_URL in pc.dumps() - - -def test_get_local_and_non_functional_hooks(): - config = Precommit.load( - io.StringIO( - dedent(""" - repos: - - repo: local - hooks: - - id: my-local-hook - - repo: https://github.com/astral-sh/ty-pre-commit - rev: v0.0.1 - hooks: - - id: ty - """).lstrip() + """) as pc: + precommit._update_precommit_ci_autofix_commit_msg(pc) + precommit._update_precommit_ci_autoupdate_commit_msg(pc) + precommit._update_precommit_ci_skip(pc) # no ci section -> nothing to do + + def sets_autofix_commit_msg(): + with ( + pytest.raises(PrecommitError, match=r"autofix_commit_msg"), + _load(""" + ci: + autofix_prs: true + repos: [] + """) as pc, + ): + precommit._update_precommit_ci_autofix_commit_msg(pc) + assert "MAINT: implement pre-commit autofixes" in pc.dumps() + + def skip_collects_local_and_non_functional_hooks(): + with ( + pytest.raises(PrecommitError, match=r"Updated ci.skip"), + _load(""" + ci: + autofix_prs: true + repos: + - repo: local + hooks: + - id: my-local-hook + - repo: https://github.com/astral-sh/ty-pre-commit + rev: v0.0.1 + hooks: + - id: ty + """) as pc, + ): + precommit._update_precommit_ci_skip(pc) + result = pc.dumps() + assert "my-local-hook" in result + assert "ty" in result + + def skip_removes_redundant_section(): + with ( + pytest.raises(PrecommitError, match=r"Removed redundant ci.skip"), + _load(""" + ci: + skip: + - some-hook + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit._update_precommit_ci_skip(pc) + assert "skip" not in pc.dumps() + + +def describe_update_repo_urls(): + def migrates_repo_maintenance_url(): + with ( + pytest.raises(PrecommitError, match=r"Updated repo URLs"), + _load(""" + repos: + - repo: https://github.com/ComPWA/repo-maintenance + rev: "1.0" + hooks: + - id: check-dev-files + """) as pc, + ): + precommit._update_repo_urls(pc) + assert _POLICY_URL in pc.dumps() + + +def describe_get_local_and_non_functional_hooks(): + def separates_local_from_non_functional(): + config = Precommit.load( + io.StringIO( + dedent(""" + repos: + - repo: local + hooks: + - id: my-local-hook + - repo: https://github.com/astral-sh/ty-pre-commit + rev: v0.0.1 + hooks: + - id: ty + """).lstrip() + ) + ).document + assert precommit.get_local_hooks(config) == ["my-local-hook"] + assert precommit.get_non_functional_hooks(config) == ["ty"] + + +def describe_update_conda_environment(): + def sets_legacy_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "environment.yml").write_text("dependencies:\n - python\n") + config = Precommit.load( + io.StringIO( + dedent(""" + repos: + - repo: https://github.com/ComPWA/prettier-pre-commit + rev: v4.0.0-alpha.8 + hooks: + - id: prettier + """).lstrip() + ) ) - ).document - assert precommit.get_local_hooks(config) == ["my-local-hook"] - assert precommit.get_non_functional_hooks(config) == ["ty"] - - -def test_update_conda_environment_sets_legacy_flag( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "environment.yml").write_text("dependencies:\n - python\n") - config = Precommit.load( - io.StringIO( - dedent(""" - repos: - - repo: https://github.com/ComPWA/prettier-pre-commit - rev: v4.0.0-alpha.8 - hooks: - - id: prettier - """).lstrip() + with pytest.raises(PrecommitError, match=r"Set PRETTIER_LEGACY_CLI"): + precommit._update_conda_environment(config) + assert "PRETTIER_LEGACY_CLI" in (tmp_path / "environment.yml").read_text() + + def removes_legacy_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "environment.yml").write_text( + "variables:\n PRETTIER_LEGACY_CLI: 1\n" ) - ) - with pytest.raises(PrecommitError, match=r"Set PRETTIER_LEGACY_CLI"): - precommit._update_conda_environment(config) - assert "PRETTIER_LEGACY_CLI" in (tmp_path / "environment.yml").read_text() - - -def test_update_conda_environment_removes_legacy_flag( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "environment.yml").write_text("variables:\n PRETTIER_LEGACY_CLI: 1\n") - config = Precommit.load(io.StringIO("repos: []\n")) - with pytest.raises(PrecommitError, match=r"Removed PRETTIER_LEGACY_CLI"): - precommit._update_conda_environment(config) - assert "PRETTIER_LEGACY_CLI" not in (tmp_path / "environment.yml").read_text() - - -def test_main_sorts_and_updates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text("[dependency-groups]\ndev = []\n") - with ( - pytest.raises(PrecommitError), - _load(""" - ci: - autofix_prs: true - repos: - - repo: https://github.com/psf/black - hooks: - - id: black - - repo: meta - hooks: - - id: check-hooks-apply - """) as pc, - ): - precommit.main(pc, has_notebooks=False) - result = pc.dumps() - assert result.index("meta") < result.index("psf/black") # hooks sorted + config = Precommit.load(io.StringIO("repos: []\n")) + with pytest.raises(PrecommitError, match=r"Removed PRETTIER_LEGACY_CLI"): + precommit._update_conda_environment(config) + assert "PRETTIER_LEGACY_CLI" not in (tmp_path / "environment.yml").read_text() + + +def describe_main(): + def sorts_and_updates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[dependency-groups]\ndev = []\n") + with ( + pytest.raises(PrecommitError), + _load(""" + ci: + autofix_prs: true + repos: + - repo: https://github.com/psf/black + hooks: + - id: black + - repo: meta + hooks: + - id: check-hooks-apply + """) as pc, + ): + precommit.main(pc, has_notebooks=False) + result = pc.dumps() + assert result.index("meta") < result.index("psf/black") # hooks sorted diff --git a/tests/format/test_prettier.py b/tests/format/test_prettier.py index ae34c7dc..065c4dc7 100644 --- a/tests/format/test_prettier.py +++ b/tests/format/test_prettier.py @@ -37,88 +37,83 @@ """).lstrip() -def test_update_prettier_hook_renames_mirror(): - with ( - pytest.raises(PrecommitError, match=r"Updated URL for Prettier"), - ModifiablePrecommit.load(io.StringIO(_WITH_MIRROR)) as precommit, +def describe_update_prettier_hook(): + def renames_mirror(): + with ( + pytest.raises(PrecommitError, match=r"Updated URL for Prettier"), + ModifiablePrecommit.load(io.StringIO(_WITH_MIRROR)) as precommit, + ): + _update_prettier_hook(precommit) + assert "https://github.com/ComPWA/prettier-pre-commit" in precommit.dumps() + + def is_noop_without_mirror(): + with ModifiablePrecommit.load(io.StringIO(_WITH_PRETTIER)) as precommit: + _update_prettier_hook(precommit) # already migrated -> no change + + +def describe_remove_configuration(): + def removes_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".prettierrc.json").write_text("{}") + (tmp_path / ".prettierrc").write_text("{}") + with pytest.raises( + PrecommitError, match=r"Removed redundant configuration files" + ): + _remove_configuration() + assert not (tmp_path / ".prettierrc.json").exists() + assert not (tmp_path / ".prettierrc").exists() + + def is_noop_without_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + _remove_configuration() # no config files and no badge to remove + + +def describe_update_prettier_ignore(): + def removes_forbidden_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".prettierignore").write_text(".cspell.json\nbuild/\n") + with pytest.raises(PrecommitError, match=r"Removed forbidden paths"): + _update_prettier_ignore() + assert ".cspell.json" not in (tmp_path / ".prettierignore").read_text() + + def inserts_obligatory_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "LICENSE").touch() + (tmp_path / ".prettierignore").write_text("build/\n") + with pytest.raises(PrecommitError, match=r"Added paths"): + _update_prettier_ignore() + assert "LICENSE" in (tmp_path / ".prettierignore").read_text() + + def removes_empty_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".prettierignore").write_text("") + with pytest.raises(PrecommitError, match=r"is not needed"): + _update_prettier_ignore() + assert not (tmp_path / ".prettierignore").exists() + + +def describe_main(): + def updates_readme_with_prettier_repo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - _update_prettier_hook(precommit) - assert "https://github.com/ComPWA/prettier-pre-commit" in precommit.dumps() - - -def test_update_prettier_hook_without_mirror(): - with ModifiablePrecommit.load(io.StringIO(_WITH_PRETTIER)) as precommit: - _update_prettier_hook(precommit) # already migrated -> no change - - -def test_remove_configuration_removes_files( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / ".prettierrc.json").write_text("{}") - (tmp_path / ".prettierrc").write_text("{}") - with pytest.raises(PrecommitError, match=r"Removed redundant configuration files"): - _remove_configuration() - assert not (tmp_path / ".prettierrc.json").exists() - assert not (tmp_path / ".prettierrc").exists() - - -def test_remove_configuration_without_files( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - _remove_configuration() # no config files and no badge to remove - - -def test_update_prettier_ignore_removes_forbidden( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / ".prettierignore").write_text(".cspell.json\nbuild/\n") - with pytest.raises(PrecommitError, match=r"Removed forbidden paths"): - _update_prettier_ignore() - assert ".cspell.json" not in (tmp_path / ".prettierignore").read_text() - - -def test_update_prettier_ignore_inserts_obligatory( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "LICENSE").touch() - (tmp_path / ".prettierignore").write_text("build/\n") - with pytest.raises(PrecommitError, match=r"Added paths"): - _update_prettier_ignore() - assert "LICENSE" in (tmp_path / ".prettierignore").read_text() - - -def test_update_prettier_ignore_removes_empty_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / ".prettierignore").write_text("") - with pytest.raises(PrecommitError, match=r"is not needed"): - _update_prettier_ignore() - assert not (tmp_path / ".prettierignore").exists() - - -def test_main_with_prettier_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - with ( - ModifiablePrecommit.load(io.StringIO(_WITH_PRETTIER)) as precommit, - pytest.raises(PrecommitError), + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + with ( + ModifiablePrecommit.load(io.StringIO(_WITH_PRETTIER)) as precommit, + pytest.raises(PrecommitError), + ): + main(precommit) + assert "prettier" in (tmp_path / "README.md").read_text() + + def cleans_up_without_prettier_repo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - main(precommit) - assert "prettier" in (tmp_path / "README.md").read_text() - - -def test_main_without_prettier_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - (tmp_path / ".prettierrc.json").write_text("{}") - with ( - ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, - pytest.raises(PrecommitError, match=r"Removed redundant configuration"), - ): - main(precommit) + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / ".prettierrc.json").write_text("{}") + with ( + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + pytest.raises(PrecommitError, match=r"Removed redundant configuration"), + ): + main(precommit) diff --git a/tests/format/test_toml.py b/tests/format/test_toml.py index cd5da375..7479549a 100644 --- a/tests/format/test_toml.py +++ b/tests/format/test_toml.py @@ -38,143 +38,138 @@ def _git_init(directory: Path) -> None: subprocess.run(["git", "init", "-q"], cwd=directory, check=True) # noqa: S607 -def test_rename_precommit_url_migrates_mirror(): - with ( - pytest.raises(PrecommitError, match=r"Renamed mirrors-taplo"), - ModifiablePrecommit.load(io.StringIO(_WITH_MIRRORS_TAPLO)) as precommit, +def describe_rename_precommit_url(): + def migrates_mirror(): + with ( + pytest.raises(PrecommitError, match=r"Renamed mirrors-taplo"), + ModifiablePrecommit.load(io.StringIO(_WITH_MIRRORS_TAPLO)) as precommit, + ): + _rename_precommit_url(precommit) + result = precommit.dumps() + assert "mirrors-taplo" not in result + assert "https://github.com/ComPWA/taplo-pre-commit" in result + assert "rev: v0.8.1" in result # preserves the pinned revision + + def adds_hook_without_mirror(): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _rename_precommit_url(precommit) + assert "https://github.com/ComPWA/taplo-pre-commit" in precommit.dumps() + + +def describe_update_precommit_repo(): + def adds_taplo(): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _update_precommit_repo(precommit) + result = precommit.dumps() + assert "https://github.com/ComPWA/taplo-pre-commit" in result + assert "id: taplo-format" in result + + def migrates_mirror(): + with ( + pytest.raises(PrecommitError, match=r"Renamed mirrors-taplo"), + ModifiablePrecommit.load(io.StringIO(_WITH_MIRRORS_TAPLO)) as precommit, + ): + _update_precommit_repo(precommit) + result = precommit.dumps() + assert "mirrors-taplo" not in result + assert "https://github.com/ComPWA/taplo-pre-commit" in result + + +def describe_update_tomlsort_hook(): + def adds_hook_without_excludes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _update_tomlsort_hook(precommit) + result = precommit.dumps() + assert "https://github.com/pappasam/toml-sort" in result + assert "exclude" not in result + + def adds_excludes_when_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + (tmp_path / "labels.toml").touch() + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + _update_tomlsort_hook(precommit) + assert "exclude" in precommit.dumps() + + +def describe_rename_taplo_config(): + def renames_to_dotfile(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "taplo.toml").write_text("include = []\n") + with pytest.raises(PrecommitError, match=r"Renamed taplo\.toml"): + _rename_taplo_config() + assert not (tmp_path / "taplo.toml").exists() + assert (tmp_path / ".taplo.toml").exists() + + +def describe_update_tomlsort_config(): + def configures_sort_first(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + with pytest.raises(PrecommitError, match=r"Updated toml-sort configuration"): + _update_tomlsort_config() + result = (tmp_path / "pyproject.toml").read_text() + assert "[tool.tomlsort]" in result + assert 'sort_first = ["project"]' in result + + def is_idempotent_without_known_tables( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - _rename_precommit_url(precommit) - result = precommit.dumps() - assert "mirrors-taplo" not in result - assert "https://github.com/ComPWA/taplo-pre-commit" in result - assert "rev: v0.8.1" in result # preserves the pinned revision - - -def test_update_precommit_repo_adds_taplo(): - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, - ): - _update_precommit_repo(precommit) - result = precommit.dumps() - assert "https://github.com/ComPWA/taplo-pre-commit" in result - assert "id: taplo-format" in result - - -def test_update_tomlsort_hook_without_excludes( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, - ): - _update_tomlsort_hook(precommit) - result = precommit.dumps() - assert "https://github.com/pappasam/toml-sort" in result - assert "exclude" not in result - - -def test_update_tomlsort_hook_with_excludes( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - _git_init(tmp_path) - (tmp_path / "labels.toml").touch() - monkeypatch.chdir(tmp_path) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, - ): - _update_tomlsort_hook(precommit) - assert "exclude" in precommit.dumps() - - -def test_rename_taplo_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "taplo.toml").write_text("include = []\n") - with pytest.raises(PrecommitError, match=r"Renamed taplo\.toml"): - _rename_taplo_config() - assert not (tmp_path / "taplo.toml").exists() - assert (tmp_path / ".taplo.toml").exists() - - -def test_rename_precommit_url_without_mirror(): - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, - ): - _rename_precommit_url(precommit) - assert "https://github.com/ComPWA/taplo-pre-commit" in precommit.dumps() - - -def test_update_precommit_repo_migrates_mirror(): - with ( - pytest.raises(PrecommitError, match=r"Renamed mirrors-taplo"), - ModifiablePrecommit.load(io.StringIO(_WITH_MIRRORS_TAPLO)) as precommit, - ): - _update_precommit_repo(precommit) - result = precommit.dumps() - assert "mirrors-taplo" not in result - assert "https://github.com/ComPWA/taplo-pre-commit" in result - - -def test_update_tomlsort_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - with pytest.raises(PrecommitError, match=r"Updated toml-sort configuration"): - _update_tomlsort_config() - result = (tmp_path / "pyproject.toml").read_text() - assert "[tool.tomlsort]" in result - assert 'sort_first = ["project"]' in result - - -def test_update_tomlsort_config_without_known_tables_is_idempotent( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text("[tool.other]\nkey = 1\n") - with pytest.raises(PrecommitError, match=r"Updated toml-sort configuration"): - _update_tomlsort_config() - assert "sort_first" not in (tmp_path / "pyproject.toml").read_text() - _update_tomlsort_config() # second run is a no-op - - -def test_update_taplo_config_creates_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - with pytest.raises(PrecommitError, match=r"Added .*\.taplo\.toml"): - _update_taplo_config() - assert (tmp_path / ".taplo.toml").exists() - - -def test_update_vscode_extensions(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - # cspell:ignore tamasfe - monkeypatch.chdir(tmp_path) - with pytest.raises(PrecommitError): - _update_vscode_extensions() - extensions = (tmp_path / ".vscode" / "extensions.json").read_text() - assert "tamasfe.even-better-toml" in extensions - - -def test_main_runs_when_triggered(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, - ): - main(precommit) - result = precommit.dumps() - assert "https://github.com/ComPWA/taplo-pre-commit" in result - assert "https://github.com/pappasam/toml-sort" in result - - -def test_main_skips_without_trigger_files( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - with ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit: - main(precommit) # no pyproject.toml or taplo config -> no-op + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.other]\nkey = 1\n") + with pytest.raises(PrecommitError, match=r"Updated toml-sort configuration"): + _update_tomlsort_config() + assert "sort_first" not in (tmp_path / "pyproject.toml").read_text() + _update_tomlsort_config() # second run is a no-op + + +def describe_update_taplo_config(): + def creates_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError, match=r"Added .*\.taplo\.toml"): + _update_taplo_config() + assert (tmp_path / ".taplo.toml").exists() + + +def describe_update_vscode_extensions(): + def recommends_even_better_toml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # cspell:ignore tamasfe + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_extensions() + extensions = (tmp_path / ".vscode" / "extensions.json").read_text() + assert "tamasfe.even-better-toml" in extensions + + +def describe_main(): + def runs_when_triggered(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit, + ): + main(precommit) + result = precommit.dumps() + assert "https://github.com/ComPWA/taplo-pre-commit" in result + assert "https://github.com/pappasam/toml-sort" in result + + def skips_without_trigger_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with ModifiablePrecommit.load(io.StringIO(_META_ONLY)) as precommit: + main(precommit) # no pyproject.toml or taplo config -> no-op diff --git a/tests/github/test_workflows.py b/tests/github/test_workflows.py index d5d4fb7e..dedff96c 100644 --- a/tests/github/test_workflows.py +++ b/tests/github/test_workflows.py @@ -63,89 +63,82 @@ def _run_main( ) -def test_main_creates_workflows(workflows_repo: Path): - with pytest.raises(PrecommitError): - _run_main() - - assert (workflows_repo / _WORKFLOW_DIR / "cd.yml").exists() - assert (workflows_repo / _WORKFLOW_DIR / "ci.yml").exists() - assert (workflows_repo / _WORKFLOW_DIR / "pr-linting.yml").exists() - assert (workflows_repo / _WORKFLOW_DIR / "clean-caches.yml").exists() - - -def test_main_with_options(workflows_repo: Path): - with pytest.raises(PrecommitError): - _run_main( - doc_apt_packages=["graphviz"], - environment_variables={"PYTHONHASHSEED": "0"}, - github_pages=True, - macos_python_version="3.12", - python_version="3.12", - single_threaded=True, - skip_tests=["3.10"], - ) - - ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() - assert "graphviz" in ci - assert "PYTHONHASHSEED" in ci - - -def test_main_no_cd(workflows_repo: Path): - with pytest.raises(PrecommitError): - _run_main(no_cd=True) - assert not (workflows_repo / _WORKFLOW_DIR / "cd.yml").exists() - - -def test_main_bans_cd_jobs(workflows_repo: Path): - with pytest.raises(PrecommitError): - _run_main(no_pypi=True, no_milestones=True, no_version_branches=True) - cd_path = workflows_repo / _WORKFLOW_DIR / "cd.yml" - if cd_path.exists(): - assert "pypi" not in cd_path.read_text() - - -def test_main_with_codecov(workflows_repo: Path): - (workflows_repo / "codecov.yml").touch() - (workflows_repo / ".python-version").write_text("3.11\n") - with pytest.raises(PrecommitError): - _run_main() - ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() - assert "CODECOV_TOKEN" in ci - assert "3.11" in ci # coverage python version from .python-version - - -def test_main_removes_style_job_when_outsourced(workflows_repo: Path): - precommit = "ci:\n autofix_prs: true\nrepos: []\n" - with pytest.raises(PrecommitError): - _run_main(precommit_content=precommit) - ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() - assert "style:" not in ci # style job outsourced to pre-commit.ci - - -def test_main_removes_doc_and_test_jobs( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 - (tmp_path / "pyproject.toml").write_text('[project]\nname = "my-package"\n') - subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 - monkeypatch.chdir(tmp_path) - with pytest.raises(PrecommitError): - _run_main() - ci = (tmp_path / _WORKFLOW_DIR / "ci.yml").read_text() - assert "doc:" not in ci # no documentation -> doc job removed - assert "test:" not in ci # no tests directory -> test job removed - - -def test_remove_workflow_absent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - remove_workflow("ci-tests.yml") # nothing to remove - - -def test_remove_workflow_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - workflow = tmp_path / _WORKFLOW_DIR / "ci-tests.yml" - workflow.parent.mkdir(parents=True) - workflow.touch() - with pytest.raises(PrecommitError, match=r"Removed deprecated ci-tests.yml"): - remove_workflow("ci-tests.yml") - assert not workflow.exists() +def describe_main(): + def creates_workflows(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main() + + assert (workflows_repo / _WORKFLOW_DIR / "cd.yml").exists() + assert (workflows_repo / _WORKFLOW_DIR / "ci.yml").exists() + assert (workflows_repo / _WORKFLOW_DIR / "pr-linting.yml").exists() + assert (workflows_repo / _WORKFLOW_DIR / "clean-caches.yml").exists() + + def applies_options(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main( + doc_apt_packages=["graphviz"], + environment_variables={"PYTHONHASHSEED": "0"}, + github_pages=True, + macos_python_version="3.12", + python_version="3.12", + single_threaded=True, + skip_tests=["3.10"], + ) + + ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() + assert "graphviz" in ci + assert "PYTHONHASHSEED" in ci + + def skips_cd_workflow(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main(no_cd=True) + assert not (workflows_repo / _WORKFLOW_DIR / "cd.yml").exists() + + def bans_cd_jobs(workflows_repo: Path): + with pytest.raises(PrecommitError): + _run_main(no_pypi=True, no_milestones=True, no_version_branches=True) + cd_path = workflows_repo / _WORKFLOW_DIR / "cd.yml" + if cd_path.exists(): + assert "pypi" not in cd_path.read_text() + + def configures_codecov(workflows_repo: Path): + (workflows_repo / "codecov.yml").touch() + (workflows_repo / ".python-version").write_text("3.11\n") + with pytest.raises(PrecommitError): + _run_main() + ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() + assert "CODECOV_TOKEN" in ci + assert "3.11" in ci # coverage python version from .python-version + + def removes_style_job_when_outsourced(workflows_repo: Path): + precommit = "ci:\n autofix_prs: true\nrepos: []\n" + with pytest.raises(PrecommitError): + _run_main(precommit_content=precommit) + ci = (workflows_repo / _WORKFLOW_DIR / "ci.yml").read_text() + assert "style:" not in ci # style job outsourced to pre-commit.ci + + def removes_doc_and_test_jobs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "pyproject.toml").write_text('[project]\nname = "my-package"\n') + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _run_main() + ci = (tmp_path / _WORKFLOW_DIR / "ci.yml").read_text() + assert "doc:" not in ci # no documentation -> doc job removed + assert "test:" not in ci # no tests directory -> test job removed + + +def describe_remove_workflow(): + def is_noop_when_absent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + remove_workflow("ci-tests.yml") # nothing to remove + + def removes_present_workflow(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + workflow = tmp_path / _WORKFLOW_DIR / "ci-tests.yml" + workflow.parent.mkdir(parents=True) + workflow.touch() + with pytest.raises(PrecommitError, match=r"Removed deprecated ci-tests.yml"): + remove_workflow("ci-tests.yml") + assert not workflow.exists() diff --git a/tests/pyright/test_pyright.py b/tests/pyright/test_pyright.py index 40d7db88..7991508b 100644 --- a/tests/pyright/test_pyright.py +++ b/tests/pyright/test_pyright.py @@ -20,76 +20,81 @@ def this_dir() -> Path: return Path(__file__).parent -def test_merge_config_into_pyproject(this_dir: Path): - input_stream = io.StringIO() - old_config_path = this_dir / "pyrightconfig.json" - with ( - pytest.raises( - PrecommitError, - match=re.escape(f"Imported pyright configuration from {old_config_path}"), - ), - ModifiablePyproject.load(input_stream) as pyproject, - ): - _merge_config_into_pyproject(pyproject, old_config_path, remove=False) +def describe_merge_config_into_pyproject(): + def imports_from_json(this_dir: Path): + input_stream = io.StringIO() + old_config_path = this_dir / "pyrightconfig.json" + with ( + pytest.raises( + PrecommitError, + match=re.escape( + f"Imported pyright configuration from {old_config_path}" + ), + ), + ModifiablePyproject.load(input_stream) as pyproject, + ): + _merge_config_into_pyproject(pyproject, old_config_path, remove=False) - result = input_stream.getvalue() - expected_result = dedent(""" - [tool.pyright] - include = ["src/**/*.py"] - exclude = ["tests/**/*.py"] - pythonVersion = "3.9" - reportMissingTypeStubs = false - reportMissingImports = true - """) - assert result.strip() == expected_result.strip() + result = input_stream.getvalue() + expected_result = dedent(""" + [tool.pyright] + include = ["src/**/*.py"] + exclude = ["tests/**/*.py"] + pythonVersion = "3.9" + reportMissingTypeStubs = false + reportMissingImports = true + """) + assert result.strip() == expected_result.strip() -def test_update_precommit(): - bad_config = dedent(""" - repos: - - repo: meta - hooks: - - id: check-hooks-apply - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, - ): - _update_precommit(precommit) +def describe_update_precommit(): + def adds_pyright_hook(): + bad_config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, + ): + _update_precommit(precommit) - expected = dedent(""" - repos: - - repo: meta - hooks: - - id: check-hooks-apply + expected = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply - - repo: https://github.com/ComPWA/pyright-pre-commit - rev: PLEASE-UPDATE - hooks: - - id: pyright - """).lstrip() - assert precommit.dumps() == expected + - repo: https://github.com/ComPWA/pyright-pre-commit + rev: PLEASE-UPDATE + hooks: + - id: pyright + """).lstrip() + assert precommit.dumps() == expected -def test_update_settings(): - bad_config = dedent(""" - [tool.pyright] - include = ["**/*.py"] - reportUnusedImport = true - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated pyright configuration"), - ModifiablePyproject.load(io.StringIO(bad_config)) as pyproject, - ): - _update_settings(pyproject) +def describe_update_settings(): + def adds_strict_settings(): + bad_config = dedent(""" + [tool.pyright] + include = ["**/*.py"] + reportUnusedImport = true + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated pyright configuration"), + ModifiablePyproject.load(io.StringIO(bad_config)) as pyproject, + ): + _update_settings(pyproject) - result = pyproject.dumps() - expected_result = dedent(""" - [tool.pyright] - include = ["**/*.py"] - reportUnusedImport = true - typeCheckingMode = "strict" - venv = ".venv" - venvPath = "." - """) - assert result.strip() == expected_result.strip() + result = pyproject.dumps() + expected_result = dedent(""" + [tool.pyright] + include = ["**/*.py"] + reportUnusedImport = true + typeCheckingMode = "strict" + venv = ".venv" + venvPath = "." + """) + assert result.strip() == expected_result.strip() diff --git a/tests/python/test_black.py b/tests/python/test_black.py index f41436fe..413b0ef7 100644 --- a/tests/python/test_black.py +++ b/tests/python/test_black.py @@ -15,141 +15,140 @@ from compwa_policy.utilities.pyproject import ModifiablePyproject -def test_remove_outdated_settings(): - config = dedent(""" - [tool.black] - line-length = 88 - preview = true - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Removed line-length"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _remove_outdated_settings(pyproject) - assert "line-length" not in pyproject.dumps() - - -def test_remove_outdated_settings_keeps_other_options(): - config = dedent(""" - [tool.black] - preview = true - """).lstrip() - with ModifiablePyproject.load(io.StringIO(config)) as pyproject: - _remove_outdated_settings(pyproject) - assert "preview = true" in pyproject.dumps() - - -def test_update_black_settings_with_requires_python(): - config = dedent(""" - [project] - requires-python = ">=3.10" - - [tool.black] - target-version = ["py39"] - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _update_black_settings(pyproject) - result = pyproject.dumps() - assert "target-version" not in result - assert "preview = true" in result - - -def test_update_black_settings_without_target_version(): - config = dedent(""" - [project] - requires-python = ">=3.10" - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated black configuration"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _update_black_settings(pyproject) - result = pyproject.dumps() - assert "preview = true" in result - assert "target-version" not in result - - -def test_update_black_settings_derives_target_version_from_classifiers(): - config = dedent(""" - [project] - classifiers = [ - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - ] - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated black configuration"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _update_black_settings(pyproject) - result = pyproject.dumps() - assert '"py310"' in result - assert '"py311"' in result - - -def test_update_black_settings_already_compliant(): - config = dedent(""" - [project] - requires-python = ">=3.10" - - [tool.black] - preview = true - """).lstrip() - with ModifiablePyproject.load(io.StringIO(config)) as pyproject: - _update_black_settings(pyproject) # already compliant -> no change - assert "preview = true" in pyproject.dumps() - - -@pytest.mark.parametrize("has_notebooks", [False, True]) -def test_update_precommit_repo(has_notebooks: bool): - config = dedent(""" - repos: - - repo: meta - hooks: - - id: check-hooks-apply - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - _update_precommit_repo(precommit, has_notebooks) - result = precommit.dumps() - assert "https://github.com/psf/black-pre-commit-mirror" in result - assert ("black-jupyter" in result) is has_notebooks - - -def test_main_without_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: - main(precommit, has_notebooks=False) # no pyproject.toml -> no-op - - -def test_main_replaces_black_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text( - dedent(""" - [project] - requires-python = ">=3.10" - - [tool.black] - line-length = 88 +def describe_remove_outdated_settings(): + def removes_line_length(): + config = dedent(""" + [tool.black] + line-length = 88 + preview = true """).lstrip() - ) - config = dedent(""" - repos: - - repo: https://github.com/psf/black - rev: 24.4.2 - hooks: - - id: black - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - main(precommit, has_notebooks=False) - result = precommit.dumps() - assert "https://github.com/psf/black\n" not in result - assert "https://github.com/psf/black-pre-commit-mirror" in result + with ( + pytest.raises(PrecommitError, match=r"Removed line-length"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _remove_outdated_settings(pyproject) + assert "line-length" not in pyproject.dumps() + + def keeps_other_options(): + config = dedent(""" + [tool.black] + preview = true + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _remove_outdated_settings(pyproject) + assert "preview = true" in pyproject.dumps() + + +def describe_update_black_settings(): + def drops_target_version_with_requires_python(): + config = dedent(""" + [project] + requires-python = ">=3.10" + + [tool.black] + target-version = ["py39"] + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_black_settings(pyproject) + result = pyproject.dumps() + assert "target-version" not in result + assert "preview = true" in result + + def enables_preview_without_target_version(): + config = dedent(""" + [project] + requires-python = ">=3.10" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated black configuration"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_black_settings(pyproject) + result = pyproject.dumps() + assert "preview = true" in result + assert "target-version" not in result + + def derives_target_version_from_classifiers(): + config = dedent(""" + [project] + classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated black configuration"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_black_settings(pyproject) + result = pyproject.dumps() + assert '"py310"' in result + assert '"py311"' in result + + def is_noop_when_already_compliant(): + config = dedent(""" + [project] + requires-python = ">=3.10" + + [tool.black] + preview = true + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_black_settings(pyproject) # already compliant -> no change + assert "preview = true" in pyproject.dumps() + + +def describe_update_precommit_repo(): + @pytest.mark.parametrize("has_notebooks", [False, True]) + def replaces_with_mirror(has_notebooks: bool): + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_repo(precommit, has_notebooks) + result = precommit.dumps() + assert "https://github.com/psf/black-pre-commit-mirror" in result + assert ("black-jupyter" in result) is has_notebooks + + +def describe_main(): + def is_noop_without_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: + main(precommit, has_notebooks=False) # no pyproject.toml -> no-op + + def replaces_black_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + requires-python = ">=3.10" + + [tool.black] + line-length = 88 + """).lstrip() + ) + config = dedent(""" + repos: + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, has_notebooks=False) + result = precommit.dumps() + assert "https://github.com/psf/black\n" not in result + assert "https://github.com/psf/black-pre-commit-mirror" in result diff --git a/tests/python/test_mypy.py b/tests/python/test_mypy.py index 799f61f5..4a69b25c 100644 --- a/tests/python/test_mypy.py +++ b/tests/python/test_mypy.py @@ -28,116 +28,117 @@ """).lstrip() -def test_merge_mypy_into_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / ".mypy.ini").write_text( - dedent(""" - [mypy] - ignore_missing_imports = True +def describe_merge_mypy_into_pyproject(): + def imports_and_removes_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".mypy.ini").write_text( + dedent(""" + [mypy] + ignore_missing_imports = True + """).lstrip() + ) + with ( + pytest.raises(PrecommitError, match=r"Imported mypy configuration"), + ModifiablePyproject.load(io.StringIO("")) as pyproject, + ): + _merge_mypy_into_pyproject(pyproject) + assert "[tool.mypy]" in pyproject.dumps() + assert not (tmp_path / ".mypy.ini").exists() + + def is_noop_without_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with ModifiablePyproject.load(io.StringIO("")) as pyproject: + _merge_mypy_into_pyproject(pyproject) # nothing to import + + +def describe_update_precommit_config(): + def adds_mypy_hook(): + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply """).lstrip() - ) - with ( - pytest.raises(PrecommitError, match=r"Imported mypy configuration"), - ModifiablePyproject.load(io.StringIO("")) as pyproject, - ): - _merge_mypy_into_pyproject(pyproject) - assert "[tool.mypy]" in pyproject.dumps() - assert not (tmp_path / ".mypy.ini").exists() - - -def test_merge_mypy_into_pyproject_without_ini( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - with ModifiablePyproject.load(io.StringIO("")) as pyproject: - _merge_mypy_into_pyproject(pyproject) # nothing to import - - -def test_update_precommit_config(): - config = dedent(""" - repos: - - repo: meta - hooks: - - id: check-hooks-apply - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - _update_precommit_config(precommit) - result = precommit.dumps() - assert "id: mypy" in result - assert "entry: mypy" in result - - -def test_remove_mypy(): - pyproject_config = dedent(""" - [dependency-groups] - style = ["mypy"] - - [tool.mypy] - strict = true - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_WITH_MYPY)) as precommit, - ModifiablePyproject.load(io.StringIO(pyproject_config)) as pyproject, - ): - _remove_mypy(precommit, pyproject) - assert "mypy" not in precommit.dumps() - assert "tool.mypy" not in pyproject.dumps() - - -def test_update_vscode_settings_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - with pytest.raises(PrecommitError): - _update_vscode_settings(mypy=True) - settings = json.loads((tmp_path / ".vscode" / "settings.json").read_text()) - assert "mypy-type-checker.args" in settings - - -def test_update_vscode_settings_inactive( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - with pytest.raises(PrecommitError): - _update_vscode_settings(mypy=False) - extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) - assert "ms-python.mypy-type-checker" in extensions["unwantedRecommendations"] - - -def test_remove_mypy_without_configuration_table(): - with ( - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - ModifiablePyproject.load(io.StringIO("")) as pyproject, + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_config(precommit) + result = precommit.dumps() + assert "id: mypy" in result + assert "entry: mypy" in result + + +def describe_remove_mypy(): + def removes_hook_and_config(): + pyproject_config = dedent(""" + [dependency-groups] + style = ["mypy"] + + [tool.mypy] + strict = true + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_WITH_MYPY)) as precommit, + ModifiablePyproject.load(io.StringIO(pyproject_config)) as pyproject, + ): + _remove_mypy(precommit, pyproject) + assert "mypy" not in precommit.dumps() + assert "tool.mypy" not in pyproject.dumps() + + def is_noop_without_configuration_table(): + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ModifiablePyproject.load(io.StringIO("")) as pyproject, + ): + _remove_mypy(precommit, pyproject) # no tool.mypy table to remove + + +def describe_update_vscode_settings(): + def configures_extension_when_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - _remove_mypy(precommit, pyproject) # no tool.mypy table to remove - - -def test_main_activates_mypy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - (tmp_path / "README.md").write_text("# My Package\n\nSome text.\n") - config = dedent(""" - repos: - - repo: meta - hooks: - - id: check-hooks-apply - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_settings(mypy=True) + settings = json.loads((tmp_path / ".vscode" / "settings.json").read_text()) + assert "mypy-type-checker.args" in settings + + def marks_extension_unwanted_when_inactive( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - main(active=True, precommit=precommit) - assert "id: mypy" in precommit.dumps() - - -def test_main_deactivates_mypy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text("[tool.mypy]\nstrict = true\n") - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_WITH_MYPY)) as precommit, - ): - main(active=False, precommit=precommit) - assert "mypy" not in precommit.dumps() + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_settings(mypy=False) + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "ms-python.mypy-type-checker" in extensions["unwantedRecommendations"] + + +def describe_main(): + def activates_mypy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + (tmp_path / "README.md").write_text("# My Package\n\nSome text.\n") + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(active=True, precommit=precommit) + assert "id: mypy" in precommit.dumps() + + def deactivates_mypy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.mypy]\nstrict = true\n") + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_WITH_MYPY)) as precommit, + ): + main(active=False, precommit=precommit) + assert "mypy" not in precommit.dumps() diff --git a/tests/python/test_pytest.py b/tests/python/test_pytest.py index 2150c75a..ff4b68c1 100644 --- a/tests/python/test_pytest.py +++ b/tests/python/test_pytest.py @@ -20,188 +20,198 @@ # cspell:ignore addopts importmode minversion numprocesses ryanluker xdist -def test_deny_ini_options_raises(): - config = dedent(""" - [tool.pytest.ini_options] - addopts = "--color=yes" - """).lstrip() - with ( - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - pytest.raises(PrecommitError, match=r"migrate to a native TOML"), - ): - _deny_ini_options(pyproject) - - -def test_deny_ini_options_sets_minversion(): - config = dedent(""" - [tool.pytest] - addopts = "--color=yes" - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"minimum pytest version"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _deny_ini_options(pyproject) - assert pyproject.get_table("tool.pytest")["minversion"] == "9.0" - - -def test_deny_ini_options_noop_with_minversion(): - config = dedent(""" - [tool.pytest] - minversion = "8.0" - """).lstrip() - with ModifiablePyproject.load(io.StringIO(config)) as pyproject: - _deny_ini_options(pyproject) # minversion present -> no-op - - -def test_update_settings_from_string(): - config = dedent(""" - [tool.pytest] - addopts = "--color=no -ra" - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated \[tool.pytest\]"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _update_settings(pyproject) - addopts = list(pyproject.get_table("tool.pytest")["addopts"]) - assert "--color=yes" in addopts - assert "--import-mode=importlib" in addopts - assert "-ra" in addopts - assert "--color=no" not in addopts - - -def test_update_settings_is_idempotent(): - config = dedent(""" - [tool.pytest] - addopts = ["--color=yes", "--import-mode=importlib"] - """).lstrip() - with ModifiablePyproject.load(io.StringIO(config)) as pyproject: - _update_settings(pyproject) # already up to date -> no error - - -def test_update_settings_noop_without_table(): - with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: - _update_settings(pyproject) # no [tool.pytest] -> no-op - - -def test_merge_pytest_into_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pytest.ini").write_text("[pytest]\nminversion = 7.0\n") - with ( - pytest.raises(PrecommitError, match=r"Imported pytest configuration"), - ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject, - ): - _merge_pytest_into_pyproject(pyproject) - assert not (tmp_path / "pytest.ini").exists() - assert "ini_options" in pyproject.dumps() - - -def test_merge_pytest_into_pyproject_noop( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: - _merge_pytest_into_pyproject(pyproject) # no pytest.ini -> no-op - - -def test_merge_coverage_into_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pytest.ini").write_text( - "[coverage:run]\nbranch = True\nsource = my_pkg\n" - ) - with ( - pytest.raises(PrecommitError, match=r"Imported Coverage.py configuration"), - ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject, - ): - _merge_coverage_into_pyproject(pyproject) - coverage = pyproject.get_table("tool.coverage.run") - assert coverage["source"] == ["my_pkg"] - - -def test_merge_coverage_into_pyproject_noop( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "pytest.ini").write_text("[pytest]\nminversion = 7.0\n") - with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: - _merge_coverage_into_pyproject(pyproject) # no [coverage:run] -> no-op - - -def test_update_codecov_settings(): - config = dedent(""" - [project] - name = "x" - - [dependency-groups] - test = ["pytest-cov"] - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated pytest coverage settings"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _update_codecov_settings(pyproject) - coverage = pyproject.get_table("tool.coverage.run") - assert coverage["branch"] is True - assert coverage["source"] == ["src"] - - -def test_update_codecov_settings_noop_without_coverage(): - with ModifiablePyproject.load(io.StringIO("[project]\nname = 'x'\n")) as pyproject: - _update_codecov_settings(pyproject) # no coverage dependency -> no-op - - -def test_update_vscode_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - pyproject = Pyproject.load(io.StringIO('[project]\nname = "my-package"\n')) - with pytest.raises(PrecommitError): - _update_vscode_settings(pyproject, coverage_gutters=True, single_threaded=False) - settings = json.loads((tmp_path / ".vscode" / "settings.json").read_text()) - assert settings["testing.showCoverageInExplorer"] is True - extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) - assert "ryanluker.vscode-coverage-gutters" in extensions["recommendations"] - - -def test_main(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text( - dedent(""" - [project] - name = "my-package" - - [dependency-groups] - test = ["pytest", "pytest-cov"] - - [tool.pytest] - addopts = "--color=no" +def describe_deny_ini_options(): + def raises_on_ini_options(): + config = dedent(""" + [tool.pytest.ini_options] + addopts = "--color=yes" """).lstrip() - ) - with pytest.raises(PrecommitError): - main(coverage_gutters=False, single_threaded=True) - result = (tmp_path / "pyproject.toml").read_text() - assert "[tool.coverage.run]" in result - - -def test_main_multithreaded_adds_xdist(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text( - dedent(""" - [project] - name = "my-package" - - [dependency-groups] - test = ["pytest"] - - [tool.pytest] - addopts = ["--color=yes", "--import-mode=importlib"] + with ( + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + pytest.raises(PrecommitError, match=r"migrate to a native TOML"), + ): + _deny_ini_options(pyproject) + + def sets_minversion(): + config = dedent(""" + [tool.pytest] + addopts = "--color=yes" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"minimum pytest version"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _deny_ini_options(pyproject) + assert pyproject.get_table("tool.pytest")["minversion"] == "9.0" + + def is_noop_with_minversion(): + config = dedent(""" + [tool.pytest] + minversion = "8.0" """).lstrip() - ) - with pytest.raises(PrecommitError): - main(coverage_gutters=True, single_threaded=False) - assert "pytest-xdist" in (tmp_path / "pyproject.toml").read_text() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _deny_ini_options(pyproject) # minversion present -> no-op -def test_main_without_pytest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text('[project]\nname = "my-package"\n') - main(coverage_gutters=False, single_threaded=True) # no pytest dependency -> no-op +def describe_update_settings(): + def updates_from_string(): + config = dedent(""" + [tool.pytest] + addopts = "--color=no -ra" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated \[tool.pytest\]"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_settings(pyproject) + addopts = list(pyproject.get_table("tool.pytest")["addopts"]) + assert "--color=yes" in addopts + assert "--import-mode=importlib" in addopts + assert "-ra" in addopts + assert "--color=no" not in addopts + + def is_idempotent(): + config = dedent(""" + [tool.pytest] + addopts = ["--color=yes", "--import-mode=importlib"] + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_settings(pyproject) # already up to date -> no error + + def is_noop_without_table(): + with ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject: + _update_settings(pyproject) # no [tool.pytest] -> no-op + + +def describe_merge_pytest_into_pyproject(): + def imports_and_removes_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pytest.ini").write_text("[pytest]\nminversion = 7.0\n") + with ( + pytest.raises(PrecommitError, match=r"Imported pytest configuration"), + ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject, + ): + _merge_pytest_into_pyproject(pyproject) + assert not (tmp_path / "pytest.ini").exists() + assert "ini_options" in pyproject.dumps() + + def is_noop_without_ini(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject: + _merge_pytest_into_pyproject(pyproject) # no pytest.ini -> no-op + + +def describe_merge_coverage_into_pyproject(): + def imports_coverage_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pytest.ini").write_text( + "[coverage:run]\nbranch = True\nsource = my_pkg\n" + ) + with ( + pytest.raises(PrecommitError, match=r"Imported Coverage.py configuration"), + ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject, + ): + _merge_coverage_into_pyproject(pyproject) + coverage = pyproject.get_table("tool.coverage.run") + assert coverage["source"] == ["my_pkg"] + + def is_noop_without_coverage_section( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "pytest.ini").write_text("[pytest]\nminversion = 7.0\n") + with ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject: + _merge_coverage_into_pyproject(pyproject) # no [coverage:run] -> no-op + + +def describe_update_codecov_settings(): + def sets_branch_and_source(): + config = dedent(""" + [project] + name = "x" + + [dependency-groups] + test = ["pytest-cov"] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated pytest coverage settings"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_codecov_settings(pyproject) + coverage = pyproject.get_table("tool.coverage.run") + assert coverage["branch"] is True + assert coverage["source"] == ["src"] + + def is_noop_without_coverage(): + with ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject: + _update_codecov_settings(pyproject) # no coverage dependency -> no-op + + +def describe_update_vscode_settings(): + def enables_coverage_gutters(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + pyproject = Pyproject.load(io.StringIO('[project]\nname = "my-package"\n')) + with pytest.raises(PrecommitError): + _update_vscode_settings( + pyproject, coverage_gutters=True, single_threaded=False + ) + settings = json.loads((tmp_path / ".vscode" / "settings.json").read_text()) + assert settings["testing.showCoverageInExplorer"] is True + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "ryanluker.vscode-coverage-gutters" in extensions["recommendations"] + + +def describe_main(): + def writes_coverage_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + + [dependency-groups] + test = ["pytest", "pytest-cov"] + + [tool.pytest] + addopts = "--color=no" + """).lstrip() + ) + with pytest.raises(PrecommitError): + main(coverage_gutters=False, single_threaded=True) + result = (tmp_path / "pyproject.toml").read_text() + assert "[tool.coverage.run]" in result + + def adds_xdist_when_multithreaded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + + [dependency-groups] + test = ["pytest"] + + [tool.pytest] + addopts = ["--color=yes", "--import-mode=importlib"] + """).lstrip() + ) + with pytest.raises(PrecommitError): + main(coverage_gutters=True, single_threaded=False) + assert "pytest-xdist" in (tmp_path / "pyproject.toml").read_text() + + def is_noop_without_pytest(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "my-package"\n') + main(coverage_gutters=False, single_threaded=True) # no pytest dep -> no-op diff --git a/tests/python/test_pyupgrade.py b/tests/python/test_pyupgrade.py index 8d294515..97056792 100644 --- a/tests/python/test_pyupgrade.py +++ b/tests/python/test_pyupgrade.py @@ -24,61 +24,62 @@ def _project_with_classifiers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) - ) -@pytest.mark.usefixtures("_project_with_classifiers") -def test_main_installs_pyupgrade(): - config = dedent(""" - repos: - - repo: https://github.com/nbQA-dev/nbQA - rev: 1.8.5 - hooks: - - id: nbqa-isort - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - main(precommit, no_ruff=True) - - result = precommit.dumps() - assert "https://github.com/asottile/pyupgrade" in result - assert "--py310-plus" in result - assert "nbqa-pyupgrade" in result +def describe_main(): + @pytest.mark.usefixtures("_project_with_classifiers") + def installs_pyupgrade(): + config = dedent(""" + repos: + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.8.5 + hooks: + - id: nbqa-isort + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, no_ruff=True) + result = precommit.dumps() + assert "https://github.com/asottile/pyupgrade" in result + assert "--py310-plus" in result + assert "nbqa-pyupgrade" in result -def test_main_removes_pyupgrade_when_ruff_is_used(): - config = dedent(""" - repos: - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 - hooks: - - id: pyupgrade - args: [--py310-plus] - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"pyupgrade"), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - main(precommit, no_ruff=False) + def removes_pyupgrade_when_ruff_is_used(): + config = dedent(""" + repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + args: [--py310-plus] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"pyupgrade"), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, no_ruff=False) - assert "pyupgrade" not in precommit.dumps() + assert "pyupgrade" not in precommit.dumps() -def test_remove_pyupgrade_also_removes_nbqa_hook(): - config = dedent(""" - repos: - - repo: https://github.com/asottile/pyupgrade - rev: v3.15.0 - hooks: - - id: pyupgrade - - repo: https://github.com/nbQA-dev/nbQA - rev: 1.8.5 - hooks: - - id: nbqa-pyupgrade - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - _remove_pyupgrade(precommit) +def describe_remove_pyupgrade(): + def also_removes_nbqa_hook(): + config = dedent(""" + repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.0 + hooks: + - id: pyupgrade + - repo: https://github.com/nbQA-dev/nbQA + rev: 1.8.5 + hooks: + - id: nbqa-pyupgrade + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _remove_pyupgrade(precommit) - assert "pyupgrade" not in precommit.dumps() + assert "pyupgrade" not in precommit.dumps() diff --git a/tests/python/test_ruff.py b/tests/python/test_ruff.py index db480fb7..3a634af4 100644 --- a/tests/python/test_ruff.py +++ b/tests/python/test_ruff.py @@ -64,137 +64,132 @@ def ruff_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return tmp_path -def test_main_with_notebooks(ruff_repo: Path): - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_TO_CLEAN)) as precommit, - ): - main(precommit, has_notebooks=True, imports_on_top=True) - - pyproject = (ruff_repo / "pyproject.toml").read_text() - assert "[tool.black]" not in pyproject # black settings removed - assert "[tool.ruff.lint]" in pyproject # linting config migrated - assert 'select = ["ALL"]' in pyproject - assert '"*.ipynb"' in pyproject # per-file-ignores for notebooks - - config = precommit.dumps() - assert "flake8" not in config # flake8 hook removed - assert "https://github.com/astral-sh/ruff-pre-commit" in config - - -@pytest.mark.usefixtures("ruff_repo") -def test_main_without_notebooks(): - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_TO_CLEAN)) as precommit, - ): - main(precommit, has_notebooks=False, imports_on_top=False) - - config = precommit.dumps() - assert "https://github.com/astral-sh/ruff-pre-commit" in config - assert "ruff-format" in config - - -def test_main_migrates_legacy_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 - (tmp_path / "README.md").write_text("# Title\n") - (tmp_path / "pyproject.toml").write_text( - dedent(""" - [project] - name = "my-package" - requires-python = ">=3.10" - - [tool.ruff] - target-version = "py39" - - [tool.ruff.lint] - extend-select = ["C90"] - ignore = ["ANN101", "D203"] - - [tool.nbqa.addopts] - black = ["--line-length=85"] - flake8 = ["--ignore=E501"] - isort = ["--profile=black"] +def describe_main(): + def migrates_config_with_notebooks(ruff_repo: Path): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_TO_CLEAN)) as precommit, + ): + main(precommit, has_notebooks=True, imports_on_top=True) + + pyproject = (ruff_repo / "pyproject.toml").read_text() + assert "[tool.black]" not in pyproject # black settings removed + assert "[tool.ruff.lint]" in pyproject # linting config migrated + assert 'select = ["ALL"]' in pyproject + assert '"*.ipynb"' in pyproject # per-file-ignores for notebooks + + config = precommit.dumps() + assert "flake8" not in config # flake8 hook removed + assert "https://github.com/astral-sh/ruff-pre-commit" in config + + @pytest.mark.usefixtures("ruff_repo") + def adds_ruff_format_without_notebooks(): + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(_PRECOMMIT_TO_CLEAN)) as precommit, + ): + main(precommit, has_notebooks=False, imports_on_top=False) + + config = precommit.dumps() + assert "https://github.com/astral-sh/ruff-pre-commit" in config + assert "ruff-format" in config + + def migrates_legacy_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + requires-python = ">=3.10" + + [tool.ruff] + target-version = "py39" + + [tool.ruff.lint] + extend-select = ["C90"] + ignore = ["ANN101", "D203"] + + [tool.nbqa.addopts] + black = ["--line-length=85"] + flake8 = ["--ignore=E501"] + isort = ["--profile=black"] + """).lstrip() + ) + monkeypatch.chdir(tmp_path) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + main(precommit, has_notebooks=True, imports_on_top=False) + + pyproject = (tmp_path / "pyproject.toml").read_text() + assert "target-version" not in pyproject # dropped for requires-python + assert "extend-select" not in pyproject # folded into select + assert "ANN101" not in pyproject # deprecated rule removed + + +def describe_move_ruff_lint_config(): + def moves_settings_under_lint_table(): + config = dedent(""" + [tool.ruff] + select = ["E", "F"] + ignore = ["D203"] + + [tool.ruff.isort] + known-first-party = ["my_package"] """).lstrip() - ) - monkeypatch.chdir(tmp_path) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - ): - main(precommit, has_notebooks=True, imports_on_top=False) - - pyproject = (tmp_path / "pyproject.toml").read_text() - assert "target-version" not in pyproject # dropped in favor of requires-python - assert "extend-select" not in pyproject # folded into select - assert "ANN101" not in pyproject # deprecated rule removed - - -def test_move_ruff_lint_config(): - config = dedent(""" - [tool.ruff] - select = ["E", "F"] - ignore = ["D203"] - - [tool.ruff.isort] - known-first-party = ["my_package"] - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Moved linting configuration"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _move_ruff_lint_config(pyproject) - result = pyproject.dumps() - assert "[tool.ruff.lint]" in result - assert "[tool.ruff.lint.isort]" in result - - -def test_update_lint_dependencies_adds_ruff( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - config = dedent(""" - [project] - name = "my-package" - classifiers = ["Programming Language :: Python :: 3.10"] - """).lstrip() - (tmp_path / "pyproject.toml").write_text(config) - with ( - pytest.raises(PrecommitError), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _update_lint_dependencies(pyproject) - assert "ruff" in pyproject.dumps() - - -def test_update_lint_dependencies_legacy_python( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - config = dedent(""" - [project] - name = "my-package" - classifiers = ["Programming Language :: Python :: 3.6"] - """).lstrip() - (tmp_path / "pyproject.toml").write_text(config) - with ( - pytest.raises(PrecommitError), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, + with ( + pytest.raises(PrecommitError, match=r"Moved linting configuration"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _move_ruff_lint_config(pyproject) + result = pyproject.dumps() + assert "[tool.ruff.lint]" in result + assert "[tool.ruff.lint.isort]" in result + + +def describe_update_lint_dependencies(): + def adds_ruff(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + config = dedent(""" + [project] + name = "my-package" + classifiers = ["Programming Language :: Python :: 3.10"] + """).lstrip() + (tmp_path / "pyproject.toml").write_text(config) + with ( + pytest.raises(PrecommitError), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_lint_dependencies(pyproject) + assert "ruff" in pyproject.dumps() + + def pins_python_version_for_legacy_python( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - _update_lint_dependencies(pyproject) - result = pyproject.dumps() - assert "python_version" in result - assert "3.7.0" in result - - -def test_update_lint_dependencies_without_package_name( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text("[tool.foo]\nx = 1\n") - config = dedent(""" - [project] - classifiers = ["Programming Language :: Python :: 3.10"] - """).lstrip() - with ModifiablePyproject.load(io.StringIO(config)) as pyproject: - _update_lint_dependencies(pyproject) # no package name -> no-op + monkeypatch.chdir(tmp_path) + config = dedent(""" + [project] + name = "my-package" + classifiers = ["Programming Language :: Python :: 3.6"] + """).lstrip() + (tmp_path / "pyproject.toml").write_text(config) + with ( + pytest.raises(PrecommitError), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_lint_dependencies(pyproject) + result = pyproject.dumps() + assert "python_version" in result + assert "3.7.0" in result + + def is_noop_without_package_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[tool.foo]\nx = 1\n") + config = dedent(""" + [project] + classifiers = ["Programming Language :: Python :: 3.10"] + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_lint_dependencies(pyproject) # no package name -> no-op diff --git a/tests/readthedocs/test_readthedocs.py b/tests/readthedocs/test_readthedocs.py index f1965cfc..a465b570 100644 --- a/tests/readthedocs/test_readthedocs.py +++ b/tests/readthedocs/test_readthedocs.py @@ -84,65 +84,66 @@ def _expected_message(python_version: str) -> str: """).strip() -def test_update_readthedocs_extend(): - bad_config = dedent(""" - version: 2 - build: - os: ubuntu-20.04 - tools: - python: "3.10" - jobs: - post_install: - - pip install -e .[doc] - - | - wget https://julialang-s3.julialang.org/bin/linux/x64/1.9/julia-1.9.2-linux-x86_64.tar.gz - - tar xzf julia-1.9.2-linux-x86_64.tar.gz - - mkdir bin - - ln -s $PWD/julia-1.9.2/bin/julia bin/julia - - ./bin/julia docs/InstallIJulia.jl - sphinx: - configuration: docs/conf.py - """).lstrip() - input_stream = io.StringIO(bad_config) - with pytest.raises(PrecommitError) as exception: +def describe_main(): + def updates_extend_style_config(): + bad_config = dedent(""" + version: 2 + build: + os: ubuntu-20.04 + tools: + python: "3.10" + jobs: + post_install: + - pip install -e .[doc] + - | + wget https://julialang-s3.julialang.org/bin/linux/x64/1.9/julia-1.9.2-linux-x86_64.tar.gz + - tar xzf julia-1.9.2-linux-x86_64.tar.gz + - mkdir bin + - ln -s $PWD/julia-1.9.2/bin/julia bin/julia + - ./bin/julia docs/InstallIJulia.jl + sphinx: + configuration: docs/conf.py + """).lstrip() + input_stream = io.StringIO(bad_config) + with pytest.raises(PrecommitError) as exception: + readthedocs.main( + "conda", + python_version=DEFAULT_DEV_PYTHON_VERSION, + source=input_stream, + ) + assert str(exception.value).strip() == _expected_message( + DEFAULT_DEV_PYTHON_VERSION + ) + + input_stream.seek(0) + assert input_stream.read().strip() == _good_extend().strip() + + @pytest.mark.parametrize( + "good_config", + [_good_extend(), _good_overwrite(DEFAULT_DEV_PYTHON_VERSION)], + ids=["extend", "overwrite"], + ) + def leaves_good_config_unchanged(good_config: str): + input_stream = io.StringIO(good_config) readthedocs.main( "conda", python_version=DEFAULT_DEV_PYTHON_VERSION, source=input_stream, ) - assert str(exception.value).strip() == _expected_message(DEFAULT_DEV_PYTHON_VERSION) - - input_stream.seek(0) - assert input_stream.read().strip() == _good_extend().strip() - - -@pytest.mark.parametrize( - "good_config", - [_good_extend(), _good_overwrite(DEFAULT_DEV_PYTHON_VERSION)], - ids=["extend", "overwrite"], -) -def test_update_readthedocs_good(good_config: str): - input_stream = io.StringIO(good_config) - readthedocs.main( - "conda", - python_version=DEFAULT_DEV_PYTHON_VERSION, - source=input_stream, + input_stream.seek(0) + assert input_stream.read().strip() == good_config.strip() + + @pytest.mark.parametrize( + "bad_config", + [BAD_OVERWRITE_WITH_JOBS, BAD_OVERWRITE_WITHOUT_JOBS], + ids=["with-jobs", "without-jobs"], ) - input_stream.seek(0) - assert input_stream.read().strip() == good_config.strip() - - -@pytest.mark.parametrize( - "bad_config", - [BAD_OVERWRITE_WITH_JOBS, BAD_OVERWRITE_WITHOUT_JOBS], - ids=["with-jobs", "without-jobs"], -) -@pytest.mark.parametrize("python_version", ["3.9", "3.10"]) -def test_update_readthedocs_overwrite(python_version: PythonVersion, bad_config: str): - input_stream = io.StringIO(bad_config) - with pytest.raises(PrecommitError) as exception: - readthedocs.main("conda", python_version, source=input_stream) - assert str(exception.value).strip() == _expected_message(python_version) - - input_stream.seek(0) - assert input_stream.read().strip() == _good_overwrite(python_version).strip() + @pytest.mark.parametrize("python_version", ["3.9", "3.10"]) + def overwrites_bad_config(python_version: PythonVersion, bad_config: str): + input_stream = io.StringIO(bad_config) + with pytest.raises(PrecommitError) as exception: + readthedocs.main("conda", python_version, source=input_stream) + assert str(exception.value).strip() == _expected_message(python_version) + + input_stream.seek(0) + assert input_stream.read().strip() == _good_overwrite(python_version).strip() diff --git a/tests/repo/test_citation.py b/tests/repo/test_citation.py index d2e37f15..99bba9a8 100644 --- a/tests/repo/test_citation.py +++ b/tests/repo/test_citation.py @@ -35,197 +35,188 @@ """ -def test_convert_zenodo_dict(): - result = citation._convert_zenodo(_ZENODO) - assert result["title"] == "My Software" - assert "HTML" in result["abstract"] - assert result["authors"][0] == { - "family-names": "Doe", - "given-names": "John", - "affiliation": "University", - "orcid": "https://orcid.org/0000-0001", - } - assert result["authors"][1] == {"family-names": "Smith", "given-names": "Jane"} - assert result["keywords"] == ["physics"] - assert result["license"] == "MIT" - - -def test_convert_zenodo_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / ".zenodo.json").write_text(json.dumps(_ZENODO)) - with pytest.raises(PrecommitError, match=r"Converted"): - citation.convert_zenodo_json() - assert not (tmp_path / ".zenodo.json").exists() - assert (tmp_path / "CITATION.cff").exists() - - -def test_remove_zenodo_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / ".zenodo.json").write_text("{}") - with pytest.raises(PrecommitError, match=r"Removed"): - citation.remove_zenodo_json() - assert not (tmp_path / ".zenodo.json").exists() - - -def test_check_citation_keys_missing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text("cff-version: 1.2.0\n") - with pytest.raises(PrecommitError, match=r"missing the following keys"): - citation.check_citation_keys() - - -def test_check_citation_keys_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text("") - with pytest.raises(PrecommitError, match=r"is empty"): - citation.check_citation_keys() - - -def test_check_citation_keys_complete(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) - citation.check_citation_keys() # all expected keys present -> no error - - -def test_add_json_schema_precommit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) - with ( - pytest.raises(PrecommitError, match=r"Updated pre-commit hook"), - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, +def describe_convert_zenodo(): + def converts_full_metadata(): + result = citation._convert_zenodo(_ZENODO) + assert result["title"] == "My Software" + assert "HTML" in result["abstract"] + assert result["authors"][0] == { + "family-names": "Doe", + "given-names": "John", + "affiliation": "University", + "orcid": "https://orcid.org/0000-0001", + } + assert result["authors"][1] == {"family-names": "Smith", "given-names": "Jane"} + assert result["keywords"] == ["physics"] + assert result["license"] == "MIT" + + def omits_absent_fields(): + result = citation._convert_zenodo({"title": "Bare"}) + assert result["title"] == "Bare" + assert "abstract" not in result + assert "authors" not in result # no creators + assert "keywords" not in result + assert "license" not in result + + +def describe_convert_zenodo_json(): + def writes_citation_cff(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".zenodo.json").write_text(json.dumps(_ZENODO)) + with pytest.raises(PrecommitError, match=r"Converted"): + citation.convert_zenodo_json() + assert not (tmp_path / ".zenodo.json").exists() + assert (tmp_path / "CITATION.cff").exists() + + +def describe_remove_zenodo_json(): + def removes_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".zenodo.json").write_text("{}") + with pytest.raises(PrecommitError, match=r"Removed"): + citation.remove_zenodo_json() + assert not (tmp_path / ".zenodo.json").exists() + + +def describe_check_citation_keys(): + def reports_missing_keys(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text("cff-version: 1.2.0\n") + with pytest.raises(PrecommitError, match=r"missing the following keys"): + citation.check_citation_keys() + + def reports_empty_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text("") + with pytest.raises(PrecommitError, match=r"is empty"): + citation.check_citation_keys() + + def accepts_complete_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) + citation.check_citation_keys() # all expected keys present -> no error + + +def describe_add_json_schema_precommit(): + def adds_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) + with ( + pytest.raises(PrecommitError, match=r"Updated pre-commit hook"), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + citation.add_json_schema_precommit(precommit) + assert "check-jsonschema" in precommit.dumps() + + def is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # cspell:ignore schemafile + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) + existing = dedent(""" + repos: + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.28.0 + hooks: + - id: check-jsonschema + name: Check CITATION.cff + args: + - --default-filetype + - yaml + - --schemafile + - https://citation-file-format.github.io/1.2.0/schema.json + - CITATION.cff + pass_filenames: false + """).lstrip() + with ModifiablePrecommit.load(io.StringIO(existing)) as precommit: + citation.add_json_schema_precommit( + precommit + ) # already present -> no change + + def replaces_outdated_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) + existing = dedent(""" + repos: + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.28.0 + hooks: + - id: check-jsonschema + name: Check CITATION.cff + args: + - --schemafile + - https://example.test/outdated-schema.json + - CITATION.cff + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated pre-commit hook"), + ModifiablePrecommit.load(io.StringIO(existing)) as precommit, + ): + citation.add_json_schema_precommit(precommit) + result = precommit.dumps() + assert "outdated-schema" not in result # stale args replaced + assert "citation-file-format.github.io/1.2.0/schema.json" in result + + def is_noop_without_citation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: + citation.add_json_schema_precommit(precommit) # no CITATION.cff -> no-op + + def appends_to_existing_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) + existing = dedent(""" + repos: + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.28.0 + hooks: + - id: check-jsonschema + name: Check GitHub Workflows + files: ^\\.github/workflows/ + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated pre-commit hook"), + ModifiablePrecommit.load(io.StringIO(existing)) as precommit, + ): + citation.add_json_schema_precommit(precommit) + result = precommit.dumps() + assert "Check GitHub Workflows" in result # original hook kept + assert "Check CITATION.cff" in result # new hook appended + + +def describe_main(): + def processes_citation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + citation.main(precommit) + assert "check-jsonschema" in precommit.dumps() + + def converts_zenodo_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Regression test for https://github.com/ComPWA/policy/issues/616.""" + monkeypatch.chdir(tmp_path) + (tmp_path / ".zenodo.json").write_text(json.dumps(_ZENODO)) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + citation.main(precommit) + assert not (tmp_path / ".zenodo.json").exists() + assert (tmp_path / "CITATION.cff").exists() + assert "check-jsonschema" in precommit.dumps() + + def removes_zenodo_when_citation_present( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): - citation.add_json_schema_precommit(precommit) - assert "check-jsonschema" in precommit.dumps() - - -def test_add_json_schema_precommit_is_idempotent( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - # cspell:ignore schemafile - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) - existing = dedent(""" - repos: - - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.0 - hooks: - - id: check-jsonschema - name: Check CITATION.cff - args: - - --default-filetype - - yaml - - --schemafile - - https://citation-file-format.github.io/1.2.0/schema.json - - CITATION.cff - pass_filenames: false - """).lstrip() - with ModifiablePrecommit.load(io.StringIO(existing)) as precommit: - citation.add_json_schema_precommit(precommit) # already present -> no change - - -def test_add_json_schema_precommit_replaces_outdated_hook( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) - existing = dedent(""" - repos: - - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.0 - hooks: - - id: check-jsonschema - name: Check CITATION.cff - args: - - --schemafile - - https://example.test/outdated-schema.json - - CITATION.cff - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated pre-commit hook"), - ModifiablePrecommit.load(io.StringIO(existing)) as precommit, - ): - citation.add_json_schema_precommit(precommit) - result = precommit.dumps() - assert "outdated-schema" not in result # stale args replaced - assert "citation-file-format.github.io/1.2.0/schema.json" in result - - -def test_add_json_schema_precommit_without_citation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: - citation.add_json_schema_precommit(precommit) # no CITATION.cff -> no-op - - -def test_main_with_citation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - ): - citation.main(precommit) - assert "check-jsonschema" in precommit.dumps() - - -def test_main_with_only_zenodo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - """Regression test for https://github.com/ComPWA/policy/issues/616.""" - monkeypatch.chdir(tmp_path) - (tmp_path / ".zenodo.json").write_text(json.dumps(_ZENODO)) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - ): - citation.main(precommit) - assert not (tmp_path / ".zenodo.json").exists() - assert (tmp_path / "CITATION.cff").exists() - assert "check-jsonschema" in precommit.dumps() - - -def test_main_with_both_zenodo_and_citation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / ".zenodo.json").write_text(json.dumps(_ZENODO)) - (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - ): - citation.main(precommit) - assert not (tmp_path / ".zenodo.json").exists() - assert (tmp_path / "CITATION.cff").exists() - - -def test_convert_zenodo_minimal(): - result = citation._convert_zenodo({"title": "Bare"}) - assert result["title"] == "Bare" - assert "abstract" not in result - assert "authors" not in result # no creators - assert "keywords" not in result - assert "license" not in result - - -def test_add_json_schema_precommit_appends_to_existing_repo( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) - existing = dedent(""" - repos: - - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.28.0 - hooks: - - id: check-jsonschema - name: Check GitHub Workflows - files: ^\\.github/workflows/ - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated pre-commit hook"), - ModifiablePrecommit.load(io.StringIO(existing)) as precommit, - ): - citation.add_json_schema_precommit(precommit) - result = precommit.dumps() - assert "Check GitHub Workflows" in result # original hook kept - assert "Check CITATION.cff" in result # new hook appended + monkeypatch.chdir(tmp_path) + (tmp_path / ".zenodo.json").write_text(json.dumps(_ZENODO)) + (tmp_path / "CITATION.cff").write_text(_VALID_CITATION) + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): + citation.main(precommit) + assert not (tmp_path / ".zenodo.json").exists() + assert (tmp_path / "CITATION.cff").exists() diff --git a/tests/repo/test_commitlint.py b/tests/repo/test_commitlint.py index cd0e7b37..4764c7f5 100644 --- a/tests/repo/test_commitlint.py +++ b/tests/repo/test_commitlint.py @@ -6,15 +6,17 @@ from compwa_policy.repo.commitlint import main -def test_main_without_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - main() # no error and nothing to remove +def describe_main(): + def is_noop_without_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + main() # no error and nothing to remove - -def test_main_removes_outdated_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - config = tmp_path / "commitlint.config.js" - config.touch() - with pytest.raises(PrecommitError, match=r"Remove outdated commitlint\.config\.js"): - main() - assert not config.exists() + def removes_outdated_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + config = tmp_path / "commitlint.config.js" + config.touch() + with pytest.raises( + PrecommitError, match=r"Remove outdated commitlint\.config\.js" + ): + main() + assert not config.exists() diff --git a/tests/repo/test_deprecated.py b/tests/repo/test_deprecated.py index 4b45232e..076f58e8 100644 --- a/tests/repo/test_deprecated.py +++ b/tests/repo/test_deprecated.py @@ -12,52 +12,44 @@ from compwa_policy.utilities.precommit import ModifiablePrecommit -def test_remove_relink_references_without_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - _remove_relink_references("docs") # nothing to remove - - -def test_remove_relink_references_raises( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - docs = tmp_path / "docs" - docs.mkdir() - (docs / "_relink_references.py").touch() - with pytest.raises(PrecommitError, match=r"sphinx-api-relink"): - _remove_relink_references("docs") - - -def test_remove_deprecated_tools_removes_markdownlint( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - config = dedent(""" - repos: - - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.41.0 - hooks: - - id: markdownlint - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"markdownlint"), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - remove_deprecated_tools(precommit, keep_issue_templates=True) - - -def test_remove_deprecated_tools_removes_issue_templates( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - template_dir = tmp_path / ".github" / "ISSUE_TEMPLATE" - template_dir.mkdir(parents=True) - (template_dir / "bug_report.md").touch() - with ( - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - pytest.raises(PrecommitError, match=r"Removed \.github/ISSUE_TEMPLATE"), - ): - remove_deprecated_tools(precommit, keep_issue_templates=False) - assert not template_dir.exists() +def describe_remove_relink_references(): + def is_noop_without_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + _remove_relink_references("docs") # nothing to remove + + def raises_when_present(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + docs = tmp_path / "docs" + docs.mkdir() + (docs / "_relink_references.py").touch() + with pytest.raises(PrecommitError, match=r"sphinx-api-relink"): + _remove_relink_references("docs") + + +def describe_remove_deprecated_tools(): + def removes_markdownlint(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + config = dedent(""" + repos: + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.41.0 + hooks: + - id: markdownlint + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"markdownlint"), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + remove_deprecated_tools(precommit, keep_issue_templates=True) + + def removes_issue_templates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + template_dir = tmp_path / ".github" / "ISSUE_TEMPLATE" + template_dir.mkdir(parents=True) + (template_dir / "bug_report.md").touch() + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError, match=r"Removed \.github/ISSUE_TEMPLATE"), + ): + remove_deprecated_tools(precommit, keep_issue_templates=False) + assert not template_dir.exists() diff --git a/tests/repo/test_poe.py b/tests/repo/test_poe.py index df1f6258..feac5e97 100644 --- a/tests/repo/test_poe.py +++ b/tests/repo/test_poe.py @@ -68,87 +68,88 @@ def poe_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: return tmp_path -def test_main_configures_groups_and_tasks(poe_repo: Path): - with pytest.raises(PrecommitError): - main(has_notebooks=True, package_manager="uv") - - pyproject = (poe_repo / "pyproject.toml").read_text() - assert "[tool.poe.executor]" in pyproject # uv executor configured - assert "[tool.poe.groups.doc.tasks.doc]" in pyproject # doc task migrated to group - assert "[tool.poe.groups.test.tasks.test]" in pyproject - assert "test-py310" in pyproject # multi-version test-all tasks generated - assert "test-py311" in pyproject - assert "[tool.poe.tasks.upgrade]" in pyproject # upgrade task added - - -def test_main_with_pixi_package_manager(poe_repo: Path): - with pytest.raises(PrecommitError): - main(has_notebooks=True, package_manager="pixi") - - pyproject = (poe_repo / "pyproject.toml").read_text() - assert "pixi upgrade" in pyproject # pixi-specific upgrade command - - -def test_update_doclive_adds_executor(): - # cspell:ignore autobuild - config = dedent(""" - [dependency-groups] - doc = ["sphinx-autobuild"] - - [tool.poe.groups.doc.tasks.doclive] - cmd = "sphinx-autobuild docs docs/_build/html" - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated Poe the Poet doclive task"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _update_doclive(pyproject) - result = pyproject.dumps() - assert "sphinx-autobuild" in result - assert "executor" in result - - -def test_main_without_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - main(has_notebooks=False, package_manager="uv") # no pyproject.toml -> no-op - - -def test_check_expected_sections_reports_missing( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 - (tmp_path / "docs").mkdir() - (tmp_path / "docs" / "conf.py").touch() - (tmp_path / "pyproject.toml").write_text("[tool.poe.tasks]\n") - subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 - monkeypatch.chdir(tmp_path) - pyproject = Pyproject.load() - with pytest.raises(PrecommitError, match=r"missing task definitions: doc, doclive"): - _check_expected_sections(pyproject, has_notebooks=False) - - -def test_check_no_uv_run_rejects_uv_run(): - config = dedent(""" - [tool.poe.tasks.test] - cmd = "uv run pytest" - """).lstrip() - pyproject = Pyproject.load(io.StringIO(config)) - with pytest.raises(PrecommitError, match=r"should not use 'uv run'"): - _check_no_uv_run(pyproject) - - -def test_set_upgrade_task_removes_task_when_empty( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 - monkeypatch.chdir(tmp_path) - config = dedent(""" - [tool.poe.tasks.upgrade] - cmd = "outdated" - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Removed Poe the Poet upgrade task"), - ModifiablePyproject.load(io.StringIO(config)) as pyproject, - ): - _set_upgrade_task(pyproject, package_manager="conda") - assert "upgrade" not in pyproject.dumps() +def describe_main(): + def configures_groups_and_tasks(poe_repo: Path): + with pytest.raises(PrecommitError): + main(has_notebooks=True, package_manager="uv") + + pyproject = (poe_repo / "pyproject.toml").read_text() + assert "[tool.poe.executor]" in pyproject # uv executor configured + assert "[tool.poe.groups.doc.tasks.doc]" in pyproject # doc migrated to group + assert "[tool.poe.groups.test.tasks.test]" in pyproject + assert "test-py310" in pyproject # multi-version test-all tasks generated + assert "test-py311" in pyproject + assert "[tool.poe.tasks.upgrade]" in pyproject # upgrade task added + + def uses_pixi_upgrade_command(poe_repo: Path): + with pytest.raises(PrecommitError): + main(has_notebooks=True, package_manager="pixi") + + pyproject = (poe_repo / "pyproject.toml").read_text() + assert "pixi upgrade" in pyproject # pixi-specific upgrade command + + def is_noop_without_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + main(has_notebooks=False, package_manager="uv") # no pyproject.toml -> no-op + + +def describe_update_doclive(): + def adds_executor(): + # cspell:ignore autobuild + config = dedent(""" + [dependency-groups] + doc = ["sphinx-autobuild"] + + [tool.poe.groups.doc.tasks.doclive] + cmd = "sphinx-autobuild docs docs/_build/html" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated Poe the Poet doclive task"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_doclive(pyproject) + result = pyproject.dumps() + assert "sphinx-autobuild" in result + assert "executor" in result + + +def describe_check_expected_sections(): + def reports_missing_tasks(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "conf.py").touch() + (tmp_path / "pyproject.toml").write_text("[tool.poe.tasks]\n") + subprocess.run(["git", "add", "-A"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + pyproject = Pyproject.load() + with pytest.raises( + PrecommitError, match=r"missing task definitions: doc, doclive" + ): + _check_expected_sections(pyproject, has_notebooks=False) + + +def describe_check_no_uv_run(): + def rejects_uv_run(): + config = dedent(""" + [tool.poe.tasks.test] + cmd = "uv run pytest" + """).lstrip() + pyproject = Pyproject.load(io.StringIO(config)) + with pytest.raises(PrecommitError, match=r"should not use 'uv run'"): + _check_no_uv_run(pyproject) + + +def describe_set_upgrade_task(): + def removes_task_when_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + config = dedent(""" + [tool.poe.tasks.upgrade] + cmd = "outdated" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Removed Poe the Poet upgrade task"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _set_upgrade_task(pyproject, package_manager="conda") + assert "upgrade" not in pyproject.dumps() diff --git a/tests/test_cli.py b/tests/test_cli.py index 913de602..61a9acc7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,47 +23,66 @@ def _isolate_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) -def test_build_arguments_defaults() -> None: - args = build_arguments() - assert args.dev_python_version == "3.13" - assert args.package_manager == "uv" - assert args.repo_organization == "ComPWA" - assert args.type_checker == set() - assert args.excluded_python_versions == set() - assert args.keep_workflow == set() - assert args.python is None - - -def test_build_arguments_post_processing() -> None: - # cspell:ignore myproj - args = build_arguments( - type_checker=[TypeChecker.mypy, TypeChecker.ty], - excluded_python_versions="3.6, 3.7", - macos_python_version="disable", - repo_name="myproj", - ) - assert args.type_checker == {"mypy", "ty"} - assert args.excluded_python_versions == {"3.6", "3.7"} - assert args.macos_python_version is None - assert args.repo_name == "myproj" - assert args.repo_title == "myproj" # falls back to repo_name - - def _write_policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, table: str) -> None: (tmp_path / "pyproject.toml").write_text(dedent(table)) monkeypatch.chdir(tmp_path) -class TestPyprojectConfig: - def test_no_table_is_empty( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def _write_precommit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config: str +) -> Path: + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + config_file = tmp_path / ".pre-commit-config.yaml" + config_file.write_text(dedent(config).lstrip()) + return config_file + + +_CONFIG_WITH_NOTEBOOK_HOOK = """\ +repos: + - repo: https://github.com/ComPWA/policy + rev: 0.1.0 + hooks: + - id: check-dev-files + - id: set-nb-cells + args: [--add-install-cell] +""" + + +def describe_build_arguments(): + def applies_defaults() -> None: + args = build_arguments() + assert args.dev_python_version == "3.13" + assert args.package_manager == "uv" + assert args.repo_organization == "ComPWA" + assert args.type_checker == set() + assert args.excluded_python_versions == set() + assert args.keep_workflow == set() + assert args.python is None + + def post_processes_options() -> None: + # cspell:ignore myproj + args = build_arguments( + type_checker=[TypeChecker.mypy, TypeChecker.ty], + excluded_python_versions="3.6, 3.7", + macos_python_version="disable", + repo_name="myproj", + ) + assert args.type_checker == {"mypy", "ty"} + assert args.excluded_python_versions == {"3.6", "3.7"} + assert args.macos_python_version is None + assert args.repo_name == "myproj" + assert args.repo_title == "myproj" # falls back to repo_name + + +def describe_pyproject_config(): + def treats_absent_table_as_empty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _write_policy(tmp_path, monkeypatch, '[project]\nname = "x"\n') assert _read_policy_config() == {} - def test_flatten_nested_tables( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def flattens_nested_tables(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_policy( tmp_path, monkeypatch, @@ -100,9 +119,7 @@ def test_flatten_nested_tables( }, } - def test_pyproject_overrides_defaults( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def overrides_defaults(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_policy( tmp_path, monkeypatch, @@ -122,9 +139,7 @@ def test_pyproject_overrides_defaults( assert args.type_checker == {"mypy", "pyright"} assert args.environment_variables == "PYTHONHASHSEED=0" - def test_cli_overrides_pyproject( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def yields_to_cli_options(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_policy( tmp_path, monkeypatch, @@ -136,9 +151,7 @@ def test_cli_overrides_pyproject( assert load_settings(dev_python_version="3.11").dev_python_version == "3.11" assert load_settings(dev_python_version=None).dev_python_version == "3.12" - def test_unknown_option_is_rejected( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def rejects_unknown_option(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: _write_policy( tmp_path, monkeypatch, @@ -150,8 +163,8 @@ def test_unknown_option_is_rejected( with pytest.raises(ValueError, match="does_not_exist"): load_settings() - def test_environment_variables_do_not_leak( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + def ignores_environment_variables( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: _write_policy(tmp_path, monkeypatch, '[project]\nname = "x"\n') monkeypatch.setenv("NO_PYPI", "true") @@ -161,8 +174,8 @@ def test_environment_variables_do_not_leak( assert settings.package_manager == "uv" -class TestBuildPolicy: - def test_groups_into_sub_tables(self) -> None: +def describe_build_policy(): + def groups_into_sub_tables() -> None: policy = _build_policy([ "--allow-labels", "--keep-local-precommit", @@ -180,11 +193,11 @@ def test_groups_into_sub_tables(self) -> None: "repo-title": "ComPWA repository policy", } - def test_repeated_list_option(self) -> None: + def collects_repeated_list_option() -> None: policy = _build_policy(["--type-checker=mypy", "--type-checker=pyright"]) assert policy == {"python": {"type-checker": ["mypy", "pyright"]}} - def test_environment_variables_become_setup_env(self) -> None: + def maps_environment_variables_to_setup_env() -> None: policy = _build_policy([ "--environment-variables=PYTHONHASHSEED=0,MPLBACKEND=agg" ]) @@ -192,12 +205,12 @@ def test_environment_variables_become_setup_env(self) -> None: "setup": {"env": {"PYTHONHASHSEED": "0", "MPLBACKEND": "agg"}} } - def test_no_python_flag(self) -> None: + def handles_no_python_flag() -> None: assert _build_policy(["--no-python"]) == {"python": False} assert _build_policy(["--python"]) == {"python": True} - def test_round_trips_through_settings( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + def round_trips_through_settings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: args = [ "--allow-labels", @@ -214,59 +227,43 @@ def test_round_trips_through_settings( assert settings.type_checker == ["ty"] -_CONFIG_WITH_NOTEBOOK_HOOK = """\ -repos: - - repo: https://github.com/ComPWA/policy - rev: 0.1.0 - hooks: - - id: check-dev-files - - id: set-nb-cells - args: [--add-install-cell] -""" - - -class TestMigrateNotebookHooks: - def test_relocates_notebook_hooks_to_nbhooks(self, tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - config = tmp_path / ".pre-commit-config.yaml" - config.write_text(_CONFIG_WITH_NOTEBOOK_HOOK) - - migrate(config_file=config, dry_run=False) - - repos = { - repo["repo"]: repo for repo in yaml.safe_load(config.read_text())["repos"] - } - policy_hooks = { - h["id"] for h in repos["https://github.com/ComPWA/policy"]["hooks"] - } - assert policy_hooks == {"check-dev-files"} - nbhooks = repos["https://github.com/ComPWA/nbhooks"] - assert nbhooks["rev"] == "PLEASE-UPDATE" - assert {h["id"] for h in nbhooks["hooks"]} == {"set-nb-cells"} - set_nb_cells = next(h for h in nbhooks["hooks"] if h["id"] == "set-nb-cells") - assert set_nb_cells["args"] == ["--add-install-cell"], "args must be preserved" - - def test_dry_run_does_not_modify(self, tmp_path: Path) -> None: - (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - config = tmp_path / ".pre-commit-config.yaml" - config.write_text(_CONFIG_WITH_NOTEBOOK_HOOK) - with pytest.raises(typer.Exit): - migrate(config_file=config, dry_run=True) - assert config.read_text() == _CONFIG_WITH_NOTEBOOK_HOOK - - -class TestMigrate: - def _write( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config: str - ) -> Path: - monkeypatch.chdir(tmp_path) - (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - config_file = tmp_path / ".pre-commit-config.yaml" - config_file.write_text(dedent(config).lstrip()) - return config_file - - def test_missing_config_file_exits( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def describe_migrate(): + def describe_notebook_hooks(): + def relocates_to_nbhooks(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + config = tmp_path / ".pre-commit-config.yaml" + config.write_text(_CONFIG_WITH_NOTEBOOK_HOOK) + + migrate(config_file=config, dry_run=False) + + repos = { + repo["repo"]: repo + for repo in yaml.safe_load(config.read_text())["repos"] + } + policy_hooks = { + h["id"] for h in repos["https://github.com/ComPWA/policy"]["hooks"] + } + assert policy_hooks == {"check-dev-files"} + nbhooks = repos["https://github.com/ComPWA/nbhooks"] + assert nbhooks["rev"] == "PLEASE-UPDATE" + assert {h["id"] for h in nbhooks["hooks"]} == {"set-nb-cells"} + set_nb_cells = next( + h for h in nbhooks["hooks"] if h["id"] == "set-nb-cells" + ) + assert set_nb_cells["args"] == ["--add-install-cell"], ( + "args must be preserved" + ) + + def dry_run_does_not_modify(tmp_path: Path) -> None: + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + config = tmp_path / ".pre-commit-config.yaml" + config.write_text(_CONFIG_WITH_NOTEBOOK_HOOK) + with pytest.raises(typer.Exit): + migrate(config_file=config, dry_run=True) + assert config.read_text() == _CONFIG_WITH_NOTEBOOK_HOOK + + def exits_on_missing_config_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') @@ -274,8 +271,8 @@ def test_missing_config_file_exits( migrate(config_file=tmp_path / "does-not-exist.yaml") assert exc.value.exit_code == 1 - def test_missing_pyproject_exits( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + def exits_on_missing_pyproject( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) config = tmp_path / ".pre-commit-config.yaml" @@ -284,10 +281,10 @@ def test_missing_pyproject_exits( migrate(config_file=config) assert exc.value.exit_code == 1 - def test_no_hook_found_exits( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + def exits_when_no_hook_found( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - config = self._write( + config = _write_precommit( tmp_path, monkeypatch, """ @@ -301,10 +298,10 @@ def test_no_hook_found_exits( migrate(config_file=config) assert exc.value.exit_code == 0 - def test_nothing_to_migrate_exits( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + def exits_when_nothing_to_migrate( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - config = self._write( + config = _write_precommit( tmp_path, monkeypatch, """ @@ -318,10 +315,8 @@ def test_nothing_to_migrate_exits( migrate(config_file=config) assert exc.value.exit_code == 0 - def test_unknown_args_exit( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - config = self._write( + def exits_on_unknown_args(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + config = _write_precommit( tmp_path, monkeypatch, """ @@ -337,10 +332,10 @@ def test_unknown_args_exit( migrate(config_file=config) assert exc.value.exit_code == 1 - def test_migrates_args_into_pyproject( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + def migrates_args_into_pyproject( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - config = self._write( + config = _write_precommit( tmp_path, monkeypatch, """ @@ -360,10 +355,10 @@ def test_migrates_args_into_pyproject( hooks = yaml.safe_load(config.read_text())["repos"][0]["hooks"] assert "args" not in hooks[0], "args must be stripped after migration" - def test_migrates_environment_variables_into_nested_table( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + def migrates_environment_variables_into_nested_table( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - config = self._write( + config = _write_precommit( tmp_path, monkeypatch, """ diff --git a/tests/test_cspell.py b/tests/test_cspell.py index df3068a9..93bb3df7 100644 --- a/tests/test_cspell.py +++ b/tests/test_cspell.py @@ -23,127 +23,122 @@ def _git_init(directory: Path) -> None: subprocess.run(["git", "init", "-q"], cwd=directory, check=True) # noqa: S607 -def test_update_cspell_repo_url(): - bad_config = dedent(""" - repos: - - repo: https://github.com/ComPWA/mirrors-cspell - rev: v5.10.1 - hooks: - - id: cspell - """).lstrip() - with ( - pytest.raises(PrecommitError, match=r"Updated cSpell pre-commit repo URL"), - ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, - ): - _update_cspell_repo_url(precommit) - - repo_url = precommit.document["repos"][0]["repo"] - assert repo_url == "https://github.com/streetsidesoftware/cspell-cli" - - -def test_remove_configuration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / ".cspell.json").write_text("{}") - with pytest.raises(PrecommitError, match=r"no longer required"): - _remove_configuration() - assert not (tmp_path / ".cspell.json").exists() - - -def test_remove_configuration_cleans_editorconfig( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / ".editorconfig").write_text(".cspell.json\nother-entry\n") - with pytest.raises(PrecommitError, match=r"no longer"): - _remove_configuration() - assert ".cspell.json" not in (tmp_path / ".editorconfig").read_text() - - -def test_update_precommit_repo(): - config = dedent(""" - repos: - - repo: meta - hooks: - - id: check-hooks-apply - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - _update_precommit_repo(precommit) - result = precommit.dumps() - assert "https://github.com/streetsidesoftware/cspell-cli" in result - assert "id: cspell" in result - - -def test_update_config_content_fixes_value( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - template = json.loads( - (COMPWA_POLICY_DIR / ".template" / CONFIG_PATH.cspell).read_text() - ) - template["language"] = "xx-XX" - (tmp_path / ".cspell.json").write_text(json.dumps(template)) - with pytest.raises(PrecommitError, match=r"has been updated"): - _update_config_content() - config = json.loads((tmp_path / ".cspell.json").read_text()) - assert config["language"] == "en-US" - - -def test_update_config_content_from_empty( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - (tmp_path / ".cspell.json").write_text("{}") - with pytest.raises(PrecommitError, match=r"has been updated"): - _update_config_content() - config = json.loads((tmp_path / ".cspell.json").read_text()) - assert config["language"] == "en-US" - - -def test_sort_config_entries(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - monkeypatch.chdir(tmp_path) - (tmp_path / ".cspell.json").write_text( - json.dumps({"words": ["zebra", "apple", "mango"]}) - ) - with pytest.raises(PrecommitError, match=r"sorted alphabetically"): - _sort_config_entries() - config = json.loads((tmp_path / ".cspell.json").read_text()) - assert config["words"] == ["apple", "mango", "zebra"] - - -def test_main_updates_existing_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - _git_init(tmp_path) - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - (tmp_path / ".cspell.json").write_text('{"words": ["zebra", "apple"]}') - config = dedent(""" - repos: - - repo: https://github.com/streetsidesoftware/cspell-cli - rev: v8.0.0 - hooks: - - id: cspell - """).lstrip() - with ( - pytest.raises(PrecommitError), - ModifiablePrecommit.load(io.StringIO(config)) as precommit, - ): - main(precommit, no_cspell_update=True) - result = json.loads((tmp_path / ".cspell.json").read_text()) - assert result["words"] == ["apple", "zebra"] # sorted - - -def test_main_removes_config_without_hook( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - (tmp_path / ".cspell.json").write_text("{}") - with ( - ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, - pytest.raises(PrecommitError, match=r"no longer required"), - ): - main(precommit, no_cspell_update=False) - assert not (tmp_path / ".cspell.json").exists() +def describe_update_cspell_repo_url(): + def migrates_mirror_url(): + bad_config = dedent(""" + repos: + - repo: https://github.com/ComPWA/mirrors-cspell + rev: v5.10.1 + hooks: + - id: cspell + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated cSpell pre-commit repo URL"), + ModifiablePrecommit.load(io.StringIO(bad_config)) as precommit, + ): + _update_cspell_repo_url(precommit) + + repo_url = precommit.document["repos"][0]["repo"] + assert repo_url == "https://github.com/streetsidesoftware/cspell-cli" + + +def describe_remove_configuration(): + def removes_config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text("{}") + with pytest.raises(PrecommitError, match=r"no longer required"): + _remove_configuration() + assert not (tmp_path / ".cspell.json").exists() + + def cleans_editorconfig(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".editorconfig").write_text(".cspell.json\nother-entry\n") + with pytest.raises(PrecommitError, match=r"no longer"): + _remove_configuration() + assert ".cspell.json" not in (tmp_path / ".editorconfig").read_text() + + +def describe_update_precommit_repo(): + def adds_cspell_hook(): + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_repo(precommit) + result = precommit.dumps() + assert "https://github.com/streetsidesoftware/cspell-cli" in result + assert "id: cspell" in result + + +def describe_update_config_content(): + def fixes_wrong_value(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + template = json.loads( + (COMPWA_POLICY_DIR / ".template" / CONFIG_PATH.cspell).read_text() + ) + template["language"] = "xx-XX" + (tmp_path / ".cspell.json").write_text(json.dumps(template)) + with pytest.raises(PrecommitError, match=r"has been updated"): + _update_config_content() + config = json.loads((tmp_path / ".cspell.json").read_text()) + assert config["language"] == "en-US" + + def populates_empty_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text("{}") + with pytest.raises(PrecommitError, match=r"has been updated"): + _update_config_content() + config = json.loads((tmp_path / ".cspell.json").read_text()) + assert config["language"] == "en-US" + + +def describe_sort_config_entries(): + def sorts_words_alphabetically(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text( + json.dumps({"words": ["zebra", "apple", "mango"]}) + ) + with pytest.raises(PrecommitError, match=r"sorted alphabetically"): + _sort_config_entries() + config = json.loads((tmp_path / ".cspell.json").read_text()) + assert config["words"] == ["apple", "mango", "zebra"] + + +def describe_main(): + def updates_existing_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_init(tmp_path) + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / ".cspell.json").write_text('{"words": ["zebra", "apple"]}') + config = dedent(""" + repos: + - repo: https://github.com/streetsidesoftware/cspell-cli + rev: v8.0.0 + hooks: + - id: cspell + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + main(precommit, no_cspell_update=True) + result = json.loads((tmp_path / ".cspell.json").read_text()) + assert result["words"] == ["apple", "zebra"] # sorted + + def removes_config_without_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / ".cspell.json").write_text("{}") + with ( + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + pytest.raises(PrecommitError, match=r"no longer required"), + ): + main(precommit, no_cspell_update=False) + assert not (tmp_path / ".cspell.json").exists() diff --git a/tests/test_gitpod.py b/tests/test_gitpod.py index a5bc3e41..e914af29 100644 --- a/tests/test_gitpod.py +++ b/tests/test_gitpod.py @@ -1,26 +1,27 @@ from compwa_policy.repo.gitpod import _extract_extensions, _generate_gitpod_config -def test_get_gitpod_content(): - gitpod_content = _generate_gitpod_config("3.8") - assert set(gitpod_content) == { - "github", - "tasks", - "vscode", - } - assert gitpod_content["github"] == { - "prebuilds": { - "addBadge": False, - "addComment": False, - "addLabel": False, - "branches": False, - "master": True, - "pullRequests": True, - "pullRequestsFromForks": True, +def describe_generate_gitpod_config(): + def builds_expected_sections(): + gitpod_content = _generate_gitpod_config("3.8") + assert set(gitpod_content) == { + "github", + "tasks", + "vscode", } - } - assert gitpod_content["tasks"] == [ - {"init": "pyenv local 3.8"}, - {"init": "pip install -e .[dev]"}, - ] - assert gitpod_content["vscode"]["extensions"] == _extract_extensions() + assert gitpod_content["github"] == { + "prebuilds": { + "addBadge": False, + "addComment": False, + "addLabel": False, + "branches": False, + "master": True, + "pullRequests": True, + "pullRequestsFromForks": True, + } + } + assert gitpod_content["tasks"] == [ + {"init": "pyenv local 3.8"}, + {"init": "pip install -e .[dev]"}, + ] + assert gitpod_content["vscode"]["extensions"] == _extract_extensions() diff --git a/tests/test_pixi.py b/tests/test_pixi.py index 37ceb4be..fe6023a6 100644 --- a/tests/test_pixi.py +++ b/tests/test_pixi.py @@ -15,44 +15,6 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities.pyproject import ModifiablePyproject - -@pytest.mark.parametrize( - "table_key", - [ - "tool.pixi.feature.dev.tasks", - "tool.pixi.tasks", - ], -) -def test_update_docnb_and_doclive(table_key: str): - content = dedent(f""" - [{table_key}.doc] - cmd = "command executed by doc" - - [{table_key}.docnb] - cmd = "some outdated command" - - [{table_key}.docnb-test] - cmd = "should not change" - """) - with ( - pytest.raises(PrecommitError, match="Updated `cmd` of Pixi tasks docnb"), - ModifiablePyproject.load(content) as pyproject, - ): - _update_docnb_and_doclive(pyproject, table_key) - new_content = pyproject.dumps() - expected = dedent(f""" - [{table_key}.doc] - cmd = "command executed by doc" - - [{table_key}.docnb] - cmd = "pixi run doc" - - [{table_key}.docnb-test] - cmd = "should not change" - """) - assert new_content.strip() == expected.strip() - - _ENVIRONMENT_YML = dedent(""" dependencies: - python==3.12.* @@ -63,141 +25,178 @@ def test_update_docnb_and_doclive(table_key: str): """).lstrip() -def test_update_pixi_configuration_skips_non_pixi( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.chdir(tmp_path) - update_pixi_configuration( - is_python_package=True, - dev_python_version="3.12", - package_manager="uv", # not a pixi manager -> no-op - ) - assert not (tmp_path / "pixi.toml").exists() - - -def test_update_pixi_configuration_for_pyproject( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - (tmp_path / "environment.yml").write_text(_ENVIRONMENT_YML) - (tmp_path / "pyproject.toml").write_text( - dedent(""" - [project] - name = "my-package" - - [tool.pixi.project] - channels = ["conda-forge"] - - [tool.pixi.feature.dev.tasks.docnb] - cmd = "outdated" - """).lstrip() +def describe_update_docnb_and_doclive(): + @pytest.mark.parametrize( + "table_key", + [ + "tool.pixi.feature.dev.tasks", + "tool.pixi.tasks", + ], ) - with pytest.raises(PrecommitError): + def rewrites_docnb_command(table_key: str): + content = dedent(f""" + [{table_key}.doc] + cmd = "command executed by doc" + + [{table_key}.docnb] + cmd = "some outdated command" + + [{table_key}.docnb-test] + cmd = "should not change" + """) + with ( + pytest.raises(PrecommitError, match="Updated `cmd` of Pixi tasks docnb"), + ModifiablePyproject.load(content) as pyproject, + ): + _update_docnb_and_doclive(pyproject, table_key) + new_content = pyproject.dumps() + expected = dedent(f""" + [{table_key}.doc] + cmd = "command executed by doc" + + [{table_key}.docnb] + cmd = "pixi run doc" + + [{table_key}.docnb-test] + cmd = "should not change" + """) + assert new_content.strip() == expected.strip() + + +def describe_update_pixi_configuration(): + def skips_non_pixi(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) update_pixi_configuration( is_python_package=True, dev_python_version="3.12", - package_manager="pixi", + package_manager="uv", # not a pixi manager -> no-op ) - - pyproject = (tmp_path / "pyproject.toml").read_text() - assert "[tool.pixi.workspace]" in pyproject # project table renamed - assert "graphviz" in pyproject # conda dependency imported - assert "MY_VARIABLE" in pyproject # conda variable imported - assert 'python = "3.12.*"' in pyproject # dev Python version set - assert "my-package" in pyproject # installed as editable pypi-dependency - assert 'cmd = "pixi run doc"' in pyproject # docnb task outsourced - - -def test_update_pixi_configuration_for_pixi_uv( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 - monkeypatch.chdir(tmp_path) - (tmp_path / "README.md").write_text("# Title\n") - (tmp_path / "pixi.toml").write_text( - dedent(""" - [feature.dev.tasks.sty] - cmd = "pre-commit run -a" - - [feature.dev.tasks.docnb] - cmd = "build docs" - """).lstrip() - ) - with pytest.raises(PrecommitError): - update_pixi_configuration( - is_python_package=True, - dev_python_version="3.12", - package_manager="pixi+uv", + assert not (tmp_path / "pixi.toml").exists() + + def configures_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "environment.yml").write_text(_ENVIRONMENT_YML) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + + [tool.pixi.project] + channels = ["conda-forge"] + + [tool.pixi.feature.dev.tasks.docnb] + cmd = "outdated" + """).lstrip() ) - - pixi = (tmp_path / "pixi.toml").read_text() - assert "[feature.dev.tasks.ci]" in pixi # combined CI job defined - assert "depends_on" in pixi - - -def test_define_combined_ci_job_selects_tests_and_doc(): - content = dedent(""" - [feature.dev.tasks.tests] - cmd = "pytest" - - [feature.dev.tasks.doc] - cmd = "build docs" - """) - with ( - pytest.raises(PrecommitError, match=r"Updated combined CI job"), - ModifiablePyproject.load(content) as config, - ): - _define_combined_ci_job(config) - result = config.dumps() - assert "tests" in result - assert "doc" in result - - -def test_clean_up_task_env_removes_redundant_variables(): - content = dedent(""" - [activation.env] - SHARED = "global" - - [feature.dev.tasks.test.env] - SHARED = "global" - LOCAL = "value" - """) - with ( - pytest.raises(PrecommitError, match=r"Removed redundant environment variables"), - ModifiablePyproject.load(content) as config, - ): - _clean_up_task_env(config) - result = config.dumps() - assert "LOCAL" in result - assert result.count("SHARED") == 1 # only the global activation entry remains - - -def test_update_dev_environment_lists_optional_dependency_features(): - content = dedent(""" - [project.optional-dependencies] - dev = ["pytest"] - doc = ["sphinx"] - """) - with ( - pytest.raises(PrecommitError, match=r"Updated Pixi developer environment"), - ModifiablePyproject.load(content) as config, - ): - _update_dev_environment(config) - result = config.dumps() - assert "features" in result - assert "doc" in result - - -def test_set_dev_python_version(): - content = dedent(""" - [dependencies] - python = "3.10.*" - """) - with ( - pytest.raises(PrecommitError, match=r"Set Python version"), - ModifiablePyproject.load(content) as config, - ): - _set_dev_python_version(config, "3.12") - assert 'python = "3.12.*"' in config.dumps() + with pytest.raises(PrecommitError): + update_pixi_configuration( + is_python_package=True, + dev_python_version="3.12", + package_manager="pixi", + ) + + pyproject = (tmp_path / "pyproject.toml").read_text() + assert "[tool.pixi.workspace]" in pyproject # project table renamed + assert "graphviz" in pyproject # conda dependency imported + assert "MY_VARIABLE" in pyproject # conda variable imported + assert 'python = "3.12.*"' in pyproject # dev Python version set + assert "my-package" in pyproject # installed as editable pypi-dependency + assert 'cmd = "pixi run doc"' in pyproject # docnb task outsourced + + def configures_pixi_toml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + monkeypatch.chdir(tmp_path) + (tmp_path / "README.md").write_text("# Title\n") + (tmp_path / "pixi.toml").write_text( + dedent(""" + [feature.dev.tasks.sty] + cmd = "pre-commit run -a" + + [feature.dev.tasks.docnb] + cmd = "build docs" + """).lstrip() + ) + with pytest.raises(PrecommitError): + update_pixi_configuration( + is_python_package=True, + dev_python_version="3.12", + package_manager="pixi+uv", + ) + + pixi = (tmp_path / "pixi.toml").read_text() + assert "[feature.dev.tasks.ci]" in pixi # combined CI job defined + assert "depends_on" in pixi + + +def describe_define_combined_ci_job(): + def selects_tests_and_doc(): + content = dedent(""" + [feature.dev.tasks.tests] + cmd = "pytest" + + [feature.dev.tasks.doc] + cmd = "build docs" + """) + with ( + pytest.raises(PrecommitError, match=r"Updated combined CI job"), + ModifiablePyproject.load(content) as config, + ): + _define_combined_ci_job(config) + result = config.dumps() + assert "tests" in result + assert "doc" in result + + +def describe_clean_up_task_env(): + def removes_redundant_variables(): + content = dedent(""" + [activation.env] + SHARED = "global" + + [feature.dev.tasks.test.env] + SHARED = "global" + LOCAL = "value" + """) + with ( + pytest.raises( + PrecommitError, match=r"Removed redundant environment variables" + ), + ModifiablePyproject.load(content) as config, + ): + _clean_up_task_env(config) + result = config.dumps() + assert "LOCAL" in result + assert result.count("SHARED") == 1 # only the global activation entry remains + + +def describe_update_dev_environment(): + def lists_optional_dependency_features(): + content = dedent(""" + [project.optional-dependencies] + dev = ["pytest"] + doc = ["sphinx"] + """) + with ( + pytest.raises(PrecommitError, match=r"Updated Pixi developer environment"), + ModifiablePyproject.load(content) as config, + ): + _update_dev_environment(config) + result = config.dumps() + assert "features" in result + assert "doc" in result + + +def describe_set_dev_python_version(): + def sets_version(): + content = dedent(""" + [dependencies] + python = "3.10.*" + """) + with ( + pytest.raises(PrecommitError, match=r"Set Python version"), + ModifiablePyproject.load(content) as config, + ): + _set_dev_python_version(config, "3.12") + assert 'python = "3.12.*"' in config.dumps() diff --git a/tests/utilities/precommit/test_class.py b/tests/utilities/precommit/test_class.py index db2a44b6..d7ba3f8d 100644 --- a/tests/utilities/precommit/test_class.py +++ b/tests/utilities/precommit/test_class.py @@ -18,8 +18,8 @@ def example_config(this_dir: Path) -> str: return file.read() -class TestModifiablePrecommit: - def test_no_context_manager(self, example_config: str): +def describe_modifiable_precommit(): + def rejects_changes_outside_context_manager(example_config: str): precommit = ModifiablePrecommit.load(example_config) precommit.document["fail_fast"] = True with pytest.raises( @@ -28,7 +28,7 @@ def test_no_context_manager(self, example_config: str): ): precommit.changelog.append("Fake modification") - def test_context_manager_path(self, example_config: str): + def restores_path_source_on_change(example_config: str): input_stream = io.StringIO(example_config) with ( pytest.raises(PrecommitError, match=r"Fake modification$"), @@ -38,7 +38,7 @@ def test_context_manager_path(self, example_config: str): yaml = precommit.dumps() assert yaml == example_config - def test_context_manager_string_stream(self, example_config: str): + def writes_back_to_string_stream(example_config: str): stream = io.StringIO(example_config) with ( pytest.raises(PrecommitError, match=r"Fake modification$"), @@ -50,8 +50,8 @@ def test_context_manager_string_stream(self, example_config: str): assert yaml == example_config -class TestPrecommit: - def test_dumps(self, this_dir: Path, example_config: str): +def describe_precommit(): + def dumps_round_trips_config(this_dir: Path, example_config: str): precommit = Precommit.load(this_dir / ".pre-commit-config.yaml") yaml = precommit.dumps() assert yaml == example_config diff --git a/tests/utilities/precommit/test_getters.py b/tests/utilities/precommit/test_getters.py index b84356e4..b0e40116 100644 --- a/tests/utilities/precommit/test_getters.py +++ b/tests/utilities/precommit/test_getters.py @@ -19,74 +19,76 @@ def _offline_git_ls_remote() -> None: """Override the global offline patch so the real implementation is exercised.""" -@pytest.mark.parametrize("use_stream", [True, False]) -def test_load_precommit_config(example_yaml: str, use_stream: bool): - if use_stream: - stream = io.StringIO(example_yaml) - config = Precommit.load(stream).document - else: +def describe_load(): + @pytest.mark.parametrize("use_stream", [True, False]) + def reads_config(example_yaml: str, use_stream: bool): + if use_stream: + stream = io.StringIO(example_yaml) + config = Precommit.load(stream).document + else: + config = Precommit.load(example_yaml).document + assert set(config) == {"ci", "repos"} + + ci = config.get("ci") + assert ci is not None + assert ci.get("autoupdate_schedule") == "quarterly" + + repos = config.get("repos") + assert repos is not None + assert len(repos) == 3 + + def reads_from_default_path(): + config = Precommit.load().document + assert "ci" in config + ci = config.get("ci") + assert ci is not None + assert ci.get("autoupdate_commit_msg") == "MAINT: upgrade lock files" + + +def describe_find_repo(): + def returns_matching_repo(example_yaml: str): config = Precommit.load(example_yaml).document - assert set(config) == {"ci", "repos"} + repo = find_repo(config, "ComPWA/policy") + assert repo is not None + assert repo["repo"] == "https://github.com/ComPWA/policy" + assert repo["rev"] == "0.3.0" + assert len(repo["hooks"]) == 1 - ci = config.get("ci") - assert ci is not None - assert ci.get("autoupdate_schedule") == "quarterly" - repos = config.get("repos") - assert repos is not None - assert len(repos) == 3 - - -def test_load_precommit_config_path(): - config = Precommit.load().document - assert "ci" in config - ci = config.get("ci") - assert ci is not None - assert ci.get("autoupdate_commit_msg") == "MAINT: upgrade lock files" - - -def test_find_repo(example_yaml: str): - config = Precommit.load(example_yaml).document - repo = find_repo(config, "ComPWA/policy") - assert repo is not None - assert repo["repo"] == "https://github.com/ComPWA/policy" - assert repo["rev"] == "0.3.0" - assert len(repo["hooks"]) == 1 - - -def test_find_repo_with_index(example_yaml: str): - config = Precommit.load(example_yaml).document +def describe_find_repo_with_index(): + def returns_index_and_repo(example_yaml: str): + config = Precommit.load(example_yaml).document - repo_and_idx = find_repo_with_index(config, "ComPWA/policy") - assert repo_and_idx is not None - index, repo = repo_and_idx - assert index == 1 - assert repo["repo"] == "https://github.com/ComPWA/policy" + repo_and_idx = find_repo_with_index(config, "ComPWA/policy") + assert repo_and_idx is not None + index, repo = repo_and_idx + assert index == 1 + assert repo["repo"] == "https://github.com/ComPWA/policy" - assert find_repo_with_index(config, "non-existent") is None + assert find_repo_with_index(config, "non-existent") is None -class TestGetLatestRev: - def test_returns_the_highest_tag(self, monkeypatch: pytest.MonkeyPatch) -> None: +def describe_get_latest_rev(): + def returns_the_highest_tag(monkeypatch: pytest.MonkeyPatch) -> None: ls_remote = ( "sha1\trefs/tags/0.0.1\nsha2\trefs/tags/0.0.10\nsha3\trefs/tags/0.0.2\n" ) monkeypatch.setattr(getters, "_git_ls_remote_tags", lambda _url: ls_remote) assert getters.get_latest_rev("https://example.test/repo") == "0.0.10" - def test_ignores_non_version_tags(self, monkeypatch: pytest.MonkeyPatch) -> None: + def ignores_non_version_tags(monkeypatch: pytest.MonkeyPatch) -> None: ls_remote = "sha1\trefs/tags/nightly\nsha2\trefs/tags/1.2.3\n" monkeypatch.setattr(getters, "_git_ls_remote_tags", lambda _url: ls_remote) assert getters.get_latest_rev("https://example.test/repo") == "1.2.3" - def test_falls_back_without_tags(self, monkeypatch: pytest.MonkeyPatch) -> None: + def falls_back_without_tags(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(getters, "_git_ls_remote_tags", lambda _url: "") assert getters.get_latest_rev("https://example.test/repo") == "PLEASE-UPDATE" assert getters.get_latest_rev("https://x", fallback="1.0.0") == "1.0.0" - def test_git_ls_remote_tags_returns_empty_when_offline( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: + +def describe_git_ls_remote_tags(): + def returns_empty_when_offline(monkeypatch: pytest.MonkeyPatch) -> None: def _raise(*_a: object, **_k: object) -> str: msg = "no network" raise OSError(msg) diff --git a/tests/utilities/pyproject/test_getters.py b/tests/utilities/pyproject/test_getters.py index a9f23ada..e88a58c3 100644 --- a/tests/utilities/pyproject/test_getters.py +++ b/tests/utilities/pyproject/test_getters.py @@ -13,128 +13,134 @@ ) -def test_get_package_name(): - src = """ - [project] - name = "my-package" - """ - pyproject = load_pyproject_toml(src, modifiable=False) - package_name = get_package_name(pyproject) - assert package_name == "my-package" - - -def test_get_package_name_missing(): - src = """ - [server] - ip = "192.168.1.1" - port = 8000 - [server.http] - enable = true - port = 8080 - [[users]] - name = "Alice" - [[users]] - name = "Bob" - """ - pyproject = load_pyproject_toml(src, modifiable=False) - package_name = get_package_name(pyproject) - assert package_name is None - with pytest.raises(PrecommitError): - package_name = get_package_name(pyproject, raise_on_missing=True) - - -def test_get_project_urls(): - src = """ - [project] - name = "my-package" - [project.urls] - Documentation = "https://ampform.rtfd.io" - Source = "https://github.com/ComPWA/ampform" - """ - pyproject = load_pyproject_toml(src, modifiable=False) - assert get_project_urls(pyproject) == { - "Documentation": "https://ampform.rtfd.io", - "Source": "https://github.com/ComPWA/ampform", - } - - repo_url = get_source_url(pyproject) - assert repo_url == "https://github.com/ComPWA/ampform" - - -def test_get_project_urls_missing(): - src = """ - [project] - name = "my-package" - """ - pyproject = load_pyproject_toml(src, modifiable=False) - with pytest.raises( - PrecommitError, - match=r"pyproject\.toml does not contain project URLs", - ): - get_project_urls(pyproject) - - -def test_get_source_url_missing(): - src = """ - [project.urls] - Documentation = "https://ampform.rtfd.io" - """ - pyproject = load_pyproject_toml(src, modifiable=False) - with pytest.raises( - PrecommitError, - match=r'\[project\.urls\] in pyproject\.toml does not contain a "Source" URL', - ): - get_source_url(pyproject) - - -def test_get_supported_python_versions(): - src = """ - [project] - name = "my-package" - classifiers = [ - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - ] - """ - pyproject = load_pyproject_toml(src, modifiable=False) - python_versions = get_supported_python_versions(pyproject) - assert python_versions == ["3.7", "3.8", "3.9"] - - -def test_get_sub_table(): - document = rtoml.loads(""" - [project] - name = "my-package" - - [project.urls] - homepage = "https://github.com" - """) - sub_table = get_sub_table(document, "project") - assert sub_table == document["project"] - - urls = get_sub_table(document, "project.urls") - assert urls["homepage"] == "https://github.com" - - homepage = get_sub_table(document, "project.urls.homepage") - assert homepage == "https://github.com" - - with pytest.raises(KeyError, match=r"TOML data does not contain 'server.http'"): - sub_table = get_sub_table(document, "server.http") - with pytest.raises(KeyError, match=r"TOML data does not contain 'non-existent'"): - sub_table = get_sub_table(document, "non-existent") - - -def test_has_sub_table(): - document = rtoml.loads(""" - [project] - name = "my-package" - - [project.urls] - homepage = "https://github.com" - """) - assert has_sub_table(document, "project") - assert has_sub_table(document, "project.urls") - assert not has_sub_table(document, "tool") - assert not has_sub_table(document, "tool.poetry") +def describe_get_package_name(): + def returns_name(): + src = """ + [project] + name = "my-package" + """ + pyproject = load_pyproject_toml(src, modifiable=False) + package_name = get_package_name(pyproject) + assert package_name == "my-package" + + def handles_missing_name(): + src = """ + [server] + ip = "192.168.1.1" + port = 8000 + [server.http] + enable = true + port = 8080 + [[users]] + name = "Alice" + [[users]] + name = "Bob" + """ + pyproject = load_pyproject_toml(src, modifiable=False) + package_name = get_package_name(pyproject) + assert package_name is None + with pytest.raises(PrecommitError): + package_name = get_package_name(pyproject, raise_on_missing=True) + + +def describe_get_project_urls(): + def returns_urls_and_source(): + src = """ + [project] + name = "my-package" + [project.urls] + Documentation = "https://ampform.rtfd.io" + Source = "https://github.com/ComPWA/ampform" + """ + pyproject = load_pyproject_toml(src, modifiable=False) + assert get_project_urls(pyproject) == { + "Documentation": "https://ampform.rtfd.io", + "Source": "https://github.com/ComPWA/ampform", + } + + repo_url = get_source_url(pyproject) + assert repo_url == "https://github.com/ComPWA/ampform" + + def raises_when_missing(): + src = """ + [project] + name = "my-package" + """ + pyproject = load_pyproject_toml(src, modifiable=False) + with pytest.raises( + PrecommitError, + match=r"pyproject\.toml does not contain project URLs", + ): + get_project_urls(pyproject) + + +def describe_get_source_url(): + def raises_when_missing(): + src = """ + [project.urls] + Documentation = "https://ampform.rtfd.io" + """ + pyproject = load_pyproject_toml(src, modifiable=False) + with pytest.raises( + PrecommitError, + match=r'\[project\.urls\] in pyproject\.toml does not contain a "Source" URL', + ): + get_source_url(pyproject) + + +def describe_get_supported_python_versions(): + def reads_from_classifiers(): + src = """ + [project] + name = "my-package" + classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + ] + """ + pyproject = load_pyproject_toml(src, modifiable=False) + python_versions = get_supported_python_versions(pyproject) + assert python_versions == ["3.7", "3.8", "3.9"] + + +def describe_get_sub_table(): + def returns_nested_tables_and_values(): + document = rtoml.loads(""" + [project] + name = "my-package" + + [project.urls] + homepage = "https://github.com" + """) + sub_table = get_sub_table(document, "project") + assert sub_table == document["project"] + + urls = get_sub_table(document, "project.urls") + assert urls["homepage"] == "https://github.com" + + homepage = get_sub_table(document, "project.urls.homepage") + assert homepage == "https://github.com" + + with pytest.raises(KeyError, match=r"TOML data does not contain 'server.http'"): + sub_table = get_sub_table(document, "server.http") + with pytest.raises( + KeyError, match=r"TOML data does not contain 'non-existent'" + ): + sub_table = get_sub_table(document, "non-existent") + + +def describe_has_sub_table(): + def detects_presence(): + document = rtoml.loads(""" + [project] + name = "my-package" + + [project.urls] + homepage = "https://github.com" + """) + assert has_sub_table(document, "project") + assert has_sub_table(document, "project.urls") + assert not has_sub_table(document, "tool") + assert not has_sub_table(document, "tool.poetry") diff --git a/tests/utilities/pyproject/test_setters.py b/tests/utilities/pyproject/test_setters.py index bb9a33c8..0933ca49 100644 --- a/tests/utilities/pyproject/test_setters.py +++ b/tests/utilities/pyproject/test_setters.py @@ -12,90 +12,6 @@ ) -def test_add_dependency(): - src = """ - [project] - name = "my-package" - """ - pyproject = load_pyproject_toml(src, modifiable=True) - updated = add_dependency(pyproject, "attrs") - assert updated is True - - new_content = tomlkit.dumps(pyproject) - expected = """ - [project] - name = "my-package" - dependencies = ["attrs"] - """ - assert new_content == expected - - -def test_add_dependency_existing(): - src = """ - [project] - dependencies = ["attrs"] - [project.optional-dependencies] - lint = ["ruff"] - """ - pyproject = load_pyproject_toml(src, modifiable=True) - updated = add_dependency(pyproject, "attrs") - assert updated is False - - updated = add_dependency(pyproject, "ruff", optional_key="lint") - assert updated is False - - -def test_add_dependency_nested(): - src = dedent(""" - [project] - name = "my-package" - """) - pyproject = load_pyproject_toml(src, modifiable=True) - add_dependency(pyproject, "ruff", optional_key=["lint", "style", "dev"]) - new_content = tomlkit.dumps(pyproject) - expected = dedent(""" - [project] - name = "my-package" - - [project.optional-dependencies] - lint = ["ruff"] - style = ["my-package[lint]"] - dev = ["my-package[style]"] - """) - assert new_content == expected - - pyproject = load_pyproject_toml(src, modifiable=True) - add_dependency(pyproject, "ruff", optional_key=["lint"]) - new_content = tomlkit.dumps(pyproject) - expected = dedent(""" - [project] - name = "my-package" - - [project.optional-dependencies] - lint = ["ruff"] - """) - assert new_content == expected - - -def test_add_dependency_optional(): - src = dedent(""" - [project] - name = "my-package" - """) - pyproject = load_pyproject_toml(src, modifiable=True) - add_dependency(pyproject, "ruff", optional_key="lint") - - new_content = tomlkit.dumps(pyproject) - expected = dedent(""" - [project] - name = "my-package" - - [project.optional-dependencies] - lint = ["ruff"] - """) - assert new_content == expected - - @pytest.fixture def pyproject_example() -> PyprojectTOML: src = dedent(""" @@ -113,76 +29,158 @@ def pyproject_example() -> PyprojectTOML: return load_pyproject_toml(src, modifiable=True) -def test_remove_dependency(pyproject_example: PyprojectTOML): - remove_dependency(pyproject_example, "attrs") - expected = dedent(""" - [project] - name = "my-package" - dependencies = ["ruff"] - - [project.optional-dependencies] - lint = [ - "mypy", - "ruff", - ] - style = ["ruff"] - """) - new_content = tomlkit.dumps(pyproject_example) - assert new_content == expected - - -def test_remove_dependency_nested(pyproject_example: PyprojectTOML): - remove_dependency(pyproject_example, "ruff", ignored_sections=["sty", "style"]) - new_content = tomlkit.dumps(pyproject_example) - expected = dedent(""" - [project] - name = "my-package" - dependencies = ["attrs"] - - [project.optional-dependencies] - lint = [ - "mypy", - ] - style = ["ruff"] - """) - assert new_content == expected - - -@pytest.mark.parametrize("table_key", ["project", "project.optional-dependencies"]) -def test_create_sub_table(table_key: str): - pyproject = load_pyproject_toml("", modifiable=True) - dependencies = create_sub_table(pyproject, table_key) - - new_content = tomlkit.dumps(pyproject) - expected = dedent(f""" - [{table_key}] - """) - assert new_content.strip() == expected.strip() - - dependencies["lint"] = ["ruff"] - new_content = tomlkit.dumps(pyproject) - expected = dedent(f""" - [{table_key}] - lint = ["ruff"] - """) - assert new_content.strip() == expected.strip() - - -def test_create_sub_table_with_super_table(): - pyproject = load_pyproject_toml("", modifiable=True) - pixi = create_sub_table(pyproject, "tool.pixi") - pixi["channels"] = ["conda-forge"] - pixi["platforms"] = ["linux-64"] - task_table = create_sub_table(pyproject, "tool.pixi.feature.dev.tasks.test") - task_table["cmd"] = "pytest" - - new_content = tomlkit.dumps(pyproject) - expected = dedent(""" - [tool.pixi] - channels = ["conda-forge"] - platforms = ["linux-64"] - - [tool.pixi.feature.dev.tasks.test] - cmd = "pytest" - """) - assert new_content.strip() == expected.strip() +def describe_add_dependency(): + def adds_to_project(): + src = """ + [project] + name = "my-package" + """ + pyproject = load_pyproject_toml(src, modifiable=True) + updated = add_dependency(pyproject, "attrs") + assert updated is True + + new_content = tomlkit.dumps(pyproject) + expected = """ + [project] + name = "my-package" + dependencies = ["attrs"] + """ + assert new_content == expected + + def skips_existing_dependency(): + src = """ + [project] + dependencies = ["attrs"] + [project.optional-dependencies] + lint = ["ruff"] + """ + pyproject = load_pyproject_toml(src, modifiable=True) + updated = add_dependency(pyproject, "attrs") + assert updated is False + + updated = add_dependency(pyproject, "ruff", optional_key="lint") + assert updated is False + + def chains_nested_optional_keys(): + src = dedent(""" + [project] + name = "my-package" + """) + pyproject = load_pyproject_toml(src, modifiable=True) + add_dependency(pyproject, "ruff", optional_key=["lint", "style", "dev"]) + new_content = tomlkit.dumps(pyproject) + expected = dedent(""" + [project] + name = "my-package" + + [project.optional-dependencies] + lint = ["ruff"] + style = ["my-package[lint]"] + dev = ["my-package[style]"] + """) + assert new_content == expected + + pyproject = load_pyproject_toml(src, modifiable=True) + add_dependency(pyproject, "ruff", optional_key=["lint"]) + new_content = tomlkit.dumps(pyproject) + expected = dedent(""" + [project] + name = "my-package" + + [project.optional-dependencies] + lint = ["ruff"] + """) + assert new_content == expected + + def adds_optional_dependency(): + src = dedent(""" + [project] + name = "my-package" + """) + pyproject = load_pyproject_toml(src, modifiable=True) + add_dependency(pyproject, "ruff", optional_key="lint") + + new_content = tomlkit.dumps(pyproject) + expected = dedent(""" + [project] + name = "my-package" + + [project.optional-dependencies] + lint = ["ruff"] + """) + assert new_content == expected + + +def describe_remove_dependency(): + def removes_from_main_dependencies(pyproject_example: PyprojectTOML): + remove_dependency(pyproject_example, "attrs") + expected = dedent(""" + [project] + name = "my-package" + dependencies = ["ruff"] + + [project.optional-dependencies] + lint = [ + "mypy", + "ruff", + ] + style = ["ruff"] + """) + new_content = tomlkit.dumps(pyproject_example) + assert new_content == expected + + def respects_ignored_sections(pyproject_example: PyprojectTOML): + remove_dependency(pyproject_example, "ruff", ignored_sections=["sty", "style"]) + new_content = tomlkit.dumps(pyproject_example) + expected = dedent(""" + [project] + name = "my-package" + dependencies = ["attrs"] + + [project.optional-dependencies] + lint = [ + "mypy", + ] + style = ["ruff"] + """) + assert new_content == expected + + +def describe_create_sub_table(): + @pytest.mark.parametrize("table_key", ["project", "project.optional-dependencies"]) + def creates_table(table_key: str): + pyproject = load_pyproject_toml("", modifiable=True) + dependencies = create_sub_table(pyproject, table_key) + + new_content = tomlkit.dumps(pyproject) + expected = dedent(f""" + [{table_key}] + """) + assert new_content.strip() == expected.strip() + + dependencies["lint"] = ["ruff"] + new_content = tomlkit.dumps(pyproject) + expected = dedent(f""" + [{table_key}] + lint = ["ruff"] + """) + assert new_content.strip() == expected.strip() + + def creates_super_table(): + pyproject = load_pyproject_toml("", modifiable=True) + pixi = create_sub_table(pyproject, "tool.pixi") + pixi["channels"] = ["conda-forge"] + pixi["platforms"] = ["linux-64"] + task_table = create_sub_table(pyproject, "tool.pixi.feature.dev.tasks.test") + task_table["cmd"] = "pytest" + + new_content = tomlkit.dumps(pyproject) + expected = dedent(""" + [tool.pixi] + channels = ["conda-forge"] + platforms = ["linux-64"] + + [tool.pixi.feature.dev.tasks.test] + cmd = "pytest" + """) + assert new_content.strip() == expected.strip() diff --git a/tests/utilities/test_cfg.py b/tests/utilities/test_cfg.py index cb6316a4..4ebf799d 100644 --- a/tests/utilities/test_cfg.py +++ b/tests/utilities/test_cfg.py @@ -7,100 +7,103 @@ from compwa_policy.utilities.cfg import format_config, open_config -@pytest.mark.parametrize( - ("unformatted", "expected"), - [ - ( # replace tabs - """\ - folders = - \tdocs, - \tsrc, - """, - """\ - folders = - docs, - src, - """, - ), - ( # remove spaces before comments - """\ - [metadata] - name = compwa-policy # comment - """, - """\ - [metadata] - name = compwa-policy # comment - """, - ), - ( # remove trailing white-space - """\ - ends with a tab\t - ends with some spaces \n - """, - """\ - ends with a tab - ends with some spaces - """, - ), - ( # end file with one and only one newline - """\ - [metadata] - name = compwa-policy +def describe_format_config(): + @pytest.mark.parametrize( + ("unformatted", "expected"), + [ + ( # replace tabs + """\ + folders = + \tdocs, + \tsrc, + """, + """\ + folders = + docs, + src, + """, + ), + ( # remove spaces before comments + """\ + [metadata] + name = compwa-policy # comment + """, + """\ + [metadata] + name = compwa-policy # comment + """, + ), + ( # remove trailing white-space + """\ + ends with a tab\t + ends with some spaces \n + """, + """\ + ends with a tab + ends with some spaces + """, + ), + ( # end file with one and only one newline + """\ + [metadata] + name = compwa-policy - """, - """\ - [metadata] - name = compwa-policy - """, - ), - ( # only two linebreaks - """\ - [section1] - option1 = one + """, + """\ + [metadata] + name = compwa-policy + """, + ), + ( # only two linebreaks + """\ + [section1] + option1 = one - [section2] - option2 = two - """, - """\ - [section1] - option1 = one + [section2] + option2 = two + """, + """\ + [section1] + option1 = one - [section2] - option2 = two - """, - ), - ], -) -def test_format_config(unformatted: str, expected: str): - unformatted = dedent(unformatted) - formatted = io.StringIO() - format_config(input=io.StringIO(unformatted), output=formatted) - formatted.seek(0) - assert formatted.read() == dedent(expected) + [section2] + option2 = two + """, + ), + ], + ) + def normalizes_whitespace(unformatted: str, expected: str): + unformatted = dedent(unformatted) + formatted = io.StringIO() + format_config(input=io.StringIO(unformatted), output=formatted) + formatted.seek(0) + assert formatted.read() == dedent(expected) -def test_open_config_exception(): - path = "non-existent.cfg" - with pytest.raises(PrecommitError, match=rf'^Config file "{path}" does not exist$'): - open_config(path) +def describe_open_config(): + def raises_for_missing_file(): + path = "non-existent.cfg" + with pytest.raises( + PrecommitError, match=rf'^Config file "{path}" does not exist$' + ): + open_config(path) + def reads_from_stream(): + msg = """\ + [section1] + option1 = + some_setting = false + option2 = two -def test_open_config_from_stream(): - msg = """\ - [section1] - option1 = - some_setting = false - option2 = two - - [section2] - option3 = - =src - """ - content = dedent(msg) - print(content) - stream = io.StringIO(content) - cfg = open_config(stream) - assert cfg.sections() == ["section1", "section2"] - assert cfg.get("section1", "option2") == "two" + [section2] + option3 = + =src + """ + content = dedent(msg) + print(content) + stream = io.StringIO(content) + cfg = open_config(stream) + assert cfg.sections() == ["section1", "section2"] + assert cfg.get("section1", "option2") == "two" diff --git a/tests/utilities/test_executor.py b/tests/utilities/test_executor.py index 1889f58f..323360f1 100644 --- a/tests/utilities/test_executor.py +++ b/tests/utilities/test_executor.py @@ -4,8 +4,8 @@ from compwa_policy.utilities.executor import Executor -class TestExecutor: - def test_error_messages(self): +def describe_executor(): + def collects_and_merges_error_messages(): def do_without_args() -> None: msg = "Function did not have arguments" raise PrecommitError(msg) diff --git a/tests/utilities/test_pyproject.py b/tests/utilities/test_pyproject.py index 3d853bc5..d21ea349 100644 --- a/tests/utilities/test_pyproject.py +++ b/tests/utilities/test_pyproject.py @@ -11,9 +11,9 @@ POLICY_REPO_DIR = Path(__file__).absolute().parent.parent.parent -class TestPyprojectToml: +def describe_load(): @pytest.mark.parametrize("path", [None, POLICY_REPO_DIR / "pyproject.toml"]) - def test_load_from_path(self, path: Path | None): + def reads_from_path(path: Path | None): if path is None: pyproject = Pyproject.load() else: @@ -21,7 +21,7 @@ def test_load_from_path(self, path: Path | None): assert "build-system" in pyproject._document assert "tool" in pyproject._document - def test_load_from_str(self): + def reads_from_str(): pyproject = Pyproject.load(""" [build-system] build-backend = "setuptools.build_meta" @@ -48,43 +48,44 @@ def test_load_from_str(self): "sympy >=1.10", ] - def test_load_type_error(self): + def raises_type_error_for_unsupported_source(): with pytest.raises(TypeError, match="Source of type int is not supported"): _ = Pyproject.load(1) # ty:ignore[invalid-argument-type] -def test_edit_and_dump(): - src = dedent(""" - [owner] - name = "John Smith" - age = 30 - [owner.address] - city = "Wonderland" - street = "123 Main St" - """) - with ModifiablePyproject.load(src) as pyproject: - address = pyproject.get_table("owner.address") - address["city"] = "New York" - work = pyproject.get_table("owner.work", create=True) - work["type"] = "scientist" - tools = pyproject.get_table("tool", create=True) - tools["black"] = to_toml_array(["--line-length=79"], multiline=True) +def describe_edit_and_dump(): + def creates_and_modifies_tables(): + src = dedent(""" + [owner] + name = "John Smith" + age = 30 + [owner.address] + city = "Wonderland" + street = "123 Main St" + """) + with ModifiablePyproject.load(src) as pyproject: + address = pyproject.get_table("owner.address") + address["city"] = "New York" + work = pyproject.get_table("owner.work", create=True) + work["type"] = "scientist" + tools = pyproject.get_table("tool", create=True) + tools["black"] = to_toml_array(["--line-length=79"], multiline=True) - new_content = pyproject.dumps() - expected = dedent(""" - [owner] - name = "John Smith" - age = 30 - [owner.address] - city = "New York" - street = "123 Main St" + new_content = pyproject.dumps() + expected = dedent(""" + [owner] + name = "John Smith" + age = 30 + [owner.address] + city = "New York" + street = "123 Main St" - [owner.work] - type = "scientist" + [owner.work] + type = "scientist" - [tool] - black = [ - "--line-length=79", - ] - """) - assert new_content.strip() == expected.strip() + [tool] + black = [ + "--line-length=79", + ] + """) + assert new_content.strip() == expected.strip() diff --git a/tests/utilities/test_toml.py b/tests/utilities/test_toml.py index 137e80f2..495dda3f 100644 --- a/tests/utilities/test_toml.py +++ b/tests/utilities/test_toml.py @@ -8,69 +8,68 @@ from compwa_policy.utilities.toml import to_toml_array -def test_to_toml_array_empty(): - array = to_toml_array([]) - assert _dump(array) == "a = []" - +def _dump(array): + return tomlkit.dumps({"a": array}).strip() -def test_to_toml_array_single_item(): - lst = [1] - array = to_toml_array(lst) - assert _dump(array) == "a = [1]" - array = to_toml_array(lst, multiline=True) - expected = dedent(""" - a = [ - 1, - ] - """) - assert _dump(array) == expected.strip() +def describe_to_toml_array(): + def renders_empty_array(): + array = to_toml_array([]) + assert _dump(array) == "a = []" + def renders_single_item(): + lst = [1] + array = to_toml_array(lst) + assert _dump(array) == "a = [1]" -@pytest.mark.parametrize( - ("lst", "multiline", "expected"), - [ - ([0], False, "a = [0]"), - ( - [0], - True, - """ - a = [ - 0, - ] - """, - ), - ([0], None, "a = [0]"), - ([1, 2, 3], False, "a = [1, 2, 3]"), - ( - [1, 2, 3], - True, - """ + array = to_toml_array(lst, multiline=True) + expected = dedent(""" a = [ 1, - 2, - 3, ] - """, - ), - ( - [1, 2, 3], - None, - """ - a = [ - 1, - 2, - 3, - ] - """, - ), - ], -) -def test_to_toml_array_multiple_items(lst: list[int], multiline: bool, expected: str): - array = to_toml_array(lst, multiline) - expected = dedent(expected).strip() - assert _dump(array) == expected.strip() - + """) + assert _dump(array) == expected.strip() -def _dump(array): - return tomlkit.dumps({"a": array}).strip() + @pytest.mark.parametrize( + ("lst", "multiline", "expected"), + [ + ([0], False, "a = [0]"), + ( + [0], + True, + """ + a = [ + 0, + ] + """, + ), + ([0], None, "a = [0]"), + ([1, 2, 3], False, "a = [1, 2, 3]"), + ( + [1, 2, 3], + True, + """ + a = [ + 1, + 2, + 3, + ] + """, + ), + ( + [1, 2, 3], + None, + """ + a = [ + 1, + 2, + 3, + ] + """, + ), + ], + ) + def renders_multiple_items(lst: list[int], multiline: bool, expected: str): + array = to_toml_array(lst, multiline) + expected = dedent(expected).strip() + assert _dump(array) == expected.strip() From 2784bf4e281a4b1d264be7f10507030d662971ce Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:25:26 +0200 Subject: [PATCH 13/20] DX: test `readthedocs` module * DX: increase test coverage to 72% --- .codex/skills/pre-merge | 1 + .codex/skills/update-pr | 1 + AGENTS.md | 1 + codecov.yml | 2 +- pyproject.toml | 2 +- tests/conftest.py | 2 + tests/readthedocs/test_readthedocs.py | 125 ++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 2 deletions(-) create mode 120000 .codex/skills/pre-merge create mode 120000 .codex/skills/update-pr create mode 120000 AGENTS.md diff --git a/.codex/skills/pre-merge b/.codex/skills/pre-merge new file mode 120000 index 00000000..4a49068d --- /dev/null +++ b/.codex/skills/pre-merge @@ -0,0 +1 @@ +../../.claude/skills/pre-merge \ No newline at end of file diff --git a/.codex/skills/update-pr b/.codex/skills/update-pr new file mode 120000 index 00000000..5d42ea07 --- /dev/null +++ b/.codex/skills/update-pr @@ -0,0 +1 @@ +../../.claude/skills/update-pr \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/codecov.yml b/codecov.yml index 8ccd3fc4..55be72e1 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 70% + target: 72% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index f1c23b3c..d297388b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,7 +206,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=70 \ + --cov-fail-under=72 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/conftest.py b/tests/conftest.py index df337d1c..f7ca7033 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import pytest from compwa_policy import _characterization +from compwa_policy.repo import readthedocs from compwa_policy.utilities import match from compwa_policy.utilities.precommit import getters @@ -23,6 +24,7 @@ def _clear_git_ls_files_cache() -> None: match._git_ls_files_cmd.cache_clear() _characterization.has_documentation.cache_clear() _characterization.has_python_code.cache_clear() + readthedocs._determine_docs_dir.cache_clear() @pytest.fixture(autouse=True) # noqa: RUF076 diff --git a/tests/readthedocs/test_readthedocs.py b/tests/readthedocs/test_readthedocs.py index a465b570..de33b68d 100644 --- a/tests/readthedocs/test_readthedocs.py +++ b/tests/readthedocs/test_readthedocs.py @@ -1,6 +1,7 @@ from __future__ import annotations import io +import subprocess # noqa: S404 from textwrap import dedent from typing import TYPE_CHECKING @@ -11,8 +12,12 @@ from compwa_policy.repo import readthedocs if TYPE_CHECKING: + from pathlib import Path + from compwa_policy.utilities.pyproject.getters import PythonVersion +# cspell:ignore apt poethepoet pyproject + BAD_OVERWRITE_WITH_JOBS = dedent(""" version: 2 build: @@ -147,3 +152,123 @@ def overwrites_bad_config(python_version: PythonVersion, bad_config: str): input_stream.seek(0) assert input_stream.read().strip() == _good_overwrite(python_version).strip() + + def returns_early_when_config_missing(tmp_path: Path): + readthedocs.main( # no .readthedocs.yml -> no-op, no error + "uv", + python_version=DEFAULT_DEV_PYTHON_VERSION, + source=tmp_path / ".readthedocs.yml", + ) + + def configures_uv_build(rtd_repo: Path): + (rtd_repo / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[dependency-groups]\ndoc = ["poethepoet"]\n' + ) + (rtd_repo / ".readthedocs.yml").write_text( + dedent(""" + version: 2 + build: + os: ubuntu-24.04 + apt_packages: + - graphviz + tools: + python: "3.12" + jobs: + post_install: + - pip install -e .[doc] + sphinx: + configuration: docs/conf.py + """).lstrip() + ) + with pytest.raises(PrecommitError): + readthedocs.main("uv", python_version="3.12") + result = (rtd_repo / ".readthedocs.yml").read_text() + assert "pixi global install graphviz uv" in result + assert "uvx --from poethepoet poe doc" in result + assert "apt_packages" not in result # redundant settings removed + + def configures_pixi_build_with_poe(rtd_repo: Path): + (rtd_repo / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[dependency-groups]\ndoc = ["poethepoet"]\n' + ) + (rtd_repo / ".readthedocs.yml").write_text( + dedent(""" + version: 2 + build: + os: ubuntu-24.04 + tools: + python: "3.12" + sphinx: + configuration: docs/conf.py + """).lstrip() + ) + with pytest.raises(PrecommitError): + readthedocs.main("pixi+uv", python_version="3.12") + result = (rtd_repo / ".readthedocs.yml").read_text() + assert "pixi run poe doc" in result + + def sets_sphinx_configuration_when_missing(rtd_repo: Path): + (rtd_repo / "pyproject.toml").write_text('[project]\nname = "x"\n') + (rtd_repo / ".readthedocs.yml").write_text( + dedent(""" + version: 2 + build: + os: ubuntu-24.04 + tools: + python: "3.12" + """).lstrip() + ) + with pytest.raises(PrecommitError, match=r"Set sphinx.configuration"): + readthedocs.main("conda", python_version="3.12") + result = (rtd_repo / ".readthedocs.yml").read_text() + assert "configuration: docs/conf.py" in result + + def reuses_existing_pixi_packages(rtd_repo: Path): + (rtd_repo / "pyproject.toml").write_text('[project]\nname = "x"\n') + (rtd_repo / ".readthedocs.yml").write_text( + dedent(""" + version: 2 + build: + os: ubuntu-24.04 + tools: + python: "3.12" + commands: + - | + export PIXI_HOME=$READTHEDOCS_VIRTUALENV_PATH + curl -fsSL https://pixi.sh/install.sh | bash + pixi global install graphviz julia + sphinx: + configuration: docs/conf.py + """).lstrip() + ) + with pytest.raises(PrecommitError): + readthedocs.main("uv", python_version="3.12") + result = (rtd_repo / ".readthedocs.yml").read_text() + assert "pixi global install graphviz julia uv" in result # existing kept + + def configures_pixi_build_without_poe(rtd_repo: Path): + (rtd_repo / "pyproject.toml").write_text('[project]\nname = "x"\n') + (rtd_repo / ".readthedocs.yml").write_text( + dedent(""" + version: 2 + build: + os: ubuntu-24.04 + tools: + python: "3.12" + sphinx: + configuration: docs/conf.py + """).lstrip() + ) + with pytest.raises(PrecommitError): + readthedocs.main("pixi+uv", python_version="3.12") + result = (rtd_repo / ".readthedocs.yml").read_text() + assert "pixi run doc" in result + + +@pytest.fixture +def rtd_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + subprocess.run(["git", "init", "-q"], cwd=tmp_path, check=True) # noqa: S607 + (tmp_path / "docs").mkdir() + (tmp_path / "docs" / "conf.py").touch() + monkeypatch.chdir(tmp_path) + return tmp_path From 2444500f32c18276a1bfed1e3b727ff1fbe1bdc8 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:42:07 +0200 Subject: [PATCH 14/20] DX: test `conda`, `binder`, `pyproject`, and CLI checks * DX: increase test coverage to 81% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/cli/test_checks.py | 120 +++++++++++++++++++++ tests/env/test_conda.py | 77 ++++++++++++++ tests/nb/test_binder.py | 80 ++++++++++++++ tests/python/test_pyproject.py | 185 +++++++++++++++++++++++++++++++++ 6 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 tests/cli/test_checks.py create mode 100644 tests/env/test_conda.py create mode 100644 tests/nb/test_binder.py create mode 100644 tests/python/test_pyproject.py diff --git a/codecov.yml b/codecov.yml index 55be72e1..14a2d813 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 72% + target: 81% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index d297388b..3b7ae2eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,7 +206,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=72 \ + --cov-fail-under=81 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/cli/test_checks.py b/tests/cli/test_checks.py new file mode 100644 index 00000000..293770be --- /dev/null +++ b/tests/cli/test_checks.py @@ -0,0 +1,120 @@ +import subprocess # noqa: S404 +from pathlib import Path +from textwrap import dedent + +import pytest +import typer + +from compwa_policy.cli._checks import ( + ALL_GROUPS, + check_dev_python_version, + compute_context, + dispatch, + run_all, +) +from compwa_policy.cli._options import build_arguments + +# cspell:ignore capsys classifiers pyproject + +_PYPROJECT = dedent(""" + [project] + name = "my-package" + classifiers = [ + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ] +""").lstrip() + + +def _git_commit(directory: Path) -> None: + subprocess.run(["git", "init", "-q"], cwd=directory, check=True) # noqa: S607 + subprocess.run(["git", "add", "-A"], cwd=directory, check=True) # noqa: S607 + git_author = ["-c", "user.name=t", "-c", "user.email=t@t"] + commit = ["git", *git_author, "commit", "-qm", "init", "--allow-empty"] + subprocess.run(commit, cwd=directory, check=True) # noqa: S603 + + +def describe_check_dev_python_version(): + def passes_without_pyproject(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + args = build_arguments(dev_python_version="3.12") + assert check_dev_python_version(args) == 0 + + def passes_for_supported_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text(_PYPROJECT) + args = build_arguments(dev_python_version="3.12") + assert check_dev_python_version(args) == 0 + + def fails_for_unsupported_version( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text(_PYPROJECT) + args = build_arguments(dev_python_version="3.9") + assert check_dev_python_version(args) == 1 + assert "not listed in the supported Python versions" in capsys.readouterr().out + + +def describe_compute_context(): + def detects_python_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "module.py").write_text("x = 1\n") + _git_commit(tmp_path) + monkeypatch.chdir(tmp_path) + args = build_arguments(dev_python_version="3.12") + ctx = compute_context(args) + assert ctx.is_python_repo is True + assert ctx.has_notebooks is False + + def respects_explicit_python_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + _git_commit(tmp_path) + monkeypatch.chdir(tmp_path) + args = build_arguments(dev_python_version="3.12", python=False) + ctx = compute_context(args) + assert ctx.is_python_repo is False + + +def describe_all_groups(): + def covers_every_subcommand_group(): + assert ( + frozenset({"python", "github", "env", "nb", "format", "repo"}) == ALL_GROUPS + ) + + +def _runnable_repo(directory: Path) -> None: + (directory / ".pre-commit-config.yaml").write_text("repos: []\n") + (directory / "pyproject.toml").write_text( + '[project]\nname = "x"\nrequires-python = ">=3.12"\n' + ) + (directory / "src" / "x").mkdir(parents=True) + (directory / "src" / "x" / "module.py").write_text("x = 1\n") + _git_commit(directory) + + +def describe_run_all(): + def applies_changes_and_returns_one( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, + ): + _runnable_repo(tmp_path) + monkeypatch.chdir(tmp_path) + args = build_arguments(dev_python_version="3.12", package_manager="uv") + assert run_all(args) == 1 # pristine repo needs updates + capsys.readouterr() + + +def describe_dispatch(): + def raises_typer_exit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture, + ): + _runnable_repo(tmp_path) + monkeypatch.chdir(tmp_path) + args = build_arguments(dev_python_version="3.12", package_manager="uv") + with pytest.raises(typer.Exit): + dispatch(args, "env") + capsys.readouterr() diff --git a/tests/env/test_conda.py b/tests/env/test_conda.py new file mode 100644 index 00000000..345e67fa --- /dev/null +++ b/tests/env/test_conda.py @@ -0,0 +1,77 @@ +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.env import conda +from compwa_policy.errors import PrecommitError + +# cspell:ignore condaenv pyproject + +_ENVIRONMENT = dedent(""" + name: my-package + channels: + - defaults + dependencies: + - python==3.10.* + - pip + - pip: + - -e .[dev] +""").lstrip() + + +def _write_pyproject(directory: Path) -> None: + (directory / "pyproject.toml").write_text('[project]\nname = "my-package"\n') + + +def describe_main(): + def creates_environment_for_conda(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + _write_pyproject(tmp_path) + with pytest.raises(PrecommitError, match=r"Updated Conda environment"): + conda.main("3.12", "conda") + result = (tmp_path / "environment.yml").read_text() + assert "python==3.12.*" in result + + def removes_environment_for_other_manager( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "environment.yml").write_text(_ENVIRONMENT) + with pytest.raises(PrecommitError, match=r"Removed Conda configuration"): + conda.main("3.12", "uv") + assert not (tmp_path / "environment.yml").exists() + + +def describe_update_conda_environment(): + def is_noop_without_package_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text("[project]\nversion = '0.1'\n") + conda.update_conda_environment("3.12") # no package name -> no-op + + def updates_python_version(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + _write_pyproject(tmp_path) + (tmp_path / "environment.yml").write_text(_ENVIRONMENT) + with pytest.raises(PrecommitError, match=r"Updated Conda environment"): + conda.update_conda_environment("3.12") + result = (tmp_path / "environment.yml").read_text() + assert "python==3.12.*" in result + + def is_idempotent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + _write_pyproject(tmp_path) + (tmp_path / "environment.yml").write_text(_ENVIRONMENT.replace("3.10", "3.12")) + conda.update_conda_environment("3.12") # already up to date -> no error + + def uses_constraints_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + _write_pyproject(tmp_path) + constraints = tmp_path / ".constraints" + constraints.mkdir() + (constraints / "py3.12.txt").write_text("numpy==1.0\n") + (tmp_path / "environment.yml").write_text(_ENVIRONMENT.replace("3.10", "3.12")) + with pytest.raises(PrecommitError, match=r"Updated Conda environment"): + conda.update_conda_environment("3.12") + result = (tmp_path / "environment.yml").read_text() + assert "-c .constraints/py3.12.txt -e .[dev]" in result diff --git a/tests/nb/test_binder.py b/tests/nb/test_binder.py new file mode 100644 index 00000000..449cf227 --- /dev/null +++ b/tests/nb/test_binder.py @@ -0,0 +1,80 @@ +import os +import stat +from pathlib import Path + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.nb import binder + +# cspell:ignore apt astral nenv pyproject + + +def describe_main(): + def configures_uv(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + binder.main("uv", "3.12", ["graphviz"]) + assert (tmp_path / ".binder" / "apt.txt").read_text() == "graphviz\n" + assert (tmp_path / ".binder" / "runtime.txt").read_text() == "python-3.12\n" + post_build = (tmp_path / ".binder" / "postBuild").read_text() + assert "astral.sh/uv/install.sh" in post_build + + def configures_pixi_with_activation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "pixi.toml").write_text( + '[activation]\nscripts = ["setup.sh"]\nenv = {MY_VAR = "1"}\n' + ) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[dependency-groups]\njupyter = ["jupyterlab"]\n' + ) + with pytest.raises(PrecommitError): + binder.main("pixi+uv", "3.12", []) + post_build = (tmp_path / ".binder" / "postBuild").read_text() + assert "pixi.sh/install.sh" in post_build + assert 'export MY_VAR="1"' in post_build + assert "bash setup.sh" in post_build + assert "--group jupyter" in post_build + + +def describe_update_apt_txt(): + def removes_when_no_packages(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + apt_txt = tmp_path / ".binder" / "apt.txt" + apt_txt.parent.mkdir() + apt_txt.write_text("graphviz\n") + with pytest.raises(PrecommitError, match=r"Removed"): + binder._update_apt_txt([]) + assert not apt_txt.exists() + + def is_noop_when_no_packages_and_absent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + binder._update_apt_txt([]) # no packages, no file -> no error + + +def describe_update_post_build(): + def raises_for_unsupported_manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(NotImplementedError, match=r"conda is not supported"): + binder._update_post_build("conda") + + +def describe_make_executable(): + def sets_executable_bit(tmp_path: Path): + script = tmp_path / "postBuild" + script.write_text("#!/bin/bash\n") + script.chmod(0o644) + with pytest.raises(PrecommitError, match=r"made executable"): + binder._make_executable(script) + assert os.access(script, os.X_OK) + + def is_noop_when_already_executable(tmp_path: Path): + script = tmp_path / "postBuild" + script.write_text("#!/bin/bash\n") + script.chmod(0o755) + binder._make_executable(script) # already executable -> no error + assert script.stat().st_mode & stat.S_IXUSR diff --git a/tests/python/test_pyproject.py b/tests/python/test_pyproject.py new file mode 100644 index 00000000..b71c9769 --- /dev/null +++ b/tests/python/test_pyproject.py @@ -0,0 +1,185 @@ +import io +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.python.pyproject import ( + _convert_to_dependency_groups, + _rename_sty_to_style, + _update_pypi_link_names, + _update_python_version_classifiers, + _update_requires_python, + main, +) +from compwa_policy.utilities.pyproject import ModifiablePyproject + +# cspell:ignore pyproject + + +def describe_update_pypi_link_names(): + def renames_known_labels(): + config = dedent(""" + [project.urls] + Repository = "https://github.com/ComPWA/policy" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Renamed"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_pypi_link_names(pyproject) + urls = pyproject.get_table("project.urls") + assert "Source" in urls + assert "Repository" not in urls + + def capitalizes_lowercase_names(): + config = dedent(""" + [project.urls] + homepage = "https://compwa.github.io" + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Capitalized"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_pypi_link_names(pyproject) + assert "Homepage" in pyproject.get_table("project.urls") + + def is_noop_without_urls(): + with ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject: + _update_pypi_link_names(pyproject) # no project.urls -> no-op + + +def describe_convert_to_dependency_groups(): + def moves_dev_groups(): + config = dedent(""" + [project] + name = "my-package" + + [project.optional-dependencies] + test = ["pytest"] + viz = ["matplotlib"] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Converted optional-dependencies"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _convert_to_dependency_groups(pyproject) + groups = pyproject.get_table("dependency-groups") + assert list(groups["test"]) == ["pytest"] + optional = pyproject.get_table("project.optional-dependencies") + assert "viz" in optional # non-dev group kept + + def is_noop_without_optional_dependencies(): + with ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject: + _convert_to_dependency_groups(pyproject) # no table -> no-op + + +def describe_rename_sty_to_style(): + def renames_group_and_includes(): + config = dedent(""" + [dependency-groups] + sty = ["ruff"] + dev = [{include-group = "sty"}] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Renamed 'sty'"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _rename_sty_to_style(pyproject) + groups = pyproject.get_table("dependency-groups") + assert "style" in groups + assert "sty" not in groups + assert groups["dev"][0]["include-group"] == "style" + + def is_noop_without_sty(): + with ModifiablePyproject.load( + io.StringIO("[dependency-groups]\ndev = []\n") + ) as pyproject: + _rename_sty_to_style(pyproject) # no 'sty' group -> no-op + + +def describe_update_requires_python(): + def derives_from_python_version_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / ".python-version").write_text("3.12\n") + with ( + pytest.raises(PrecommitError, match=r"requires-python"), + ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject, + ): + _update_requires_python(pyproject) + assert pyproject.get_table("project")["requires-python"] == ">=3.12" + + def is_noop_when_already_set(): + config = dedent(""" + [project] + name = "x" + requires-python = ">=3.10" + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_requires_python(pyproject) # already set -> no-op + + +def describe_update_python_version_classifiers(): + def updates_outdated_classifiers(): + config = dedent(""" + [project] + name = "x" + requires-python = ">=3.12" + classifiers = ["Programming Language :: Python :: 3.10"] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Updated Python version classifiers"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_python_version_classifiers( + pyproject, excluded_python_versions=set() + ) + classifiers = list(pyproject.get_table("project")["classifiers"]) + assert "Programming Language :: Python :: 3.12" in classifiers + assert "Programming Language :: Python :: 3.10" not in classifiers + + def is_noop_without_classifiers_or_tests( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) # no tests/ dir here + config = dedent(""" + [project] + name = "x" + requires-python = ">=3.12" + """).lstrip() + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _update_python_version_classifiers( + pyproject, excluded_python_versions=set() + ) # no classifiers and no tests/ dir -> no-op + + +def describe_main(): + def runs_all_updates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + dedent(""" + [project] + name = "my-package" + requires-python = ">=3.12" + classifiers = ["Programming Language :: Python :: 3.10"] + """).lstrip() + ) + with pytest.raises(PrecommitError): + main(excluded_python_versions=set()) + result = (tmp_path / "pyproject.toml").read_text() + assert "Python :: 3.12" in result + + def returns_early_without_pyproject( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + main(excluded_python_versions=set()) # no pyproject.toml -> no-op From 7caf108d10dfe36570711f43216a7dba2e926667 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:54:57 +0200 Subject: [PATCH 15/20] DX: test `ty` and `pyright` modules * DX: increase test coverage to 83% --- codecov.yml | 2 +- pyproject.toml | 2 +- tests/pyright/test_pyright.py | 103 ++++++++++++++++++++++ tests/python/test_ty.py | 158 ++++++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 tests/python/test_ty.py diff --git a/codecov.yml b/codecov.yml index 14a2d813..bca06d9a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 81% + target: 83% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 3b7ae2eb..40a6681f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -206,7 +206,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=81 \ + --cov-fail-under=83 \ --cov-report=html \ --cov-report=xml \ ${paths} diff --git a/tests/pyright/test_pyright.py b/tests/pyright/test_pyright.py index 7991508b..e31af8c4 100644 --- a/tests/pyright/test_pyright.py +++ b/tests/pyright/test_pyright.py @@ -1,4 +1,5 @@ import io +import json import re from pathlib import Path from textwrap import dedent @@ -8,12 +9,18 @@ from compwa_policy.errors import PrecommitError from compwa_policy.python.pyright import ( _merge_config_into_pyproject, + _remove_excludes, + _remove_pyright, _update_precommit, _update_settings, + _update_vscode_settings, + main, ) from compwa_policy.utilities.precommit import ModifiablePrecommit from compwa_policy.utilities.pyproject import ModifiablePyproject +# cspell:ignore pylance pyproject pyrightconfig + @pytest.fixture def this_dir() -> Path: @@ -21,6 +28,13 @@ def this_dir() -> Path: def describe_merge_config_into_pyproject(): + def is_noop_without_config(tmp_path: Path): + input_stream = io.StringIO("[project]\nname = 'x'\n") + with ModifiablePyproject.load(input_stream) as pyproject: + _merge_config_into_pyproject( + pyproject, tmp_path / "pyrightconfig.json" + ) # no config file -> no-op + def imports_from_json(this_dir: Path): input_stream = io.StringIO() old_config_path = this_dir / "pyrightconfig.json" @@ -98,3 +112,92 @@ def adds_strict_settings(): venvPath = "." """) assert result.strip() == expected_result.strip() + + +def describe_remove_excludes(): + def removes_exclude_key(): + config = dedent(""" + [tool.pyright] + include = ["src"] + exclude = ["tests"] + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Removed pyright excludes"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _remove_excludes(pyproject) + assert "exclude" not in pyproject.get_table("tool.pyright") + + def is_noop_without_exclude(): + config = '[tool.pyright]\ninclude = ["src"]\n' + with ModifiablePyproject.load(io.StringIO(config)) as pyproject: + _remove_excludes(pyproject) # no exclude -> no-op + + def is_noop_without_pyright_table(): + with ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject: + _remove_excludes(pyproject) # no tool.pyright -> no-op + + +def describe_update_vscode_settings(): + def recommends_pylance_when_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_settings(active=True) + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "ms-python.vscode-pylance" in extensions["recommendations"] + + def removes_pylance_when_inactive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + (vscode_dir / "extensions.json").write_text( + json.dumps({"recommendations": ["ms-python.vscode-pylance"]}) + ) + with pytest.raises(PrecommitError): + _update_vscode_settings(active=False) + extensions = json.loads((vscode_dir / "extensions.json").read_text()) + assert "ms-python.vscode-pylance" not in extensions.get("recommendations", []) + + +def describe_remove_pyright(): + def removes_config_and_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyrightconfig.json").write_text("{}") + config = dedent(""" + [project] + name = "x" + + [tool.pyright] + typeCheckingMode = "strict" + + [dependency-groups] + style = ["pyright"] + """).lstrip() + precommit_yaml = dedent(""" + repos: + - repo: https://github.com/ComPWA/pyright-pre-commit + rev: v1.1.0 + hooks: + - id: pyright + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(precommit_yaml)) as precommit, + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _remove_pyright(precommit, pyproject) + assert not (tmp_path / "pyrightconfig.json").exists() + assert "tool.pyright" not in pyproject.dumps() + assert "id: pyright" not in precommit.dumps() + + +def describe_main(): + def configures_vscode_when_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: + main(active=True, precommit=precommit) + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "ms-python.vscode-pylance" in extensions["recommendations"] diff --git a/tests/python/test_ty.py b/tests/python/test_ty.py new file mode 100644 index 00000000..293cadf1 --- /dev/null +++ b/tests/python/test_ty.py @@ -0,0 +1,158 @@ +import io +import json +from pathlib import Path +from textwrap import dedent + +import pytest + +from compwa_policy.errors import PrecommitError +from compwa_policy.python.ty import ( + _remove_ty, + _update_configuration, + _update_precommit_config, + _update_vscode_settings, + main, +) +from compwa_policy.utilities.precommit import ModifiablePrecommit +from compwa_policy.utilities.pyproject import ModifiablePyproject + +# cspell:ignore pyproject + + +def describe_update_configuration(): + def sets_rules_and_terminal(): + with ( + pytest.raises(PrecommitError, match=r"tool.ty"), + ModifiablePyproject.load( + io.StringIO("[project]\nname = 'x'\n") + ) as pyproject, + ): + _update_configuration(pyproject) + rules = pyproject.get_table("tool.ty.rules") + assert rules["division-by-zero"] == "warn" + assert pyproject.get_table("tool.ty.terminal")["error-on-warning"] is True + + def removes_default_unused_ignore_rule(): + config = dedent(""" + [tool.ty.rules] + unused-ignore-comment = "warn" + division-by-zero = "warn" + possibly-missing-import = "warn" + possibly-unresolved-reference = "warn" + + [tool.ty.terminal] + error-on-warning = true + """).lstrip() + with ( + pytest.raises(PrecommitError, match=r"Removed tool.ty.rules"), + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _update_configuration(pyproject) + assert "unused-ignore-comment" not in pyproject.get_table("tool.ty.rules") + + +def describe_update_precommit_config(): + def adds_local_ty_hook(): + config = dedent(""" + repos: + - repo: meta + hooks: + - id: check-hooks-apply + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_config(precommit) + result = precommit.dumps() + assert "repo: local" in result + assert "id: ty" in result + + def preserves_existing_exclude(): + config = dedent(""" + repos: + - repo: local + hooks: + - id: ty + name: ty + entry: ty check + language: system + exclude: ^docs/ + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(config)) as precommit, + ): + _update_precommit_config(precommit) + assert "exclude: ^docs/" in precommit.dumps() + + +def describe_update_vscode_settings(): + def recommends_extension_when_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + with pytest.raises(PrecommitError): + _update_vscode_settings({"ty"}) + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "astral-sh.ty" in extensions["recommendations"] + + def removes_extension_when_inactive( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + (vscode_dir / "extensions.json").write_text( + json.dumps({"recommendations": ["astral-sh.ty"]}) + ) + with pytest.raises(PrecommitError): + _update_vscode_settings({"mypy"}) + extensions = json.loads((vscode_dir / "extensions.json").read_text()) + assert "astral-sh.ty" not in extensions.get("recommendations", []) + + +def describe_remove_ty(): + def removes_config_and_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "ty.toml").write_text("") + config = dedent(""" + [project] + name = "x" + + [tool.ty.rules] + division-by-zero = "warn" + + [dependency-groups] + style = ["ty"] + """).lstrip() + precommit_yaml = dedent(""" + repos: + - repo: local + hooks: + - id: ty + name: ty + entry: ty check + language: system + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(precommit_yaml)) as precommit, + ModifiablePyproject.load(io.StringIO(config)) as pyproject, + ): + _remove_ty(precommit, pyproject) + assert not (tmp_path / "ty.toml").exists() + assert "tool.ty" not in pyproject.dumps() + assert "id: ty" not in precommit.dumps() + + +def describe_main(): + def configures_vscode_when_selected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: + main({"ty"}, keep_precommit=False, precommit=precommit) + extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) + assert "astral-sh.ty" in extensions["recommendations"] From c63b55294a0632c28e31b31084c245b69a3c234b Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:19:43 +0200 Subject: [PATCH 16/20] FIX: apply all `ty`/`pyright` updates in one pass Route every sub-step in `ty.main`/`pyright.main` (and their `_update_vscode_settings` helpers) through an `Executor`, as the other check modules already do. Previously the functions called each fallible step directly, so the first `PrecommitError` (e.g. the initial VS Code change) aborted the `with` body and was swallowed by the empty-changelog `ModifiablePyproject` context. That made the config, dependency, and pre-commit updates land only on later runs; they now all apply at once. --- .cspell.json | 2 ++ src/compwa_policy/format/prettier.py | 2 +- src/compwa_policy/python/pyright.py | 52 ++++++++++++++++----------- src/compwa_policy/python/ty.py | 37 ++++++++++--------- tests/cli/test_checks.py | 2 -- tests/env/test_conda.py | 2 -- tests/nb/test_binder.py | 2 +- tests/pyright/test_pyright.py | 35 +++++++++++++++--- tests/python/test_pyproject.py | 2 -- tests/python/test_pytest.py | 2 +- tests/python/test_ty.py | 35 +++++++++++++++--- tests/readthedocs/test_readthedocs.py | 1 - tests/repo/test_poe.py | 1 - 13 files changed, 118 insertions(+), 57 deletions(-) diff --git a/.cspell.json b/.cspell.json index a5949869..1c5e0ab8 100644 --- a/.cspell.json +++ b/.cspell.json @@ -48,6 +48,7 @@ "autonumbering", "autoupdate", "bdist", + "capsys", "celltoolbar", "codecov", "colab", @@ -71,6 +72,7 @@ "nbcell", "nbformat", "nbhooks", + "nbmake", "nbqa", "nbstripout", "noqa", diff --git a/src/compwa_policy/format/prettier.py b/src/compwa_policy/format/prettier.py index 70e36fda..4d3a5bd9 100644 --- a/src/compwa_policy/format/prettier.py +++ b/src/compwa_policy/format/prettier.py @@ -15,7 +15,7 @@ from compwa_policy.utilities.precommit import ModifiablePrecommit -# cspell:ignore esbenp rettier +# cspell:ignore rettier __VSCODE_EXTENSION_NAME = "esbenp.prettier-vscode" __BADGE = """ [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) diff --git a/src/compwa_policy/python/pyright.py b/src/compwa_policy/python/pyright.py index 727711d8..4699cbc0 100644 --- a/src/compwa_policy/python/pyright.py +++ b/src/compwa_policy/python/pyright.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING from compwa_policy.utilities import CONFIG_PATH, remove_lines, vscode +from compwa_policy.utilities.executor import Executor from compwa_policy.utilities.precommit.struct import Hook, Repo from compwa_policy.utilities.pyproject import ModifiablePyproject, complies_with_subset from compwa_policy.utilities.toml import to_toml_array @@ -17,15 +18,15 @@ def main(active: bool, precommit: ModifiablePrecommit) -> None: - with ModifiablePyproject.load() as pyproject: - _update_vscode_settings(active) + with Executor() as do, ModifiablePyproject.load() as pyproject: + do(_update_vscode_settings, active) if active: - _merge_config_into_pyproject(pyproject) - _update_precommit(precommit) - _remove_excludes(pyproject) - _update_settings(pyproject) + do(_merge_config_into_pyproject, pyproject) + do(_update_precommit, precommit) + do(_remove_excludes, pyproject) + do(_update_settings, pyproject) else: - _remove_pyright(precommit, pyproject) + do(_remove_pyright, precommit, pyproject) def _merge_config_into_pyproject( @@ -89,20 +90,29 @@ def _update_settings(pyproject: ModifiablePyproject) -> None: def _update_vscode_settings(active: bool) -> None: - if active: - vscode.add_extension_recommendation("ms-python.vscode-pylance") - vscode.update_settings({ - "python.analysis.autoImportCompletions": False, - "python.analysis.inlayHints.pytestParameters": True, - }) - else: - vscode.remove_settings([ - "python.analysis.autoImportCompletions", - "python.analysis.inlayHints.pytestParameters", - ]) - vscode.remove_extension_recommendation( - "ms-python.vscode-pylance", unwanted=True - ) + with Executor() as do: + if active: + do(vscode.add_extension_recommendation, "ms-python.vscode-pylance") + do( + vscode.update_settings, + { + "python.analysis.autoImportCompletions": False, + "python.analysis.inlayHints.pytestParameters": True, + }, + ) + else: + do( + vscode.remove_settings, + [ + "python.analysis.autoImportCompletions", + "python.analysis.inlayHints.pytestParameters", + ], + ) + do( + vscode.remove_extension_recommendation, + "ms-python.vscode-pylance", + unwanted=True, + ) def _remove_pyright( diff --git a/src/compwa_policy/python/ty.py b/src/compwa_policy/python/ty.py index 1e78f411..cc426384 100644 --- a/src/compwa_policy/python/ty.py +++ b/src/compwa_policy/python/ty.py @@ -8,6 +8,7 @@ from ruamel.yaml.comments import CommentedSeq from compwa_policy.utilities import vscode +from compwa_policy.utilities.executor import Executor from compwa_policy.utilities.precommit.getters import find_hook from compwa_policy.utilities.precommit.struct import Hook, Repo from compwa_policy.utilities.pyproject import ModifiablePyproject @@ -25,15 +26,15 @@ def main( keep_precommit: bool, precommit: ModifiablePrecommit, ) -> None: - with ModifiablePyproject.load() as pyproject: - _update_vscode_settings(type_checkers) + with Executor() as do, ModifiablePyproject.load() as pyproject: + do(_update_vscode_settings, type_checkers) if "ty" in type_checkers: - _update_configuration(pyproject) - pyproject.add_dependency("ty", dependency_group=["style", "dev"]) + do(_update_configuration, pyproject) + do(pyproject.add_dependency, "ty", dependency_group=["style", "dev"]) if not keep_precommit: - _update_precommit_config(precommit) + do(_update_precommit_config, precommit) else: - _remove_ty(precommit, pyproject) + do(_remove_ty, precommit, pyproject) def _update_vscode_settings(type_checkers: set[TypeChecker]) -> None: @@ -42,17 +43,19 @@ def _update_vscode_settings(type_checkers: set[TypeChecker]) -> None: "ty.diagnosticMode": "workspace", "ty.importStrategy": "fromEnvironment", } - if "ty" in type_checkers: - if "pyright" not in type_checkers: - vscode.remove_settings(["python.languageServer"]) - vscode.add_extension_recommendation("astral-sh.ty") - vscode.update_settings(settings) - add_badge( - "[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)" - ) - else: - vscode.remove_extension_recommendation("astral-sh.ty", unwanted=True) - vscode.remove_settings([*settings, "python.languageServer"]) + with Executor() as do: + if "ty" in type_checkers: + if "pyright" not in type_checkers: + do(vscode.remove_settings, ["python.languageServer"]) + do(vscode.add_extension_recommendation, "astral-sh.ty") + do(vscode.update_settings, settings) + do( + add_badge, + "[![ty](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ty/main/assets/badge/v0.json)](https://github.com/astral-sh/ty)", + ) + else: + do(vscode.remove_extension_recommendation, "astral-sh.ty", unwanted=True) + do(vscode.remove_settings, [*settings, "python.languageServer"]) def _update_configuration(pyproject: ModifiablePyproject) -> None: diff --git a/tests/cli/test_checks.py b/tests/cli/test_checks.py index 293770be..207ead7a 100644 --- a/tests/cli/test_checks.py +++ b/tests/cli/test_checks.py @@ -14,8 +14,6 @@ ) from compwa_policy.cli._options import build_arguments -# cspell:ignore capsys classifiers pyproject - _PYPROJECT = dedent(""" [project] name = "my-package" diff --git a/tests/env/test_conda.py b/tests/env/test_conda.py index 345e67fa..4200d81f 100644 --- a/tests/env/test_conda.py +++ b/tests/env/test_conda.py @@ -6,8 +6,6 @@ from compwa_policy.env import conda from compwa_policy.errors import PrecommitError -# cspell:ignore condaenv pyproject - _ENVIRONMENT = dedent(""" name: my-package channels: diff --git a/tests/nb/test_binder.py b/tests/nb/test_binder.py index 449cf227..5f5aee9c 100644 --- a/tests/nb/test_binder.py +++ b/tests/nb/test_binder.py @@ -7,7 +7,7 @@ from compwa_policy.errors import PrecommitError from compwa_policy.nb import binder -# cspell:ignore apt astral nenv pyproject +# cspell:ignore nenv def describe_main(): diff --git a/tests/pyright/test_pyright.py b/tests/pyright/test_pyright.py index e31af8c4..096e88d1 100644 --- a/tests/pyright/test_pyright.py +++ b/tests/pyright/test_pyright.py @@ -19,8 +19,6 @@ from compwa_policy.utilities.precommit import ModifiablePrecommit from compwa_policy.utilities.pyproject import ModifiablePyproject -# cspell:ignore pylance pyproject pyrightconfig - @pytest.fixture def this_dir() -> Path: @@ -194,10 +192,39 @@ def removes_config_and_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): def describe_main(): - def configures_vscode_when_active(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + def configures_everything_when_active( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): monkeypatch.chdir(tmp_path) (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): main(active=True, precommit=precommit) + # All steps are applied in a single pass (no per-step short-circuit). + pyproject_text = (tmp_path / "pyproject.toml").read_text() + assert "typeCheckingMode" in pyproject_text # _update_settings ran + assert "id: pyright" in precommit.dumps() extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) assert "ms-python.vscode-pylance" in extensions["recommendations"] + + def removes_pyright_when_inactive(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[tool.pyright]\ntypeCheckingMode = "strict"\n' + ) + precommit_yaml = dedent(""" + repos: + - repo: https://github.com/ComPWA/pyright-pre-commit + rev: v1.1.0 + hooks: + - id: pyright + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(precommit_yaml)) as precommit, + ): + main(active=False, precommit=precommit) + assert "tool.pyright" not in (tmp_path / "pyproject.toml").read_text() + assert "id: pyright" not in precommit.dumps() diff --git a/tests/python/test_pyproject.py b/tests/python/test_pyproject.py index b71c9769..e88e88e3 100644 --- a/tests/python/test_pyproject.py +++ b/tests/python/test_pyproject.py @@ -15,8 +15,6 @@ ) from compwa_policy.utilities.pyproject import ModifiablePyproject -# cspell:ignore pyproject - def describe_update_pypi_link_names(): def renames_known_labels(): diff --git a/tests/python/test_pytest.py b/tests/python/test_pytest.py index ff4b68c1..6335a1cc 100644 --- a/tests/python/test_pytest.py +++ b/tests/python/test_pytest.py @@ -17,7 +17,7 @@ ) from compwa_policy.utilities.pyproject import ModifiablePyproject, Pyproject -# cspell:ignore addopts importmode minversion numprocesses ryanluker xdist +# cspell:ignore minversion ryanluker xdist def describe_deny_ini_options(): diff --git a/tests/python/test_ty.py b/tests/python/test_ty.py index 293cadf1..fbad2c2a 100644 --- a/tests/python/test_ty.py +++ b/tests/python/test_ty.py @@ -16,8 +16,6 @@ from compwa_policy.utilities.precommit import ModifiablePrecommit from compwa_policy.utilities.pyproject import ModifiablePyproject -# cspell:ignore pyproject - def describe_update_configuration(): def sets_rules_and_terminal(): @@ -147,12 +145,41 @@ def removes_config_and_hook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): def describe_main(): - def configures_vscode_when_selected( + def configures_everything_when_selected( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): monkeypatch.chdir(tmp_path) (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') - with ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit: + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO("repos: []\n")) as precommit, + ): main({"ty"}, keep_precommit=False, precommit=precommit) + # All steps are applied in a single pass (no per-step short-circuit). + pyproject_text = (tmp_path / "pyproject.toml").read_text() + assert "[tool.ty.rules]" in pyproject_text + assert "id: ty" in precommit.dumps() extensions = json.loads((tmp_path / ".vscode" / "extensions.json").read_text()) assert "astral-sh.ty" in extensions["recommendations"] + + def removes_ty_when_not_selected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "x"\n\n[tool.ty.rules]\ndivision-by-zero = "warn"\n' + ) + precommit_yaml = dedent(""" + repos: + - repo: local + hooks: + - id: ty + name: ty + entry: ty check + language: system + """).lstrip() + with ( + pytest.raises(PrecommitError), + ModifiablePrecommit.load(io.StringIO(precommit_yaml)) as precommit, + ): + main(set(), keep_precommit=False, precommit=precommit) + assert "tool.ty" not in (tmp_path / "pyproject.toml").read_text() + assert "id: ty" not in precommit.dumps() diff --git a/tests/readthedocs/test_readthedocs.py b/tests/readthedocs/test_readthedocs.py index de33b68d..9b0378c6 100644 --- a/tests/readthedocs/test_readthedocs.py +++ b/tests/readthedocs/test_readthedocs.py @@ -16,7 +16,6 @@ from compwa_policy.utilities.pyproject.getters import PythonVersion -# cspell:ignore apt poethepoet pyproject BAD_OVERWRITE_WITH_JOBS = dedent(""" version: 2 diff --git a/tests/repo/test_poe.py b/tests/repo/test_poe.py index feac5e97..61f0dc87 100644 --- a/tests/repo/test_poe.py +++ b/tests/repo/test_poe.py @@ -15,7 +15,6 @@ ) from compwa_policy.utilities.pyproject import ModifiablePyproject, Pyproject -# cspell:ignore nbmake _PYPROJECT = dedent(""" [project] name = "my-package" From 794701cdbd1ff0e24d9b1e2fa56e3b308c080935 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:59:58 +0200 Subject: [PATCH 17/20] TEST: disable branch coverage --- .pre-commit-config.yaml | 14 -------------- pyproject.toml | 1 - 2 files changed, 15 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9175645b..4a8c5c46 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,20 +39,6 @@ repos: args: ["--django"] - id: trailing-whitespace - - repo: local - hooks: - - id: check-dev-files - name: Check developer config files in the repository - entry: check-dev-files - language: python - always_run: true - pass_filenames: false - - id: self-check - name: self-check - entry: self-check - language: python - files: ^\.pre\-commit\-(config|hooks)\.yaml$ - - repo: https://github.com/ComPWA/prettier-pre-commit rev: v3.8.1 hooks: diff --git a/pyproject.toml b/pyproject.toml index 40a6681f..2c8634da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,7 +135,6 @@ exclude_also = [ ] [tool.coverage.run] -branch = true omit = [ "benchmarks/**/*.py", "docs/**/*.ipynb", From 9a364febcbbf0593aa65138962d61ad68bac9d42 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:07:52 +0200 Subject: [PATCH 18/20] Revert "TEST: disable branch coverage" This reverts commit 794701cdbd1ff0e24d9b1e2fa56e3b308c080935. --- .pre-commit-config.yaml | 14 ++++++++++++++ pyproject.toml | 1 + 2 files changed, 15 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a8c5c46..9175645b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,6 +39,20 @@ repos: args: ["--django"] - id: trailing-whitespace + - repo: local + hooks: + - id: check-dev-files + name: Check developer config files in the repository + entry: check-dev-files + language: python + always_run: true + pass_filenames: false + - id: self-check + name: self-check + entry: self-check + language: python + files: ^\.pre\-commit\-(config|hooks)\.yaml$ + - repo: https://github.com/ComPWA/prettier-pre-commit rev: v3.8.1 hooks: diff --git a/pyproject.toml b/pyproject.toml index 2c8634da..40a6681f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -135,6 +135,7 @@ exclude_also = [ ] [tool.coverage.run] +branch = true omit = [ "benchmarks/**/*.py", "docs/**/*.ipynb", From 7f0cd1e1e071303507877d5412e4904f2a7099e8 Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:37:16 +0200 Subject: [PATCH 19/20] DX: disable branch coverage * FIX: remove comitted Codex configuration --- .codex/skills/pre-merge | 1 - .codex/skills/update-pr | 1 - .gitignore | 2 ++ AGENTS.md | 1 - pyproject.toml | 3 ++- 5 files changed, 4 insertions(+), 4 deletions(-) delete mode 120000 .codex/skills/pre-merge delete mode 120000 .codex/skills/update-pr delete mode 120000 AGENTS.md diff --git a/.codex/skills/pre-merge b/.codex/skills/pre-merge deleted file mode 120000 index 4a49068d..00000000 --- a/.codex/skills/pre-merge +++ /dev/null @@ -1 +0,0 @@ -../../.claude/skills/pre-merge \ No newline at end of file diff --git a/.codex/skills/update-pr b/.codex/skills/update-pr deleted file mode 120000 index 5d42ea07..00000000 --- a/.codex/skills/update-pr +++ /dev/null @@ -1 +0,0 @@ -../../.claude/skills/update-pr \ No newline at end of file diff --git a/.gitignore b/.gitignore index e7a21e7f..454c0a32 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,9 @@ uv.lock # Settings .claude/ +.codex/ .idea/ +AGENTS.md CLAUDE.md **.code-workspace diff --git a/AGENTS.md b/AGENTS.md deleted file mode 120000 index 681311eb..00000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 40a6681f..92480432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ allow-labels = true no-pypi = true [tool.compwa.policy.python] +branch-coverage = false keep-local-precommit = true type-checker = ["ty"] @@ -135,7 +136,7 @@ exclude_also = [ ] [tool.coverage.run] -branch = true +branch = false omit = [ "benchmarks/**/*.py", "docs/**/*.ipynb", From cfb897be49d85962f1be1e5b24fa08f9da68a48e Mon Sep 17 00:00:00 2001 From: Remco de Boer <29308176+redeboer@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:06:17 +0200 Subject: [PATCH 20/20] DX: increase test coverage to 85% --- codecov.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codecov.yml b/codecov.yml index bca06d9a..dd491386 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,7 +9,7 @@ coverage: status: project: default: - target: 83% + target: 85% threshold: 1% base: auto if_no_uploads: error diff --git a/pyproject.toml b/pyproject.toml index 92480432..c77e40b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,7 +207,7 @@ heading = "Testing" cmd = """ pytest \ --cov=compwa_policy \ - --cov-fail-under=83 \ + --cov-fail-under=85 \ --cov-report=html \ --cov-report=xml \ ${paths}