fix: repair dev modules and scoped release git trust
This commit is contained in:
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from typing import Any
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[2]
|
||||
@@ -49,6 +50,23 @@ class InstallPlan:
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalInstallationExpectation:
|
||||
requirement: RequirementEntry
|
||||
distribution: str
|
||||
entry_points: tuple[tuple[str, str, str], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EnvironmentValidation:
|
||||
stale_requirements: tuple[RequirementEntry, ...] = ()
|
||||
issues: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def current(self) -> bool:
|
||||
return not self.stale_requirements
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
@@ -85,8 +103,9 @@ def main() -> int:
|
||||
fingerprint = build_fingerprint(requirements=requirements, python=python, local_requirements=local_requirements)
|
||||
previous = load_stamp(stamp)
|
||||
current = previous.get("digest") == fingerprint["digest"]
|
||||
environment = validate_local_installations(python, local_requirements)
|
||||
|
||||
if current and not args.force:
|
||||
if current and environment.current and not args.force:
|
||||
payload = stamp_payload(
|
||||
requirements=requirements,
|
||||
python=python,
|
||||
@@ -107,20 +126,31 @@ def main() -> int:
|
||||
print(f"Python environment is current for {requirements.name}.")
|
||||
return 0
|
||||
|
||||
print(f"Python environment metadata is stale for {requirements.name}.")
|
||||
if current and not environment.current:
|
||||
print(f"Python environment metadata is current for {requirements.name}, but installed packages need repair.")
|
||||
else:
|
||||
print(f"Python environment metadata is stale for {requirements.name}.")
|
||||
print(f"Tracked inputs: {len(fingerprint['inputs'])}")
|
||||
for issue in environment.issues:
|
||||
print(f"warning: {issue}", file=sys.stderr)
|
||||
|
||||
if args.check:
|
||||
return 1
|
||||
|
||||
plan = build_install_plan(
|
||||
previous=previous,
|
||||
fingerprint=fingerprint,
|
||||
requirements=requirements,
|
||||
python=python,
|
||||
local_requirements=local_requirements,
|
||||
force=args.force,
|
||||
)
|
||||
if current and environment.stale_requirements and not args.force:
|
||||
plan = build_environment_repair_plan(
|
||||
python=python,
|
||||
stale_requirements=environment.stale_requirements,
|
||||
)
|
||||
else:
|
||||
plan = build_install_plan(
|
||||
previous=previous,
|
||||
fingerprint=fingerprint,
|
||||
requirements=requirements,
|
||||
python=python,
|
||||
local_requirements=local_requirements,
|
||||
force=args.force,
|
||||
)
|
||||
|
||||
print(f"{plan.mode}: {plan.reason}")
|
||||
for warning in plan.warnings:
|
||||
@@ -141,6 +171,13 @@ def main() -> int:
|
||||
for command in commands:
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
repaired_environment = validate_local_installations(python, local_requirements)
|
||||
if not repaired_environment.current:
|
||||
for issue in repaired_environment.issues:
|
||||
print(f"error: {issue}", file=sys.stderr)
|
||||
print("Python environment synchronization did not install all local package metadata.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
write_stamp(
|
||||
stamp,
|
||||
stamp_payload(
|
||||
@@ -297,6 +334,127 @@ def build_install_plan(
|
||||
)
|
||||
|
||||
|
||||
def build_environment_repair_plan(
|
||||
*,
|
||||
python: str,
|
||||
stale_requirements: tuple[RequirementEntry, ...],
|
||||
) -> InstallPlan:
|
||||
command = (
|
||||
python,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
*(
|
||||
argument
|
||||
for requirement in stale_requirements
|
||||
for argument in requirement.install_args
|
||||
),
|
||||
)
|
||||
return InstallPlan(
|
||||
"Selective Python environment repair",
|
||||
f"reinstalling {len(stale_requirements)} missing or stale local distribution(s).",
|
||||
(command,),
|
||||
)
|
||||
|
||||
|
||||
def validate_local_installations(
|
||||
python: str,
|
||||
local_requirements: tuple[RequirementEntry, ...],
|
||||
) -> EnvironmentValidation:
|
||||
expectations = local_installation_expectations(local_requirements)
|
||||
if not expectations:
|
||||
return EnvironmentValidation()
|
||||
|
||||
probe_input = [
|
||||
{
|
||||
"key": expectation.requirement.key,
|
||||
"distribution": expectation.distribution,
|
||||
"entry_points": [
|
||||
{"group": group, "name": name, "value": value}
|
||||
for group, name, value in expectation.entry_points
|
||||
],
|
||||
}
|
||||
for expectation in expectations
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
(python, "-c", _LOCAL_INSTALLATION_PROBE),
|
||||
check=False,
|
||||
text=True,
|
||||
input=json.dumps(probe_input),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
return EnvironmentValidation(
|
||||
stale_requirements=tuple(item.requirement for item in expectations),
|
||||
issues=(f"could not inspect installed local distributions ({type(exc).__name__})",),
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or result.stdout.strip() or "installation probe failed"
|
||||
return EnvironmentValidation(
|
||||
stale_requirements=tuple(item.requirement for item in expectations),
|
||||
issues=(f"could not inspect installed local distributions: {detail}",),
|
||||
)
|
||||
try:
|
||||
payload = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return EnvironmentValidation(
|
||||
stale_requirements=tuple(item.requirement for item in expectations),
|
||||
issues=("installed local distribution probe returned invalid JSON",),
|
||||
)
|
||||
|
||||
raw_issues = payload.get("issues") if isinstance(payload, dict) else None
|
||||
issues_by_key = raw_issues if isinstance(raw_issues, dict) else {}
|
||||
stale = tuple(
|
||||
expectation.requirement
|
||||
for expectation in expectations
|
||||
if expectation.requirement.key in issues_by_key
|
||||
)
|
||||
issues = tuple(
|
||||
f"{expectation.distribution}: {issues_by_key[expectation.requirement.key]}"
|
||||
for expectation in expectations
|
||||
if expectation.requirement.key in issues_by_key
|
||||
)
|
||||
return EnvironmentValidation(stale_requirements=stale, issues=issues)
|
||||
|
||||
|
||||
def local_installation_expectations(
|
||||
local_requirements: tuple[RequirementEntry, ...],
|
||||
) -> tuple[LocalInstallationExpectation, ...]:
|
||||
expectations: list[LocalInstallationExpectation] = []
|
||||
for requirement in local_requirements:
|
||||
if requirement.pyproject is None:
|
||||
continue
|
||||
with Path(requirement.pyproject).open("rb") as handle:
|
||||
payload = tomllib.load(handle)
|
||||
project = payload.get("project")
|
||||
if not isinstance(project, dict):
|
||||
continue
|
||||
distribution = project.get("name")
|
||||
if not isinstance(distribution, str) or not distribution.strip():
|
||||
continue
|
||||
declared_entry_points: list[tuple[str, str, str]] = []
|
||||
entry_point_groups = project.get("entry-points")
|
||||
if isinstance(entry_point_groups, dict):
|
||||
for group, entries in entry_point_groups.items():
|
||||
if not isinstance(group, str) or not isinstance(entries, dict):
|
||||
continue
|
||||
for name, value in entries.items():
|
||||
if isinstance(name, str) and isinstance(value, str):
|
||||
declared_entry_points.append((group, name, value))
|
||||
expectations.append(
|
||||
LocalInstallationExpectation(
|
||||
requirement=requirement,
|
||||
distribution=distribution,
|
||||
entry_points=tuple(sorted(declared_entry_points)),
|
||||
)
|
||||
)
|
||||
return tuple(expectations)
|
||||
|
||||
|
||||
def requirement_delta_plan(previous_entries: object, current_entries: tuple[RequirementEntry, ...]) -> InstallPlan | None:
|
||||
parsed_previous = previous_requirement_entries(previous_entries)
|
||||
if parsed_previous is None:
|
||||
@@ -481,5 +639,35 @@ def format_command(command: tuple[str, ...]) -> str:
|
||||
return " ".join(shlex.quote(item) for item in command)
|
||||
|
||||
|
||||
_LOCAL_INSTALLATION_PROBE = """
|
||||
import importlib.metadata
|
||||
import json
|
||||
import sys
|
||||
|
||||
expectations = json.load(sys.stdin)
|
||||
issues = {}
|
||||
for expectation in expectations:
|
||||
key = expectation["key"]
|
||||
distribution_name = expectation["distribution"]
|
||||
try:
|
||||
distribution = importlib.metadata.distribution(distribution_name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
issues[key] = "distribution is not installed"
|
||||
continue
|
||||
installed = {
|
||||
(entry_point.group, entry_point.name): entry_point.value
|
||||
for entry_point in distribution.entry_points
|
||||
}
|
||||
mismatches = []
|
||||
for entry_point in expectation["entry_points"]:
|
||||
identity = (entry_point["group"], entry_point["name"])
|
||||
if installed.get(identity) != entry_point["value"]:
|
||||
mismatches.append(f"{identity[0]}:{identity[1]}")
|
||||
if mismatches:
|
||||
issues[key] = "entry-point metadata is missing or stale: " + ", ".join(mismatches)
|
||||
print(json.dumps({"issues": issues}, sort_keys=True))
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user