Files
driver-standard/napalm_standard/check.py
T
christianmanivong db6b2e4ea7
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 30s
CI / test (3.13) (push) Successful in 37s
feat: standards, shared CI and release tooling for NAPALM drivers
STANDARDS.md binds every repository in the NAPALM org: one default branch,
Conventional Commits, the device-types contract, test-driven development,
clean-code limits, a quality ratchet for existing code, packaging rules and
releases by tag to PyPI.

napalm-standard holds what a tool can check: the conformance check (with
release rules on a tag), the quality ratchet (ported from netOrk's
quality_ratchet.py), and the Gitea release from the CHANGELOG section.
.gitea/workflows/driver.yml is the CI every driver calls; template/ is a new
driver's starting point and passes the check as it is.
2026-10-22 19:01:14 +02:00

197 lines
6.5 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}")
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