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.
This commit is contained in:
commit
db6b2e4ea7
37 files changed
+2894
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from napalm_standard.cli import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,25 @@
|
||||
"""The CHANGELOG.md section of one version, the text of its release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
#: A version heading in CHANGELOG.md: "## [1.2.3] - 2026-10-01".
|
||||
_HEADING = re.compile(r"^## \[(?P<version>[^\]]+)\]", re.MULTILINE)
|
||||
|
||||
|
||||
def changelog_section(text: str, version: str) -> str | None:
|
||||
"""The body under ``## [version]`` up to the next version heading, or None."""
|
||||
version = version.removeprefix("v")
|
||||
headings = list(_HEADING.finditer(text))
|
||||
for i, heading in enumerate(headings):
|
||||
if heading.group("version") != version:
|
||||
continue
|
||||
line_end = text.find("\n", heading.end())
|
||||
start = len(text) if line_end == -1 else line_end + 1
|
||||
end = headings[i + 1].start() if i + 1 < len(headings) else len(text)
|
||||
body = text[start:end].strip()
|
||||
# Sections are sometimes closed by a horizontal rule; it is layout, not content.
|
||||
body = re.sub(r"\n*^---\s*\Z", "", body, flags=re.MULTILINE).strip()
|
||||
return body or None
|
||||
return None
|
||||
@@ -0,0 +1,196 @@
|
||||
"""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
|
||||
@@ -0,0 +1,101 @@
|
||||
"""napalm-standard: the commands the shared workflow runs in every driver repo.
|
||||
|
||||
napalm-standard check [--tag vX.Y.Z] [PATH] does the repo meet the standard?
|
||||
napalm-standard ratchet [--write] [PATH] hold the quality baseline, or rewrite it
|
||||
napalm-standard notes vX.Y.Z [PATH] print that version's CHANGELOG section
|
||||
napalm-standard release vX.Y.Z [PATH] create or update the Gitea release;
|
||||
reads GITEA_URL, GITEA_REPOSITORY, GITEA_TOKEN
|
||||
napalm-standard package [PATH] print the import package (for mypy, bandit, cov)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
from napalm_standard import ratchet
|
||||
from napalm_standard.changelog import changelog_section
|
||||
from napalm_standard.check import Repo, check_repo
|
||||
from napalm_standard.release import gitea_api, publish
|
||||
|
||||
_RELEASE_ENV = ("GITEA_URL", "GITEA_REPOSITORY", "GITEA_TOKEN")
|
||||
|
||||
|
||||
def _repo(path: pathlib.Path) -> Repo:
|
||||
return Repo(path, tomllib.loads((path / "pyproject.toml").read_text()))
|
||||
|
||||
|
||||
def _check(args: argparse.Namespace) -> int:
|
||||
findings = check_repo(args.path, tag=args.tag)
|
||||
for finding in findings:
|
||||
print(finding)
|
||||
if findings:
|
||||
print(f"{len(findings)} finding(s); see STANDARDS.md in NAPALM/driver-standard")
|
||||
return 1
|
||||
print("meets the standard")
|
||||
return 0
|
||||
|
||||
|
||||
def _ratchet(args: argparse.Namespace) -> int:
|
||||
code, text = ratchet.run(args.path, _repo(args.path).package, write=args.write)
|
||||
print(text)
|
||||
return code
|
||||
|
||||
|
||||
def _notes(args: argparse.Namespace) -> int:
|
||||
section = changelog_section((args.path / "CHANGELOG.md").read_text(), args.tag)
|
||||
if section is None:
|
||||
return 1
|
||||
print(section)
|
||||
return 0
|
||||
|
||||
|
||||
def _release(args: argparse.Namespace) -> int:
|
||||
missing = [name for name in _RELEASE_ENV if not os.environ.get(name)]
|
||||
if missing:
|
||||
print(f"missing environment: {', '.join(missing)}", file=sys.stderr)
|
||||
return 2
|
||||
api = gitea_api(*(os.environ[name] for name in _RELEASE_ENV))
|
||||
changelog = (args.path / "CHANGELOG.md").read_text()
|
||||
result = publish(args.tag, _repo(args.path).name, changelog, api)
|
||||
print(f"release {result.get('id')} for {args.tag}")
|
||||
return 0
|
||||
|
||||
|
||||
def _package(args: argparse.Namespace) -> int:
|
||||
print(_repo(args.path).package)
|
||||
return 0
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="napalm-standard",
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
def command(name: str, handler: object) -> argparse.ArgumentParser:
|
||||
sub = commands.add_parser(name)
|
||||
sub.set_defaults(handler=handler)
|
||||
return sub
|
||||
|
||||
check = command("check", _check)
|
||||
check.add_argument("--tag", help="also apply the release rules for this tag")
|
||||
ratchet_cmd = command("ratchet", _ratchet)
|
||||
ratchet_cmd.add_argument("--write", action="store_true", help="record today's state")
|
||||
for name, handler in (("notes", _notes), ("release", _release)):
|
||||
command(name, handler).add_argument("tag")
|
||||
command("package", _package)
|
||||
for sub in commands.choices.values():
|
||||
sub.add_argument("path", nargs="?", type=pathlib.Path, default=pathlib.Path("."))
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
code: int = args.handler(args)
|
||||
return code
|
||||
Whitespace-only changes.
@@ -0,0 +1,232 @@
|
||||
"""Measure a driver against the limits of STANDARDS.md §5, and hold the line (§6).
|
||||
|
||||
The limits:
|
||||
|
||||
- function length 50 lines, cyclomatic complexity 10
|
||||
- file length 1000 lines (hard maximum)
|
||||
- public functions annotated
|
||||
- no module exempt from strict mypy
|
||||
- the coverage floor in ``[tool.coverage.report] fail_under``
|
||||
|
||||
Existing code exceeds them in places. That is recorded in
|
||||
``tests/quality_baseline.json``: an entry may shrink but not grow, and
|
||||
everything else keeps the limit. A baseline entry that was fixed is reported as
|
||||
stale, so progress is written down and holds from then on.
|
||||
|
||||
Code is measured from the AST, not with ruff or radon, so the numbers do not
|
||||
move with a tool's version. Complexity is mccabe-style: 1 plus one per
|
||||
``if``/``elif``, loop, ``except``, ternary, ``match`` case, comprehension
|
||||
``for`` and ``if``, and each extra operand of ``and``/``or``. A nested
|
||||
function counts for itself, not for the function around it.
|
||||
|
||||
Ported from netOrk's scripts/quality_ratchet.py (#402).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import pathlib
|
||||
import tomllib
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
LIMITS = {
|
||||
"function_lines": 50,
|
||||
"function_complexity": 10,
|
||||
"python_file_lines": 1000,
|
||||
}
|
||||
|
||||
BASELINE = pathlib.Path("tests") / "quality_baseline.json"
|
||||
|
||||
#: mypy settings that strict = true turns on; an override setting one to False loosens it.
|
||||
_STRICT_FLAGS = frozenset(
|
||||
{
|
||||
"strict",
|
||||
"check_untyped_defs",
|
||||
"disallow_any_generics",
|
||||
"disallow_incomplete_defs",
|
||||
"disallow_untyped_calls",
|
||||
"disallow_untyped_decorators",
|
||||
"disallow_untyped_defs",
|
||||
"extra_checks",
|
||||
"no_implicit_reexport",
|
||||
"strict_equality",
|
||||
"warn_return_any",
|
||||
}
|
||||
)
|
||||
|
||||
_FUNCTION = (ast.FunctionDef, ast.AsyncFunctionDef)
|
||||
FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
|
||||
|
||||
|
||||
def complexity(func: ast.AST) -> int:
|
||||
score = 1
|
||||
stack = list(ast.iter_child_nodes(func))
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if isinstance(node, (*_FUNCTION, ast.ClassDef, ast.Lambda)):
|
||||
continue # counts for itself
|
||||
if isinstance(
|
||||
node, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.IfExp, ast.ExceptHandler)
|
||||
):
|
||||
score += 1
|
||||
elif isinstance(node, ast.BoolOp):
|
||||
score += len(node.values) - 1
|
||||
elif isinstance(node, ast.comprehension):
|
||||
score += 1 + len(node.ifs)
|
||||
elif isinstance(node, ast.match_case):
|
||||
score += 1
|
||||
stack.extend(ast.iter_child_nodes(node))
|
||||
return score
|
||||
|
||||
|
||||
def _typed(func: FunctionNode, is_method: bool) -> bool:
|
||||
args = [*func.args.posonlyargs, *func.args.args, *func.args.kwonlyargs]
|
||||
if is_method and args and args[0].arg in ("self", "cls"):
|
||||
args = args[1:]
|
||||
extras = [a for a in (func.args.vararg, func.args.kwarg) if a is not None]
|
||||
return func.returns is not None and all(a.annotation is not None for a in [*args, *extras])
|
||||
|
||||
|
||||
def iter_python_functions(tree: ast.AST) -> Iterator[tuple[str, FunctionNode, bool, bool]]:
|
||||
"""Every function in *tree*, outermost first: its qualified name, its node,
|
||||
whether it sits directly in a class, and whether it is public."""
|
||||
|
||||
def visit(
|
||||
node: ast.AST, prefix: str, in_class: bool, public_scope: bool
|
||||
) -> Iterator[tuple[str, FunctionNode, bool, bool]]:
|
||||
for child in ast.iter_child_nodes(node):
|
||||
if isinstance(child, ast.ClassDef):
|
||||
yield from visit(
|
||||
child,
|
||||
f"{prefix}{child.name}.",
|
||||
True,
|
||||
public_scope and not child.name.startswith("_"),
|
||||
)
|
||||
elif isinstance(child, _FUNCTION):
|
||||
name = f"{prefix}{child.name}"
|
||||
yield name, child, in_class, public_scope and not child.name.startswith("_")
|
||||
yield from visit(child, f"{name}.", False, False)
|
||||
elif isinstance(child, ast.stmt):
|
||||
# a def under if/with/try/for belongs to the same scope
|
||||
yield from visit(child, prefix, in_class, public_scope)
|
||||
|
||||
yield from visit(tree, "", False, True)
|
||||
|
||||
|
||||
def python_functions_in(source: str, relpath: str) -> dict[str, dict[str, Any]]:
|
||||
"""Every function in *source*: lines, complexity, whether it is typed and public."""
|
||||
return {
|
||||
f"{relpath}::{name}": {
|
||||
"lines": (node.end_lineno or node.lineno) - node.lineno + 1,
|
||||
"complexity": complexity(node),
|
||||
"typed": _typed(node, in_class),
|
||||
"public": public,
|
||||
}
|
||||
for name, node, in_class, public in iter_python_functions(ast.parse(source))
|
||||
}
|
||||
|
||||
|
||||
def relaxed_mypy_modules(pyproject: dict[str, Any]) -> list[str]:
|
||||
"""Modules a ``[[tool.mypy.overrides]]`` entry exempts from strict checking."""
|
||||
out: set[str] = set()
|
||||
for override in pyproject.get("tool", {}).get("mypy", {}).get("overrides", []):
|
||||
loosens = override.get("ignore_errors") is True or any(
|
||||
override.get(flag) is False for flag in _STRICT_FLAGS
|
||||
)
|
||||
if loosens:
|
||||
modules = override.get("module", [])
|
||||
out.update([modules] if isinstance(modules, str) else modules)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def coverage_floor(pyproject: dict[str, Any]) -> float:
|
||||
floor = pyproject.get("tool", {}).get("coverage", {}).get("report", {}).get("fail_under", 0)
|
||||
return float(floor)
|
||||
|
||||
|
||||
def _lines(source: str) -> int:
|
||||
return source.count("\n") + (0 if source.endswith("\n") else 1)
|
||||
|
||||
|
||||
def measure(repo: pathlib.Path, package: str) -> dict[str, Any]:
|
||||
"""Today's excesses, keyed the way the baseline stores them."""
|
||||
function_lines: dict[str, int] = {}
|
||||
function_complexity: dict[str, int] = {}
|
||||
python_file_lines: dict[str, int] = {}
|
||||
untyped_public: list[str] = []
|
||||
for path in sorted((repo / package).rglob("*.py")):
|
||||
rel = path.relative_to(repo).as_posix()
|
||||
source = path.read_text()
|
||||
if _lines(source) > LIMITS["python_file_lines"]:
|
||||
python_file_lines[rel] = _lines(source)
|
||||
for key, f in python_functions_in(source, rel).items():
|
||||
if f["lines"] > LIMITS["function_lines"]:
|
||||
function_lines[key] = f["lines"]
|
||||
if f["complexity"] > LIMITS["function_complexity"]:
|
||||
function_complexity[key] = f["complexity"]
|
||||
if f["public"] and not f["typed"]:
|
||||
untyped_public.append(key)
|
||||
pyproject = tomllib.loads((repo / "pyproject.toml").read_text())
|
||||
return {
|
||||
"function_lines": function_lines,
|
||||
"function_complexity": function_complexity,
|
||||
"python_file_lines": python_file_lines,
|
||||
"untyped_public": sorted(untyped_public),
|
||||
"mypy_relaxed": relaxed_mypy_modules(pyproject),
|
||||
"coverage_floor": coverage_floor(pyproject),
|
||||
}
|
||||
|
||||
|
||||
def problems(measured: dict[str, Any], baseline: dict[str, Any]) -> list[str]:
|
||||
"""What is new over a limit, or grew past what the baseline allows."""
|
||||
out = []
|
||||
for metric in LIMITS:
|
||||
allowed = baseline.get(metric, {})
|
||||
for key, value in sorted(measured[metric].items()):
|
||||
if key not in allowed:
|
||||
out.append(f"{metric}: {key} = {value} (new)")
|
||||
elif value > allowed[key]:
|
||||
out.append(f"{metric}: {key} = {value} (recorded {allowed[key]})")
|
||||
for metric in ("untyped_public", "mypy_relaxed"):
|
||||
new = sorted(set(measured[metric]) - set(baseline.get(metric, [])))
|
||||
out.extend(f"{metric}: {key} (new)" for key in new)
|
||||
recorded_floor = baseline.get("coverage_floor", 0)
|
||||
if measured["coverage_floor"] < recorded_floor:
|
||||
out.append(f"coverage_floor: {measured['coverage_floor']:g} (recorded {recorded_floor:g})")
|
||||
return out
|
||||
|
||||
|
||||
def stale_entries(measured: dict[str, Any], baseline: dict[str, Any]) -> list[str]:
|
||||
"""Baseline entries that are fixed, shrunk or gone: the ratchet can tighten."""
|
||||
out = []
|
||||
for metric in LIMITS:
|
||||
for key, value in sorted(baseline.get(metric, {}).items()):
|
||||
now = measured[metric].get(key)
|
||||
if now is None or now < value:
|
||||
out.append(f"{metric}: {key}")
|
||||
for metric in ("untyped_public", "mypy_relaxed"):
|
||||
gone = sorted(set(baseline.get(metric, [])) - set(measured[metric]))
|
||||
out.extend(f"{metric}: {key}" for key in gone)
|
||||
recorded_floor = baseline.get("coverage_floor")
|
||||
if recorded_floor is not None and measured["coverage_floor"] > recorded_floor:
|
||||
out.append(f"coverage_floor: {recorded_floor:g} -> {measured['coverage_floor']:g}")
|
||||
return out
|
||||
|
||||
|
||||
def run(repo: pathlib.Path, package: str, write: bool = False) -> tuple[int, str]:
|
||||
"""Check the repo against its baseline, or (*write*) record today's state as it."""
|
||||
measured = measure(repo, package)
|
||||
path = repo / BASELINE
|
||||
if write:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(measured, indent=1, sort_keys=True) + "\n")
|
||||
return 0, f"wrote {BASELINE}"
|
||||
baseline = json.loads(path.read_text()) if path.is_file() else {}
|
||||
lines = problems(measured, baseline)
|
||||
lines += [
|
||||
f"stale: {entry} (run napalm-standard ratchet --write)"
|
||||
for entry in stale_entries(measured, baseline)
|
||||
]
|
||||
return (1, "\n".join(lines)) if lines else (0, "within the baseline")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Create or update the Gitea release of a version tag (STANDARDS.md §9).
|
||||
|
||||
The upload to PyPI is twine's job in the workflow; this only writes the
|
||||
release page, with that version's CHANGELOG.md section as its text. It never
|
||||
creates a tag, so it never pushes anything and never starts a CI run.
|
||||
|
||||
Ported from netOrk's scripts/release.py (#561).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from napalm_standard.changelog import changelog_section
|
||||
|
||||
Api = Callable[..., Any]
|
||||
|
||||
|
||||
class NotFound(Exception):
|
||||
"""The API answered 404."""
|
||||
|
||||
|
||||
def publish(tag: str, package: str, changelog: str, api: Api) -> Any:
|
||||
"""Create the release of *tag*, or update it when it exists already."""
|
||||
version = tag.removeprefix("v")
|
||||
body = changelog_section(changelog, version)
|
||||
if body is None:
|
||||
raise ValueError(f"CHANGELOG.md has no section for [{version}]")
|
||||
payload = {
|
||||
"tag_name": tag,
|
||||
"name": f"{package} {version}",
|
||||
"body": body,
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
}
|
||||
try:
|
||||
existing = api("GET", f"/releases/tags/{tag}")
|
||||
except NotFound:
|
||||
return api("POST", "/releases", payload)
|
||||
return api("PATCH", f"/releases/{existing['id']}", payload)
|
||||
|
||||
|
||||
def gitea_api(base_url: str, repository: str, token: str) -> Api: # pragma: no cover - I/O
|
||||
"""A small JSON client for ``/api/v1/repos/<repository>``."""
|
||||
root = f"{base_url.rstrip('/')}/api/v1/repos/{repository}"
|
||||
|
||||
def call(method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
|
||||
request = urllib.request.Request(
|
||||
root + path,
|
||||
method=method,
|
||||
data=json.dumps(payload).encode() if payload is not None else None,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310
|
||||
body = response.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
raise NotFound(path) from exc
|
||||
raise
|
||||
return json.loads(body) if body else {}
|
||||
|
||||
return call
|
||||
Reference in new issue
Block a user