check now requires requires-python >= 3.12. Every standard driver moved to that floor in its own PR, so none of them goes red with this.
234 lines
8.5 KiB
Python
234 lines
8.5 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
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"license_line",
|
|
['license = { text = "Apache-2.0" }', 'license = { file = "LICENSE" }'],
|
|
)
|
|
def test_a_license_table_is_reported(make_repo: RepoFactory, license_line: str) -> None:
|
|
"""setuptools refuses the table form from 2027-02-18 (PEP 639)."""
|
|
pyproject = PYPROJECT.replace('license = "Apache-2.0"', license_line)
|
|
|
|
findings = check_repo(make_repo({"pyproject.toml": pyproject}))
|
|
|
|
assert rules(findings) == {"metadata"}
|
|
assert "SPDX" in findings[0].message
|
|
|
|
|
|
def test_a_license_classifier_is_reported(make_repo: RepoFactory) -> None:
|
|
pyproject = PYPROJECT.replace(
|
|
'requires-python = ">=3.12"\n',
|
|
'requires-python = ">=3.12"\n'
|
|
'classifiers = ["License :: OSI Approved :: Apache Software License"]\n',
|
|
)
|
|
|
|
assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"metadata"}
|
|
|
|
|
|
@pytest.mark.parametrize("spec", [">=3.9", ">=3.10", ">=3.11", ""])
|
|
def test_python_below_312_or_unbounded_is_reported(make_repo: RepoFactory, spec: str) -> None:
|
|
pyproject = PYPROJECT.replace('requires-python = ">=3.12"', 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.12"', '">=3.13"')
|
|
|
|
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"}
|