"""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.10"\n', 'requires-python = ">=3.10"\n' 'classifiers = ["License :: OSI Approved :: Apache Software License"]\n', ) assert rules(check_repo(make_repo({"pyproject.toml": pyproject}))) == {"metadata"} @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"}