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,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
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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
|
||||
Reference in new issue
Block a user