Source code for repomatic.lint_repo.context

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""What the repository checks read, resolved once per run.

{class}`LintContext` is the one context that `lint-repo` and `setup-guide`
both read, so the two commands cannot disagree on a gate. It holds what the
command alone knows (the repository it names, which secrets exist), the facts
`pyproject.toml` states, and the `[tool.repomatic]` configuration.

Every remote read is a cached property: a check that never asks for one makes
no call, and two checks asking for the same one share a single call.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path

from ..changelog import resolved_changelog_path
from ..cloudflare.r2 import oversized_files
from ..config import Config, deploys_to
from ..github.gh import gh_json
from ..github.labels import LabelConfigError, declared_label_names
from ..github.token import check_all_pat_permissions, check_pat_repository_scope
from ..github.urls import split_slug
from ..github.workflow_files import parse_workflow_texts, read_workflow_texts
from ..http import FetchError
from ..metadata.core import Metadata
from ..pyproject import get_project_name
from ..upstream import is_awesome_repo

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Mapping
    from typing import Any

    from ..checks import CheckResult
    from ..file_inventory import FileInventory
    from ..github.token import PatPermissionResults


DOCS_URL_KEYS = ("documentation", "docs")
"""Keys in `[project.urls]` naming the published documentation site.

Checked in priority order, and looked up in a lowercased index of the
project's own keys: PEP 621 leaves the spelling to the project, so
`Documentation`, `documentation` and `Docs` all occur in the wild. Mirrors the
same convention `_SOURCE_URL_KEYS` in {mod}`repomatic.pypi` applies to the PyPI
copy of the same mapping.
"""

WRANGLER_TOML = Path("wrangler.toml")
"""The root-level Wrangler configuration a Cloudflare Pages project may commit."""


[docs] def documentation_url(project_urls: Mapping[str, str] | None) -> str | None: """The documentation site a project declares in `[project.urls]`. :param project_urls: The `[project.urls]` mapping, keys untouched. :return: The first URL found per {data}`DOCS_URL_KEYS`, or `None` when the project declares none. """ if not project_urls: return None by_key = {key.lower(): str(value).strip() for key, value in project_urls.items()} for key in DOCS_URL_KEYS: if candidate := by_key.get(key): return candidate return None
def _resolve_declared_labels( config: Config, *, is_awesome: bool ) -> frozenset[str] | None: """Every label name the configured sources declare, or `None` if unknown. A label definition that cannot be fetched or read leaves the set unknown rather than short, so {func}`~repomatic.lint_repo.repo.check_undeclared_labels` reports skipped instead of calling that file's labels orphans. :param config: The resolved `[tool.repomatic]` configuration. :param is_awesome: Whether the repository is an `awesome-*` list. :return: The declared names, or `None` when a definition is unreachable or unreadable. """ try: return frozenset(declared_label_names(config, is_awesome=is_awesome)) except (FetchError, LabelConfigError) as exc: logging.warning(f"Could not read every label definition: {exc}") return None
[docs] @dataclass class LintContext: """Everything the repository checks and the setup guide read. Build it with {meth}`from_project`, which reads the checkout the way the CLI does, or directly for a hand-assembled probe. The fields hold what is known before any check runs. The cached properties hold what a check reads on demand: a GitHub API call, a walk of the working tree, or a parse of the workflows. """ config: Config = field(default_factory=Config) """The resolved `[tool.repomatic]` configuration.""" repo: str | None = None """Repository in `owner/repo` format. `None` turns off every check that reads the GitHub API. """ repo_name: str | None = None """The repository name, from `--repo-name` or the name part of {attr}`repo`.""" package_name: str | None = None """The `[project] name` of `pyproject.toml`.""" is_package: bool = False """Whether the project builds a distributable package. Per {func}`repomatic.pyproject.is_python_package`. Gates the checks that only make sense for something actually published to PyPI. """ is_sphinx: bool = False """Whether the project uses Sphinx documentation.""" has_changelog: bool = False """Whether the configured changelog exists. The release workflow publishes a GitHub release for each release commit, and only `prepare-release` writes one, from the changelog. So this is also whether the repository publishes GitHub releases, which is what the immutable releases setting protects. """ project_description: str | None = None """Description from `pyproject.toml`.""" docs_url: str | None = None """Documentation site declared in `[project.urls]`, per {data}`DOCS_URL_KEYS`.""" keywords: list[str] | None = None """Keywords list from `pyproject.toml`.""" tool_table: Mapping[str, Any] = field(default_factory=dict) """The `[tool]` table of `pyproject.toml`, parsed.""" nuitka_active: bool = False """Whether this project compiles binaries with Nuitka.""" has_pat: bool = False """Whether `REPOMATIC_PAT` is configured.""" has_virustotal_key: bool = False """Whether `VIRUSTOTAL_API_KEY` is configured.""" has_cloudflare_api_token: bool = False """Whether `CLOUDFLARE_API_TOKEN` is configured.""" has_cloudflare_r2_keys: bool = False """Whether `CLOUDFLARE_R2_ACCESS_KEY_ID` and `CLOUDFLARE_R2_SECRET_ACCESS_KEY` are both configured.""" has_notifications_pat: bool = False """Whether `REPOMATIC_NOTIFICATIONS_PAT` is configured.""" @property def slug(self) -> str: """{attr}`repo`, for a check that runs only when it is set. :raises ValueError: When no repository is named. A check reading the GitHub API declares that it needs one, so reaching this error means the check ran without its gate. """ if not self.repo: msg = "This check reads the GitHub API, and no repository is named." raise ValueError(msg) return self.repo @property def owner(self) -> str: """The owner part of {attr}`slug`.""" return split_slug(self.slug)[0] @property def is_awesome(self) -> bool: """Whether the repository is an `awesome-*` curated list.""" return bool(self.repo_name and is_awesome_repo(self.repo_name)) @property def md(self) -> Metadata: """CI and project context, for the repository identity the guide renders.""" return Metadata()
[docs] def deploys_to(self, target: str) -> bool: """Whether this repository publishes its site to *target*. Routes through {func}`repomatic.config.deploys_to`: one host's prerequisite is the other's noise, and exactly one of them applies. """ return deploys_to(self.config.site_deploy, target, is_sphinx=self.is_sphinx)
[docs] @cached_property def repo_object(self) -> dict[str, Any] | None: """The repository object of the GitHub REST API, read once. One ``GET /repos/{repo}`` answers the description, the website field, the topics, the fork flag and the owner type, which four checks and the setup guide read. `None` when no repository is named or the read failed, which each reader reports as skipped rather than failed. """ if not self.repo: return None data = gh_json(["api", f"repos/{self.repo}"], soft=True) return data if isinstance(data, dict) else None
[docs] @cached_property def rulesets(self) -> list[dict[str, Any]] | None: """The repository's rulesets, the ones inherited from a parent included. One endpoint serves the two ruleset checks, which filter the same payload on different `target` values. A failed read is kept too: both checks then report the same unreadable API instead of asking twice. :return: The ruleset list, or `None` when the API could not be read. """ if not self.repo: return None data = gh_json( [ "api", f"repos/{self.repo}/rulesets", "--method", "GET", "--field", "includes_parents=true", ], soft=True, ) return data if isinstance(data, list) else None
[docs] @cached_property def declared_labels(self) -> frozenset[str] | None: """Every label name the configured sources declare, per `sync-labels`. Read here rather than inside the check so the check reads its expectation from the same place the sync writes it. Reading it fetches the `labels.extra-files` definitions. `None` when a label definition could not be fetched or read: the set is unknown rather than short, and the check reports skipped. """ return _resolve_declared_labels(self.config, is_awesome=self.is_awesome)
[docs] @cached_property def pat_permissions(self) -> PatPermissionResults | None: """The results of the PAT permission probes, or `None` without a PAT. Both `lint-repo` and the setup guide read them, so they run once. """ if not (self.has_pat and self.repo): return None return check_all_pat_permissions(self.repo)
[docs] @cached_property def pat_scope(self) -> CheckResult | None: """The result of the PAT repository-scope probe, or `None` without a PAT.""" if not (self.has_pat and self.repo): return None return check_pat_repository_scope(self.repo)
@property def files(self) -> FileInventory: """What the repository holds on disk, `.gitignore` applied. The inventory of the {class}`~repomatic.metadata.core.Metadata` singleton, so the checks share one walk of the tree with every other reader of the run. """ return self.md.files
[docs] @cached_property def redirects_files(self) -> list[Path]: """Committed Cloudflare Pages `_redirects` files, `.gitignore` honoured. The gitignore filter is what keeps a generated site tree (an `output/` or `docs/_build/` copy of the same file) out of the audit: the engine replica must read the source of truth, not a build artifact of it. """ return self.files.glob_files("**/_redirects")
[docs] @cached_property def oversized_files(self) -> list[Path]: """Tracked files over the Cloudflare Pages limit, `.gitignore` honoured. Built trees stay out, like the `_redirects` inventory above: the deploy step reads those, and it names each file it moves or drops. The size rule is the one the deploy applies, from {func}`~repomatic.cloudflare.r2.oversized_files`. """ return oversized_files(self.files.glob_files("**/*"))
@property def has_wrangler_toml(self) -> bool: """Whether the repository commits a root-level `wrangler.toml`.""" return WRANGLER_TOML.is_file()
[docs] @cached_property def workflow_texts(self) -> dict[Path, str]: """Every workflow file's raw text, read once for the whole run. Some checks match the files as written, others parse them. Reading once here spares each check its own walk of `.github/workflows/`. """ return read_workflow_texts()
[docs] @cached_property def workflows(self) -> dict[Path, dict[str, Any]]: """The parsed workflows, from {attr}`workflow_texts`.""" return parse_workflow_texts(self.workflow_texts)
[docs] @classmethod def from_project( cls, config: Config, *, repo: str | None = None, repo_name: str | None = None, has_pat: bool = False, has_virustotal_key: bool = False, has_cloudflare_api_token: bool = False, has_cloudflare_r2_keys: bool = False, has_notifications_pat: bool = False, ) -> LintContext: """Resolve the project facts from the current checkout. Everything `pyproject.toml`, the `Metadata` singleton and the configuration can answer is read here, so a command hands over only what it alone knows: which repository it names, and which secrets exist. :param config: The resolved `[tool.repomatic]` configuration. :param repo: Repository in `owner/repo` format, or `None`. :param repo_name: Repository name; derived from *repo* when omitted. :param has_pat: Whether `REPOMATIC_PAT` is configured. :param has_virustotal_key: Whether `VIRUSTOTAL_API_KEY` is configured. :param has_cloudflare_api_token: Whether `CLOUDFLARE_API_TOKEN` is configured. :param has_cloudflare_r2_keys: Whether both R2 upload secrets are configured. :param has_notifications_pat: Whether `REPOMATIC_NOTIFICATIONS_PAT` is configured. """ if repo_name is None and repo: repo_name = split_slug(repo)[1] metadata = Metadata() pyproject = metadata.pyproject_toml project_table = pyproject.get("project", {}) return cls( config=config, repo=repo or None, repo_name=repo_name, package_name=get_project_name(pyproject), is_package=metadata.is_python_package, is_sphinx=metadata.is_sphinx, has_changelog=resolved_changelog_path(config).exists(), project_description=metadata.project_description, docs_url=documentation_url(project_table.get("urls")), keywords=project_table.get("keywords"), tool_table=pyproject.get("tool", {}), nuitka_active=config.nuitka_enabled and bool(metadata.script_entries), has_pat=has_pat, has_virustotal_key=has_virustotal_key, has_cloudflare_api_token=has_cloudflare_api_token, has_cloudflare_r2_keys=has_cloudflare_r2_keys, has_notifications_pat=has_notifications_pat, )