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.
216 lines
7.9 KiB
Python
216 lines
7.9 KiB
Python
"""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"}
|