intermediate commit
This commit is contained in:
445
tools/release/govoplan_release/contracts.py
Normal file
445
tools/release/govoplan_release/contracts.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""Manifest contract extraction and validation for release planning.
|
||||
|
||||
The static scanner intentionally parses manifest source files with ``ast``
|
||||
instead of importing module packages. That keeps contract checks usable before
|
||||
``pip install`` has been rerun for a changed ``pyproject.toml`` or entry point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .model import (
|
||||
CompatibilityIssue,
|
||||
InterfaceProviderSnapshot,
|
||||
InterfaceRequirementSnapshot,
|
||||
ModuleContractSnapshot,
|
||||
RepositorySnapshot,
|
||||
)
|
||||
|
||||
|
||||
def collect_contracts(repositories: tuple[RepositorySnapshot, ...]) -> tuple[ModuleContractSnapshot, ...]:
|
||||
contracts: list[ModuleContractSnapshot] = []
|
||||
for repo in repositories:
|
||||
if not repo.exists or not repo.is_git:
|
||||
continue
|
||||
root = Path(repo.absolute_path)
|
||||
for manifest in sorted(root.glob("src/**/backend/manifest.py")):
|
||||
contract = parse_manifest_contract(manifest, repo_name=repo.spec.name)
|
||||
if contract is not None:
|
||||
contracts.append(contract)
|
||||
return tuple(contracts)
|
||||
|
||||
|
||||
def validate_contracts(contracts: tuple[ModuleContractSnapshot, ...]) -> tuple[CompatibilityIssue, ...]:
|
||||
"""Validate the statically collected module interface graph.
|
||||
|
||||
This mirrors the runtime interface closure validation in
|
||||
``govoplan_core.core.registry`` but reports all issues at once for CI and
|
||||
release planning.
|
||||
"""
|
||||
|
||||
issues: list[CompatibilityIssue] = []
|
||||
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
|
||||
module_ids: dict[str, list[ModuleContractSnapshot]] = {}
|
||||
|
||||
for contract in contracts:
|
||||
module_ids.setdefault(contract.module_id, []).append(contract)
|
||||
local_provider_names: set[str] = set()
|
||||
local_requirement_names: set[str] = set()
|
||||
|
||||
for provider in contract.provides_interfaces:
|
||||
if provider.name in local_provider_names:
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="duplicate_provided_interface",
|
||||
message=f"{contract.module_id} provides interface {provider.name!r} more than once.",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=provider.name,
|
||||
)
|
||||
)
|
||||
local_provider_names.add(provider.name)
|
||||
if not valid_interface_name(provider.name):
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="invalid_provided_interface_name",
|
||||
message=f"{contract.module_id} provides invalid interface name {provider.name!r}.",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=provider.name,
|
||||
)
|
||||
)
|
||||
if not provider.version.strip():
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="missing_provided_interface_version",
|
||||
message=f"{contract.module_id} provides interface {provider.name!r} without a version.",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=provider.name,
|
||||
)
|
||||
)
|
||||
providers.setdefault(provider.name, []).append((contract, provider))
|
||||
|
||||
for requirement in contract.requires_interfaces:
|
||||
if requirement.name in local_requirement_names:
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="duplicate_required_interface",
|
||||
message=f"{contract.module_id} requires interface {requirement.name!r} more than once.",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=requirement.name,
|
||||
)
|
||||
)
|
||||
local_requirement_names.add(requirement.name)
|
||||
if not valid_interface_name(requirement.name):
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="invalid_required_interface_name",
|
||||
message=f"{contract.module_id} requires invalid interface name {requirement.name!r}.",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=requirement.name,
|
||||
)
|
||||
)
|
||||
if requirement.version_min is not None and not requirement.version_min.strip():
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="blank_required_interface_minimum",
|
||||
message=f"{contract.module_id} has a blank minimum version for {requirement.name!r}.",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=requirement.name,
|
||||
)
|
||||
)
|
||||
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="blank_required_interface_maximum",
|
||||
message=f"{contract.module_id} has a blank maximum version for {requirement.name!r}.",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=requirement.name,
|
||||
)
|
||||
)
|
||||
if not version_range_is_valid(
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
):
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="invalid_required_interface_range",
|
||||
message=(
|
||||
f"{contract.module_id} requires {requirement.name!r} with invalid range "
|
||||
f"{format_version_range(version_min=requirement.version_min, version_max_exclusive=requirement.version_max_exclusive)}."
|
||||
),
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=requirement.name,
|
||||
)
|
||||
)
|
||||
|
||||
for module_id, matches in sorted(module_ids.items()):
|
||||
if len(matches) > 1:
|
||||
repos = ", ".join(sorted(contract.repo for contract in matches))
|
||||
for contract in matches:
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="duplicate_module_id",
|
||||
message=f"Module id {module_id!r} is declared by multiple repositories: {repos}.",
|
||||
repo=contract.repo,
|
||||
module_id=module_id,
|
||||
)
|
||||
)
|
||||
|
||||
for contract in contracts:
|
||||
for requirement in contract.requires_interfaces:
|
||||
available = providers.get(requirement.name, [])
|
||||
matching = [
|
||||
(provider_contract, provider)
|
||||
for provider_contract, provider in available
|
||||
if version_satisfies_range(
|
||||
provider.version,
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
version_range = format_version_range(
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
if not available:
|
||||
if requirement.optional:
|
||||
continue
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="blocker",
|
||||
code="required_interface_missing",
|
||||
message=f"{contract.module_id} requires unavailable interface {requirement.name!r} ({version_range}).",
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=requirement.name,
|
||||
)
|
||||
)
|
||||
continue
|
||||
available_versions = ", ".join(
|
||||
f"{provider_contract.module_id}@{provider.version}" for provider_contract, provider in available
|
||||
)
|
||||
issues.append(
|
||||
CompatibilityIssue(
|
||||
severity="warning" if requirement.optional else "blocker",
|
||||
code="interface_version_mismatch",
|
||||
message=(
|
||||
f"{contract.module_id} requires interface {requirement.name!r} ({version_range}) "
|
||||
f"but available providers are {available_versions}."
|
||||
),
|
||||
repo=contract.repo,
|
||||
module_id=contract.module_id,
|
||||
interface=requirement.name,
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(sorted(issues, key=issue_sort_key))
|
||||
|
||||
|
||||
def interface_impact_map(
|
||||
contracts: tuple[ModuleContractSnapshot, ...],
|
||||
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot], ...]]:
|
||||
consumers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceRequirementSnapshot]]] = {}
|
||||
for contract in contracts:
|
||||
for requirement in contract.requires_interfaces:
|
||||
consumers.setdefault(requirement.name, []).append((contract, requirement))
|
||||
return {name: tuple(items) for name, items in sorted(consumers.items())}
|
||||
|
||||
|
||||
def provided_interface_map(
|
||||
contracts: tuple[ModuleContractSnapshot, ...],
|
||||
) -> dict[str, tuple[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot], ...]]:
|
||||
providers: dict[str, list[tuple[ModuleContractSnapshot, InterfaceProviderSnapshot]]] = {}
|
||||
for contract in contracts:
|
||||
for provider in contract.provides_interfaces:
|
||||
providers.setdefault(provider.name, []).append((contract, provider))
|
||||
return {name: tuple(items) for name, items in sorted(providers.items())}
|
||||
|
||||
|
||||
def parse_manifest_contract(path: Path, *, repo_name: str) -> ModuleContractSnapshot | None:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
constants = imported_string_constants(tree, path)
|
||||
constants.update(module_constants(tree, initial=constants))
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if call_name(node.func) != "ModuleManifest":
|
||||
continue
|
||||
keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}
|
||||
module_id = value_as_string(keywords.get("id"), constants)
|
||||
if module_id is None:
|
||||
continue
|
||||
return ModuleContractSnapshot(
|
||||
repo=repo_name,
|
||||
module_id=module_id,
|
||||
module_version=value_as_string(keywords.get("version"), constants),
|
||||
manifest_path=str(path),
|
||||
provides_interfaces=parse_providers(keywords.get("provides_interfaces"), constants),
|
||||
requires_interfaces=parse_requirements(keywords.get("requires_interfaces"), constants),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def parse_providers(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceProviderSnapshot, ...]:
|
||||
providers: list[InterfaceProviderSnapshot] = []
|
||||
for call in interface_calls(node, "ModuleInterfaceProvider"):
|
||||
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
|
||||
name = value_as_string(keywords.get("name"), constants)
|
||||
version = value_as_string(keywords.get("version"), constants)
|
||||
if name and version:
|
||||
providers.append(InterfaceProviderSnapshot(name=name, version=version))
|
||||
return tuple(providers)
|
||||
|
||||
|
||||
def parse_requirements(node: ast.AST | None, constants: dict[str, str]) -> tuple[InterfaceRequirementSnapshot, ...]:
|
||||
requirements: list[InterfaceRequirementSnapshot] = []
|
||||
for call in interface_calls(node, "ModuleInterfaceRequirement"):
|
||||
keywords = {keyword.arg: keyword.value for keyword in call.keywords if keyword.arg}
|
||||
name = value_as_string(keywords.get("name"), constants)
|
||||
if not name:
|
||||
continue
|
||||
requirements.append(
|
||||
InterfaceRequirementSnapshot(
|
||||
name=name,
|
||||
version_min=value_as_string(keywords.get("version_min"), constants),
|
||||
version_max_exclusive=value_as_string(keywords.get("version_max_exclusive"), constants),
|
||||
optional=value_as_bool(keywords.get("optional")),
|
||||
)
|
||||
)
|
||||
return tuple(requirements)
|
||||
|
||||
|
||||
def interface_calls(node: ast.AST | None, expected_name: str) -> tuple[ast.Call, ...]:
|
||||
if node is None:
|
||||
return ()
|
||||
return tuple(
|
||||
child
|
||||
for child in ast.walk(node)
|
||||
if isinstance(child, ast.Call) and call_name(child.func) == expected_name
|
||||
)
|
||||
|
||||
|
||||
def imported_string_constants(tree: ast.Module, path: Path) -> dict[str, str]:
|
||||
constants: dict[str, str] = {}
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ImportFrom):
|
||||
continue
|
||||
source = import_source_path(path, module=node.module, level=node.level)
|
||||
if source is None or not source.exists():
|
||||
continue
|
||||
try:
|
||||
imported = module_constants(ast.parse(source.read_text(encoding="utf-8"), filename=str(source)))
|
||||
except SyntaxError:
|
||||
continue
|
||||
for alias in node.names:
|
||||
if alias.name in imported:
|
||||
constants[alias.asname or alias.name] = imported[alias.name]
|
||||
return constants
|
||||
|
||||
|
||||
def import_source_path(path: Path, *, module: str | None, level: int) -> Path | None:
|
||||
if level:
|
||||
base = path.parent
|
||||
for _ in range(level - 1):
|
||||
base = base.parent
|
||||
if module:
|
||||
base = base.joinpath(*module.split("."))
|
||||
candidate = base.with_suffix(".py")
|
||||
else:
|
||||
src_root = next((parent for parent in path.parents if parent.name == "src"), None)
|
||||
if src_root is None or module is None:
|
||||
return None
|
||||
candidate = src_root.joinpath(*module.split(".")).with_suffix(".py")
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
init = candidate.with_suffix("") / "__init__.py"
|
||||
return init if init.exists() else candidate
|
||||
|
||||
|
||||
def module_constants(tree: ast.Module, *, initial: dict[str, str] | None = None) -> dict[str, str]:
|
||||
constants: dict[str, str] = dict(initial or {})
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
value = node.value
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
resolved = value_as_string(value, constants)
|
||||
if resolved is not None:
|
||||
constants[target.id] = resolved
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
resolved = value_as_string(node.value, constants)
|
||||
if resolved is not None:
|
||||
constants[node.target.id] = resolved
|
||||
return constants
|
||||
|
||||
|
||||
def value_as_string(node: ast.AST | None, constants: dict[str, str]) -> str | None:
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
return constants.get(node.id)
|
||||
return None
|
||||
|
||||
|
||||
def value_as_bool(node: ast.AST | None) -> bool:
|
||||
return bool(node.value) if isinstance(node, ast.Constant) and isinstance(node.value, bool) else False
|
||||
|
||||
|
||||
def call_name(node: ast.AST) -> str:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr
|
||||
return ""
|
||||
|
||||
|
||||
def issue_sort_key(issue: CompatibilityIssue) -> tuple[int, str, str, str, str]:
|
||||
severity_order = {"blocker": 0, "warning": 1, "info": 2}
|
||||
return (
|
||||
severity_order.get(issue.severity, 9),
|
||||
issue.repo or "",
|
||||
issue.module_id or "",
|
||||
issue.interface or "",
|
||||
issue.code,
|
||||
)
|
||||
|
||||
|
||||
def valid_interface_name(value: str) -> bool:
|
||||
return bool(re.match(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$", value))
|
||||
|
||||
|
||||
def version_satisfies_range(
|
||||
version: str,
|
||||
*,
|
||||
version_min: str | None = None,
|
||||
version_max_exclusive: str | None = None,
|
||||
) -> bool:
|
||||
if version_min is not None and compare_versions(version, version_min) < 0:
|
||||
return False
|
||||
if version_max_exclusive is not None and compare_versions(version, version_max_exclusive) >= 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def version_range_is_valid(
|
||||
*,
|
||||
version_min: str | None = None,
|
||||
version_max_exclusive: str | None = None,
|
||||
) -> bool:
|
||||
if version_min is None or version_max_exclusive is None:
|
||||
return True
|
||||
return compare_versions(version_min, version_max_exclusive) < 0
|
||||
|
||||
|
||||
def format_version_range(
|
||||
*,
|
||||
version_min: str | None = None,
|
||||
version_max_exclusive: str | None = None,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if version_min is not None:
|
||||
parts.append(f">= {version_min}")
|
||||
if version_max_exclusive is not None:
|
||||
parts.append(f"< {version_max_exclusive}")
|
||||
return ", ".join(parts) if parts else "any version"
|
||||
|
||||
|
||||
def compare_versions(left: str, right: str) -> int:
|
||||
left_parts = version_tuple(left)
|
||||
right_parts = version_tuple(right)
|
||||
max_length = max(len(left_parts), len(right_parts))
|
||||
padded_left = left_parts + (0,) * (max_length - len(left_parts))
|
||||
padded_right = right_parts + (0,) * (max_length - len(right_parts))
|
||||
if padded_left < padded_right:
|
||||
return -1
|
||||
if padded_left > padded_right:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def version_tuple(value: str) -> tuple[int, ...]:
|
||||
match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value)
|
||||
if not match:
|
||||
return (0,)
|
||||
return tuple(int(part) for part in match.group(1).split("."))
|
||||
Reference in New Issue
Block a user