From 6c77e4fec62a05bb06cec3c2884a1319c677e874 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 15 Oct 2026 15:17:36 +0200 Subject: [PATCH 1/5] feat: add project metadata checks --- .../pyansys_quality_report.py | 75 ++- .../quality_rules/__init__.py | 54 +- .../quality_rules/project_metadata.py | 514 ++++++++++++++++++ tests/test_pyansys_quality_report.py | 52 +- 4 files changed, 673 insertions(+), 22 deletions(-) create mode 100644 src/ansys/pre_commit_hooks/quality_rules/project_metadata.py diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 521ca34f..079d1ade 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -34,13 +34,18 @@ import click -from ansys.pre_commit_hooks.quality_rules import README, repo_review_checks +from ansys.pre_commit_hooks.quality_rules import ( + DEFAULT_RULE_FAMILIES, + RULE_FAMILIES, + repo_review_checks, +) from ansys.pre_commit_hooks.quality_rules.common import ( ERROR, PASSED, WARNING, RuleStatus, _first_doc_line, + _QualityRule, normalize_check_result, readme_path, ) @@ -217,17 +222,43 @@ def _normalize_rule_ids(argument_values: list[str]) -> set[str]: } +def _families_to_run( + selected_families: set[str] | None, + selected_rule_ids: set[str] | None, +) -> tuple[str, ...]: + """Return the rule families to evaluate. + + Parameters + ---------- + selected_families : set[str] or None + Explicitly requested families. + selected_rule_ids : set[str] or None + Explicitly requested rule IDs. + + Returns + ------- + tuple[str, ...] + The requested families, every family when only rule IDs are given, or the + default families otherwise. + """ + if selected_families: + return tuple(sorted(selected_families)) + if selected_rule_ids: + return tuple(RULE_FAMILIES) + return DEFAULT_RULE_FAMILIES + + def _execute_check( - rule: README, + rule: _QualityRule, rule_id: str, repository_root: Path, detected_readme_file: str | None, ) -> _RuleResult: - """Execute and serialize one README rule. + """Execute and serialize one rule. Parameters ---------- - rule : README + rule : _QualityRule Rule instance to execute. rule_id : str Stable identifier for the rule. @@ -262,24 +293,29 @@ def _run_checks( *, selected_rule_ids: set[str] | None = None, ignored_rule_ids: set[str] | None = None, + selected_families: set[str] | None = None, ) -> _QualityReport: - """Evaluate registered README rules for a repository. + """Evaluate registered rules for a repository. Parameters ---------- repository_root : pathlib.Path Repository root directory. selected_rule_ids : set[str] or None, optional - Rule IDs to evaluate. All rules are evaluated when omitted or empty. + Rule IDs to evaluate. All rules of the evaluated families are used when + omitted or empty. ignored_rule_ids : set[str] or None, optional Rule IDs to omit from the report. + selected_families : set[str] or None, optional + Rule families to evaluate. Defaults to the README family, or to every + family when ``selected_rule_ids`` is given. Returns ------- _QualityReport Rule results, status counts, and the resulting score. """ - registered_rules = repo_review_checks() + registered_rules = repo_review_checks(_families_to_run(selected_families, selected_rule_ids)) normalized_selected_ids = {rule_id.upper() for rule_id in selected_rule_ids or set()} normalized_ignored_ids = {rule_id.upper() for rule_id in ignored_rule_ids or set()} if normalized_selected_ids: @@ -312,20 +348,23 @@ def _run_checks( def _metadata_report( selected_rule_ids: set[str] | None = None, + selected_families: set[str] | None = None, ) -> list[_RuleMetadata]: - """Return metadata for available README rules. + """Return metadata for available rules. Parameters ---------- selected_rule_ids : set[str] or None, optional Rule IDs to include. All rules are included when omitted or empty. + selected_families : set[str] or None, optional + Rule families to include. Every family is listed when omitted or empty. Returns ------- list[_RuleMetadata] Metadata records ordered by rule ID. """ - registered_rules = repo_review_checks() + registered_rules = repo_review_checks(selected_families or tuple(RULE_FAMILIES)) normalized_selected_ids = {rule_id.upper() for rule_id in selected_rule_ids or set()} return [ { @@ -440,7 +479,7 @@ def _validate_rule_ids( rule_ids : set[str] Normalized rule IDs to validate. """ - available_rule_ids = sorted(repo_review_checks()) + available_rule_ids = sorted(repo_review_checks(tuple(RULE_FAMILIES))) unknown_rule_ids = sorted(rule_ids.difference(available_rule_ids)) if unknown_rule_ids: raise click.UsageError( @@ -453,7 +492,7 @@ def _validate_rule_ids( @click.option("--json", "json_output", is_flag=True, help="Emit a JSON report.") @click.option("--metadata", is_flag=True, help="List available rule metadata.") @click.option("--check", "check", multiple=True, help="Select rule IDs.") -@click.option("--family", multiple=True, help="Select rule family.") +@click.option("--family", multiple=True, help="Select rule family (readme, project_metadata).") @click.option("--ignore", multiple=True, help="Ignore rule IDs.") @click.option("--show-all", is_flag=True, help="Include passing checks.") @click.option( @@ -489,12 +528,19 @@ def _cli( rule_family.strip().lower() for argument_value in family for rule_family in argument_value.split(",") + if rule_family.strip() } - if selected_rule_families and selected_rule_families - {"readme"}: - raise click.UsageError("This release supports only the readme rule family") + unknown_families = selected_rule_families - set(RULE_FAMILIES) + if unknown_families: + raise click.UsageError( + f"unknown rule family(ies): {', '.join(sorted(unknown_families))}. " + f"Available families: {', '.join(RULE_FAMILIES)}" + ) if metadata: - autoflush_print(json.dumps(_metadata_report(selected_rule_ids), indent=2)) + autoflush_print( + json.dumps(_metadata_report(selected_rule_ids, selected_rule_families), indent=2) + ) return 0 repository_root = _find_project_root(Path.cwd()) @@ -502,6 +548,7 @@ def _cli( repository_root, selected_rule_ids=selected_rule_ids, ignored_rule_ids=ignored_rule_ids, + selected_families=selected_rule_families, ) if json_output: autoflush_print(json.dumps(quality_report, indent=2)) diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index 7e1f2f3c..85c18614 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -22,25 +22,65 @@ """Quality-check registry subpackage.""" +from collections.abc import Iterable + +from ansys.pre_commit_hooks.quality_rules.common import _QualityRule +from ansys.pre_commit_hooks.quality_rules.project_metadata import ProjectMetadata from ansys.pre_commit_hooks.quality_rules.readme import README README_QUALITY_RULES: tuple[type[README], ...] = tuple( sorted(README.__subclasses__(), key=lambda rule_class: rule_class.__name__) ) +PROJECT_METADATA_QUALITY_RULES: tuple[type[ProjectMetadata], ...] = tuple( + sorted(ProjectMetadata.__subclasses__(), key=lambda rule_class: rule_class.__name__) +) + +# Maps each rule family name to its rule classes. +RULE_FAMILIES: dict[str, tuple[type[_QualityRule], ...]] = { + README.family: README_QUALITY_RULES, + ProjectMetadata.family: PROJECT_METADATA_QUALITY_RULES, +} +# Families evaluated when no ``--family`` is requested. +DEFAULT_RULE_FAMILIES: tuple[str, ...] = (README.family,) # Re-export each rule class so the names in ``__all__`` resolve. -globals().update({rule_class.__name__: rule_class for rule_class in README_QUALITY_RULES}) +globals().update( + { + rule_class.__name__: rule_class + for rule_classes in RULE_FAMILIES.values() + for rule_class in rule_classes + } +) + +__all__ = [ + "DEFAULT_RULE_FAMILIES", + "PROJECT_METADATA_QUALITY_RULES", + "README", + "README_QUALITY_RULES", + "RULE_FAMILIES", + "ProjectMetadata", + "repo_review_checks", +] +__all__.extend( + rule_class.__name__ for rule_classes in RULE_FAMILIES.values() for rule_class in rule_classes +) -__all__ = ["README", "README_QUALITY_RULES", "repo_review_checks"] -__all__.extend(rule_class.__name__ for rule_class in README_QUALITY_RULES) +def repo_review_checks(families: Iterable[str] = DEFAULT_RULE_FAMILIES) -> dict[str, _QualityRule]: + """Return the registered rule instances for the requested families. -def repo_review_checks() -> dict[str, README]: - """Return the registered README rule instances. + Parameters + ---------- + families : collections.abc.Iterable[str], default: ``("readme",)`` + Rule family names to include. Returns ------- - dict[str, README] + dict[str, _QualityRule] Rule instances keyed by their rule IDs. """ - return {rule_class.__name__: rule_class() for rule_class in README_QUALITY_RULES} + return { + rule_class.__name__: rule_class() + for family in families + for rule_class in RULE_FAMILIES[family] + } diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py new file mode 100644 index 00000000..45d0f2aa --- /dev/null +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -0,0 +1,514 @@ +# Copyright (C) 2023 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Project metadata checks. + +This rule set validates repository metadata against PyAnsys standards. + +The checks cover: + +* Governance and repository files + - AUTHORS + - CHANGELOG.md + - CODE_OF_CONDUCT.md + - CONTRIBUTING.md + - CONTRIBUTORS.md + - LICENSE + - SECURITY.md + - .github/CODEOWNERS + +* Packaging metadata + - pyproject.toml references the README and LICENSE files + - Project name follows the ``ansys-*-*`` convention + - Project version follows the accepted versioning schemes + - Author and maintainer metadata is configured + - Supported Python versions have explicit bounds + - Support URLs are declared + +* Licensing + - LICENSE file contains recognized Apache 2.0 wording + +* Repository layout + - Documentation and tests directories + - Task runner configuration +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from pathlib import Path +import re +from typing import ClassVar, Final + +from ansys.pre_commit_hooks.quality_rules.common import ( + RuleCheckResult, + _repository_file, + file_content, + file_exists, +) + +__all__ = [ + "PM001", + "PM002", + "PM003", + "PM004", + "PM005", + "PM006", + "PM008", + "PM009", + "PM010", + "PM011", + "PM012", + "PM013", + "PM014", + "PM015", + "PM016", + "PM017", + "PM021", + "PM022", + "PM024", + "PM025", + "PM026", + "PM027", + "ProjectMetadata", +] + +_PYPROJECT: Final = "pyproject.toml" +_LICENSE: Final = "LICENSE" +_CODEOWNERS: Final = ".github/CODEOWNERS" + +_DEFAULT_AUTHOR: Final = "Synopsys, Inc. and ANSYS, Inc." +_DEFAULT_EMAIL: Final = "pyansys-core@synopsys.com" + +_POETRY_BACKEND: Final = re.compile(r"poetry[._-]core") + + +def _has_any_file(repository_root: Path, relative_paths: Sequence[str]) -> bool: + """Return whether any of the given files exists in the repository.""" + return any(file_exists(repository_root, path) for path in relative_paths) + + +def _has_any_directory(repository_root: Path, relative_paths: Sequence[str]) -> bool: + """Return whether any of the given directories exists inside the repository.""" + for path in relative_paths: + resolved_directory = _repository_file(repository_root, path) + try: + if resolved_directory is not None and resolved_directory.is_dir(): + return True + except OSError: + continue + return False + + +def _read(repository_root: Path, relative_path: str) -> str | None: + """Return file content, or ``None`` if the file is missing or unreadable.""" + if not file_exists(repository_root, relative_path): + return None + return file_content(repository_root, relative_path) + + +def _missing(relative_path: str) -> str: + """Return the warning used when a file needed for a check is unavailable.""" + return f"WARNING: {relative_path} not found or not readable, so this check was skipped." + + +def _has_url_entry(content: str, key: str) -> bool: + """Return whether a top-level ``key = "http(s)://..."`` entry exists.""" + return bool(re.search(rf"^{key}\s*=\s*[\"']https?://", content, re.MULTILINE)) + + +class ProjectMetadata(ABC): + """Project metadata rule family. + + Base class of the project metadata quality rules. Each rule is a subclass + named ``PM`` with a static ``check`` method that receives the repository + root and the name of the detected README file. + """ + + family: ClassVar[str] = "project_metadata" + + @staticmethod + @abstractmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Evaluate a project metadata rule. + + Parameters + ---------- + repository_root : pathlib.Path + Repository root directory. + readme_file : str or None + Detected README path, or ``None`` when no supported README exists. + + Returns + ------- + bool or str + Raw rule result consumed by the quality-report runner. + """ + raise NotImplementedError + + +class PM001(ProjectMetadata): + """The AUTHORS file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the AUTHORS file is present.""" + return file_exists(repository_root, "AUTHORS") + + +class PM002(ProjectMetadata): + """The CHANGELOG.md file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the changelog file is present.""" + return file_exists(repository_root, "CHANGELOG.md") + + +class PM003(ProjectMetadata): + """The CODE_OF_CONDUCT.md file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the code-of-conduct file is present.""" + return file_exists(repository_root, "CODE_OF_CONDUCT.md") + + +class PM004(ProjectMetadata): + """The CONTRIBUTING.md file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the contributing guide is present.""" + return file_exists(repository_root, "CONTRIBUTING.md") + + +class PM005(ProjectMetadata): + """The CONTRIBUTORS.md file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the contributors file is present.""" + return file_exists(repository_root, "CONTRIBUTORS.md") + + +class PM006(ProjectMetadata): + """The LICENSE file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the license file is present.""" + return file_exists(repository_root, _LICENSE) + + +class PM008(ProjectMetadata): + """The SECURITY.md file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the security policy file is present.""" + return file_exists(repository_root, "SECURITY.md") + + +class PM009(ProjectMetadata): + """The .github/CODEOWNERS file exists.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the CODEOWNERS file is present.""" + return file_exists(repository_root, _CODEOWNERS) + + +class PM010(ProjectMetadata): + """Pyproject.toml references the README file.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether pyproject.toml references the expected README file.""" + content = _read(repository_root, _PYPROJECT) + if content is None: + return _missing(_PYPROJECT) + + if (readme_file or "README.rst") in content: + return True + if "README" in content: + return "WARNING: readme key found but exact README filename not confirmed." + return False + + +class PM011(ProjectMetadata): + """Pyproject.toml references the LICENSE file.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether pyproject.toml references the license file.""" + content = _read(repository_root, _PYPROJECT) + if content is None: + return _missing(_PYPROJECT) + + if _POETRY_BACKEND.search(content): + return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", content)) + + return bool( + re.search(r"license-files\s*=", content) + or re.search(r"license\s*=\s*\{[^}]*file", content) + or re.search(r'license\s*=\s*["\']LICENSE["\']', content) + ) + + +class PM012(ProjectMetadata): + """Project name follows the ansys-*-* convention.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the project name matches the PyAnsys naming convention.""" + content = _read(repository_root, _PYPROJECT) + if content is None: + return _missing(_PYPROJECT) + + match = re.search(r'^name\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + if not match: + return "WARNING: project name not found in pyproject.toml." + + name = match.group(1) + if re.fullmatch(r"ansys-[a-z0-9-]+-[a-z0-9-]+", name): + return True + return f"WARNING: project name '{name}' does not match ansys-*-*." + + +class PM013(ProjectMetadata): + """Project version follows an accepted versioning scheme.""" + + # Only the subset supported by the PyAnsys tooling: N.N.N[{a|b|rc}N] or PEP 440 dev. + _RELEASE: ClassVar[re.Pattern[str]] = re.compile(r"\d+\.\d+\.\d+(?:(?:a|b|rc)\d+)?") + _DEV: ClassVar[re.Pattern[str]] = re.compile(r"\d+\.\d+(?:\.\d+)?\.dev\d+") + _DYNAMIC_VERSION: ClassVar[re.Pattern[str]] = re.compile( + r"^dynamic\s*=\s*\[[^\]]*[\"']version[\"']", re.MULTILINE + ) + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the project version uses a valid release or dev version.""" + content = _read(repository_root, _PYPROJECT) + if content is None: + return _missing(_PYPROJECT) + + match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + if not match: + # The build backend resolves a dynamic version (for example from __init__.py). + if PM013._DYNAMIC_VERSION.search(content): + return True + return "WARNING: project version not found in pyproject.toml." + + version = match.group(1) + if PM013._RELEASE.fullmatch(version) or PM013._DEV.fullmatch(version): + return True + return ( + f"WARNING: project version '{version}' does not follow N.N.N[{{a|b|rc}}N] " + "or the accepted Python dev-version form." + ) + + +class PM014(ProjectMetadata): + """Project author and maintainer metadata matches PyAnsys defaults.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether author and maintainer metadata are configured as expected.""" + content = _read(repository_root, _PYPROJECT) + if content is None: + return _missing(_PYPROJECT) + + def block_ok(key: str) -> bool: + block_match = re.search(rf"{key}\s*=\s*\[([\s\S]*?)\]", content) + block = block_match.group(1) if block_match else "" + return bool( + re.search(rf'name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', block) + and re.search(rf'email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', block) + ) + + if block_ok("authors") and block_ok("maintainers"): + return True + return ( + "WARNING: author/maintainer metadata does not match " + f"{_DEFAULT_AUTHOR} / {_DEFAULT_EMAIL}." + ) + + +class PM015(ProjectMetadata): + """The LICENSE file includes recognized Apache 2.0 license wording.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the LICENSE file contains expected Apache 2.0 text.""" + content = _read(repository_root, _LICENSE) + if content is None: + return _missing(_LICENSE) + + if "Apache License" in content: + return True + return "WARNING: LICENSE file content is missing a recognized Apache 2.0 statement." + + +class PM016(ProjectMetadata): + """The .github/CODEOWNERS file contains at least one valid owner entry.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether the CODEOWNERS file contains at least one valid ownership entry.""" + content = _read(repository_root, _CODEOWNERS) + if content is None: + return _missing(_CODEOWNERS) + + if re.search(r"^\s*[^#\n]+\s+@\S+", content, re.MULTILINE): + return True + return f"WARNING: {_CODEOWNERS} exists but has no owner entries." + + +class PM017(ProjectMetadata): + """Project declares supported Python versions with explicit bounds.""" + + @staticmethod + def _validate_python_version_spec(spec: str) -> RuleCheckResult: + """Validate that a Python version spec defines lower and upper bounds.""" + if not re.search(r">=\d+\.\d+", spec) or not re.search(r"[<,]=?\d+", spec): + return ( + f"WARNING: requires-python '{spec}' does not declare a supported PyAnsys " + "version range. Use >=3.10,<4 or a more recent support window." + ) + return True + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether Python support is declared with both lower and upper bounds.""" + sources = ((_PYPROJECT, "requires-python"), ("setup.py", "python_requires")) + for relative_path, key in sources: + content = _read(repository_root, relative_path) + match = re.search(rf'{key}\s*=\s*["\']([^"\']+)["\']', content) if content else None + if match: + return PM017._validate_python_version_spec(match.group(1)) + return "WARNING: requires-python is not declared in pyproject.toml or setup.py." + + +class PM021(ProjectMetadata): + """The project includes a docs directory.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether a documentation directory is present.""" + return _has_any_directory(repository_root, ("docs", "doc")) + + +class PM022(ProjectMetadata): + """The project includes a tests directory.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether a test directory is present.""" + return _has_any_directory(repository_root, ("tests", "test")) + + +class PM024(ProjectMetadata): + """The project supports a task runner such as nox, tox, or pixi.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether a task runner is configured for the project.""" + return _has_any_file( + repository_root, + ( + "tox.ini", + "noxfile.py", + "noxfile.toml", + "pixi.toml", + "justfile", + "Makefile", + ), + ) + + +class PM025(ProjectMetadata): + """Project contact and support links are provided in pyproject metadata.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether key support URLs are declared under project.urls.""" + content = _read(repository_root, _PYPROJECT) + if content is None: + return _missing(_PYPROJECT) + + missing = [ + key + for key in ("Issues", "Discussions", "Documentation") + if not _has_url_entry(content, key) + ] + if not missing: + return True + return "WARNING: missing support/contact project URLs in pyproject.toml: " + ", ".join( + missing + ) + + +class PM026(ProjectMetadata): + """The AUTHORS file contains compliant contributor ownership information.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether AUTHORS includes at least one entry and the corporate owner.""" + content = _read(repository_root, "AUTHORS") + if content is None: + return _missing("AUTHORS") + + lines = [ + line.strip() + for line in content.splitlines() + if line.strip() and not line.startswith("#") + ] + if not lines: + return "WARNING: AUTHORS exists but has no contributor ownership entries." + if not any(_DEFAULT_AUTHOR in line for line in lines): + return "WARNING: AUTHORS is missing the expected corporate owner entry." + return True + + +class PM027(ProjectMetadata): + """The CONTRIBUTORS.md file includes lead and main contributors sections.""" + + @staticmethod + def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: + """Return whether CONTRIBUTORS.md includes lead and contributor details.""" + content = _read(repository_root, "CONTRIBUTORS.md") + if content is None: + return _missing("CONTRIBUTORS.md") + + has_lead_heading = bool(re.search(r"^##\s+Project\s+Lead\b", content, re.MULTILINE)) + has_individual_heading = bool( + re.search(r"^##\s+Individual\s+Contributors\b", content, re.MULTILINE) + ) + has_bullets = bool(re.search(r"^\*\s+\[[^\]]+\]\([^)]+\)", content, re.MULTILINE)) + if has_lead_heading and has_individual_heading and has_bullets: + return True + return ( + "WARNING: CONTRIBUTORS.md should include 'Project Lead' and 'Individual " + "Contributors' sections with at least one contributor entry." + ) diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 3b6cafcc..15a427b0 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -372,7 +372,57 @@ def test_cli_rejects_unknown_rule_ids(tmp_path, monkeypatch, capsys, option): error = capsys.readouterr().err assert exit_info.value.code == 2 assert f"unknown rule ID(s) for {option}: RM999, XX1." in error - assert "Available rules: RM000, RM001" in error + assert "Available rules: PM001" in error + assert "RM000, RM001" in error + + +def test_project_metadata_family_is_opt_in(tmp_path, monkeypatch, capsys): + """Project metadata rules run only when requested by family or rule ID.""" + (tmp_path / "pyproject.toml").write_text('[project]\nname = "ansys-foo-bar"\n', "utf-8") + monkeypatch.chdir(tmp_path) + + default_ids = {item["id"] for item in report._run_checks(tmp_path)["results"]} + family_ids = { + item["id"] + for item in report._run_checks(tmp_path, selected_families={"project_metadata"})["results"] + } + by_id = { + item["id"]: item + for item in report._run_checks(tmp_path, selected_rule_ids={"PM012"})["results"] + } + + assert not any(rule_id.startswith("PM") for rule_id in default_ids) + assert family_ids and all(rule_id.startswith("PM") for rule_id in family_ids) + assert by_id["PM012"]["status"] == "PASSED" + assert by_id["PM012"]["family"] == "project_metadata" + + +@pytest.mark.parametrize( + ("version_line", "expected"), + [ + ('version = "1.2.3"', "PASSED"), + ('version = "1.2.3rc1"', "PASSED"), + ('version = "1.2.dev0"', "PASSED"), + ('version = "1.2.3-beta.1"', "WARNING"), + ('dynamic = ["version"]', "PASSED"), + ], +) +def test_pm013_version_formats(tmp_path, version_line, expected): + """Only N.N.N[{a|b|rc}N], dev versions and dynamic versions are accepted.""" + (tmp_path / "pyproject.toml").write_text(f"[project]\n{version_line}\n", encoding="utf-8") + + results = report._run_checks(tmp_path, selected_rule_ids={"PM013"})["results"] + + assert results[0]["status"] == expected + + +def test_unknown_rule_family_is_rejected(capsys): + """Unsupported rule families stop the run.""" + with pytest.raises(SystemExit) as exit_info: + report.main(["--family", "nope"]) + + assert exit_info.value.code == 2 + assert "unknown rule family(ies): nope" in capsys.readouterr().err def test_cli_rejects_unknown_rule_ids_with_metadata(capsys): From 4214b576c4cadda38de592ae6373606d23c5df65 Mon Sep 17 00:00:00 2001 From: pyansys-ci-bot <92810346+pyansys-ci-bot@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:19:03 +0000 Subject: [PATCH 2/5] chore: adding changelog file 501.added.md [dependabot-skip] --- doc/changelog.d/501.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changelog.d/501.added.md diff --git a/doc/changelog.d/501.added.md b/doc/changelog.d/501.added.md new file mode 100644 index 00000000..097a90f5 --- /dev/null +++ b/doc/changelog.d/501.added.md @@ -0,0 +1 @@ +Add project metadata checks From de05de1b414c248ac83641c9a2e87759843c0494 Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 15 Oct 2026 15:27:09 +0200 Subject: [PATCH 3/5] fix: update the pyproject test --- .../pyansys_quality_report.py | 50 ++++++++++++++++++- tests/test_pyansys_quality_report.py | 33 ++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 079d1ade..8a618741 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -34,6 +34,11 @@ import click +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 relies on ``toml``. + import toml as tomllib + from ansys.pre_commit_hooks.quality_rules import ( DEFAULT_RULE_FAMILIES, RULE_FAMILIES, @@ -46,6 +51,7 @@ RuleStatus, _first_doc_line, _QualityRule, + file_content, normalize_check_result, readme_path, ) @@ -488,12 +494,50 @@ def _validate_rule_ids( ) +def _pyproject_ignored_rule_ids(repository_root: Path) -> set[str]: + """Read ignored rule IDs from ``[tool.pyansys-quality-report]``. + + Parameters + ---------- + repository_root : pathlib.Path + Repository root directory. + + Returns + ------- + set[str] + Normalized rule IDs listed under the ``ignore`` key. The set is empty when + ``pyproject.toml`` is missing or has no such key. + + Raises + ------ + click.UsageError + If ``pyproject.toml`` is invalid TOML or ``ignore`` is not a list of strings. + """ + pyproject_content = file_content(repository_root, "pyproject.toml") + if pyproject_content is None: + return set() + try: + pyproject_data = tomllib.loads(pyproject_content) + except (TypeError, ValueError) as error: + raise click.UsageError(f"pyproject.toml is not valid TOML: {error}") from error + + tool_settings = pyproject_data.get("tool", {}).get("pyansys-quality-report", {}) + ignored_values = tool_settings.get("ignore", []) if isinstance(tool_settings, dict) else [] + if not isinstance(ignored_values, list) or not all( + isinstance(value, str) for value in ignored_values + ): + raise click.UsageError( + "[tool.pyansys-quality-report] ignore in pyproject.toml must be a list of strings" + ) + return _normalize_rule_ids(ignored_values) + + @click.command(help="Run PyAnsys README quality checks.") @click.option("--json", "json_output", is_flag=True, help="Emit a JSON report.") @click.option("--metadata", is_flag=True, help="List available rule metadata.") @click.option("--check", "check", multiple=True, help="Select rule IDs.") @click.option("--family", multiple=True, help="Select rule family (readme, project_metadata).") -@click.option("--ignore", multiple=True, help="Ignore rule IDs.") +@click.option("--ignore", multiple=True, help="Ignore rule IDs (also read from pyproject.toml).") @click.option("--show-all", is_flag=True, help="Include passing checks.") @click.option( "--fails-only", @@ -544,10 +588,12 @@ def _cli( return 0 repository_root = _find_project_root(Path.cwd()) + configured_ignored_rule_ids = _pyproject_ignored_rule_ids(repository_root) + _validate_rule_ids("[tool.pyansys-quality-report] ignore", configured_ignored_rule_ids) quality_report = _run_checks( repository_root, selected_rule_ids=selected_rule_ids, - ignored_rule_ids=ignored_rule_ids, + ignored_rule_ids=ignored_rule_ids | configured_ignored_rule_ids, selected_families=selected_rule_families, ) if json_output: diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 15a427b0..7c22bd15 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -376,6 +376,39 @@ def test_cli_rejects_unknown_rule_ids(tmp_path, monkeypatch, capsys, option): assert "RM000, RM001" in error +def test_cli_reads_ignored_rules_from_pyproject(tmp_path, monkeypatch, capsys): + """Rule IDs under [tool.pyansys-quality-report] are merged with --ignore.""" + (tmp_path / "pyproject.toml").write_text( + '[tool.pyansys-quality-report]\nignore = ["rm005"]\n', encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + report.main(["--ignore", "RM006", "--json"]) + + ids = {item["id"] for item in json.loads(capsys.readouterr().out)["results"]} + assert "RM005" not in ids + assert "RM006" not in ids + assert "RM007" in ids + + +@pytest.mark.parametrize( + ("ignore_value", "message"), + [('["RM999"]', "unknown rule ID(s)"), ('"RM005"', "must be a list of strings")], +) +def test_cli_rejects_invalid_pyproject_ignore(tmp_path, monkeypatch, capsys, ignore_value, message): + """Invalid ignore settings in pyproject.toml stop the run.""" + (tmp_path / "pyproject.toml").write_text( + f"[tool.pyansys-quality-report]\nignore = {ignore_value}\n", encoding="utf-8" + ) + monkeypatch.chdir(tmp_path) + + with pytest.raises(SystemExit) as exit_info: + report.main([]) + + assert exit_info.value.code == 2 + assert message in capsys.readouterr().err + + def test_project_metadata_family_is_opt_in(tmp_path, monkeypatch, capsys): """Project metadata rules run only when requested by family or rule ID.""" (tmp_path / "pyproject.toml").write_text('[project]\nname = "ansys-foo-bar"\n', "utf-8") From 63f59ad8e482aac2665c04795d5a69751bbecb4d Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Thu, 15 Oct 2026 15:43:46 +0200 Subject: [PATCH 4/5] fix: update the default runs --- .../pre_commit_hooks/pyansys_quality_report.py | 17 ++++------------- .../pre_commit_hooks/quality_rules/__init__.py | 4 ++-- tests/test_pyansys_quality_report.py | 17 +++++++++-------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/ansys/pre_commit_hooks/pyansys_quality_report.py b/src/ansys/pre_commit_hooks/pyansys_quality_report.py index 8a618741..2e079f6a 100644 --- a/src/ansys/pre_commit_hooks/pyansys_quality_report.py +++ b/src/ansys/pre_commit_hooks/pyansys_quality_report.py @@ -228,29 +228,21 @@ def _normalize_rule_ids(argument_values: list[str]) -> set[str]: } -def _families_to_run( - selected_families: set[str] | None, - selected_rule_ids: set[str] | None, -) -> tuple[str, ...]: +def _families_to_run(selected_families: set[str] | None) -> tuple[str, ...]: """Return the rule families to evaluate. Parameters ---------- selected_families : set[str] or None Explicitly requested families. - selected_rule_ids : set[str] or None - Explicitly requested rule IDs. Returns ------- tuple[str, ...] - The requested families, every family when only rule IDs are given, or the - default families otherwise. + The requested families, or every family when none is requested. """ if selected_families: return tuple(sorted(selected_families)) - if selected_rule_ids: - return tuple(RULE_FAMILIES) return DEFAULT_RULE_FAMILIES @@ -313,15 +305,14 @@ def _run_checks( ignored_rule_ids : set[str] or None, optional Rule IDs to omit from the report. selected_families : set[str] or None, optional - Rule families to evaluate. Defaults to the README family, or to every - family when ``selected_rule_ids`` is given. + Rule families to evaluate. Defaults to every family. Returns ------- _QualityReport Rule results, status counts, and the resulting score. """ - registered_rules = repo_review_checks(_families_to_run(selected_families, selected_rule_ids)) + registered_rules = repo_review_checks(_families_to_run(selected_families)) normalized_selected_ids = {rule_id.upper() for rule_id in selected_rule_ids or set()} normalized_ignored_ids = {rule_id.upper() for rule_id in ignored_rule_ids or set()} if normalized_selected_ids: diff --git a/src/ansys/pre_commit_hooks/quality_rules/__init__.py b/src/ansys/pre_commit_hooks/quality_rules/__init__.py index 85c18614..48670ff6 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/__init__.py +++ b/src/ansys/pre_commit_hooks/quality_rules/__init__.py @@ -41,7 +41,7 @@ ProjectMetadata.family: PROJECT_METADATA_QUALITY_RULES, } # Families evaluated when no ``--family`` is requested. -DEFAULT_RULE_FAMILIES: tuple[str, ...] = (README.family,) +DEFAULT_RULE_FAMILIES: tuple[str, ...] = tuple(RULE_FAMILIES) # Re-export each rule class so the names in ``__all__`` resolve. globals().update( @@ -71,7 +71,7 @@ def repo_review_checks(families: Iterable[str] = DEFAULT_RULE_FAMILIES) -> dict[ Parameters ---------- - families : collections.abc.Iterable[str], default: ``("readme",)`` + families : collections.abc.Iterable[str], default: every family Rule family names to include. Returns diff --git a/tests/test_pyansys_quality_report.py b/tests/test_pyansys_quality_report.py index 7c22bd15..53fc03a0 100644 --- a/tests/test_pyansys_quality_report.py +++ b/tests/test_pyansys_quality_report.py @@ -42,9 +42,10 @@ ) -def test_registry_contains_only_readme_rules(): - """The first release exposes only the README rule family.""" - assert list(repo_review_checks()) == [f"RM{number:03d}" for number in range(8)] +def test_registry_contains_every_rule_family(): + """The registry exposes the README and project metadata rule families.""" + assert list(repo_review_checks(["readme"])) == [f"RM{number:03d}" for number in range(8)] + assert {rule_id[:2] for rule_id in repo_review_checks()} == {"RM", "PM"} def test_rm003_matches_mit_project_license(tmp_path): @@ -99,7 +100,7 @@ def test_missing_readme_is_reported_by_every_rule(tmp_path, capsys): """Without a README no rule is skipped: each one fails or warns.""" (tmp_path / "pyproject.toml").write_text('[project]\nlicense = "MIT"\n', encoding="utf-8") - review = report._run_checks(tmp_path) + review = report._run_checks(tmp_path, selected_families={"readme"}) statuses = {item["id"]: item["status"] for item in review["results"]} assert statuses == { @@ -409,8 +410,8 @@ def test_cli_rejects_invalid_pyproject_ignore(tmp_path, monkeypatch, capsys, ign assert message in capsys.readouterr().err -def test_project_metadata_family_is_opt_in(tmp_path, monkeypatch, capsys): - """Project metadata rules run only when requested by family or rule ID.""" +def test_project_metadata_family_runs_by_default(tmp_path, monkeypatch): + """Every family runs unless --family narrows the selection.""" (tmp_path / "pyproject.toml").write_text('[project]\nname = "ansys-foo-bar"\n', "utf-8") monkeypatch.chdir(tmp_path) @@ -424,7 +425,7 @@ def test_project_metadata_family_is_opt_in(tmp_path, monkeypatch, capsys): for item in report._run_checks(tmp_path, selected_rule_ids={"PM012"})["results"] } - assert not any(rule_id.startswith("PM") for rule_id in default_ids) + assert {"RM000", "PM001"} <= default_ids assert family_ids and all(rule_id.startswith("PM") for rule_id in family_ids) assert by_id["PM012"]["status"] == "PASSED" assert by_id["PM012"]["family"] == "project_metadata" @@ -707,7 +708,7 @@ def test_unreadable_readme_is_an_error_for_every_content_rule(tmp_path): (tmp_path / "pyproject.toml").write_text('[project]\nlicense = "MIT"\n', encoding="utf-8") (tmp_path / "README.rst").write_bytes(b"\xff") - review = report._run_checks(tmp_path) + review = report._run_checks(tmp_path, selected_families={"readme"}) statuses = {item["id"]: item["status"] for item in review["results"] if item["id"] != "RM000"} assert set(statuses.values()) == {"ERROR"} From 15b1adffd0793f09b5414c58659eadd808a985af Mon Sep 17 00:00:00 2001 From: Revathyvenugopal162 Date: Mon, 19 Oct 2026 12:04:35 +0200 Subject: [PATCH 5/5] fix: tasks order --- .../quality_rules/project_metadata.py | 105 ++++++++++-------- 1 file changed, 56 insertions(+), 49 deletions(-) diff --git a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py index 45d0f2aa..638308f7 100644 --- a/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py +++ b/src/ansys/pre_commit_hooks/quality_rules/project_metadata.py @@ -96,11 +96,32 @@ _PYPROJECT: Final = "pyproject.toml" _LICENSE: Final = "LICENSE" _CODEOWNERS: Final = ".github/CODEOWNERS" +_AUTHORS: Final = "AUTHORS" +_CONTRIBUTORS: Final = "CONTRIBUTORS.md" _DEFAULT_AUTHOR: Final = "Synopsys, Inc. and ANSYS, Inc." _DEFAULT_EMAIL: Final = "pyansys-core@synopsys.com" +_DOCS_DIRECTORIES: Final = ("docs", "doc") +_TESTS_DIRECTORIES: Final = ("tests", "test") +_TASK_RUNNER_FILES: Final = ( + "tox.ini", + "noxfile.py", + "noxfile.toml", + "pixi.toml", + "justfile", + "Makefile", +) +_REQUIRED_PROJECT_URLS: Final = ("Issues", "Discussions", "Documentation") + _POETRY_BACKEND: Final = re.compile(r"poetry[._-]core") +_POETRY_LICENSE: Final = re.compile(r"\[tool\.poetry\][\s\S]*?license\s*=") +_PROJECT_NAME: Final = re.compile(r"""^name\s*=\s*["']([^"']+)["']""", re.MULTILINE) +_PROJECT_VERSION: Final = re.compile(r"""^version\s*=\s*["']([^"']+)["']""", re.MULTILINE) +_CODEOWNER_ENTRY: Final = re.compile(r"^\s*[^#\n]+\s+@\S+", re.MULTILINE) +_LEAD_HEADING: Final = re.compile(r"^##\s+Project\s+Lead\b", re.MULTILINE) +_INDIVIDUAL_HEADING: Final = re.compile(r"^##\s+Individual\s+Contributors\b", re.MULTILINE) +_CONTRIBUTOR_BULLET: Final = re.compile(r"^\*\s+\[[^\]]+\]\([^)]+\)", re.MULTILINE) def _has_any_file(repository_root: Path, relative_paths: Sequence[str]) -> bool: @@ -134,7 +155,17 @@ def _missing(relative_path: str) -> str: def _has_url_entry(content: str, key: str) -> bool: """Return whether a top-level ``key = "http(s)://..."`` entry exists.""" - return bool(re.search(rf"^{key}\s*=\s*[\"']https?://", content, re.MULTILINE)) + return bool(re.search(rf"^{re.escape(key)}\s*=\s*[\"']https?://", content, re.MULTILINE)) + + +def _has_default_contact(content: str, key: str) -> bool: + """Return whether the ``key = [...]`` block lists the default author name and email.""" + block_match = re.search(rf"{re.escape(key)}\s*=\s*\[([\s\S]*?)\]", content) + block = block_match.group(1) if block_match else "" + return bool( + re.search(rf'name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', block) + and re.search(rf'email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', block) + ) class ProjectMetadata(ABC): @@ -173,7 +204,7 @@ class PM001(ProjectMetadata): @staticmethod def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: """Return whether the AUTHORS file is present.""" - return file_exists(repository_root, "AUTHORS") + return file_exists(repository_root, _AUTHORS) class PM002(ProjectMetadata): @@ -209,7 +240,7 @@ class PM005(ProjectMetadata): @staticmethod def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: """Return whether the contributors file is present.""" - return file_exists(repository_root, "CONTRIBUTORS.md") + return file_exists(repository_root, _CONTRIBUTORS) class PM006(ProjectMetadata): @@ -267,7 +298,7 @@ def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: return _missing(_PYPROJECT) if _POETRY_BACKEND.search(content): - return bool(re.search(r"\[tool\.poetry\][\s\S]*?license\s*=", content)) + return bool(_POETRY_LICENSE.search(content)) return bool( re.search(r"license-files\s*=", content) @@ -286,7 +317,7 @@ def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: if content is None: return _missing(_PYPROJECT) - match = re.search(r'^name\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + match = _PROJECT_NAME.search(content) if not match: return "WARNING: project name not found in pyproject.toml." @@ -313,7 +344,7 @@ def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: if content is None: return _missing(_PYPROJECT) - match = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', content, re.MULTILINE) + match = _PROJECT_VERSION.search(content) if not match: # The build backend resolves a dynamic version (for example from __init__.py). if PM013._DYNAMIC_VERSION.search(content): @@ -339,15 +370,9 @@ def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: if content is None: return _missing(_PYPROJECT) - def block_ok(key: str) -> bool: - block_match = re.search(rf"{key}\s*=\s*\[([\s\S]*?)\]", content) - block = block_match.group(1) if block_match else "" - return bool( - re.search(rf'name\s*=\s*["\']{re.escape(_DEFAULT_AUTHOR)}["\']', block) - and re.search(rf'email\s*=\s*["\']{re.escape(_DEFAULT_EMAIL)}["\']', block) - ) - - if block_ok("authors") and block_ok("maintainers"): + if _has_default_contact(content, "authors") and _has_default_contact( + content, "maintainers" + ): return True return ( "WARNING: author/maintainer metadata does not match " @@ -380,7 +405,7 @@ def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: if content is None: return _missing(_CODEOWNERS) - if re.search(r"^\s*[^#\n]+\s+@\S+", content, re.MULTILINE): + if _CODEOWNER_ENTRY.search(content): return True return f"WARNING: {_CODEOWNERS} exists but has no owner entries." @@ -416,7 +441,7 @@ class PM021(ProjectMetadata): @staticmethod def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: """Return whether a documentation directory is present.""" - return _has_any_directory(repository_root, ("docs", "doc")) + return _has_any_directory(repository_root, _DOCS_DIRECTORIES) class PM022(ProjectMetadata): @@ -425,7 +450,7 @@ class PM022(ProjectMetadata): @staticmethod def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: """Return whether a test directory is present.""" - return _has_any_directory(repository_root, ("tests", "test")) + return _has_any_directory(repository_root, _TESTS_DIRECTORIES) class PM024(ProjectMetadata): @@ -434,17 +459,7 @@ class PM024(ProjectMetadata): @staticmethod def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: """Return whether a task runner is configured for the project.""" - return _has_any_file( - repository_root, - ( - "tox.ini", - "noxfile.py", - "noxfile.toml", - "pixi.toml", - "justfile", - "Makefile", - ), - ) + return _has_any_file(repository_root, _TASK_RUNNER_FILES) class PM025(ProjectMetadata): @@ -457,11 +472,7 @@ def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: if content is None: return _missing(_PYPROJECT) - missing = [ - key - for key in ("Issues", "Discussions", "Documentation") - if not _has_url_entry(content, key) - ] + missing = [key for key in _REQUIRED_PROJECT_URLS if not _has_url_entry(content, key)] if not missing: return True return "WARNING: missing support/contact project URLs in pyproject.toml: " + ", ".join( @@ -475,15 +486,12 @@ class PM026(ProjectMetadata): @staticmethod def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: """Return whether AUTHORS includes at least one entry and the corporate owner.""" - content = _read(repository_root, "AUTHORS") + content = _read(repository_root, _AUTHORS) if content is None: - return _missing("AUTHORS") + return _missing(_AUTHORS) - lines = [ - line.strip() - for line in content.splitlines() - if line.strip() and not line.startswith("#") - ] + stripped_lines = (line.strip() for line in content.splitlines()) + lines = [line for line in stripped_lines if line and not line.startswith("#")] if not lines: return "WARNING: AUTHORS exists but has no contributor ownership entries." if not any(_DEFAULT_AUTHOR in line for line in lines): @@ -497,16 +505,15 @@ class PM027(ProjectMetadata): @staticmethod def check(repository_root: Path, readme_file: str | None) -> RuleCheckResult: """Return whether CONTRIBUTORS.md includes lead and contributor details.""" - content = _read(repository_root, "CONTRIBUTORS.md") + content = _read(repository_root, _CONTRIBUTORS) if content is None: - return _missing("CONTRIBUTORS.md") + return _missing(_CONTRIBUTORS) - has_lead_heading = bool(re.search(r"^##\s+Project\s+Lead\b", content, re.MULTILINE)) - has_individual_heading = bool( - re.search(r"^##\s+Individual\s+Contributors\b", content, re.MULTILINE) - ) - has_bullets = bool(re.search(r"^\*\s+\[[^\]]+\]\([^)]+\)", content, re.MULTILINE)) - if has_lead_heading and has_individual_heading and has_bullets: + if ( + _LEAD_HEADING.search(content) + and _INDIVIDUAL_HEADING.search(content) + and _CONTRIBUTOR_BULLET.search(content) + ): return True return ( "WARNING: CONTRIBUTORS.md should include 'Project Lead' and 'Individual "