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/.
202 lines
6.9 KiB
Python
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
|