fix: repair dev modules and scoped release git trust
This commit is contained in:
@@ -77,6 +77,76 @@ class PythonEnvironmentSyncTests(unittest.TestCase):
|
|||||||
self.assertIn(str(root / "govoplan-core"), command)
|
self.assertIn(str(root / "govoplan-core"), command)
|
||||||
self.assertIn(str(root / "govoplan-access"), command)
|
self.assertIn(str(root / "govoplan-access"), command)
|
||||||
|
|
||||||
|
def test_missing_editable_distribution_is_not_hidden_by_current_stamp(self) -> None:
|
||||||
|
sync = load_sync_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
project_root = root / "govoplan-probe-missing"
|
||||||
|
project_root.mkdir()
|
||||||
|
(project_root / "pyproject.toml").write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
"[project]",
|
||||||
|
'name = "govoplan-probe-definitely-not-installed"',
|
||||||
|
'version = "0.1.0"',
|
||||||
|
"",
|
||||||
|
'[project.entry-points."govoplan.modules"]',
|
||||||
|
'probe_missing = "govoplan_probe.backend.manifest:get_manifest"',
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
requirements = root / "requirements-dev.txt"
|
||||||
|
requirements.write_text("-e ./govoplan-probe-missing\n", encoding="utf-8")
|
||||||
|
entries = sync.local_requirement_entries(requirements)
|
||||||
|
|
||||||
|
validation = sync.validate_local_installations(sys.executable, entries)
|
||||||
|
plan = sync.build_environment_repair_plan(
|
||||||
|
python=sys.executable,
|
||||||
|
stale_requirements=validation.stale_requirements,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(validation.current)
|
||||||
|
self.assertEqual(validation.stale_requirements, entries)
|
||||||
|
self.assertIn("distribution is not installed", validation.issues[0])
|
||||||
|
self.assertEqual(plan.mode, "Selective Python environment repair")
|
||||||
|
self.assertEqual(plan.commands[0][-2:], ("-e", str(project_root)))
|
||||||
|
|
||||||
|
def test_declared_module_entry_points_are_part_of_environment_validation(self) -> None:
|
||||||
|
sync = load_sync_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-python-sync-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
project_root = root / "govoplan-probe"
|
||||||
|
project_root.mkdir()
|
||||||
|
(project_root / "pyproject.toml").write_text(
|
||||||
|
"\n".join(
|
||||||
|
(
|
||||||
|
"[project]",
|
||||||
|
'name = "govoplan-probe"',
|
||||||
|
'version = "0.1.0"',
|
||||||
|
"",
|
||||||
|
'[project.entry-points."govoplan.modules"]',
|
||||||
|
'probe = "govoplan_probe.backend.manifest:get_manifest"',
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
requirements = root / "requirements-dev.txt"
|
||||||
|
requirements.write_text("-e ./govoplan-probe\n", encoding="utf-8")
|
||||||
|
|
||||||
|
expectations = sync.local_installation_expectations(
|
||||||
|
sync.local_requirement_entries(requirements)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(expectations), 1)
|
||||||
|
self.assertEqual(expectations[0].distribution, "govoplan-probe")
|
||||||
|
self.assertEqual(
|
||||||
|
expectations[0].entry_points,
|
||||||
|
(("govoplan.modules", "probe", "govoplan_probe.backend.manifest:get_manifest"),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
39
tests/test_release_git_state.py
Normal file
39
tests/test_release_git_state.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release import git_state # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseGitStateTests(unittest.TestCase):
|
||||||
|
def test_git_trusts_only_the_resolved_repository_for_each_command(self) -> None:
|
||||||
|
repository = Path("/workspace/../workspace/govoplan-core")
|
||||||
|
completed = subprocess.CompletedProcess([], 0, "", "")
|
||||||
|
|
||||||
|
with patch.object(git_state.subprocess, "run", return_value=completed) as run:
|
||||||
|
result = git_state.git(repository, "status", "--porcelain")
|
||||||
|
|
||||||
|
self.assertEqual(result.returncode, 0)
|
||||||
|
command = run.call_args.args[0]
|
||||||
|
self.assertIn(f"safe.directory={repository.resolve()}", command)
|
||||||
|
self.assertNotIn("safe.directory=*", command)
|
||||||
|
self.assertEqual(run.call_args.kwargs["cwd"], repository)
|
||||||
|
self.assertEqual(
|
||||||
|
run.call_args.kwargs["env"]["GIT_CONFIG_GLOBAL"],
|
||||||
|
"/dev/null",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -150,7 +150,7 @@ def test_pytest_style_function():
|
|||||||
checks = workflow.index("bash tools/checks/check-release-integration.sh")
|
checks = workflow.index("bash tools/checks/check-release-integration.sh")
|
||||||
|
|
||||||
self.assertLess(install, checks)
|
self.assertLess(install, checks)
|
||||||
self.assertIn("pytest>=8,<9", requirements)
|
self.assertIn("pytest>=9.0.3,<10", requirements)
|
||||||
self.assertNotIn("requirements-release-tests.txt", (meta_root / "requirements-release.txt").read_text(encoding="utf-8"))
|
self.assertNotIn("requirements-release-tests.txt", (meta_root / "requirements-release.txt").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -164,9 +164,10 @@ def git_success(path: Path, *args: str, timeout: int = 10) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
|
def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProcess[str]:
|
||||||
|
command = scoped_git_command(path, *args)
|
||||||
try:
|
try:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
["/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args],
|
command,
|
||||||
cwd=path,
|
cwd=path,
|
||||||
check=False,
|
check=False,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -176,7 +177,20 @@ def git(path: Path, *args: str, timeout: int = 10) -> subprocess.CompletedProces
|
|||||||
env=sanitized_git_environment(),
|
env=sanitized_git_environment(),
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
return subprocess.CompletedProcess(["/usr/bin/git", *args], 124, exc.stdout or "", exc.stderr or "git command timed out")
|
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git command timed out")
|
||||||
|
|
||||||
|
|
||||||
|
def scoped_git_command(path: Path, *args: str) -> tuple[str, ...]:
|
||||||
|
"""Trust exactly one resolved catalog checkout for one Git invocation."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
"/usr/bin/git",
|
||||||
|
"-c",
|
||||||
|
"core.hooksPath=/dev/null",
|
||||||
|
"-c",
|
||||||
|
f"safe.directory={path.resolve()}",
|
||||||
|
*args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def sanitized_git_environment(
|
def sanitized_git_environment(
|
||||||
|
|||||||
@@ -1985,6 +1985,8 @@ def _git_bytes(
|
|||||||
"-C",
|
"-C",
|
||||||
str(path),
|
str(path),
|
||||||
"-c",
|
"-c",
|
||||||
|
f"safe.directory={path.resolve()}",
|
||||||
|
"-c",
|
||||||
"core.sharedRepository=0600",
|
"core.sharedRepository=0600",
|
||||||
"-c",
|
"-c",
|
||||||
"core.fsmonitor=false",
|
"core.fsmonitor=false",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from .git_state import collect_repository_snapshot
|
from .git_state import collect_repository_snapshot, scoped_git_command
|
||||||
from .repository_push import command_text, compact_output
|
from .repository_push import command_text, compact_output
|
||||||
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
@@ -131,7 +131,12 @@ def resolve_message(*, repo: str, version: str | None, message: str | None) -> s
|
|||||||
|
|
||||||
|
|
||||||
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
effective_command = (
|
||||||
|
scoped_git_command(cwd, *command[1:])
|
||||||
|
if command and Path(command[0]).name == "git"
|
||||||
|
else command
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return subprocess.run(command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
return subprocess.run(effective_command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git command timed out")
|
return subprocess.CompletedProcess(effective_command, 124, exc.stdout or "", exc.stderr or "git command timed out")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from .git_state import collect_repository_snapshot
|
from .git_state import collect_repository_snapshot, scoped_git_command
|
||||||
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
|
|
||||||
@@ -138,10 +138,15 @@ def command_text(command: tuple[str, ...]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
effective_command = (
|
||||||
|
scoped_git_command(cwd, *command[1:])
|
||||||
|
if command and Path(command[0]).name == "git"
|
||||||
|
else command
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return subprocess.run(command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
return subprocess.run(effective_command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git push timed out")
|
return subprocess.CompletedProcess(effective_command, 124, exc.stdout or "", exc.stderr or "git push timed out")
|
||||||
|
|
||||||
|
|
||||||
def compact_output(value: str) -> str:
|
def compact_output(value: str) -> str:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from .git_state import collect_repository_snapshot
|
from .git_state import collect_repository_snapshot, scoped_git_command
|
||||||
from .repository_push import command_text, compact_output
|
from .repository_push import command_text, compact_output
|
||||||
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
from .workspace import load_repository_specs, resolve_repo_path, resolve_workspace_root
|
||||||
|
|
||||||
@@ -93,7 +93,12 @@ def sync_repositories(
|
|||||||
|
|
||||||
|
|
||||||
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
def run(command: tuple[str, ...], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
effective_command = (
|
||||||
|
scoped_git_command(cwd, *command[1:])
|
||||||
|
if command and Path(command[0]).name == "git"
|
||||||
|
else command
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return subprocess.run(command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
return subprocess.run(effective_command, cwd=cwd, check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=120)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "git fetch timed out")
|
return subprocess.CompletedProcess(effective_command, 124, exc.stdout or "", exc.stderr or "git fetch timed out")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .git_state import (
|
|||||||
collect_repository_snapshot,
|
collect_repository_snapshot,
|
||||||
git_text,
|
git_text,
|
||||||
sanitized_git_environment,
|
sanitized_git_environment,
|
||||||
|
scoped_git_command,
|
||||||
)
|
)
|
||||||
from .model import RepositorySnapshot
|
from .model import RepositorySnapshot
|
||||||
from .repository_push import command_text, compact_output
|
from .repository_push import command_text, compact_output
|
||||||
@@ -479,12 +480,7 @@ def run(
|
|||||||
effective_command = command
|
effective_command = command
|
||||||
effective_environment = env
|
effective_environment = env
|
||||||
if command and Path(command[0]).name == "git":
|
if command and Path(command[0]).name == "git":
|
||||||
effective_command = (
|
effective_command = scoped_git_command(cwd, *command[1:])
|
||||||
"/usr/bin/git",
|
|
||||||
"-c",
|
|
||||||
"core.hooksPath=/dev/null",
|
|
||||||
*command[1:],
|
|
||||||
)
|
|
||||||
effective_environment = sanitized_git_environment(env)
|
effective_environment = sanitized_git_environment(env)
|
||||||
try:
|
try:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .git_state import (
|
|||||||
collect_repository_snapshot,
|
collect_repository_snapshot,
|
||||||
git_text,
|
git_text,
|
||||||
sanitized_git_environment,
|
sanitized_git_environment,
|
||||||
|
scoped_git_command,
|
||||||
)
|
)
|
||||||
from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit
|
from .repository_tag import RemoteTagResult, ref_commit, remote_tag_commit
|
||||||
from .version_alignment import repository_version_issues
|
from .version_alignment import repository_version_issues
|
||||||
@@ -417,9 +418,10 @@ def _git_file(path: Path, tag: str, name: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
command = scoped_git_command(path, *args)
|
||||||
try:
|
try:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
("/usr/bin/git", "-c", "core.hooksPath=/dev/null", *args),
|
command,
|
||||||
cwd=path,
|
cwd=path,
|
||||||
check=False,
|
check=False,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -429,7 +431,7 @@ def _git(path: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|||||||
env=sanitized_git_environment(),
|
env=sanitized_git_environment(),
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
return subprocess.CompletedProcess(("/usr/bin/git", *args), 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
return subprocess.CompletedProcess(command, 124, exc.stdout or "", exc.stderr or "Git command timed out")
|
||||||
|
|
||||||
|
|
||||||
def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]:
|
def _deduplicate(issues: list[SourceTagProvenanceIssue]) -> list[SourceTagProvenanceIssue]:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
META_ROOT = Path(__file__).resolve().parents[2]
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -49,6 +50,23 @@ class InstallPlan:
|
|||||||
warnings: tuple[str, ...] = ()
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -85,8 +103,9 @@ def main() -> int:
|
|||||||
fingerprint = build_fingerprint(requirements=requirements, python=python, local_requirements=local_requirements)
|
fingerprint = build_fingerprint(requirements=requirements, python=python, local_requirements=local_requirements)
|
||||||
previous = load_stamp(stamp)
|
previous = load_stamp(stamp)
|
||||||
current = previous.get("digest") == fingerprint["digest"]
|
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(
|
payload = stamp_payload(
|
||||||
requirements=requirements,
|
requirements=requirements,
|
||||||
python=python,
|
python=python,
|
||||||
@@ -107,12 +126,23 @@ def main() -> int:
|
|||||||
print(f"Python environment is current for {requirements.name}.")
|
print(f"Python environment is current for {requirements.name}.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
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"Python environment metadata is stale for {requirements.name}.")
|
||||||
print(f"Tracked inputs: {len(fingerprint['inputs'])}")
|
print(f"Tracked inputs: {len(fingerprint['inputs'])}")
|
||||||
|
for issue in environment.issues:
|
||||||
|
print(f"warning: {issue}", file=sys.stderr)
|
||||||
|
|
||||||
if args.check:
|
if args.check:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
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(
|
plan = build_install_plan(
|
||||||
previous=previous,
|
previous=previous,
|
||||||
fingerprint=fingerprint,
|
fingerprint=fingerprint,
|
||||||
@@ -141,6 +171,13 @@ def main() -> int:
|
|||||||
for command in commands:
|
for command in commands:
|
||||||
subprocess.run(command, check=True)
|
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(
|
write_stamp(
|
||||||
stamp,
|
stamp,
|
||||||
stamp_payload(
|
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:
|
def requirement_delta_plan(previous_entries: object, current_entries: tuple[RequirementEntry, ...]) -> InstallPlan | None:
|
||||||
parsed_previous = previous_requirement_entries(previous_entries)
|
parsed_previous = previous_requirement_entries(previous_entries)
|
||||||
if parsed_previous is None:
|
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)
|
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__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
Reference in New Issue
Block a user