feat: standards, shared CI and release tooling for NAPALM drivers
CI / test (3.11) (push) Successful in 34s
CI / test (3.12) (push) Successful in 30s
CI / test (3.13) (push) Successful in 37s

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:
christianmanivong committed 2026-10-22 19:01:14 +02:00
commit db6b2e4ea7
37 files changed
+2894

No files matched your search

View File
Whitespace-only changes.
+88
View File
@@ -0,0 +1,88 @@
"""A driver repository on disk that meets the standard, to break one rule at a time."""
from __future__ import annotations
import pathlib
import textwrap
from collections.abc import Callable
import pytest
PYPROJECT = """\
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "napalm-example"
version = "1.2.0"
description = "NAPALM driver for Example devices"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = [
"napalm>=5.0",
"napalm-device-types>=4.21",
]
[project.urls]
Repository = "https://git.netork.io/NAPALM/napalm-example"
Issues = "https://git.netork.io/NAPALM/napalm-example/issues"
[project.entry-points."napalm.drivers"]
example = "napalm_example:ExampleDriver"
[tool.mypy]
strict = true
[tool.coverage.run]
source = ["napalm_example"]
[tool.coverage.report]
fail_under = 80
"""
CHANGELOG = """\
# Changelog
## [Unreleased]
## [1.2.0] - 2026-10-22
### Added
- Interfaces.
## [1.1.0] - 2026-10-01
### Fixed
- Facts.
"""
RepoFactory = Callable[..., pathlib.Path]
@pytest.fixture
def make_repo(tmp_path: pathlib.Path) -> RepoFactory:
"""Write a conforming repo; ``files`` overrides or (with None) removes a path."""
def make(files: dict[str, str | None] | None = None) -> pathlib.Path:
layout: dict[str, str | None] = {
"pyproject.toml": PYPROJECT,
"README.md": "# napalm-example\n\nA driver.\n",
"CHANGELOG.md": CHANGELOG,
"LICENSE": "Apache License\nVersion 2.0, January 2004\n",
"napalm_example/__init__.py": "",
"napalm_example/py.typed": "",
".gitea/workflows/ci.yml": "name: CI\n",
"tests/test_driver.py": "def test_nothing():\n pass\n",
}
layout.update(files or {})
for rel, content in layout.items():
if content is None:
continue
path = tmp_path / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(content))
return tmp_path
return make
+48
View File
@@ -0,0 +1,48 @@
"""Release notes come from CHANGELOG.md (Keep a Changelog headings)."""
from __future__ import annotations
from napalm_standard.changelog import changelog_section
TEXT = """\
# Changelog
## [Unreleased]
- not yet
## [1.2.0] - 2026-10-22
### Added
- Interfaces.
---
## [1.1.0] - 2026-10-01
- Facts.
"""
def test_the_section_runs_to_the_next_version_heading() -> None:
assert changelog_section(TEXT, "1.2.0") == "### Added\n- Interfaces."
def test_a_leading_v_is_ignored() -> None:
assert changelog_section(TEXT, "v1.1.0") == "- Facts."
def test_an_unknown_version_has_no_section() -> None:
assert changelog_section(TEXT, "9.9.9") is None
def test_an_empty_section_has_none() -> None:
assert changelog_section("## [1.0.0] - x\n\n## [0.9.0] - y\n- a\n", "1.0.0") is None
def test_a_heading_without_brackets_is_not_a_version_heading() -> None:
assert changelog_section("## 1.0.0\n\n- a\n", "1.0.0") is None
def test_the_last_section_runs_to_the_end() -> None:
assert changelog_section("## [1.0.0] - x\n- a\n- b", "1.0.0") == "- a\n- b"
+215
View File
@@ -0,0 +1,215 @@
"""The conformance check: one test per rule, each breaking exactly that rule."""
from __future__ import annotations
import pytest
from napalm_standard.check import check_repo
from tests.conftest import PYPROJECT, RepoFactory
def rules(findings: list) -> set[str]:
return {f.rule for f in findings}
def test_a_conforming_repo_has_no_findings(make_repo: RepoFactory) -> None:
assert check_repo(make_repo()) == []
def test_a_conforming_repo_passes_at_its_own_tag(make_repo: RepoFactory) -> None:
assert check_repo(make_repo(), tag="v1.2.0") == []
@pytest.mark.parametrize("path", ["LICENSE", "README.md", "CHANGELOG.md"])
def test_a_missing_top_level_file_is_reported(make_repo: RepoFactory, path: str) -> None:
findings = check_repo(make_repo({path: None}))
assert rules(findings) == {"files"}
assert path in findings[0].message
def test_an_empty_readme_counts_as_missing(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"README.md": "\n"}))) == {"files"}
def test_a_repo_without_pyproject_reports_only_that(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"pyproject.toml": None}))) == {"pyproject"}
def test_an_unparsable_pyproject_is_reported(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"pyproject.toml": "[project\n"}))) == {"pyproject"}
@pytest.mark.parametrize("field", ["name", "version", "description", "license"])
def test_a_missing_project_field_is_reported(make_repo: RepoFactory, field: str) -> None:
pyproject = "\n".join(
line for line in PYPROJECT.splitlines() if not line.startswith(f"{field} = ")
)
findings = check_repo(make_repo({"pyproject.toml": pyproject}))
assert rules(findings) == {"metadata"}
assert field in findings[0].message
def test_a_license_table_is_accepted(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('license = "Apache-2.0"', 'license = { text = "Apache-2.0" }')
assert check_repo(make_repo({"pyproject.toml": pyproject})) == []
@pytest.mark.parametrize("spec", [">=3.8", ">=3.9", ""])
def test_python_below_310_or_unbounded_is_reported(make_repo: RepoFactory, spec: str) -> None:
pyproject = PYPROJECT.replace('requires-python = ">=3.10"', f'requires-python = "{spec}"')
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"python"}
def test_a_higher_python_floor_is_fine(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('">=3.10"', '">=3.11"')
assert check_repo(make_repo({"pyproject.toml": pyproject})) == []
def test_a_driver_must_depend_on_device_types(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace(' "napalm-device-types>=4.21",\n', "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"device-types"}
@pytest.mark.parametrize("spec", ["napalm-device-types", "napalm-device-types<5"])
def test_device_types_without_a_floor_is_reported(make_repo: RepoFactory, spec: str) -> None:
pyproject = PYPROJECT.replace('"napalm-device-types>=4.21"', f'"{spec}"')
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"device-types"}
def test_device_types_is_matched_by_its_normalised_name(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('"napalm-device-types>=4.21"', '"napalm_device_types>=4.21"')
assert check_repo(make_repo({"pyproject.toml": pyproject})) == []
def test_device_types_itself_needs_no_such_dependency_or_entry_point(
make_repo: RepoFactory,
) -> None:
pyproject = (
PYPROJECT.replace('name = "napalm-example"', 'name = "napalm-device-types"')
.replace(' "napalm-device-types>=4.21",\n', "")
.replace('[project.entry-points."napalm.drivers"]\n', "")
.replace('example = "napalm_example:ExampleDriver"\n', "")
.replace("NAPALM/napalm-example", "NAPALM/napalm-device-types")
)
repo = make_repo(
{
"pyproject.toml": pyproject,
"napalm_device_types/__init__.py": "",
"napalm_device_types/py.typed": "",
}
)
assert check_repo(repo) == []
def test_a_driver_without_napalm_entry_point_is_reported(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('[project.entry-points."napalm.drivers"]\n', "").replace(
'example = "napalm_example:ExampleDriver"\n', ""
)
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"entry-point"}
def test_urls_must_name_a_repository(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace(
'Repository = "https://git.netork.io/NAPALM/napalm-example"\n', ""
)
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"urls"}
@pytest.mark.parametrize(
"url",
[
"https://github.com/chrismanivong/napalm-example",
"https://github.com/napalm-automation-community/napalm-example",
"https://git.netork.io/christianmanivong/napalm-example",
],
)
def test_urls_outside_the_napalm_org_are_reported(make_repo: RepoFactory, url: str) -> None:
pyproject = PYPROJECT.replace("https://git.netork.io/NAPALM/napalm-example/issues", url)
findings = check_repo(make_repo({"pyproject.toml": pyproject}))
assert rules(findings) == {"urls"}
assert url in findings[0].message
def test_a_package_without_py_typed_is_reported(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({"napalm_example/py.typed": None}))) == {"py.typed"}
def test_the_package_directory_can_be_named_explicitly(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT + '\n[tool.napalm-standard]\npackage = "napalm_other"\n'
repo = make_repo(
{
"pyproject.toml": pyproject,
"napalm_example/py.typed": None,
"napalm_other/__init__.py": "",
"napalm_other/py.typed": "",
}
)
assert check_repo(repo) == []
def test_ci_must_live_under_gitea(make_repo: RepoFactory) -> None:
assert rules(check_repo(make_repo({".gitea/workflows/ci.yml": None}))) == {"ci"}
def test_a_leftover_github_workflow_is_reported(make_repo: RepoFactory) -> None:
findings = check_repo(make_repo({".github/workflows/ci.yml": "name: CI\n"}))
assert rules(findings) == {"ci"}
def test_mypy_must_be_strict(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace("[tool.mypy]\nstrict = true\n", "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"ratchet"}
def test_coverage_must_have_a_floor(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace("[tool.coverage.report]\nfail_under = 80\n", "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"ratchet"}
def test_a_tag_must_match_the_version(make_repo: RepoFactory) -> None:
findings = check_repo(make_repo(), tag="v1.3.0")
assert rules(findings) == {"release"}
assert "1.2.0" in findings[0].message
@pytest.mark.parametrize("tag", ["1.2.0", "v1.2", "release-1.2.0", "v1.2.0-rc1"])
def test_a_tag_that_is_no_release_tag_is_reported(make_repo: RepoFactory, tag: str) -> None:
assert rules(check_repo(make_repo(), tag=tag)) == {"release"}
def test_a_tag_needs_its_changelog_section(make_repo: RepoFactory) -> None:
changelog = "# Changelog\n\n## [1.1.0] - 2026-10-01\n\n- Facts.\n"
findings = check_repo(make_repo({"CHANGELOG.md": changelog}), tag="v1.2.0")
assert rules(findings) == {"release"}
assert "CHANGELOG" in findings[0].message
def test_an_empty_changelog_section_does_not_count(make_repo: RepoFactory) -> None:
changelog = "# Changelog\n\n## [1.2.0] - 2026-10-22\n\n## [1.1.0] - 2026-10-01\n\n- x\n"
assert rules(check_repo(make_repo({"CHANGELOG.md": changelog}), tag="v1.2.0")) == {"release"}
def test_coverage_must_name_its_source(make_repo: RepoFactory) -> None:
pyproject = PYPROJECT.replace('[tool.coverage.run]\nsource = ["napalm_example"]\n', "")
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"ratchet"}
+137
View File
@@ -0,0 +1,137 @@
"""The command line the shared workflow calls."""
from __future__ import annotations
import pathlib
import pytest
from napalm_standard import cli
from napalm_standard.check import Finding
from tests.conftest import RepoFactory
def test_check_passes_a_conforming_repo(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["check", str(make_repo())]) == 0
assert "meets the standard" in capsys.readouterr().out
def test_check_lists_every_finding_and_fails(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
repo = make_repo({"LICENSE": None, "napalm_example/py.typed": None})
assert cli.main(["check", str(repo)]) == 1
out = capsys.readouterr().out
assert "[files] LICENSE is missing or empty" in out
assert "[py.typed]" in out
def test_check_with_a_tag_applies_the_release_rules(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["check", "--tag", "v9.0.0", str(make_repo())]) == 1
assert "[release]" in capsys.readouterr().out
def test_ratchet_writes_then_holds(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
repo = make_repo({"napalm_example/mod.py": "def raw(a):\n pass\n"})
assert cli.main(["ratchet", str(repo)]) == 1
assert cli.main(["ratchet", "--write", str(repo)]) == 0
assert cli.main(["ratchet", str(repo)]) == 0
assert "within the baseline" in capsys.readouterr().out
def test_notes_prints_the_section(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["notes", "v1.2.0", str(make_repo())]) == 0
assert capsys.readouterr().out.strip() == "### Added\n- Interfaces."
def test_notes_fails_for_a_version_without_a_section(make_repo: RepoFactory) -> None:
assert cli.main(["notes", "v9.9.9", str(make_repo())]) == 1
def test_release_publishes_with_the_environment(
make_repo: RepoFactory,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
seen: dict[str, object] = {}
def fake_api(base_url: str, repository: str, token: str) -> object:
seen["client"] = (base_url, repository, token)
return "api"
def fake_publish(tag: str, package: str, changelog: str, api: object) -> dict[str, int]:
seen["publish"] = (tag, package, api)
return {"id": 5}
monkeypatch.setenv("GITEA_URL", "https://git.example")
monkeypatch.setenv("GITEA_REPOSITORY", "NAPALM/napalm-example")
monkeypatch.setenv("GITEA_TOKEN", "t0ken")
monkeypatch.setattr(cli, "gitea_api", fake_api)
monkeypatch.setattr(cli, "publish", fake_publish)
assert cli.main(["release", "v1.2.0", str(make_repo())]) == 0
assert seen["client"] == ("https://git.example", "NAPALM/napalm-example", "t0ken")
assert seen["publish"] == ("v1.2.0", "napalm-example", "api")
assert "release 5 for v1.2.0" in capsys.readouterr().out
def test_release_without_its_environment_fails(
make_repo: RepoFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
for name in ("GITEA_URL", "GITEA_REPOSITORY", "GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
assert cli.main(["release", "v1.2.0", str(make_repo())]) == 2
def test_the_path_defaults_to_the_working_directory(
make_repo: RepoFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(make_repo())
assert cli.main(["check"]) == 0
def test_a_finding_reads_as_rule_and_message() -> None:
assert str(Finding("files", "LICENSE is missing")) == "[files] LICENSE is missing"
def test_an_unparsable_dependency_is_left_to_the_build(make_repo: RepoFactory) -> None:
from tests.conftest import PYPROJECT
pyproject = PYPROJECT.replace('"napalm>=5.0",', '"napalm >= = 5",')
assert cli.main(["check", str(make_repo({"pyproject.toml": pyproject}))]) == 0
def test_the_module_runs_as_a_script(tmp_path: pathlib.Path) -> None:
import subprocess
import sys
result = subprocess.run(
[sys.executable, "-m", "napalm_standard", "check", str(tmp_path)],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
assert "[pyproject]" in result.stdout
def test_package_prints_the_import_package(
make_repo: RepoFactory, capsys: pytest.CaptureFixture[str]
) -> None:
assert cli.main(["package", str(make_repo())]) == 0
assert capsys.readouterr().out == "napalm_example\n"
+310
View File
@@ -0,0 +1,310 @@
"""The quality ratchet: measured from the AST, held against a baseline (STANDARDS.md §6).
Ported from netOrk's scripts/quality_ratchet.py (#402), Python only, plus the
two settings a driver ratchets in pyproject.toml: modules exempt from strict
mypy, and the coverage floor.
"""
from __future__ import annotations
import ast
import json
import pathlib
import textwrap
import pytest
from napalm_standard import ratchet as qr
def _functions(source: str) -> dict:
return qr.python_functions_in(textwrap.dedent(source), "mod.py")
class TestMeasuring:
def test_function_length_and_qualified_names(self) -> None:
funcs = _functions(
"""
def top():
return 1
class C:
def method(self):
def inner():
return 2
return inner()
"""
)
assert funcs["mod.py::top"]["lines"] == 2
assert funcs["mod.py::C.method"]["lines"] == 4
assert funcs["mod.py::C.method.inner"]["lines"] == 2
def test_complexity_counts_decision_points(self) -> None:
funcs = _functions(
"""
def f(a, b, xs):
if a and b:
pass
elif a:
pass
for x in xs:
while x:
x -= 1
try:
pass
except ValueError:
pass
return [y for y in xs if y] if a else None
"""
)
# 1 + if + and + elif + for + while + except + comprehension + its if + ternary
assert funcs["mod.py::f"]["complexity"] == 10
def test_match_cases_count(self) -> None:
funcs = _functions(
"""
def f(x):
match x:
case 1:
return 1
case _:
return 0
"""
)
assert funcs["mod.py::f"]["complexity"] == 3
def test_a_nested_function_counts_for_itself_only(self) -> None:
funcs = _functions(
"""
def outer():
def inner(x):
if x:
return 1
return 0
return inner
"""
)
assert funcs["mod.py::outer"]["complexity"] == 1
assert funcs["mod.py::outer.inner"]["complexity"] == 2
@pytest.mark.parametrize(
("source", "typed"),
[
("def f(a: int) -> int:\n return a\n", True),
("def f(a) -> int:\n return a\n", False),
("def f(a: int):\n return a\n", False),
("def f(*a: int, **k: int) -> None:\n pass\n", True),
("def f(*a) -> None:\n pass\n", False),
("class C:\n def f(self, a: int) -> None:\n pass\n", True),
],
)
def test_typed(self, source: str, typed: bool) -> None:
[entry] = _functions(source).values()
assert entry["typed"] is typed
def test_private_names_and_what_they_contain_are_not_public(self) -> None:
funcs = _functions(
"""
def _helper():
def inner():
pass
class _Hidden:
def method(self):
pass
class Shown:
def _private(self):
pass
def public(self):
pass
"""
)
public = {k for k, v in funcs.items() if v["public"]}
assert public == {"mod.py::Shown.public"}
def test_the_walker_yields_each_function_once_with_its_qualified_name(self) -> None:
tree = ast.parse(
"def top():\n pass\n\nclass C:\n def m(self):\n def inner():\n"
" pass\n"
)
walked = [
(name, node.name, in_class)
for name, node, in_class, _ in qr.iter_python_functions(tree)
]
assert walked == [("top", "top", False), ("C.m", "m", True), ("C.m.inner", "inner", False)]
def test_a_function_inside_a_block_is_still_measured(self) -> None:
funcs = _functions(
"""
def outer(flag):
with lock():
def _stream():
if flag:
return 1
return 0
return _stream
class C:
if True:
def m(self):
pass
"""
)
assert funcs["mod.py::outer._stream"]["complexity"] == 2
assert "mod.py::C.m" in funcs
# ── pyproject settings ───────────────────────────────────────────────────
def test_relaxed_mypy_modules_are_the_overrides_that_loosen_strict() -> None:
pyproject = {
"tool": {
"mypy": {
"strict": True,
"overrides": [
{"module": "napalm.*", "ignore_missing_imports": True},
{"module": ["pkg.old", "pkg.older"], "ignore_errors": True},
{"module": "pkg.legacy", "disallow_untyped_defs": False},
{"module": "pkg.loose", "strict": False},
],
}
}
}
assert qr.relaxed_mypy_modules(pyproject) == [
"pkg.legacy",
"pkg.loose",
"pkg.old",
"pkg.older",
]
def test_coverage_floor_is_read_from_pyproject() -> None:
assert qr.coverage_floor({"tool": {"coverage": {"report": {"fail_under": 63.5}}}}) == 63.5
assert qr.coverage_floor({}) == 0
# ── the ratchet against a repo ───────────────────────────────────────────
LONG = "def long(x: int) -> int:\n" + " x += 1\n" * 60 + " return x\n"
def _repo(tmp_path: pathlib.Path, source: str, pyproject: str = "") -> pathlib.Path:
(tmp_path / "pkg").mkdir()
(tmp_path / "pkg" / "mod.py").write_text(source)
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "pkg"\n\n' + (pyproject or "[tool.coverage.report]\nfail_under = 50\n")
)
return tmp_path
def test_measure_reports_only_what_is_over_the_limit(tmp_path: pathlib.Path) -> None:
repo = _repo(
tmp_path, LONG + "\ndef ok(a: int) -> int:\n return a\n\ndef raw(a):\n pass\n"
)
measured = qr.measure(repo, "pkg")
assert measured["function_lines"] == {"pkg/mod.py::long": 62}
assert measured["function_complexity"] == {}
assert measured["python_file_lines"] == {}
assert measured["untyped_public"] == ["pkg/mod.py::raw"]
assert measured["coverage_floor"] == 50
def test_a_file_over_the_hard_maximum_is_measured(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "x = 1\n" * 1001)
assert qr.measure(repo, "pkg")["python_file_lines"] == {"pkg/mod.py": 1001}
def test_without_a_baseline_everything_over_the_limit_is_new(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
problems = qr.problems(qr.measure(repo, "pkg"), {})
assert problems == ["function_lines: pkg/mod.py::long = 62 (new)"]
def test_a_recorded_excess_may_stay_but_not_grow(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
measured = qr.measure(repo, "pkg")
assert qr.problems(measured, {"function_lines": {"pkg/mod.py::long": 62}}) == []
assert qr.problems(measured, {"function_lines": {"pkg/mod.py::long": 61}}) == [
"function_lines: pkg/mod.py::long = 62 (recorded 61)"
]
def test_a_new_untyped_public_function_is_a_problem(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "def raw(a):\n pass\n")
assert qr.problems(qr.measure(repo, "pkg"), {"untyped_public": []}) == [
"untyped_public: pkg/mod.py::raw (new)"
]
def test_a_new_relaxed_mypy_module_is_a_problem(tmp_path: pathlib.Path) -> None:
pyproject = (
"[tool.mypy]\nstrict = true\n\n[[tool.mypy.overrides]]\n"
'module = "pkg.mod"\nignore_errors = true\n'
)
repo = _repo(tmp_path, "", pyproject)
assert qr.problems(qr.measure(repo, "pkg"), {}) == ["mypy_relaxed: pkg.mod (new)"]
assert qr.problems(qr.measure(repo, "pkg"), {"mypy_relaxed": ["pkg.mod"]}) == []
def test_a_lowered_coverage_floor_is_a_problem(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "")
assert qr.problems(qr.measure(repo, "pkg"), {"coverage_floor": 60}) == [
"coverage_floor: 50 (recorded 60)"
]
def test_progress_shows_up_as_stale_entries(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, "def ok(a: int) -> int:\n return a\n")
baseline = {
"function_lines": {"pkg/mod.py::gone": 70},
"untyped_public": ["pkg/mod.py::fixed"],
"mypy_relaxed": ["pkg.mod"],
"coverage_floor": 40,
}
assert qr.stale_entries(qr.measure(repo, "pkg"), baseline) == [
"function_lines: pkg/mod.py::gone",
"untyped_public: pkg/mod.py::fixed",
"mypy_relaxed: pkg.mod",
"coverage_floor: 40 -> 50",
]
def test_write_then_check_is_clean(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
assert qr.run(repo, "pkg", write=True)[0] == 0
baseline = json.loads((repo / qr.BASELINE).read_text())
assert baseline["function_lines"] == {"pkg/mod.py::long": 62}
assert qr.run(repo, "pkg") == (0, "within the baseline")
def test_check_fails_on_a_problem_and_on_a_stale_entry(tmp_path: pathlib.Path) -> None:
repo = _repo(tmp_path, LONG)
(repo / "tests").mkdir()
(repo / qr.BASELINE).write_text(json.dumps({"function_lines": {"pkg/mod.py::old": 99}}))
code, text = qr.run(repo, "pkg")
assert code == 1
assert "pkg/mod.py::long = 62 (new)" in text
assert "stale: function_lines: pkg/mod.py::old" in text
+71
View File
@@ -0,0 +1,71 @@
"""The Gitea release that goes with a tag (STANDARDS.md §9)."""
from __future__ import annotations
import json
from typing import Any
import pytest
from napalm_standard.release import NotFound, publish
CHANGELOG = "# Changelog\n\n## [1.2.0] - 2026-10-22\n\n- Interfaces.\n\n## [1.1.0] - x\n\n- y\n"
class FakeApi:
def __init__(self, existing: dict[str, Any] | None = None) -> None:
self.existing = existing
self.calls: list[tuple[str, str, dict[str, Any] | None]] = []
def __call__(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
self.calls.append((method, path, payload))
if method == "GET":
if self.existing is None:
raise NotFound(path)
return self.existing
return {"id": 7, **(payload or {})}
def test_a_missing_release_is_created_from_the_changelog() -> None:
api = FakeApi()
publish("v1.2.0", "napalm-example", CHANGELOG, api)
assert api.calls[0][:2] == ("GET", "/releases/tags/v1.2.0")
method, path, payload = api.calls[1]
assert (method, path) == ("POST", "/releases")
assert payload == {
"tag_name": "v1.2.0",
"name": "napalm-example 1.2.0",
"body": "- Interfaces.",
"draft": False,
"prerelease": False,
}
def test_an_existing_release_is_updated_not_duplicated() -> None:
api = FakeApi(existing={"id": 3})
publish("v1.2.0", "napalm-example", CHANGELOG, api)
assert [c[:2] for c in api.calls] == [
("GET", "/releases/tags/v1.2.0"),
("PATCH", "/releases/3"),
]
def test_a_version_without_a_changelog_section_is_refused() -> None:
api = FakeApi()
with pytest.raises(ValueError, match="1.3.0"):
publish("v1.3.0", "napalm-example", CHANGELOG, api)
assert api.calls == []
def test_the_payload_is_json_serialisable() -> None:
api = FakeApi()
publish("v1.2.0", "napalm-example", CHANGELOG, api)
json.dumps(api.calls[1][2])
+29
View File
@@ -0,0 +1,29 @@
"""The template a new driver starts from meets the standard as it is."""
from __future__ import annotations
import pathlib
import shutil
from napalm_standard import ratchet
from napalm_standard.check import check_repo
TEMPLATE = pathlib.Path(__file__).resolve().parents[1] / "template"
def test_the_template_passes_the_check() -> None:
assert check_repo(TEMPLATE) == []
def test_the_template_is_within_its_ratchet(tmp_path: pathlib.Path) -> None:
repo = tmp_path / "repo"
shutil.copytree(TEMPLATE, repo)
assert ratchet.run(repo, "napalm_example") == (0, "within the baseline")
def test_the_template_ci_calls_the_shared_workflow() -> None:
ci = (TEMPLATE / ".gitea" / "workflows" / "ci.yml").read_text()
assert "uses: NAPALM/driver-standard/.gitea/workflows/driver.yml@" in ci
assert "PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}" in ci