Files
christianmanivong 0b21f62553 fix(check): SPDX license string; E501 off in tests (device-types pilot)
The pilot's build warned that setuptools refuses a license table and
License classifiers from 2027-02-18 (PEP 639); check now reports both and
the template builds with setuptools>=77. Recorded device output in tests
keeps its lines, so the template turns E501 off under tests/.
2026-10-22 19:08:06 +02:00

202 lines
6.9 KiB
Python

"""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