"""Does a driver repository meet the standard? (STANDARDS.md ยง8) Every rule is a function over a :class:`Repo` that returns its findings. A rule reports what it can see and nothing else, so one broken thing produces one finding, not a cascade. """ from __future__ import annotations import pathlib import re import tomllib from collections.abc import Callable, Iterator from dataclasses import dataclass from typing import Any from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import SpecifierSet from packaging.utils import canonicalize_name from packaging.version import Version from napalm_standard.changelog import changelog_section DEVICE_TYPES = "napalm-device-types" MIN_PYTHON = Version("3.10") URL_PREFIX = "https://git.netork.io/NAPALM/" REQUIRED_FILES = ("LICENSE", "README.md", "CHANGELOG.md") REQUIRED_FIELDS = ("name", "version", "description", "license") RELEASE_TAG = re.compile(r"^v(\d+\.\d+\.\d+)$") @dataclass(frozen=True) class Finding: rule: str message: str def __str__(self) -> str: return f"[{self.rule}] {self.message}" @dataclass(frozen=True) class Repo: root: pathlib.Path pyproject: dict[str, Any] @property def project(self) -> dict[str, Any]: project: dict[str, Any] = self.pyproject.get("project", {}) return project @property def name(self) -> str: return canonicalize_name(str(self.project.get("name", ""))) @property def is_device_types(self) -> bool: return self.name == DEVICE_TYPES @property def package(self) -> str: """The import package: ``[tool.napalm-standard] package``, else the name.""" own: dict[str, Any] = self.pyproject.get("tool", {}).get("napalm-standard", {}) return str(own.get("package", self.name.replace("-", "_"))) Rule = Callable[[Repo], Iterator[Finding]] def _files(repo: Repo) -> Iterator[Finding]: for rel in REQUIRED_FILES: path = repo.root / rel if not path.is_file() or not path.read_text().strip(): yield Finding("files", f"{rel} is missing or empty") def _metadata(repo: Repo) -> Iterator[Finding]: for field in REQUIRED_FIELDS: if not repo.project.get(field): yield Finding("metadata", f"[project] has no {field}") # PEP 639: setuptools refuses the table form and license classifiers from 2027-02-18. if isinstance(repo.project.get("license"), dict): yield Finding("metadata", 'license must be an SPDX string: license = "Apache-2.0"') if any(c.startswith("License ::") for c in repo.project.get("classifiers", [])): yield Finding("metadata", "drop the License :: classifier; the SPDX license says it") def _python(repo: Repo) -> Iterator[Finding]: spec = SpecifierSet(str(repo.project.get("requires-python", ""))) floors = [Version(s.version) for s in spec if s.operator in (">=", ">")] if not floors or max(floors) < MIN_PYTHON: yield Finding( "python", f"requires-python is {str(spec) or 'unset'}; napalm-device-types needs >={MIN_PYTHON}", ) def _requirements(repo: Repo) -> Iterator[Requirement]: for line in repo.project.get("dependencies", []): try: yield Requirement(line) except InvalidRequirement: continue def _device_types(repo: Repo) -> Iterator[Finding]: if repo.is_device_types: return found = [r for r in _requirements(repo) if canonicalize_name(r.name) == DEVICE_TYPES] if not found: yield Finding("device-types", f"no dependency on {DEVICE_TYPES}") elif not any(s.operator == ">=" for s in found[0].specifier): yield Finding( "device-types", f"{found[0]} has no floor: name the oldest version that has what the driver uses", ) def _entry_point(repo: Repo) -> Iterator[Finding]: if repo.is_device_types: return if not repo.project.get("entry-points", {}).get("napalm.drivers"): yield Finding("entry-point", 'no [project.entry-points."napalm.drivers"]') def _urls(repo: Repo) -> Iterator[Finding]: urls: dict[str, str] = repo.project.get("urls", {}) if "Repository" not in urls: yield Finding("urls", "[project.urls] has no Repository") for key, url in sorted(urls.items()): if not url.startswith(URL_PREFIX): yield Finding("urls", f"{key} = {url} is outside {URL_PREFIX}") def _py_typed(repo: Repo) -> Iterator[Finding]: if not repo.package: return # no name: _metadata says so if not (repo.root / repo.package / "py.typed").is_file(): yield Finding("py.typed", f"{repo.package}/py.typed is missing") def _ci(repo: Repo) -> Iterator[Finding]: workflows = repo.root / ".gitea" / "workflows" if not workflows.is_dir() or not any(workflows.glob("*.y*ml")): yield Finding("ci", "no workflow under .gitea/workflows/") if (repo.root / ".github").exists(): yield Finding("ci", ".github/ is left over; CI lives in .gitea/workflows/") def _ratchet(repo: Repo) -> Iterator[Finding]: tool = repo.pyproject.get("tool", {}) if tool.get("mypy", {}).get("strict") is not True: yield Finding("ratchet", "[tool.mypy] is not strict = true") coverage = tool.get("coverage", {}) if not coverage.get("run", {}).get("source"): yield Finding("ratchet", "[tool.coverage.run] has no source (CI runs pytest --cov)") if "fail_under" not in coverage.get("report", {}): yield Finding("ratchet", "[tool.coverage.report] has no fail_under") RULES: tuple[Rule, ...] = ( _files, _metadata, _python, _device_types, _entry_point, _urls, _py_typed, _ci, _ratchet, ) def _release(repo: Repo, tag: str) -> Iterator[Finding]: match = RELEASE_TAG.match(tag) if not match: yield Finding("release", f"{tag} is not a release tag (vMAJOR.MINOR.PATCH)") return version = str(repo.project.get("version", "")) if match.group(1) != version: yield Finding("release", f"tag {tag} does not match the version {version}") return changelog = repo.root / "CHANGELOG.md" text = changelog.read_text() if changelog.is_file() else "" if changelog_section(text, version) is None: yield Finding("release", f"CHANGELOG.md has no section for [{version}]") def check_repo(root: pathlib.Path, tag: str | None = None) -> list[Finding]: """Every finding for the repository at *root*; with *tag*, also the release rules.""" path = root / "pyproject.toml" if not path.is_file(): return [Finding("pyproject", "pyproject.toml is missing")] try: pyproject = tomllib.loads(path.read_text()) except tomllib.TOMLDecodeError as exc: return [Finding("pyproject", f"pyproject.toml does not parse: {exc}")] repo = Repo(root, pyproject) findings = [finding for rule in RULES for finding in rule(repo)] if tag is not None: findings.extend(_release(repo, tag)) return findings