diff --git a/Makefile b/Makefile index 21f3191e..d1f3e6f6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install validate validate-structure validate-collection-schema validate-collection-compliance validate-compass-manifests validate-skill-design validate-skill-design-changed validate-mcp-tools validate-spelling package clean check-uv +.PHONY: help install validate validate-structure validate-collection-schema validate-collection-compliance validate-compass-manifests validate-lifecycle-ceiling validate-skill-design validate-skill-design-changed validate-mcp-tools validate-spelling package clean check-uv help: @echo "agentic-plugins" @@ -10,6 +10,7 @@ help: @echo " validate-collection-schema - Schema + roster + banners (subset of compliance)" @echo " validate-collection-compliance - Full .catalog compliance (includes collection.json drift)" @echo " validate-compass-manifests - Compass manifests, roster, refs, and skill references/ layout" + @echo " validate-lifecycle-ceiling - Compass lifecycle ceiling (skill <= plugin lifecycle) + unit tests" @echo " validate-skill-design - Validate all skills (use PACK=rh-sre for a specific pack)" @echo " validate-skill-design-changed - Validate only changed skills (staged + unstaged, for local dev)" @echo " validate-mcp-tools - Validate allowed-tools against live MCP servers (requires podman)" @@ -62,6 +63,10 @@ validate: check-uv uv run python scripts/validate_collection_compliance.py || EXIT=1; \ echo "=== Validating Compass manifests..."; \ uv run python scripts/validate_compass_manifests.py || EXIT=1; \ + echo "=== Validating Compass lifecycle ceiling (skill <= plugin lifecycle)..."; \ + uv run python scripts/validate_lifecycle_ceiling.py || EXIT=1; \ + echo "=== Running lifecycle ceiling unit tests..."; \ + uv run pytest scripts/test_validate_lifecycle_ceiling.py || EXIT=1; \ echo "=== Validating MCP tool references (skips gracefully without podman)..."; \ uv run python scripts/validate_mcp_tools.py --summary-only --log-file .validate/mcp-tools.log || EXIT=1; \ echo "=== Validating skill design principles..."; \ @@ -88,6 +93,10 @@ validate-structure: check-uv uv run python scripts/validate_collection_compliance.py || EXIT=1; \ echo "=== Validating Compass manifests..."; \ uv run python scripts/validate_compass_manifests.py || EXIT=1; \ + echo "=== Validating Compass lifecycle ceiling (skill <= plugin lifecycle)..."; \ + uv run python scripts/validate_lifecycle_ceiling.py || EXIT=1; \ + echo "=== Running lifecycle ceiling unit tests..."; \ + uv run pytest scripts/test_validate_lifecycle_ceiling.py || EXIT=1; \ echo "=== Validating MCP tool references (skips gracefully without podman)..."; \ uv run python scripts/validate_mcp_tools.py --summary-only --log-file .validate/mcp-tools.log || EXIT=1; \ echo "=== Validation complete!"; \ @@ -102,6 +111,10 @@ validate-collection-compliance: check-uv validate-compass-manifests: check-uv @uv run python scripts/validate_compass_manifests.py +validate-lifecycle-ceiling: check-uv + @uv run python scripts/validate_lifecycle_ceiling.py + @uv run pytest scripts/test_validate_lifecycle_ceiling.py + validate-skill-design: check-uv @uv run python scripts/validate_skills_tier2.py $(if $(PACK),$(PACK)) diff --git a/pyproject.toml b/pyproject.toml index 5bcaafa6..466db6a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,4 +13,5 @@ dependencies = [ dev = [ "codespell>=2.3.0", "pre-commit>=4.0.0", + "pytest>=8.0", ] diff --git a/scripts/test_validate_lifecycle_ceiling.py b/scripts/test_validate_lifecycle_ceiling.py new file mode 100644 index 00000000..2427563f --- /dev/null +++ b/scripts/test_validate_lifecycle_ceiling.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Pytest unit tests for the Compass lifecycle ceiling validator.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +import yaml + +_SCRIPTS = Path(__file__).resolve().parent + + +def _load_module(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, _SCRIPTS / filename) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +lifecycle_ceiling = _load_module("validate_lifecycle_ceiling", "validate_lifecycle_ceiling.py") + + +def _write_manifest(path: Path, *, name: str, kind: str = "AiResource", lifecycle: str | None = "__unset__") -> None: + """Write a minimal Compass manifest. lifecycle='__unset__' omits the field entirely.""" + data: dict = { + "apiVersion": "backstage.io/v1alpha1", + "kind": kind, + "metadata": {"name": name}, + "spec": {}, + } + if lifecycle != "__unset__": + data["spec"]["lifecycle"] = lifecycle + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + +def _write_root_catalog(root: Path, packs: list[str]) -> None: + data = { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Location", + "metadata": {"name": "agentic-plugins"}, + "spec": {"targets": [f"./{pack}/catalog-info.yaml" for pack in packs] + ["./mcps/catalog-info.yaml"]}, + } + (root / "catalog-info.yaml").write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + (root / "mcps").mkdir(parents=True, exist_ok=True) + (root / "mcps" / "catalog-info.yaml").write_text( + yaml.safe_dump({"apiVersion": "backstage.io/v1alpha1", "kind": "Location", "spec": {"targets": []}}), + encoding="utf-8", + ) + + +def _write_pack( + root: Path, + pack: str, + *, + plugin_lifecycle: str | None = "__unset__", + skills: dict[str, str | None] | None = None, +) -> None: + """Create /-plugin.yaml and /skills//catalog-info.yaml files.""" + pack_dir = root / pack + _write_manifest(pack_dir / f"{pack}-plugin.yaml", name=pack, lifecycle=plugin_lifecycle) + for skill_name, skill_lifecycle in (skills or {}).items(): + _write_manifest( + pack_dir / "skills" / skill_name / "catalog-info.yaml", + name=skill_name, + lifecycle=skill_lifecycle, + ) + (root / pack / "catalog-info.yaml").write_text( + yaml.safe_dump({"apiVersion": "backstage.io/v1alpha1", "kind": "Location", "spec": {"targets": []}}), + encoding="utf-8", + ) + + +@pytest.fixture +def repo_root(tmp_path: Path) -> Path: + return tmp_path + + +class TestLifecycleRank: + def test_known_lifecycles_ordered(self) -> None: + assert lifecycle_ceiling.lifecycle_rank("development") == 0 + assert lifecycle_ceiling.lifecycle_rank("beta") == 1 + assert lifecycle_ceiling.lifecycle_rank("production") == 2 + + def test_missing_lifecycle_defaults_to_development(self) -> None: + assert lifecycle_ceiling.lifecycle_rank(None) == lifecycle_ceiling.lifecycle_rank("development") + + def test_unknown_lifecycle_raises(self) -> None: + with pytest.raises(ValueError): + lifecycle_ceiling.lifecycle_rank("ga") + + def test_is_deprecated(self) -> None: + assert lifecycle_ceiling.is_deprecated("deprecated") is True + assert lifecycle_ceiling.is_deprecated("Deprecated") is True + assert lifecycle_ceiling.is_deprecated("beta") is False + assert lifecycle_ceiling.is_deprecated(None) is False + + +class TestPassingCases: + def test_skill_equal_to_plugin_passes(self, repo_root: Path) -> None: + _write_pack(repo_root, "rh-demo", plugin_lifecycle="beta", skills={"demo-skill": "beta"}) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert errors == [] + + def test_skill_less_mature_than_plugin_passes(self, repo_root: Path) -> None: + _write_pack(repo_root, "rh-demo", plugin_lifecycle="production", skills={"demo-skill": "development"}) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert errors == [] + + def test_missing_lifecycles_default_to_development_and_pass(self, repo_root: Path) -> None: + # Neither plugin nor skill declares spec.lifecycle -> both default to development. + _write_pack(repo_root, "rh-demo", plugin_lifecycle="__unset__", skills={"demo-skill": "__unset__"}) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert errors == [] + + def test_multiple_skills_all_within_ceiling(self, repo_root: Path) -> None: + _write_pack( + repo_root, + "rh-demo", + plugin_lifecycle="beta", + skills={"skill-a": "development", "skill-b": "beta"}, + ) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert errors == [] + + +class TestFailCase: + def test_skill_more_mature_than_plugin_fails(self, repo_root: Path) -> None: + _write_pack(repo_root, "rh-demo", plugin_lifecycle="development", skills={"demo-skill": "beta"}) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert len(errors) == 1 + assert "demo-skill" in errors[0] + assert "'beta'" in errors[0] + assert "'development'" in errors[0] + + def test_production_skill_under_beta_plugin_fails(self, repo_root: Path) -> None: + _write_pack(repo_root, "rh-demo", plugin_lifecycle="beta", skills={"demo-skill": "production"}) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert len(errors) == 1 + + def test_only_offending_skill_is_reported(self, repo_root: Path) -> None: + _write_pack( + repo_root, + "rh-demo", + plugin_lifecycle="development", + skills={"ok-skill": "development", "bad-skill": "production"}, + ) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert len(errors) == 1 + assert "bad-skill" in errors[0] + assert "ok-skill" not in errors[0] + + +class TestDeprecatedSkipLogic: + def test_deprecated_skill_is_skipped_even_if_more_mature(self, repo_root: Path) -> None: + _write_pack(repo_root, "rh-demo", plugin_lifecycle="development", skills={"demo-skill": "deprecated"}) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert errors == [] + + def test_deprecated_plugin_skips_all_skills(self, repo_root: Path) -> None: + _write_pack( + repo_root, + "rh-demo", + plugin_lifecycle="deprecated", + skills={"demo-skill": "production"}, + ) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert errors == [] + + def test_deprecated_skill_among_others_only_skips_itself(self, repo_root: Path) -> None: + _write_pack( + repo_root, + "rh-demo", + plugin_lifecycle="development", + skills={"deprecated-skill": "deprecated", "bad-skill": "beta"}, + ) + + errors: list[str] = [] + lifecycle_ceiling.check_pack(repo_root, "rh-demo", errors) + + assert len(errors) == 1 + assert "bad-skill" in errors[0] + assert "deprecated-skill" not in errors[0] + + +class TestValidateAll: + def test_validate_all_discovers_registered_packs_from_root_catalog(self, repo_root: Path) -> None: + _write_root_catalog(repo_root, ["rh-good", "rh-bad"]) + _write_pack(repo_root, "rh-good", plugin_lifecycle="beta", skills={"good-skill": "beta"}) + _write_pack(repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "production"}) + + errors = lifecycle_ceiling.validate_all(repo_root) + + assert len(errors) == 1 + assert "bad-skill" in errors[0] + + def test_validate_all_ignores_unregistered_packs(self, repo_root: Path) -> None: + # rh-bad exists on disk but is not listed in the root catalog-info.yaml targets. + _write_root_catalog(repo_root, ["rh-good"]) + _write_pack(repo_root, "rh-good", plugin_lifecycle="beta", skills={"good-skill": "beta"}) + _write_pack(repo_root, "rh-bad", plugin_lifecycle="development", skills={"bad-skill": "production"}) + + errors = lifecycle_ceiling.validate_all(repo_root) + + assert errors == [] + + def test_validate_all_missing_root_catalog_reports_error(self, repo_root: Path) -> None: + errors = lifecycle_ceiling.validate_all(repo_root) + + assert len(errors) == 1 + assert "catalog-info.yaml" in errors[0] + + def test_pack_missing_plugin_manifest_reports_error(self, repo_root: Path) -> None: + _write_root_catalog(repo_root, ["rh-orphan"]) + (repo_root / "rh-orphan").mkdir(parents=True) + + errors = lifecycle_ceiling.validate_all(repo_root) + + assert len(errors) == 1 + assert "rh-orphan" in errors[0] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) diff --git a/scripts/validate_lifecycle_ceiling.py b/scripts/validate_lifecycle_ceiling.py new file mode 100644 index 00000000..11ad5799 --- /dev/null +++ b/scripts/validate_lifecycle_ceiling.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +""" +Validate the Compass "lifecycle ceiling" rule. + +By design, a child skill cannot have a more mature ``spec.lifecycle`` than +its parent plugin (pack). This script enforces that rule in CI: + + - The allowed lifecycle order is: development (0) < beta (1) < production (2). + - Missing lifecycles default to "development". + - Entities with lifecycle "deprecated" (skills or plugins) are skipped — + they are not compared against the ceiling. + - Packs are discovered from the root ``catalog-info.yaml`` ``spec.targets`` + (the same set Compass ingests), mirroring ``validate_compass_manifests.py``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + +_REPO_ROOT = Path(__file__).resolve().parent.parent +_ROOT_CATALOG = _REPO_ROOT / "catalog-info.yaml" + +# Allowed lifecycle maturity order: development < beta < production. +LIFECYCLE_RANK = {"development": 0, "beta": 1, "production": 2} +DEFAULT_LIFECYCLE = "development" +DEPRECATED_LIFECYCLE = "deprecated" + + +def _load_yaml(path: Path) -> dict: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{path}: expected mapping at top level") + return data + + +def normalize_lifecycle(lifecycle: str | None) -> str: + """Return the effective lifecycle string, defaulting missing values to development.""" + if lifecycle is None: + return DEFAULT_LIFECYCLE + value = str(lifecycle).strip().lower() + return value or DEFAULT_LIFECYCLE + + +def is_deprecated(lifecycle: str | None) -> bool: + """Return True when the (normalized) lifecycle is 'deprecated'.""" + return normalize_lifecycle(lifecycle) == DEPRECATED_LIFECYCLE + + +def lifecycle_rank(lifecycle: str | None) -> int: + """Map a lifecycle string to its maturity rank (missing -> development).""" + value = normalize_lifecycle(lifecycle) + if value not in LIFECYCLE_RANK: + raise ValueError( + f"unknown lifecycle '{lifecycle}'; expected one of " + f"{sorted(LIFECYCLE_RANK)} or '{DEPRECATED_LIFECYCLE}'" + ) + return LIFECYCLE_RANK[value] + + +def registered_packs(root: Path) -> list[str]: + """Return pack directory names referenced from the root catalog-info.yaml.""" + root_catalog = root / "catalog-info.yaml" + data = _load_yaml(root_catalog) + packs: list[str] = [] + for target in data.get("spec", {}).get("targets", []): + if not isinstance(target, str): + continue + if target.startswith("./mcps/"): + continue + if not target.endswith("/catalog-info.yaml"): + continue + parts = Path(target).parts + if len(parts) != 2: + continue + packs.append(parts[0]) + return sorted(set(packs)) + + +def _skill_manifests(pack_dir: Path) -> list[Path]: + skills_dir = pack_dir / "skills" + if not skills_dir.is_dir(): + return [] + return sorted(skills_dir.glob("*/catalog-info.yaml")) + + +def check_pack(root: Path, pack: str, errors: list[str]) -> None: + """Validate the lifecycle ceiling for a single pack (skills vs. their plugin).""" + pack_dir = root / pack + plugin_path = pack_dir / f"{pack}-plugin.yaml" + if not plugin_path.is_file(): + errors.append(f"{pack}: missing plugin manifest {plugin_path.relative_to(root)}") + return + + try: + plugin_data = _load_yaml(plugin_path) + except (OSError, ValueError, yaml.YAMLError) as exc: + errors.append(f"{plugin_path.relative_to(root)}: failed to load ({exc})") + return + + plugin_lifecycle = normalize_lifecycle(plugin_data.get("spec", {}).get("lifecycle")) + if is_deprecated(plugin_lifecycle): + # Deprecated plugins are exempt — none of their skills are enforced either. + return + + try: + plugin_rank = lifecycle_rank(plugin_lifecycle) + except ValueError as exc: + errors.append(f"{plugin_path.relative_to(root)}: {exc}") + return + + for manifest in _skill_manifests(pack_dir): + try: + skill_data = _load_yaml(manifest) + except (OSError, ValueError, yaml.YAMLError) as exc: + errors.append(f"{manifest.relative_to(root)}: failed to load ({exc})") + continue + + skill_name = skill_data.get("metadata", {}).get("name", manifest.parent.name) + skill_lifecycle = normalize_lifecycle(skill_data.get("spec", {}).get("lifecycle")) + + if is_deprecated(skill_lifecycle): + continue # deprecated skills are exempt from the ceiling check + + try: + skill_rank = lifecycle_rank(skill_lifecycle) + except ValueError as exc: + errors.append(f"{manifest.relative_to(root)}: {exc}") + continue + + if skill_rank > plugin_rank: + errors.append( + f"{pack}/{skill_name}: lifecycle '{skill_lifecycle}' exceeds parent " + f"plugin '{pack}' lifecycle '{plugin_lifecycle}' " + f"({manifest.relative_to(root)})" + ) + + +def validate_all(root: Path) -> list[str]: + """Run the lifecycle ceiling check for every pack registered in catalog-info.yaml.""" + errors: list[str] = [] + root_catalog = root / "catalog-info.yaml" + if not root_catalog.is_file(): + errors.append(f"missing root catalog Location: {root_catalog}") + return errors + + for pack in registered_packs(root): + check_pack(root, pack, errors) + return errors + + +def main() -> int: + errors = validate_all(_REPO_ROOT) + + if errors: + print("Lifecycle ceiling validation failed:", file=sys.stderr) + for err in errors: + print(f" • {err}", file=sys.stderr) + return 1 + + print("✓ Lifecycle ceiling validation passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index 44721375..d71878df 100644 --- a/uv.lock +++ b/uv.lock @@ -16,6 +16,7 @@ dependencies = [ dev = [ { name = "codespell" }, { name = "pre-commit" }, + { name = "pytest" }, ] [package.metadata] @@ -29,6 +30,7 @@ requires-dist = [ dev = [ { name = "codespell", specifier = ">=2.3.0" }, { name = "pre-commit", specifier = ">=4.0.0" }, + { name = "pytest", specifier = ">=8.0" }, ] [[package]] @@ -58,6 +60,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -85,6 +96,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -130,6 +150,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -139,6 +168,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pre-commit" version = "4.6.0" @@ -155,6 +193,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-discovery" version = "1.4.2"