diff --git a/.vscode/settings.json b/.vscode/settings.json index 75509a0a..1dd0d7b7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -70,7 +70,8 @@ "src/compwa_policy/.github/workflows/pr-linting.yml": true, "src/compwa_policy/.github/workflows/release-drafter.yml": true, "src/compwa_policy/.template/.gitpod.yml": true, - "src/compwa_policy/.template/.prettierrc": true + "src/compwa_policy/.template/.prettierrc": true, + "tests/**/.pre-commit-config*.yaml": true }, "telemetry.telemetryLevel": "off", "yaml.schemas": { diff --git a/docs/conf.py b/docs/conf.py index 58a37bce..b876e0a2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ "obj", "compwa_policy.check_dev_files.dependabot.DependabotOption", ), + "Frequency": "typing.Literal", "IO": "typing.IO", "Iterable": "typing.Iterable", "K": "typing.TypeVar", diff --git a/pyproject.toml b/pyproject.toml index c7318c76..f8105147 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "ini2toml", "nbformat", "pip-tools", - "pre-commit", "ruamel.yaml", # better YAML dumping "tomlkit", # preserve original TOML formatting 'rtoml', # fast, read-only parsing @@ -145,10 +144,6 @@ module = ["ruamel.*"] ignore_missing_imports = true module = ["nbformat.*"] -[[tool.mypy.overrides]] -ignore_missing_imports = true -module = ["pre_commit.commands.autoupdate.*"] - [tool.pyright] exclude = [ "**/.git", @@ -272,6 +267,9 @@ ignore = [ ] task-tags = ["cspell"] +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + [tool.ruff.lint.isort] known-first-party = ["compwa_policy"] split-on-trailing-comma = false diff --git a/src/compwa_policy/check_dev_files/__init__.py b/src/compwa_policy/check_dev_files/__init__.py index 01199d02..72a6dc53 100644 --- a/src/compwa_policy/check_dev_files/__init__.py +++ b/src/compwa_policy/check_dev_files/__init__.py @@ -7,10 +7,7 @@ from argparse import ArgumentParser from typing import TYPE_CHECKING, Any, Sequence -from compwa_policy.check_dev_files.deprecated import remove_deprecated_tools -from compwa_policy.utilities.executor import Executor - -from . import ( +from compwa_policy.check_dev_files import ( black, citation, commitlint, @@ -37,6 +34,9 @@ update_pip_constraints, vscode, ) +from compwa_policy.check_dev_files.deprecated import remove_deprecated_tools +from compwa_policy.utilities.executor import Executor +from compwa_policy.utilities.precommit import ModifiablePrecommit if TYPE_CHECKING: from compwa_policy.utilities.pyproject import PythonVersion @@ -50,18 +50,21 @@ def main(argv: Sequence[str] | None = None) -> int: args.repo_title = args.repo_name has_notebooks = not args.no_notebooks dev_python_version = __get_python_version(args.dev_python_version) - with Executor(raise_exception=False) as do: - do(citation.main) + with Executor( + raise_exception=False + ) as do, ModifiablePrecommit.load() as precommit_config: + do(citation.main, precommit_config) do(commitlint.main) do(conda.main, dev_python_version) - do(cspell.main, args.no_cspell_update) + do(cspell.main, precommit_config, args.no_cspell_update) do(dependabot.main, args.dependabot) - do(editorconfig.main, args.no_python) + do(editorconfig.main, precommit_config, args.no_python) if not args.allow_labels: do(github_labels.main) if not args.no_github_actions: do( github_workflows.main, + precommit_config, allow_deprecated=args.allow_deprecated_workflows, doc_apt_packages=_to_list(args.doc_apt_packages), github_pages=args.github_pages, @@ -76,12 +79,12 @@ def main(argv: Sequence[str] | None = None) -> int: ) if has_notebooks: do(jupyter.main) - do(nbstripout.main) - do(toml.main) # has to run before pre-commit - do(prettier.main, args.no_prettierrc) + do(nbstripout.main, precommit_config) + do(toml.main, precommit_config) # has to run before pre-commit + do(prettier.main, precommit_config, args.no_prettierrc) if is_python_repo: if args.no_ruff: - do(black.main, has_notebooks) + do(black.main, precommit_config, has_notebooks) if not args.no_github_actions: do( release_drafter.main, @@ -92,19 +95,20 @@ def main(argv: Sequence[str] | None = None) -> int: do(mypy.main) do(pyright.main) do(pytest.main) - do(pyupgrade.main, args.no_ruff) + do(pyupgrade.main, precommit_config, args.no_ruff) if not args.no_ruff: - do(ruff.main, has_notebooks) + do(ruff.main, precommit_config, has_notebooks) if args.pin_requirements != "no": do( update_pip_constraints.main, + precommit_config, frequency=args.pin_requirements, ) do(readthedocs.main, dev_python_version) - do(remove_deprecated_tools, args.keep_issue_templates) + do(remove_deprecated_tools, precommit_config, args.keep_issue_templates) do(vscode.main, has_notebooks) do(gitpod.main, args.no_gitpod, dev_python_version) - do(precommit.main) + do(precommit.main, precommit_config) do(tox.main, has_notebooks) return 1 if do.error_messages else 0 diff --git a/src/compwa_policy/check_dev_files/black.py b/src/compwa_policy/check_dev_files/black.py index cd00dc0e..75727e82 100644 --- a/src/compwa_policy/check_dev_files/black.py +++ b/src/compwa_policy/check_dev_files/black.py @@ -4,33 +4,29 @@ from compwa_policy.utilities import CONFIG_PATH, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - remove_precommit_hook, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit import ModifiablePrecommit +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 -def main(has_notebooks: bool) -> None: +def main(precommit: ModifiablePrecommit, has_notebooks: bool) -> None: if not CONFIG_PATH.pyproject.exists(): return with Executor() as do, ModifiablePyproject.load() as pyproject: do(_remove_outdated_settings, pyproject) do(_update_black_settings, pyproject) do( - remove_precommit_hook, + precommit.remove_hook, hook_id="black", repo_url="https://github.com/psf/black", ) do( - remove_precommit_hook, + precommit.remove_hook, hook_id="black-jupyter", repo_url="https://github.com/psf/black", ) - do(_update_precommit_repo, has_notebooks) + do(_update_precommit_repo, precommit, has_notebooks) do(vscode.add_extension_recommendation, "ms-python.black-formatter") do( vscode.update_settings, @@ -45,7 +41,7 @@ def main(has_notebooks: bool) -> None: }, }, ) - do(remove_precommit_hook, "nbqa-black") + do(precommit.remove_hook, "nbqa-black") def _remove_outdated_settings(pyproject: ModifiablePyproject) -> None: @@ -78,7 +74,7 @@ def _update_black_settings(pyproject: ModifiablePyproject) -> None: pyproject.append_to_changelog(msg) -def _update_precommit_repo(has_notebooks: bool) -> None: +def _update_precommit_repo(precommit: ModifiablePrecommit, has_notebooks: bool) -> None: expected_repo = Repo( repo="https://github.com/psf/black-pre-commit-mirror", rev="", @@ -91,4 +87,4 @@ def _update_precommit_repo(has_notebooks: bool) -> None: types_or=YAML(typ="rt").load("[jupyter]"), ) expected_repo["hooks"].append(black_jupyter) - update_single_hook_precommit_repo(expected_repo) + precommit.update_single_hook_repo(expected_repo) diff --git a/src/compwa_policy/check_dev_files/citation.py b/src/compwa_policy/check_dev_files/citation.py index e93ba140..396d724e 100644 --- a/src/compwa_policy/check_dev_files/citation.py +++ b/src/compwa_policy/check_dev_files/citation.py @@ -5,7 +5,7 @@ import json import os from textwrap import dedent -from typing import cast +from typing import TYPE_CHECKING, cast from html2text import HTML2Text from ruamel.yaml import YAML @@ -15,16 +15,13 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import CONFIG_PATH, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - find_repo_with_index, - load_roundtrip_precommit_config, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit.struct import Hook, Repo +if TYPE_CHECKING: + from compwa_policy.utilities.precommit import ModifiablePrecommit -def main() -> None: + +def main(precommit: ModifiablePrecommit) -> None: with Executor() as do: if CONFIG_PATH.zenodo.exists(): do(convert_zenodo_json) @@ -33,7 +30,7 @@ def main() -> None: if CONFIG_PATH.zenodo.exists(): do(remove_zenodo_json) do(check_citation_keys) - do(add_json_schema_precommit) + do(add_json_schema_precommit, precommit) do(vscode.add_extension_recommendation, "redhat.vscode-yaml") do(update_vscode_settings) @@ -168,7 +165,7 @@ def check_citation_keys() -> None: raise PrecommitError(msg) -def add_json_schema_precommit() -> None: +def add_json_schema_precommit(precommit: ModifiablePrecommit) -> None: if not CONFIG_PATH.citation.exists(): return # cspell:ignore jsonschema schemafile @@ -184,17 +181,15 @@ def add_json_schema_precommit() -> None: ], pass_filenames=False, ) - config, yaml = load_roundtrip_precommit_config() repo_url = "https://github.com/python-jsonschema/check-jsonschema" - idx_and_repo = find_repo_with_index(config, repo_url) - existing_repos = config["repos"] + idx_and_repo = precommit.find_repo_with_index(repo_url) if idx_and_repo is None: repo = Repo( repo=repo_url, rev="", hooks=[expected_hook], ) - update_single_hook_precommit_repo(repo) + precommit.update_single_hook_repo(repo) else: repo_idx, repo = idx_and_repo existing_hooks = repo["hooks"] @@ -208,11 +203,11 @@ def add_json_schema_precommit() -> None: existing_hooks.append(expected_hook) else: existing_hooks[hook_idx] = expected_hook + existing_repos = precommit.document["repos"] repos_yaml = cast(CommentedSeq, existing_repos) repos_yaml.yaml_set_comment_before_after_key(repo_idx + 1, before="\n") - yaml.dump(config, CONFIG_PATH.precommit) msg = f"Updated check-jsonschema hook in {CONFIG_PATH.precommit}" - raise PrecommitError(msg) + precommit.append_to_changelog(msg) def update_vscode_settings() -> None: diff --git a/src/compwa_policy/check_dev_files/cspell.py b/src/compwa_policy/check_dev_files/cspell.py index 96242de0..56dba85d 100644 --- a/src/compwa_policy/check_dev_files/cspell.py +++ b/src/compwa_policy/check_dev_files/cspell.py @@ -14,19 +14,14 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import COMPWA_POLICY_DIR, CONFIG_PATH, rename_file, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - find_repo, - load_precommit_config, - load_roundtrip_precommit_config, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit.struct import Hook, Repo from compwa_policy.utilities.readme import add_badge, remove_badge if TYPE_CHECKING: from pathlib import Path + from compwa_policy.utilities.precommit import ModifiablePrecommit + __VSCODE_EXTENSION_NAME = "streetsidesoftware.code-spell-checker" # cspell:ignore pelling @@ -44,18 +39,17 @@ __EXPECTED_CONFIG = json.load(__STREAM) -def main(no_cspell_update: bool) -> None: +def main(precommit: ModifiablePrecommit, no_cspell_update: bool) -> None: rename_file("cspell.json", str(CONFIG_PATH.cspell)) with Executor() as do: - do(_update_cspell_repo_url) + do(_update_cspell_repo_url, precommit) has_cspell_hook = False if CONFIG_PATH.cspell.exists(): - config = load_precommit_config() - has_cspell_hook = find_repo(config, __REPO_URL) is not None + has_cspell_hook = precommit.find_repo(__REPO_URL) is not None if not has_cspell_hook: do(_remove_configuration) else: - do(_update_precommit_repo) + do(_update_precommit_repo, precommit) if not no_cspell_update: do(_update_config_content) do(_sort_config_entries) @@ -63,19 +57,17 @@ def main(no_cspell_update: bool) -> None: do(vscode.add_extension_recommendation, __VSCODE_EXTENSION_NAME) -def _update_cspell_repo_url(path: Path = CONFIG_PATH.precommit) -> None: +def _update_cspell_repo_url(precommit: ModifiablePrecommit) -> None: old_url_patters = [ r".*/mirrors-cspell(.git)?$", ] - config, yaml = load_roundtrip_precommit_config(path) for pattern in old_url_patters: - repo = find_repo(config, pattern) + repo = precommit.find_repo(pattern) if repo is None: continue repo["repo"] = __REPO_URL - yaml.dump(config, path) - msg = f"Updated cSpell pre-commit repo URL to {__REPO_URL} in {path}" - raise PrecommitError(msg) + msg = f"Updated cSpell pre-commit repo URL to {__REPO_URL}" + precommit.append_to_changelog(msg) def _remove_configuration() -> None: @@ -101,13 +93,13 @@ def _remove_configuration() -> None: do(vscode.remove_extension_recommendation, __VSCODE_EXTENSION_NAME) -def _update_precommit_repo() -> None: +def _update_precommit_repo(precommit: ModifiablePrecommit) -> None: expected_hook = Repo( repo=__REPO_URL, rev="", hooks=[Hook(id="cspell")], ) - update_single_hook_precommit_repo(expected_hook) + precommit.update_single_hook_repo(expected_hook) def _update_config_content() -> None: diff --git a/src/compwa_policy/check_dev_files/deprecated.py b/src/compwa_policy/check_dev_files/deprecated.py index d0d74419..4a83d221 100644 --- a/src/compwa_policy/check_dev_files/deprecated.py +++ b/src/compwa_policy/check_dev_files/deprecated.py @@ -5,14 +5,16 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import remove_configs, remove_from_gitignore, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import remove_precommit_hook +from compwa_policy.utilities.precommit import ModifiablePrecommit -def remove_deprecated_tools(keep_issue_templates: bool) -> None: +def remove_deprecated_tools( + precommit: ModifiablePrecommit, keep_issue_templates: bool +) -> None: with Executor() as do: if not keep_issue_templates: do(_remove_github_issue_templates) - do(_remove_markdownlint) + do(_remove_markdownlint, precommit) for directory in ["docs", "doc"]: do(_remove_relink_references, directory) @@ -24,7 +26,7 @@ def _remove_github_issue_templates() -> None: ]) -def _remove_markdownlint() -> None: +def _remove_markdownlint(precommit: ModifiablePrecommit) -> None: with Executor() as do: do(remove_configs, [".markdownlint.json", ".markdownlint.yaml"]) do(remove_from_gitignore, ".markdownlint.json") @@ -34,7 +36,7 @@ def _remove_markdownlint() -> None: extension_name="davidanson.vscode-markdownlint", unwanted=True, ) - do(remove_precommit_hook, "markdownlint") + do(precommit.remove_hook, "markdownlint") def _remove_relink_references(directory: str) -> None: diff --git a/src/compwa_policy/check_dev_files/editorconfig.py b/src/compwa_policy/check_dev_files/editorconfig.py index dd37ca43..2b0ad1de 100644 --- a/src/compwa_policy/check_dev_files/editorconfig.py +++ b/src/compwa_policy/check_dev_files/editorconfig.py @@ -5,24 +5,26 @@ `_. """ +from __future__ import annotations + from textwrap import dedent +from typing import TYPE_CHECKING from ruamel.yaml.scalarstring import FoldedScalarString from compwa_policy.utilities import CONFIG_PATH -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit.struct import Hook, Repo + +if TYPE_CHECKING: + from compwa_policy.utilities.precommit import ModifiablePrecommit -def main(no_python: bool) -> None: +def main(precommit: ModifiablePrecommit, no_python: bool) -> None: if CONFIG_PATH.editorconfig.exists(): - _update_precommit_config(no_python) + _update_precommit_config(precommit, no_python) -def _update_precommit_config(no_python: bool) -> None: +def _update_precommit_config(precommit: ModifiablePrecommit, no_python: bool) -> None: hook = Hook( id="editorconfig-checker", name="editorconfig", @@ -42,4 +44,4 @@ def _update_precommit_config(no_python: bool) -> None: rev="", hooks=[hook], ) - update_single_hook_precommit_repo(expected_hook) + precommit.update_single_hook_repo(expected_hook) diff --git a/src/compwa_policy/check_dev_files/github_workflows.py b/src/compwa_policy/check_dev_files/github_workflows.py index b6d82ae9..1faf46aa 100644 --- a/src/compwa_policy/check_dev_files/github_workflows.py +++ b/src/compwa_policy/check_dev_files/github_workflows.py @@ -18,7 +18,6 @@ write, ) from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import load_precommit_config from compwa_policy.utilities.pyproject import Pyproject, PythonVersion, get_build_system from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml @@ -28,8 +27,11 @@ from ruamel.yaml.comments import CommentedMap from ruamel.yaml.main import YAML + from compwa_policy.utilities.precommit import Precommit + def main( + precommit: Precommit, *, allow_deprecated: bool, doc_apt_packages: list[str], @@ -47,6 +49,7 @@ def main( do(_update_cd_workflow, no_pypi, no_version_branches) do( _update_ci_workflow, + precommit, allow_deprecated, doc_apt_packages, github_pages, @@ -96,6 +99,7 @@ def _update_pr_linting() -> None: def _update_ci_workflow( # noqa: PLR0917 + precommit: Precommit, allow_deprecated: bool, doc_apt_packages: list[str], github_pages: bool, @@ -108,6 +112,7 @@ def _update_ci_workflow( # noqa: PLR0917 def update() -> None: yaml, expected_data = _get_ci_workflow( COMPWA_POLICY_DIR / CONFIG_PATH.github_workflow_dir / "ci.yml", + precommit, doc_apt_packages, github_pages, no_macos, @@ -142,6 +147,7 @@ def update() -> None: def _get_ci_workflow( # noqa: PLR0917 path: Path, + precommit: Precommit, doc_apt_packages: list[str], github_pages: bool, no_macos: bool, @@ -154,7 +160,7 @@ def _get_ci_workflow( # noqa: PLR0917 config = yaml.load(path) __update_doc_section(config, doc_apt_packages, python_version, github_pages) __update_pytest_section(config, no_macos, single_threaded, skip_tests, test_extras) - __update_style_section(config, python_version) + __update_style_section(config, python_version, precommit) return yaml, config @@ -177,20 +183,19 @@ def __update_doc_section( __update_with_section(config, job_name="doc") -def __update_style_section(config: CommentedMap, python_version: PythonVersion) -> None: +def __update_style_section( + config: CommentedMap, python_version: PythonVersion, precommit: Precommit +) -> None: if python_version != "3.9": config["jobs"]["style"]["with"] = { "python-version": DoubleQuotedScalarString(python_version) } - if __is_remove_style_job(): + if __is_remove_style_job(precommit): del config["jobs"]["style"] -def __is_remove_style_job() -> bool: - if not CONFIG_PATH.precommit.exists(): - return True - config = load_precommit_config() - precommit_ci = config.get("ci") +def __is_remove_style_job(precommit: Precommit) -> bool: + precommit_ci = precommit.document.get("ci") if precommit_ci is not None and "skip" not in precommit_ci: return True return False diff --git a/src/compwa_policy/check_dev_files/nbstripout.py b/src/compwa_policy/check_dev_files/nbstripout.py index 9bf1cc7d..705244c8 100644 --- a/src/compwa_policy/check_dev_files/nbstripout.py +++ b/src/compwa_policy/check_dev_files/nbstripout.py @@ -2,23 +2,13 @@ from ruamel.yaml.scalarstring import LiteralScalarString -from compwa_policy.utilities import CONFIG_PATH -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - find_repo, - load_precommit_config, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit import ModifiablePrecommit +from compwa_policy.utilities.precommit.struct import Hook, Repo -def main() -> None: - # cspell:ignore nbconvert showmarkdowntxt - if not CONFIG_PATH.precommit.exists(): - return - config = load_precommit_config() +def main(precommit: ModifiablePrecommit) -> None: repo_url = "https://github.com/kynan/nbstripout" - if find_repo(config, repo_url) is None: + if precommit.find_repo(repo_url) is None: return extra_keys_argument = [ "cell.attachments", @@ -34,7 +24,7 @@ def main() -> None: "metadata.toc", "metadata.toc-autonumbering", "metadata.toc-showcode", - "metadata.toc-showmarkdowntxt", + "metadata.toc-showmarkdowntxt", # cspell:ignore showmarkdowntxt "metadata.toc-showtags", "metadata.varInspector", "metadata.vscode", @@ -52,4 +42,4 @@ def main() -> None: ) ], ) - update_single_hook_precommit_repo(expected_repo) + precommit.update_single_hook_repo(expected_repo) diff --git a/src/compwa_policy/check_dev_files/precommit.py b/src/compwa_policy/check_dev_files/precommit.py index 4220f895..54e1bba5 100644 --- a/src/compwa_policy/check_dev_files/precommit.py +++ b/src/compwa_policy/check_dev_files/precommit.py @@ -4,7 +4,7 @@ import re from pathlib import Path -from typing import cast +from typing import TYPE_CHECKING, cast from ruamel.yaml.comments import CommentedMap from ruamel.yaml.scalarstring import DoubleQuotedScalarString @@ -12,39 +12,37 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import CONFIG_PATH from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import ( - PrecommitConfig, - Repo, - find_repo, - load_precommit_config, - load_roundtrip_precommit_config, -) +from compwa_policy.utilities.precommit.getters import find_repo from compwa_policy.utilities.pyproject import Pyproject, get_constraints_file from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml +if TYPE_CHECKING: + from compwa_policy.utilities.precommit import ( + ModifiablePrecommit, + Precommit, + PrecommitConfig, + Repo, + ) -def main() -> None: - if not CONFIG_PATH.precommit.exists(): - return + +def main(precommit: ModifiablePrecommit) -> None: with Executor() as do: - do(_sort_hooks) - do(_update_conda_environment) - do(_update_precommit_ci_commit_msg) - do(_update_precommit_ci_skip) - do(_update_repo_urls) + do(_sort_hooks, precommit) + do(_update_conda_environment, precommit) + do(_update_precommit_ci_commit_msg, precommit) + do(_update_precommit_ci_skip, precommit) + do(_update_repo_urls, precommit) -def _sort_hooks() -> None: - config, yaml = load_roundtrip_precommit_config() - repos = config.get("repos") +def _sort_hooks(precommit: ModifiablePrecommit) -> None: + repos = precommit.document.get("repos") if repos is None: return sorted_repos = sorted(repos, key=__repo_sort_key) if sorted_repos != repos: - config["repos"] = sorted_repos - yaml.dump(config, CONFIG_PATH.precommit) + precommit.document["repos"] = sorted_repos msg = f"Sorted pre-commit hooks in {CONFIG_PATH.precommit}" - raise PrecommitError(msg) + precommit.append_to_changelog(msg) def __repo_sort_key(repo: Repo) -> tuple[int, str]: @@ -70,11 +68,8 @@ def __repo_sort_key(repo: Repo) -> tuple[int, str]: return 4, hook_id -def _update_precommit_ci_commit_msg() -> None: - if not CONFIG_PATH.precommit.exists(): - return - config, yaml = load_roundtrip_precommit_config() - precommit_ci = config.get("ci") +def _update_precommit_ci_commit_msg(precommit: ModifiablePrecommit) -> None: + precommit_ci = precommit.document.get("ci") if precommit_ci is None: return if __has_constraint_files(): @@ -85,9 +80,8 @@ def _update_precommit_ci_commit_msg() -> None: autoupdate_commit_msg = precommit_ci.get(key) if autoupdate_commit_msg != expected_msg: precommit_ci[key] = expected_msg # type:ignore[literal-required] - yaml.dump(config, CONFIG_PATH.precommit) msg = f"Updated ci.{key} in {CONFIG_PATH.precommit} to {expected_msg!r}" - raise PrecommitError(msg) + precommit.append_to_changelog(msg) def __has_constraint_files() -> bool: @@ -97,27 +91,24 @@ def __has_constraint_files() -> bool: return any(path.exists() for path in constraint_paths) -def _update_precommit_ci_skip() -> None: - config, yaml = load_roundtrip_precommit_config() - precommit_ci = config.get("ci") +def _update_precommit_ci_skip(precommit: ModifiablePrecommit) -> None: + precommit_ci = precommit.document.get("ci") if precommit_ci is None: return - local_hooks = get_local_hooks(config) - non_functional_hooks = get_non_functional_hooks(config) + local_hooks = get_local_hooks(precommit.document) + non_functional_hooks = get_non_functional_hooks(precommit.document) expected_skips = sorted(set(non_functional_hooks) | set(local_hooks)) existing_skips = precommit_ci.get("skip") if not expected_skips and existing_skips is not None: del precommit_ci["skip"] - yaml.dump(config, CONFIG_PATH.precommit) msg = f"Removed redundant ci.skip section from {CONFIG_PATH.precommit}" - raise PrecommitError(msg) + precommit.append_to_changelog(msg) if existing_skips != expected_skips: precommit_ci["skip"] = sorted(expected_skips) - yaml_config = cast(CommentedMap, config) + yaml_config = cast(CommentedMap, precommit.document) yaml_config.yaml_set_comment_before_after_key("repos", before="\n") - yaml.dump(yaml_config, CONFIG_PATH.precommit) msg = f"Updated ci.skip section in {CONFIG_PATH.precommit}" - raise PrecommitError(msg) + precommit.append_to_changelog(msg) def get_local_hooks(config: PrecommitConfig) -> list[str]: @@ -135,7 +126,7 @@ def get_non_functional_hooks(config: PrecommitConfig) -> list[str]: ] -def _update_conda_environment() -> None: +def _update_conda_environment(precommit: Precommit) -> None: """Temporary fix for Prettier v4 alpha releases. https://prettier.io/blog/2023/11/30/cli-deep-dive#installation @@ -147,8 +138,7 @@ def _update_conda_environment() -> None: conda_env: CommentedMap = yaml.load(path) variables: CommentedMap = conda_env.get("variables", {}) key = "PRETTIER_LEGACY_CLI" - precommit_config = load_precommit_config() - if __has_prettier_v4alpha(precommit_config): + if __has_prettier_v4alpha(precommit.document): if key not in variables: variables[key] = DoubleQuotedScalarString("1") conda_env["variables"] = variables @@ -183,12 +173,11 @@ def __has_prettier_v4alpha(config: PrecommitConfig) -> bool: return rev.startswith("v4") and "alpha" in rev -def _update_repo_urls() -> None: +def _update_repo_urls(precommit: ModifiablePrecommit) -> None: redirects = { r"^.*github\.com/ComPWA/repo-maintenance$": "https://github.com/ComPWA/policy", } - config, yaml = load_roundtrip_precommit_config() - repos = config["repos"] + repos = precommit.document["repos"] updated_repos: list[tuple[str, str]] = [] for repo in repos: url = repo["repo"] @@ -197,8 +186,7 @@ def _update_repo_urls() -> None: repo["repo"] = new_url updated_repos.append((url, new_url)) if updated_repos: - yaml.dump(config, CONFIG_PATH.precommit) msg = f"Updated repo urls in {CONFIG_PATH.precommit}:" for url, new_url in updated_repos: msg += f"\n {url} -> {new_url}" - raise PrecommitError(msg) + precommit.append_to_changelog(msg) diff --git a/src/compwa_policy/check_dev_files/prettier.py b/src/compwa_policy/check_dev_files/prettier.py index 82123827..fe07f3c6 100644 --- a/src/compwa_policy/check_dev_files/prettier.py +++ b/src/compwa_policy/check_dev_files/prettier.py @@ -3,14 +3,16 @@ from __future__ import annotations import os -from typing import Iterable +from typing import TYPE_CHECKING, Iterable from compwa_policy.errors import PrecommitError from compwa_policy.utilities import COMPWA_POLICY_DIR, CONFIG_PATH, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import find_repo, load_precommit_config from compwa_policy.utilities.readme import add_badge, remove_badge +if TYPE_CHECKING: + from compwa_policy.utilities.precommit import Precommit + # cspell:ignore esbenp rettier __VSCODE_EXTENSION_NAME = "esbenp.prettier-vscode" __BADGE = """ @@ -23,9 +25,8 @@ __EXPECTED_CONFIG = __STREAM.read() -def main(no_prettierrc: bool) -> None: - config = load_precommit_config() - if find_repo(config, r".*/mirrors-prettier") is None: +def main(precommit: Precommit, no_prettierrc: bool) -> None: + if precommit.find_repo(r".*/mirrors-prettier") is None: _remove_configuration() else: with Executor() as do: diff --git a/src/compwa_policy/check_dev_files/pyupgrade.py b/src/compwa_policy/check_dev_files/pyupgrade.py index 5d75015e..012e299e 100644 --- a/src/compwa_policy/check_dev_files/pyupgrade.py +++ b/src/compwa_policy/check_dev_files/pyupgrade.py @@ -4,26 +4,21 @@ from ruamel.yaml.comments import CommentedSeq from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - remove_precommit_hook, - update_precommit_hook, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit import ModifiablePrecommit +from compwa_policy.utilities.precommit.struct import Hook, Repo from compwa_policy.utilities.pyproject import Pyproject -def main(no_ruff: bool) -> None: +def main(precommit: ModifiablePrecommit, no_ruff: bool) -> None: with Executor() as do: if no_ruff: - do(_update_precommit_repo) - do(_update_precommit_nbqa_hook) + do(_update_precommit_repo, precommit) + do(_update_precommit_nbqa_hook, precommit) else: - do(_remove_pyupgrade) + do(_remove_pyupgrade, precommit) -def _update_precommit_repo() -> None: +def _update_precommit_repo(precommit: ModifiablePrecommit) -> None: expected_hook = Repo( repo="https://github.com/asottile/pyupgrade", rev="", @@ -34,11 +29,11 @@ def _update_precommit_repo() -> None: ) ], ) - update_single_hook_precommit_repo(expected_hook) + precommit.update_single_hook_repo(expected_hook) -def _update_precommit_nbqa_hook() -> None: - update_precommit_hook( +def _update_precommit_nbqa_hook(precommit: ModifiablePrecommit) -> None: + precommit.update_hook( repo_url="https://github.com/nbQA-dev/nbQA", expected_hook=Hook( id="nbqa-pyupgrade", @@ -60,7 +55,7 @@ def __get_pyupgrade_version_argument() -> CommentedSeq: return yaml.load(f"[--py{version_repr}-plus]") -def _remove_pyupgrade() -> None: +def _remove_pyupgrade(precommit: ModifiablePrecommit) -> None: with Executor() as do: - do(remove_precommit_hook, "nbqa-pyupgrade") - do(remove_precommit_hook, "pyupgrade") + do(precommit.remove_hook, "nbqa-pyupgrade") + do(precommit.remove_hook, "pyupgrade") diff --git a/src/compwa_policy/check_dev_files/ruff.py b/src/compwa_policy/check_dev_files/ruff.py index a37edc70..58ce0ab9 100644 --- a/src/compwa_policy/check_dev_files/ruff.py +++ b/src/compwa_policy/check_dev_files/ruff.py @@ -10,12 +10,7 @@ from compwa_policy.utilities import CONFIG_PATH, natural_sorting, remove_configs, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - remove_precommit_hook, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit.struct import Hook, Repo from compwa_policy.utilities.pyproject import ( ModifiablePyproject, Pyproject, @@ -28,27 +23,32 @@ if TYPE_CHECKING: from tomlkit.items import Array + from compwa_policy.utilities.precommit import ModifiablePrecommit + -def main(has_notebooks: bool) -> None: +def main(precommit: ModifiablePrecommit, has_notebooks: bool) -> None: with Executor() as do, ModifiablePyproject.load() as pyproject: do( add_badge, "[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)", ) do(pyproject.remove_dependency, "radon") - do(_remove_black, pyproject) - do(_remove_flake8, pyproject) - do(_remove_isort, pyproject) - do(_remove_pydocstyle, pyproject) - do(_remove_pylint, pyproject) + do(_remove_black, precommit, pyproject) + do(_remove_flake8, precommit, pyproject) + do(_remove_isort, precommit, pyproject) + do(_remove_pydocstyle, precommit, pyproject) + do(_remove_pylint, precommit, pyproject) do(_move_ruff_lint_config, pyproject) - do(_update_ruff_config, pyproject, has_notebooks) - do(_update_precommit_hook, has_notebooks) + do(_update_ruff_config, precommit, pyproject, has_notebooks) + do(_update_precommit_hook, precommit, has_notebooks) do(_update_lint_dependencies, pyproject) do(_update_vscode_settings) -def _remove_black(pyproject: ModifiablePyproject) -> None: +def _remove_black( + precommit: ModifiablePrecommit, + pyproject: ModifiablePyproject, +) -> None: with Executor() as do: do( vscode.remove_extension_recommendation, @@ -62,33 +62,39 @@ def _remove_black(pyproject: ModifiablePyproject) -> None: ignored_sections=["doc", "jupyter", "test"], ) do(remove_badge, r".*https://github\.com/psf.*/black.*") - do(remove_precommit_hook, "black-jupyter") - do(remove_precommit_hook, "black") - do(remove_precommit_hook, "blacken-docs") + do(precommit.remove_hook, "black-jupyter") + do(precommit.remove_hook, "black") + do(precommit.remove_hook, "blacken-docs") do(vscode.remove_settings, ["black-formatter.importStrategy"]) -def _remove_flake8(pyproject: ModifiablePyproject) -> None: +def _remove_flake8( + precommit: ModifiablePrecommit, + pyproject: ModifiablePyproject, +) -> None: with Executor() as do: do(remove_configs, [".flake8"]) do(__remove_nbqa_option, pyproject, "flake8") do(pyproject.remove_dependency, "flake8") do(pyproject.remove_dependency, "pep8-naming") do(vscode.remove_extension_recommendation, "ms-python.flake8", unwanted=True) - do(remove_precommit_hook, "autoflake") # cspell:ignore autoflake - do(remove_precommit_hook, "flake8") - do(remove_precommit_hook, "nbqa-flake8") + do(precommit.remove_hook, "autoflake") # cspell:ignore autoflake + do(precommit.remove_hook, "flake8") + do(precommit.remove_hook, "nbqa-flake8") do(vscode.remove_settings, ["flake8.importStrategy"]) -def _remove_isort(pyproject: ModifiablePyproject) -> None: +def _remove_isort( + precommit: ModifiablePrecommit, + pyproject: ModifiablePyproject, +) -> None: with Executor() as do: do(__remove_nbqa_option, pyproject, "black") do(__remove_nbqa_option, pyproject, "isort") do(__remove_tool_table, pyproject, "isort") do(vscode.remove_extension_recommendation, "ms-python.isort", unwanted=True) - do(remove_precommit_hook, "isort") - do(remove_precommit_hook, "nbqa-isort") + do(precommit.remove_hook, "isort") + do(precommit.remove_hook, "nbqa-isort") do(vscode.remove_settings, ["isort.check", "isort.importStrategy"]) do(remove_badge, r".*https://img\.shields\.io/badge/%20imports\-isort") @@ -114,7 +120,10 @@ def __remove_tool_table(pyproject: ModifiablePyproject, tool_table: str) -> None pyproject.append_to_changelog(msg) -def _remove_pydocstyle(pyproject: ModifiablePyproject) -> None: +def _remove_pydocstyle( + precommit: ModifiablePrecommit, + pyproject: ModifiablePyproject, +) -> None: with Executor() as do: do( remove_configs, @@ -125,16 +134,19 @@ def _remove_pydocstyle(pyproject: ModifiablePyproject) -> None: ], ) do(pyproject.remove_dependency, "pydocstyle") - do(remove_precommit_hook, "pydocstyle") + do(precommit.remove_hook, "pydocstyle") -def _remove_pylint(pyproject: ModifiablePyproject) -> None: +def _remove_pylint( + precommit: ModifiablePrecommit, + pyproject: ModifiablePyproject, +) -> None: with Executor() as do: do(remove_configs, [".pylintrc"]) # cspell:ignore pylintrc do(pyproject.remove_dependency, "pylint") do(vscode.remove_extension_recommendation, "ms-python.pylint", unwanted=True) - do(remove_precommit_hook, "pylint") - do(remove_precommit_hook, "nbqa-pylint") + do(precommit.remove_hook, "pylint") + do(precommit.remove_hook, "nbqa-pylint") do(vscode.remove_settings, ["pylint.importStrategy"]) @@ -173,7 +185,11 @@ def _move_ruff_lint_config(pyproject: ModifiablePyproject) -> None: ) -def _update_ruff_config(pyproject: ModifiablePyproject, has_notebooks: bool) -> None: +def _update_ruff_config( + precommit: ModifiablePrecommit, + pyproject: ModifiablePyproject, + has_notebooks: bool, +) -> None: with Executor() as do: do(__update_global_settings, pyproject, has_notebooks) do(__update_ruff_format_settings, pyproject) @@ -181,7 +197,7 @@ def _update_ruff_config(pyproject: ModifiablePyproject, has_notebooks: bool) -> do(__update_per_file_ignores, pyproject, has_notebooks) do(__update_isort_settings, pyproject) do(__update_pydocstyle_settings, pyproject) - do(__remove_nbqa, pyproject) + do(__remove_nbqa, precommit, pyproject) def __update_global_settings( @@ -468,10 +484,13 @@ def __update_pydocstyle_settings(pyproject: ModifiablePyproject) -> None: pyproject.append_to_changelog(msg) -def __remove_nbqa(pyproject: ModifiablePyproject) -> None: +def __remove_nbqa( + precommit: ModifiablePrecommit, + pyproject: ModifiablePyproject, +) -> None: with Executor() as do: do(___remove_nbqa_settings, pyproject) - do(remove_precommit_hook, "nbqa-ruff") + do(precommit.remove_hook, "nbqa-ruff") def ___remove_nbqa_settings(pyproject: ModifiablePyproject) -> None: @@ -486,9 +505,7 @@ def ___remove_nbqa_settings(pyproject: ModifiablePyproject) -> None: pyproject.append_to_changelog(msg) -def _update_precommit_hook(has_notebooks: bool) -> None: - if not CONFIG_PATH.precommit.exists(): - return +def _update_precommit_hook(precommit: ModifiablePrecommit, has_notebooks: bool) -> None: yaml = YAML(typ="rt") lint_hook = Hook(id="ruff", args=yaml.load("[--fix]")) format_hook = Hook(id="ruff-format") @@ -501,7 +518,7 @@ def _update_precommit_hook(has_notebooks: bool) -> None: rev="", hooks=[lint_hook, format_hook], ) - update_single_hook_precommit_repo(expected_repo) + precommit.update_single_hook_repo(expected_repo) def _update_lint_dependencies(pyproject: ModifiablePyproject) -> None: diff --git a/src/compwa_policy/check_dev_files/toml.py b/src/compwa_policy/check_dev_files/toml.py index caf8489b..97a0eb0b 100644 --- a/src/compwa_policy/check_dev_files/toml.py +++ b/src/compwa_policy/check_dev_files/toml.py @@ -5,6 +5,7 @@ import shutil from glob import glob from pathlib import Path +from typing import TYPE_CHECKING import tomlkit from ruamel.yaml import YAML @@ -12,20 +13,19 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import COMPWA_POLICY_DIR, CONFIG_PATH, vscode from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import ( - Hook, - Repo, - update_single_hook_precommit_repo, -) +from compwa_policy.utilities.precommit.struct import Hook, Repo from compwa_policy.utilities.pyproject import ModifiablePyproject from compwa_policy.utilities.toml import to_toml_array +if TYPE_CHECKING: + from compwa_policy.utilities.precommit import ModifiablePrecommit + __INCORRECT_TAPLO_CONFIG_PATHS = [ Path("taplo.toml"), ] -def main() -> None: +def main(precommit: ModifiablePrecommit) -> None: trigger_files = [ CONFIG_PATH.pyproject, CONFIG_PATH.taplo, @@ -36,9 +36,9 @@ def main() -> None: with Executor() as do: do(_rename_taplo_config) do(_update_taplo_config) - do(_update_precommit_repo) + do(_update_precommit_repo, precommit) do(_update_tomlsort_config) - do(_update_tomlsort_hook) + do(_update_tomlsort_hook, precommit) do(_update_vscode_extensions) @@ -66,7 +66,7 @@ def _update_tomlsort_config() -> None: pyproject.append_to_changelog("Updated toml-sort configuration") -def _update_tomlsort_hook() -> None: +def _update_tomlsort_hook(precommit: ModifiablePrecommit) -> None: expected_hook = Repo( repo="https://github.com/pappasam/toml-sort", rev="", @@ -82,7 +82,7 @@ def _update_tomlsort_hook() -> None: if excludes: excludes = sorted(excludes, key=str.lower) expected_hook["hooks"][0]["exclude"] = "(?x)^(" + "|".join(excludes) + ")$" - update_single_hook_precommit_repo(expected_hook) + precommit.update_single_hook_repo(expected_hook) def _rename_taplo_config() -> None: @@ -119,13 +119,13 @@ def _update_taplo_config() -> None: raise PrecommitError(msg) -def _update_precommit_repo() -> None: +def _update_precommit_repo(precommit: ModifiablePrecommit) -> None: expected_hook = Repo( repo="https://github.com/ComPWA/mirrors-taplo", rev="", hooks=[Hook(id="taplo")], ) - update_single_hook_precommit_repo(expected_hook) + precommit.update_single_hook_repo(expected_hook) def _update_vscode_extensions() -> None: diff --git a/src/compwa_policy/check_dev_files/update_pip_constraints.py b/src/compwa_policy/check_dev_files/update_pip_constraints.py index 20d61ee7..fb9d48f0 100644 --- a/src/compwa_policy/check_dev_files/update_pip_constraints.py +++ b/src/compwa_policy/check_dev_files/update_pip_constraints.py @@ -9,6 +9,7 @@ import sys from glob import glob +from typing import TYPE_CHECKING from compwa_policy.check_dev_files.github_workflows import ( remove_workflow, @@ -17,9 +18,11 @@ from compwa_policy.errors import PrecommitError from compwa_policy.utilities import COMPWA_POLICY_DIR, CONFIG_PATH from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import load_precommit_config from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml +if TYPE_CHECKING: + from compwa_policy.utilities.precommit import Precommit + if sys.version_info < (3, 8): from typing_extensions import Literal else: @@ -44,10 +47,10 @@ } -def main(frequency: Frequency) -> None: +def main(precommit: Precommit, frequency: Frequency) -> None: with Executor() as do: if frequency == "outsource": - do(_check_precommit_schedule) + do(_check_precommit_schedule, precommit) do(_remove_script, "pin_requirements.py") do(_remove_script, "upgrade.sh") do(_update_requirement_workflow, frequency) @@ -94,15 +97,12 @@ def _to_cron_schedule(frequency: Frequency) -> str: return __CRON_SCHEDULES[frequency] -def _check_precommit_schedule() -> None: - msg = ( - "Cannot outsource pip constraints updates, because autoupdate_schedule has not" - f" been set under the ci key in {CONFIG_PATH.precommit}. See" - " https://pre-commit.ci/#configuration-autoupdate_schedule." - ) - if not CONFIG_PATH.precommit.exists(): - raise PrecommitError(msg) - config = load_precommit_config() - schedule = config.get("ci", {}).get("autoupdate_schedule") +def _check_precommit_schedule(precommit: Precommit) -> None: + schedule = precommit.document.get("ci", {}).get("autoupdate_schedule") if schedule is None: + msg = ( + "Cannot outsource pip constraints updates, because autoupdate_schedule has" + f" not been set under the ci key in {CONFIG_PATH.precommit}. See" + " https://pre-commit.ci/#configuration-autoupdate_schedule." + ) raise PrecommitError(msg) diff --git a/src/compwa_policy/colab_toc_visible.py b/src/compwa_policy/colab_toc_visible.py index a604242f..9c373f47 100644 --- a/src/compwa_policy/colab_toc_visible.py +++ b/src/compwa_policy/colab_toc_visible.py @@ -12,11 +12,10 @@ import nbformat +from compwa_policy.errors import PrecommitError +from compwa_policy.utilities.executor import Executor from compwa_policy.utilities.notebook import load_notebook -from .errors import PrecommitError -from .utilities.executor import Executor - def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser(__doc__) diff --git a/src/compwa_policy/fix_nbformat_version.py b/src/compwa_policy/fix_nbformat_version.py index c77b59c9..de8f35a1 100644 --- a/src/compwa_policy/fix_nbformat_version.py +++ b/src/compwa_policy/fix_nbformat_version.py @@ -13,11 +13,10 @@ import nbformat +from compwa_policy.errors import PrecommitError +from compwa_policy.utilities.executor import Executor from compwa_policy.utilities.notebook import load_notebook -from .errors import PrecommitError -from .utilities.executor import Executor - BINARY_CELL_OUTPUT = [ "image/jpeg", "image/png", diff --git a/src/compwa_policy/self_check.py b/src/compwa_policy/self_check.py index 4107f3f8..e30c488a 100644 --- a/src/compwa_policy/self_check.py +++ b/src/compwa_policy/self_check.py @@ -4,19 +4,26 @@ from io import StringIO from textwrap import dedent, indent +from typing import TYPE_CHECKING import yaml from compwa_policy.errors import PrecommitError from compwa_policy.utilities.executor import Executor -from compwa_policy.utilities.precommit import Hook, load_precommit_config +from compwa_policy.utilities.precommit import Precommit + +if TYPE_CHECKING: + from compwa_policy.utilities.precommit.struct import Hook __HOOK_DEFINITION_FILE = ".pre-commit-hooks.yaml" -def main() -> int: - cfg = load_precommit_config() - local_repos = [repo for repo in cfg["repos"] if repo["repo"] == "local"] +def main(precommit: Precommit | None = None) -> int: + if precommit is None: + precommit = Precommit.load() + local_repos = [ + repo for repo in precommit.document["repos"] if repo["repo"] == "local" + ] hook_definitions = _load_precommit_hook_definitions() with Executor(raise_exception=False) as do: for repo in local_repos: diff --git a/src/compwa_policy/utilities/cfg.py b/src/compwa_policy/utilities/cfg.py index 21d4d273..c2ea7baf 100644 --- a/src/compwa_policy/utilities/cfg.py +++ b/src/compwa_policy/utilities/cfg.py @@ -10,8 +10,7 @@ from typing import Callable, Iterable from compwa_policy.errors import PrecommitError - -from . import CONFIG_PATH, read, write +from compwa_policy.utilities import CONFIG_PATH, read, write def extract_config_section( diff --git a/src/compwa_policy/utilities/precommit.py b/src/compwa_policy/utilities/precommit.py deleted file mode 100644 index 9d66b713..00000000 --- a/src/compwa_policy/utilities/precommit.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Helper functions for modifying :file:`.pre-commit.config.yaml`.""" - -from __future__ import annotations - -import re -import socket -import sys -from functools import lru_cache -from typing import TYPE_CHECKING, cast - -from pre_commit.commands.autoupdate import autoupdate as precommit_autoupdate -from ruamel.yaml.comments import CommentedMap, CommentedSeq -from ruamel.yaml.scalarstring import PlainScalarString - -from compwa_policy.errors import PrecommitError - -from . import CONFIG_PATH -from .yaml import create_prettier_round_trip_yaml - -if sys.version_info < (3, 8): - from typing_extensions import Literal, TypedDict -else: - from typing import Literal, TypedDict -if sys.version_info < (3, 11): - from typing_extensions import NotRequired -else: - from typing import NotRequired -if TYPE_CHECKING: - from pathlib import Path - - from ruamel.yaml import YAML - - -def load_precommit_config(path: Path = CONFIG_PATH.precommit) -> PrecommitConfig: - """Load a **read-only** pre-commit config.""" - config, _ = load_roundtrip_precommit_config(path) - return config - - -def load_roundtrip_precommit_config( - path: Path = CONFIG_PATH.precommit, -) -> tuple[PrecommitConfig, YAML]: - """Load the pre-commit config as a round-trip YAML object.""" - yaml_parser = create_prettier_round_trip_yaml() - config = yaml_parser.load(path) - return config, yaml_parser - - -def find_repo(config: PrecommitConfig, search_pattern: str) -> Repo | None: - """Find pre-commit repo definition in pre-commit config.""" - repos = config.get("repos", []) - for repo in repos: - url = repo.get("repo", "") - if re.search(search_pattern, url): - return repo - return None - - -def find_repo_with_index( - config: PrecommitConfig, search_pattern: str -) -> tuple[int, Repo] | None: - """Find pre-commit repo definition and its index in pre-commit config.""" - repos = config.get("repos", []) - for i, repo in enumerate(repos): - url = repo.get("repo", "") - if re.search(search_pattern, url): - return i, repo - return None - - -def remove_precommit_hook(hook_id: str, repo_url: str | None = None) -> None: - config, yaml = load_roundtrip_precommit_config() - repo_and_hook_idx = __find_repo_and_hook_idx(config, hook_id, repo_url) - if repo_and_hook_idx is None: - return - repo_idx, hook_idx = repo_and_hook_idx - repos = config["repos"] - hooks = repos[repo_idx]["hooks"] - if len(hooks) <= 1: - repos.pop(repo_idx) - else: - hooks.pop(hook_idx) - yaml.dump(config, CONFIG_PATH.precommit) - msg = f"Removed {hook_id!r} from {CONFIG_PATH.precommit}" - raise PrecommitError(msg) - - -def __find_repo_and_hook_idx( - config: PrecommitConfig, hook_id: str, repo_url: str | None = None -) -> tuple[int, int] | None: - repos = config.get("repos", []) - for repo_idx, repo in enumerate(repos): - if repo_url is not None and repo.get("repo") != repo_url: - continue - hooks = repo.get("hooks", []) - for hook_idx, hook in enumerate(hooks): - if hook.get("id") == hook_id: - return repo_idx, hook_idx - return None - - -def update_single_hook_precommit_repo(expected: Repo) -> None: - """Update the repo definition in :code:`.pre-commit-config.yaml`. - - If the repository is not yet listed under the :code:`repos` key, a new entry will - be automatically inserted. If the repository exists, but the definition is not the - same as expected, the entry in the YAML config will be updated. - """ - if not CONFIG_PATH.precommit.exists(): - return - expected_yaml = CommentedMap(expected) - config, yaml = load_roundtrip_precommit_config() - repos = config.get("repos", []) - repo_url = expected["repo"] - idx_and_repo = find_repo_with_index(config, repo_url) - hook_id = expected["hooks"][0]["id"] - if idx_and_repo is None: - if not expected_yaml.get("rev"): - expected_yaml.pop("rev", None) - expected_yaml.insert(1, "rev", "PLEASE-UPDATE") - idx = _determine_expected_repo_index(config, hook_id) - repos_yaml = cast(CommentedSeq, repos) - repos_yaml.insert(idx, expected_yaml) - repos_yaml.yaml_set_comment_before_after_key( - idx if idx + 1 == len(repos) else idx + 1, - before="\n", - ) - yaml.dump(config, CONFIG_PATH.precommit) - if has_internet_connection(): - precommit_autoupdate( - CONFIG_PATH.precommit, - freeze=False, - repos=[repo_url], - tags_only=True, - ) - msg = f"Added {hook_id} hook to {CONFIG_PATH.precommit}." - raise PrecommitError(msg) - idx, existing_hook = idx_and_repo - if not _is_equivalent_repo(existing_hook, expected): - existing_rev = existing_hook.get("rev") - if existing_rev is not None: - expected_yaml.insert(1, "rev", PlainScalarString(existing_rev)) - repos[idx] = expected_yaml # type: ignore[assignment,call-overload] - repos_map = cast(CommentedMap, repos) - repos_map.yaml_set_comment_before_after_key(idx + 1, before="\n") - yaml.dump(config, CONFIG_PATH.precommit) - msg = f"Updated {hook_id} hook in {CONFIG_PATH.precommit}" - raise PrecommitError(msg) - - -def _determine_expected_repo_index(config: PrecommitConfig, hook_id: str) -> int: - repos = config["repos"] - for i, repo_def in enumerate(repos): - hooks = repo_def["hooks"] - if len(hooks) != 1: - continue - if hook_id.lower() <= repo_def["hooks"][0]["id"].lower(): - return i - return len(repos) - - -def _is_equivalent_repo(expected: Repo, existing: Repo) -> bool: - def remove_rev(repo: Repo) -> dict: - repo_copy = dict(repo) - repo_copy.pop("rev", None) - return repo_copy - - return remove_rev(expected) == remove_rev(existing) - - -def update_precommit_hook(repo_url: str, expected_hook: Hook) -> None: - """Update the pre-commit hook definition of a specific pre-commit repo. - - This function updates the :code:`.pre-commit-config.yaml` file, but does this only - for a specific hook definition *within* a pre-commit repository definition. - """ - if not CONFIG_PATH.precommit.exists(): - return - config, yaml = load_roundtrip_precommit_config() - idx_and_repo = find_repo_with_index(config, repo_url) - if idx_and_repo is None: - return - repo_idx, repo = idx_and_repo - repo_name = repo_url.split("/")[-1] - hooks = repo["hooks"] - hook_idx = __find_hook_idx(hooks, expected_hook["id"]) - if hook_idx is None: - hook_idx = __determine_expected_hook_idx(hooks, expected_hook["id"]) - hooks.insert(hook_idx, expected_hook) - if hook_idx == len(hooks) - 1: - repos = cast(CommentedMap, config["repos"]) - repos.yaml_set_comment_before_after_key(repo_idx + 1, before="\n") - yaml.dump(config, CONFIG_PATH.precommit) - msg = f"Added {expected_hook['id']!r} to {repo_name} pre-commit config" - raise PrecommitError(msg) - - if hooks[hook_idx] != expected_hook: - hooks[hook_idx] = expected_hook - yaml.dump(config, CONFIG_PATH.precommit) - msg = f"Updated args of {expected_hook['id']!r} {repo_name} pre-commit hook" - raise PrecommitError(msg) - - -def __find_hook_idx(hooks: list[Hook], hook_id: str) -> int | None: - msg = "" - for i, hook in enumerate(hooks): - msg += " " + hook["id"] - if hook["id"] == hook_id: - return i - return None - - -def __determine_expected_hook_idx(hooks: list[Hook], hook_id: str) -> int: - for i, hook in enumerate(hooks): - if hook["id"] > hook_id: - return i - return len(hooks) - - -@lru_cache(maxsize=None) -def has_internet_connection( - host: str = "8.8.8.8", port: int = 53, timeout: float = 0.5 -) -> bool: - try: - # cspell:ignore setdefaulttimeout - socket.setdefaulttimeout(timeout) - socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) - except OSError: - return False - else: - return True - - -class PrecommitConfig(TypedDict): - """https://pre-commit.com/#pre-commit-configyaml---top-level.""" - - ci: NotRequired[PrecommitCi] - repos: list[Repo] - default_stages: NotRequired[list[str]] - files: NotRequired[str] - exclude: NotRequired[str] - fail_fast: NotRequired[bool] - minimum_pre_commit_version: NotRequired[str] - - -class PrecommitCi(TypedDict): - """https://pre-commit.ci/#configuration.""" - - autofix_commit_msg: NotRequired[str] - autofix_prs: NotRequired[bool] - autoupdate_branch: NotRequired[str] - autoupdate_commit_msg: NotRequired[str] - autoupdate_schedule: NotRequired[Literal["weekly", "monthly", "quarterly"]] - skip: NotRequired[list[str]] - submodules: NotRequired[bool] - - -class Repo(TypedDict): - """https://pre-commit.com/#pre-commit-configyaml---repos.""" - - repo: str - rev: str - hooks: list[Hook] - - -class Hook(TypedDict): - """https://pre-commit.com/#pre-commit-configyaml---hooks.""" - - id: str - alias: NotRequired[str] - name: NotRequired[str] - language_version: NotRequired[str] - files: NotRequired[str] - exclude: NotRequired[str] - types: NotRequired[list[str]] - types_or: NotRequired[list[str]] - exclude_types: NotRequired[list[str]] - args: NotRequired[list[str]] - stages: NotRequired[list[str]] - additional_dependencies: NotRequired[list[str]] - always_run: NotRequired[bool] - verbose: NotRequired[bool] - log_file: NotRequired[str] - pass_filenames: NotRequired[bool] diff --git a/src/compwa_policy/utilities/precommit/__init__.py b/src/compwa_policy/utilities/precommit/__init__.py new file mode 100644 index 00000000..e934ac5c --- /dev/null +++ b/src/compwa_policy/utilities/precommit/__init__.py @@ -0,0 +1,150 @@ +"""Helper functions for modifying :file:`.pre-commit.config.yaml`.""" + +from __future__ import annotations + +import io +from contextlib import AbstractContextManager +from pathlib import Path +from textwrap import indent +from typing import IO, TYPE_CHECKING, TypeVar + +from compwa_policy.errors import PrecommitError +from compwa_policy.utilities import CONFIG_PATH +from compwa_policy.utilities.precommit.getters import find_repo, find_repo_with_index +from compwa_policy.utilities.precommit.setters import ( + remove_precommit_hook, + update_precommit_hook, + update_single_hook_precommit_repo, +) +from compwa_policy.utilities.yaml import create_prettier_round_trip_yaml + +if TYPE_CHECKING: + from types import TracebackType + + from ruamel.yaml import YAML + + from compwa_policy.utilities.precommit.struct import Hook, PrecommitConfig, Repo + +T = TypeVar("T", bound="Precommit") + + +class Precommit: + """Read-only representation of a :code:`.pre-commit-config.yaml` file.""" + + def __init__( + self, document: PrecommitConfig, parser: YAML, source: IO | Path | None = None + ) -> None: + self.__document = document + self.__parser = parser + self.__source = source + + @property + def document(self) -> PrecommitConfig: + return self.__document + + @property + def parser(self) -> YAML: + return self.__parser + + @property + def source(self) -> IO | Path | None: + return self.__source + + @classmethod + def load(cls: type[T], source: IO | Path | str = CONFIG_PATH.precommit) -> T: + """Load a :code:`pyproject.toml` file from a file, I/O stream, or `str`.""" + config, parser = _load_roundtrip_precommit_config(source) + if isinstance(source, str): + return cls(config, parser) + return cls(config, parser, source) + + def dumps(self) -> str: + return self.parser.dump(self.document) + + def find_repo(self, search_pattern: str) -> Repo | None: + """Find pre-commit repo definition in pre-commit config.""" + return find_repo(self.__document, search_pattern) + + def find_repo_with_index(self, search_pattern: str) -> tuple[int, Repo] | None: + """Find pre-commit repo definition and its index in pre-commit config.""" + return find_repo_with_index(self.__document, search_pattern) + + +class ModifiablePrecommit(Precommit, AbstractContextManager): + def __init__( + self, document: PrecommitConfig, parser: YAML, source: IO | Path | None = None + ) -> None: + super().__init__(document, parser, source) + self.__is_in_context = False + self.__changelog: list[str] = [] + + def __enter__(self) -> ModifiablePrecommit: + self.__is_in_context = True + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + if not self.__changelog: + return + if self.parser is None: + self.dump(self.source) + msg = "Following modifications were made" + if isinstance(self.source, Path): + msg = f" to {self.source}" + msg += ":\n\n" + modifications = indent("\n".join(self.__changelog), prefix=" - ") + raise PrecommitError(modifications) + + def dump(self, target: IO | Path | str | None = None) -> None: + if target is None: + if self.source is None: + msg = "Target required when source is not a file or I/O stream" + raise ValueError(msg) + target = self.source + if isinstance(target, io.IOBase): + current_position = target.tell() + target.seek(0) + self.parser.dump(self.document, target) + target.seek(current_position) + elif isinstance(target, (Path, str)): + src = self.dumps() + with open(target, "w") as stream: + stream.write(src) + else: + msg = f"Target of type {type(target).__name__} is not supported" + raise TypeError(msg) + + def append_to_changelog(self, message: str) -> None: + self.__assert_is_in_context() + self.__changelog.append(message) + + def __assert_is_in_context(self) -> None: + if not self.__is_in_context: + msg = "Modifications can only be made within a context" + raise RuntimeError(msg) + + def remove_hook(self, hook_id: str, repo_url: str | None = None) -> None: + remove_precommit_hook(self, hook_id, repo_url) + + def update_single_hook_repo(self, expected: Repo) -> None: + update_single_hook_precommit_repo(self, expected) + + def update_hook(self, repo_url: str, expected_hook: Hook) -> None: + update_precommit_hook(self, repo_url, expected_hook) + + +def _load_roundtrip_precommit_config( + source: IO | Path | str = CONFIG_PATH.precommit, +) -> tuple[PrecommitConfig, YAML]: + """Load the pre-commit config as a round-trip YAML object.""" + parser = create_prettier_round_trip_yaml() + if isinstance(source, str): + with io.StringIO(source) as stream: + config = parser.load(stream) + else: + config = parser.load(source) + return config, parser diff --git a/src/compwa_policy/utilities/precommit/getters.py b/src/compwa_policy/utilities/precommit/getters.py new file mode 100644 index 00000000..f14b3fce --- /dev/null +++ b/src/compwa_policy/utilities/precommit/getters.py @@ -0,0 +1,30 @@ +# noqa: D100 +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from compwa_policy.utilities.precommit.struct import PrecommitConfig, Repo + + +def find_repo(config: PrecommitConfig, search_pattern: str) -> Repo | None: + """Find pre-commit repo definition in pre-commit config.""" + repos = config.get("repos", []) + for repo in repos: + url = repo.get("repo", "") + if re.search(search_pattern, url): + return repo + return None + + +def find_repo_with_index( + config: PrecommitConfig, search_pattern: str +) -> tuple[int, Repo] | None: + """Find pre-commit repo definition and its index in pre-commit config.""" + repos = config.get("repos", []) + for i, repo in enumerate(repos): + url = repo.get("repo", "") + if re.search(search_pattern, url): + return i, repo + return None diff --git a/src/compwa_policy/utilities/precommit/setters.py b/src/compwa_policy/utilities/precommit/setters.py new file mode 100644 index 00000000..eb0e6b45 --- /dev/null +++ b/src/compwa_policy/utilities/precommit/setters.py @@ -0,0 +1,152 @@ +# noqa: D100 +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from ruamel.yaml.comments import CommentedMap, CommentedSeq +from ruamel.yaml.scalarstring import PlainScalarString + +from compwa_policy.utilities import CONFIG_PATH +from compwa_policy.utilities.precommit.getters import find_repo_with_index + +if TYPE_CHECKING: + from compwa_policy.utilities.precommit import ModifiablePrecommit + from compwa_policy.utilities.precommit.struct import Hook, PrecommitConfig, Repo + + +def remove_precommit_hook( + precommit: ModifiablePrecommit, hook_id: str, repo_url: str | None = None +) -> None: + repo_and_hook_idx = __find_repo_and_hook_idx(precommit.document, hook_id, repo_url) + if repo_and_hook_idx is None: + return + repo_idx, hook_idx = repo_and_hook_idx + repos = precommit.document["repos"] + hooks = repos[repo_idx]["hooks"] + if len(hooks) <= 1: + repos.pop(repo_idx) + else: + hooks.pop(hook_idx) + msg = f"Removed {hook_id!r} from {CONFIG_PATH.precommit}" + precommit.append_to_changelog(msg) + + +def __find_repo_and_hook_idx( + config: PrecommitConfig, hook_id: str, repo_url: str | None = None +) -> tuple[int, int] | None: + repos = config.get("repos", []) + for repo_idx, repo in enumerate(repos): + if repo_url is not None and repo.get("repo") != repo_url: + continue + hooks = repo.get("hooks", []) + for hook_idx, hook in enumerate(hooks): + if hook.get("id") == hook_id: + return repo_idx, hook_idx + return None + + +def update_single_hook_precommit_repo( + precommit: ModifiablePrecommit, expected: Repo +) -> None: + """Update the repo definition in :code:`.pre-commit-config.yaml`. + + If the repository is not yet listed under the :code:`repos` key, a new entry will + be automatically inserted. If the repository exists, but the definition is not the + same as expected, the entry in the YAML config will be updated. + """ + expected_yaml = CommentedMap(expected) + repos = precommit.document.get("repos", []) + repo_url = expected["repo"] + idx_and_repo = find_repo_with_index(precommit.document, repo_url) + hook_id = expected["hooks"][0]["id"] + if idx_and_repo is None: + if not expected_yaml.get("rev"): + expected_yaml.pop("rev", None) + expected_yaml.insert(1, "rev", "PLEASE-UPDATE") + idx = _determine_expected_repo_index(precommit.document, hook_id) + repos_yaml = cast(CommentedSeq, repos) + repos_yaml.insert(idx, expected_yaml) + repos_yaml.yaml_set_comment_before_after_key( + idx if idx + 1 == len(repos) else idx + 1, + before="\n", + ) + msg = f"Added {hook_id} hook to {CONFIG_PATH.precommit}." + precommit.append_to_changelog(msg) + if idx_and_repo is None: + return + idx, existing_hook = idx_and_repo + if not _is_equivalent_repo(existing_hook, expected): + existing_rev = existing_hook.get("rev") + if existing_rev is not None: + expected_yaml.insert(1, "rev", PlainScalarString(existing_rev)) + repos[idx] = expected_yaml # type: ignore[assignment,call-overload] + repos_map = cast(CommentedMap, repos) + repos_map.yaml_set_comment_before_after_key(idx + 1, before="\n") + msg = f"Updated {hook_id} hook in {CONFIG_PATH.precommit}" + precommit.append_to_changelog(msg) + + +def _determine_expected_repo_index(config: PrecommitConfig, hook_id: str) -> int: + repos = config["repos"] + for i, repo_def in enumerate(repos): + hooks = repo_def["hooks"] + if len(hooks) != 1: + continue + if hook_id.lower() <= repo_def["hooks"][0]["id"].lower(): + return i + return len(repos) + + +def _is_equivalent_repo(expected: Repo, existing: Repo) -> bool: + def remove_rev(repo: Repo) -> dict: + repo_copy = dict(repo) + repo_copy.pop("rev", None) + return repo_copy + + return remove_rev(expected) == remove_rev(existing) + + +def update_precommit_hook( + precommit: ModifiablePrecommit, repo_url: str, expected_hook: Hook +) -> None: + """Update the pre-commit hook definition of a specific pre-commit repo. + + This function updates the :code:`.pre-commit-config.yaml` file, but does this only + for a specific hook definition *within* a pre-commit repository definition. + """ + idx_and_repo = find_repo_with_index(precommit.document, repo_url) + if idx_and_repo is None: + return + repo_idx, repo = idx_and_repo + repo_name = repo_url.split("/")[-1] + hooks = repo["hooks"] + hook_idx = __find_hook_idx(hooks, expected_hook["id"]) + if hook_idx is None: + hook_idx = __determine_expected_hook_idx(hooks, expected_hook["id"]) + hooks.insert(hook_idx, expected_hook) + if hook_idx == len(hooks) - 1: + repos = cast(CommentedMap, precommit.document["repos"]) + repos.yaml_set_comment_before_after_key(repo_idx + 1, before="\n") + msg = f"Added {expected_hook['id']!r} to {repo_name} pre-commit config" + precommit.append_to_changelog(msg) + + if hooks[hook_idx] != expected_hook: + hooks[hook_idx] = expected_hook + msg = f"Updated args of {expected_hook['id']!r} {repo_name} pre-commit hook" + precommit.append_to_changelog(msg) + + +def __find_hook_idx(hooks: list[Hook], hook_id: str) -> int | None: + msg = "" + for i, hook in enumerate(hooks): + msg += " " + hook["id"] + if hook["id"] == hook_id: + return i + return None + + +def __determine_expected_hook_idx(hooks: list[Hook], hook_id: str) -> int: + for i, hook in enumerate(hooks): + if hook["id"] > hook_id: + return i + return len(hooks) diff --git a/src/compwa_policy/utilities/precommit/struct.py b/src/compwa_policy/utilities/precommit/struct.py new file mode 100644 index 00000000..fbc18b24 --- /dev/null +++ b/src/compwa_policy/utilities/precommit/struct.py @@ -0,0 +1,86 @@ +# noqa: D100 +from __future__ import annotations + +import sys +from functools import lru_cache +from typing import ForwardRef + +if sys.version_info < (3, 8): + from typing_extensions import Literal, TypedDict +else: + from typing import Literal, TypedDict +if sys.version_info < (3, 11): + from typing_extensions import NotRequired +else: + from typing import NotRequired + + +class PrecommitConfig(TypedDict): + """https://pre-commit.com/#pre-commit-configyaml---top-level.""" + + ci: NotRequired[PrecommitCi] + repos: list[Repo] + default_stages: NotRequired[list[str]] + files: NotRequired[str] + exclude: NotRequired[str] + fail_fast: NotRequired[bool] + minimum_pre_commit_version: NotRequired[str] + + +class PrecommitCi(TypedDict): + """https://pre-commit.ci/#configuration.""" + + autofix_commit_msg: NotRequired[str] + autofix_prs: NotRequired[bool] + autoupdate_branch: NotRequired[str] + autoupdate_commit_msg: NotRequired[str] + autoupdate_schedule: NotRequired[Literal["weekly", "monthly", "quarterly"]] + skip: NotRequired[list[str]] + submodules: NotRequired[bool] + + +class Repo(TypedDict): + """https://pre-commit.com/#pre-commit-configyaml---repos.""" + + repo: str + rev: str + hooks: list[Hook] + + +class Hook(TypedDict): + """https://pre-commit.com/#pre-commit-configyaml---hooks.""" + + id: str + alias: NotRequired[str] + name: NotRequired[str] + language_version: NotRequired[str] + files: NotRequired[str] + exclude: NotRequired[str] + types: NotRequired[list[str]] + types_or: NotRequired[list[str]] + exclude_types: NotRequired[list[str]] + args: NotRequired[list[str]] + stages: NotRequired[list[str]] + additional_dependencies: NotRequired[list[str]] + always_run: NotRequired[bool] + verbose: NotRequired[bool] + log_file: NotRequired[str] + pass_filenames: NotRequired[bool] + + +def validate(config: PrecommitConfig) -> None: + required_keys = _get_required_keys(PrecommitConfig) + missing_keys = required_keys - set(config) + if missing_keys: + msg = f"Missing required keys: {sorted(missing_keys)}" + raise ValueError(msg) + + +@lru_cache(maxsize=None) +def _get_required_keys(struct: type) -> set[str]: + annotations: dict[str, ForwardRef] = struct.__annotations__ + return { + key + for key, ref in annotations.items() + if not ref.__forward_arg__.startswith("NotRequired") + } diff --git a/src/compwa_policy/utilities/vscode.py b/src/compwa_policy/utilities/vscode.py index 8815e53f..31c90d85 100644 --- a/src/compwa_policy/utilities/vscode.py +++ b/src/compwa_policy/utilities/vscode.py @@ -9,10 +9,9 @@ from typing import TYPE_CHECKING, Iterable, OrderedDict, TypeVar, overload from compwa_policy.errors import PrecommitError +from compwa_policy.utilities import CONFIG_PATH from compwa_policy.utilities.executor import Executor -from . import CONFIG_PATH - if TYPE_CHECKING: from pathlib import Path diff --git a/tests/check_dev_files/test_cspell.py b/tests/check_dev_files/test_cspell.py index 8d2f86da..7bb1994a 100644 --- a/tests/check_dev_files/test_cspell.py +++ b/tests/check_dev_files/test_cspell.py @@ -1,54 +1,20 @@ from pathlib import Path import pytest -import yaml from compwa_policy.check_dev_files.cspell import _update_cspell_repo_url from compwa_policy.errors import PrecommitError -from compwa_policy.utilities.precommit import PrecommitConfig +from compwa_policy.utilities.precommit import ModifiablePrecommit, Precommit -@pytest.fixture(scope="session") -def test_config_dir(test_dir: Path) -> Path: - return test_dir / "check_dev_files/cspell" +def test_update_cspell_repo_url(): + test_dir = Path(__file__).parent / "cspell" + with pytest.raises( + PrecommitError, match=r"Updated cSpell pre-commit repo URL" + ), ModifiablePrecommit.load(test_dir / ".pre-commit-config-bad.yaml") as bad: + _update_cspell_repo_url(bad) - -@pytest.fixture(scope="session") -def good_config(test_config_dir: Path) -> PrecommitConfig: - with open(test_config_dir / ".pre-commit-config-good.yaml") as stream: - return yaml.safe_load(stream) - - -@pytest.mark.parametrize( - ("test_config", "error"), - [ - (".pre-commit-config-bad.yaml", True), - (".pre-commit-config-good.yaml", False), - ], -) -def test_update_cspell_repo_url( - test_config: str, - error, - test_config_dir: Path, - tmp_path: Path, - good_config: PrecommitConfig, -): - with open(test_config_dir / test_config) as stream: - config_content = stream.read() - config_path = tmp_path / test_config - config_path.write_text(config_content) - - if error: - with pytest.raises( - PrecommitError, match=r"^Updated cSpell pre-commit repo URL" - ): - _update_cspell_repo_url(config_path) - else: - _update_cspell_repo_url(config_path) - - with open(config_path) as stream: - definition: PrecommitConfig = yaml.safe_load(stream) - - imported = definition["repos"][0]["repo"] - expected = good_config["repos"][0]["repo"] + good_config = Precommit.load(test_dir / ".pre-commit-config-good.yaml") + imported = good_config.document["repos"][0]["repo"] + expected = bad.document["repos"][0]["repo"] assert imported == expected diff --git a/tests/utilities/precommit/.pre-commit-config.yaml b/tests/utilities/precommit/.pre-commit-config.yaml new file mode 100644 index 00000000..2da91e7d --- /dev/null +++ b/tests/utilities/precommit/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +# Example config for tests +ci: + autoupdate_schedule: quarterly + skip: + - mypy + +repos: + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes + + - repo: https://github.com/ComPWA/policy + rev: 0.3.0 + hooks: + - id: check-dev-files + args: + - --no-prettierrc + + - repo: local + hooks: + - id: mypy + name: mypy + entry: mypy + language: system + require_serial: true + types: + - python diff --git a/tests/utilities/precommit/__init__.py b/tests/utilities/precommit/__init__.py new file mode 100644 index 00000000..948df262 --- /dev/null +++ b/tests/utilities/precommit/__init__.py @@ -0,0 +1 @@ +"""Required to set mypy options for the tests folder.""" diff --git a/tests/utilities/precommit/test_getters.py b/tests/utilities/precommit/test_getters.py new file mode 100644 index 00000000..b93a6593 --- /dev/null +++ b/tests/utilities/precommit/test_getters.py @@ -0,0 +1,61 @@ +import io +from pathlib import Path + +import pytest + +from compwa_policy.utilities.precommit import Precommit +from compwa_policy.utilities.precommit.getters import find_repo, find_repo_with_index + + +@pytest.fixture(scope="session") +def example_yaml() -> str: + config_path = Path(__file__).parent / ".pre-commit-config.yaml" + with open(config_path) as stream: + return stream.read() + + +@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: + 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 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: autoupdate pre-commit hooks" + + +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 + + 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 diff --git a/tests/utilities/pyproject/__init__.py b/tests/utilities/pyproject/__init__.py new file mode 100644 index 00000000..948df262 --- /dev/null +++ b/tests/utilities/pyproject/__init__.py @@ -0,0 +1 @@ +"""Required to set mypy options for the tests folder."""