cf-bootstrap-source-local 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cf_bootstrap_source_local/__init__.py +20 -0
- cf_bootstrap_source_local/artifacts.py +191 -0
- cf_bootstrap_source_local/build.py +90 -0
- cf_bootstrap_source_local/capabilities.py +91 -0
- cf_bootstrap_source_local/inventory.py +255 -0
- cf_bootstrap_source_local/model.py +91 -0
- cf_bootstrap_source_local/native.py +163 -0
- cf_bootstrap_source_local/profiles.py +129 -0
- cf_bootstrap_source_local/resolver.py +573 -0
- cf_bootstrap_source_local/semantics/package.trig +13 -0
- cf_bootstrap_source_local-0.1.0.dist-info/METADATA +27 -0
- cf_bootstrap_source_local-0.1.0.dist-info/RECORD +14 -0
- cf_bootstrap_source_local-0.1.0.dist-info/WHEEL +4 -0
- cf_bootstrap_source_local-0.1.0.dist-info/entry_points.txt +8 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Offline local repository artifact resolution and package semantics."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
PACKAGE_DISTRIBUTION = "cf-bootstrap-source-local"
|
|
6
|
+
PYTHON_PACKAGE = "cf_bootstrap_source_local"
|
|
7
|
+
PACKAGE_VERSION = "0.1.0"
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def package_root() -> Path:
|
|
12
|
+
return Path(__file__).resolve().parent
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def semantics_dir() -> Path:
|
|
16
|
+
return package_root() / "semantics"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def semantic_files() -> tuple[Path, ...]:
|
|
20
|
+
return (semantics_dir() / "package.trig",)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Wheel validation and canonical artifact manifest handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import csv
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
|
|
14
|
+
import zipfile
|
|
15
|
+
|
|
16
|
+
from .inventory import NativeProject, Project
|
|
17
|
+
from .model import ResolverError, normalize_distribution
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _safe_zip_name(name: str) -> bool:
|
|
21
|
+
if not name or "\\" in name or name.startswith(("/", "//")) or re.match(r"^[A-Za-z]:", name):
|
|
22
|
+
return False
|
|
23
|
+
parts = name.split("/")
|
|
24
|
+
return all(part not in {"", ".", ".."} for part in parts) and str(PurePosixPath(name)) == name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_record(archive: zipfile.ZipFile, names: list[str], record_name: str) -> None:
|
|
28
|
+
rows = list(csv.reader(archive.read(record_name).decode("utf-8").splitlines()))
|
|
29
|
+
if any(len(row) != 3 for row in rows) or len({row[0] for row in rows}) != len(rows):
|
|
30
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel RECORD is malformed")
|
|
31
|
+
indexed = {row[0]: row for row in rows}
|
|
32
|
+
if set(indexed) != set(names):
|
|
33
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel RECORD does not cover wheel contents")
|
|
34
|
+
for name in names:
|
|
35
|
+
row = indexed[name]
|
|
36
|
+
if name == record_name:
|
|
37
|
+
if row[1] or row[2]:
|
|
38
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel RECORD self-entry is invalid")
|
|
39
|
+
continue
|
|
40
|
+
content = archive.read(name)
|
|
41
|
+
expected_hash = "sha256=" + base64.urlsafe_b64encode(hashlib.sha256(content).digest()).rstrip(b"=").decode("ascii")
|
|
42
|
+
if row[1] != expected_hash or row[2] != str(len(content)):
|
|
43
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel RECORD hash or size is invalid")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_wheel(path: Path, project: Project) -> dict[str, object]:
|
|
47
|
+
if path.suffix != ".whl":
|
|
48
|
+
raise ResolverError("ARTIFACT_INVALID", "build produced a non-wheel artifact")
|
|
49
|
+
expected_prefix = f"{project.normalized_name.replace('-', '_')}-{project.version.replace('-', '_')}"
|
|
50
|
+
if not path.name.startswith(expected_prefix + "-"):
|
|
51
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel filename does not match source project")
|
|
52
|
+
raw_wheel = path.read_bytes()
|
|
53
|
+
end = raw_wheel.rfind(b"PK\x05\x06")
|
|
54
|
+
if end < 0 or end + 22 > len(raw_wheel) or end + 22 + int.from_bytes(raw_wheel[end + 20:end + 22], "little") != len(raw_wheel):
|
|
55
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel contains trailing or malformed ZIP data")
|
|
56
|
+
try:
|
|
57
|
+
with zipfile.ZipFile(path) as archive:
|
|
58
|
+
names = archive.namelist()
|
|
59
|
+
if len(names) != len(set(names)) or any(not _safe_zip_name(name) for name in names):
|
|
60
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel has unsafe or duplicate entries")
|
|
61
|
+
dist_infos = [name for name in names if name.endswith(".dist-info/METADATA")]
|
|
62
|
+
if len(dist_infos) != 1:
|
|
63
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel must contain one METADATA")
|
|
64
|
+
prefix = dist_infos[0][:-len("METADATA")]
|
|
65
|
+
required = {prefix + "WHEEL", prefix + "RECORD"}
|
|
66
|
+
if not required.issubset(names) or any(name.endswith((".pth", "direct_url.json")) for name in names):
|
|
67
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel metadata is incomplete or unsafe")
|
|
68
|
+
_validate_record(archive, names, prefix + "RECORD")
|
|
69
|
+
metadata = archive.read(dist_infos[0]).decode("utf-8")
|
|
70
|
+
fields = dict(re.findall(r"^(Name|Version): (.+)$", metadata, re.MULTILINE))
|
|
71
|
+
if fields.get("Name") is None or fields.get("Version") is None:
|
|
72
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel metadata lacks name or version")
|
|
73
|
+
if project.normalized_name != normalize_distribution(fields["Name"]) or fields["Version"] != project.version:
|
|
74
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel metadata does not match source project")
|
|
75
|
+
if re.search(r"(?im)^editable(?:-project)?\s*:", metadata) or str(project.path.resolve()) in metadata:
|
|
76
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel contains editable or checkout metadata")
|
|
77
|
+
except zipfile.BadZipFile as error:
|
|
78
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel is not a valid zip archive") from error
|
|
79
|
+
digest = hashlib.sha256(raw_wheel).hexdigest()
|
|
80
|
+
return {"distribution": project.name, "normalized_distribution": project.normalized_name, "version": project.version, "filename": path.name, "sha256": digest, "size_bytes": path.stat().st_size, "source_project": project.relative}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_native_wheel(path: Path, project: NativeProject) -> dict[str, object]:
|
|
84
|
+
from .native import _platform
|
|
85
|
+
_, platform_tag, suffix = _platform()
|
|
86
|
+
wheel_name = project.normalized_name.replace("-", "_")
|
|
87
|
+
expected = f"{wheel_name}-{project.version}-py3-none-{platform_tag}.whl"
|
|
88
|
+
if path.name != expected:
|
|
89
|
+
raise ResolverError("ARTIFACT_INVALID", "native wheel filename or platform tag is invalid")
|
|
90
|
+
try:
|
|
91
|
+
raw_wheel = path.read_bytes()
|
|
92
|
+
end = raw_wheel.rfind(b"PK\x05\x06")
|
|
93
|
+
if end < 0 or end + 22 > len(raw_wheel) or end + 22 + int.from_bytes(raw_wheel[end + 20:end + 22], "little") != len(raw_wheel):
|
|
94
|
+
raise ResolverError("ARTIFACT_INVALID", "wheel contains trailing or malformed ZIP data")
|
|
95
|
+
with zipfile.ZipFile(path) as archive:
|
|
96
|
+
names = archive.namelist()
|
|
97
|
+
dist = f"{wheel_name}-{project.version}.dist-info/"
|
|
98
|
+
executable_name = project.binary + ("-native" if project.normalized_name == "cogniflow-tui" else "")
|
|
99
|
+
executable = f"{wheel_name}-{project.version}.data/scripts/{executable_name}{suffix}"
|
|
100
|
+
expected_names = {dist + "METADATA", dist + "WHEEL", dist + "RECORD", executable}
|
|
101
|
+
if project.normalized_name in {"cf-service-mcp-server", "cogniflow-tui"}:
|
|
102
|
+
expected_names.add(dist + "NATIVE-AUTHORITY.json")
|
|
103
|
+
if project.normalized_name == "cogniflow-tui":
|
|
104
|
+
expected_names.update({dist + "entry_points.txt", "cogniflow_tui.py"})
|
|
105
|
+
if any(not _safe_zip_name(name) for name in names) or len(names) != len(set(names)) or len({name.casefold() for name in names}) != len(names) or set(names) != expected_names:
|
|
106
|
+
raise ResolverError("ARTIFACT_INVALID", "native wheel contains unsafe, duplicate, or unexpected payload")
|
|
107
|
+
for info in archive.infolist():
|
|
108
|
+
mode = (info.external_attr >> 16) & 0o170000
|
|
109
|
+
if stat.S_ISLNK(mode) or stat.S_ISDIR(mode) or info.filename.endswith("/"):
|
|
110
|
+
raise ResolverError("ARTIFACT_INVALID", "native wheel contains a redirect or non-regular archive entry")
|
|
111
|
+
binary = archive.read(executable)
|
|
112
|
+
if (suffix and not binary.startswith(b"MZ")) or (not suffix and not binary.startswith(b"\x7fELF")) or str(project.crate_path.resolve()).encode() in binary:
|
|
113
|
+
raise ResolverError("ARTIFACT_INVALID", "native executable is not the expected checkout-independent binary")
|
|
114
|
+
if project.normalized_name in {"cf-service-mcp-server", "cogniflow-tui"}:
|
|
115
|
+
authority_raw = archive.read(dist + "NATIVE-AUTHORITY.json")
|
|
116
|
+
authority = json.loads(authority_raw)
|
|
117
|
+
records = authority.get("executables") if isinstance(authority, dict) and set(authority) == {"schema", "executables"} and authority.get("schema") == "stonecastle.native-authority.v1" else None
|
|
118
|
+
if not isinstance(records, list) or not records or authority_raw != json.dumps(authority, sort_keys=True, separators=(",", ":")).encode("utf-8"):
|
|
119
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "native wheel authority is invalid")
|
|
120
|
+
if any(not isinstance(item, dict) or set(item) != {"normalized_distribution", "sha256"} or not isinstance(item["normalized_distribution"], str) or not isinstance(item["sha256"], str) or len(item["sha256"]) != 64 or any(char not in "0123456789abcdef" for char in item["sha256"]) for item in records):
|
|
121
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "native wheel authority records are invalid")
|
|
122
|
+
identities = [item["normalized_distribution"] for item in records]
|
|
123
|
+
if identities != sorted(set(identities)) or (project.normalized_name == "cogniflow-tui" and identities != ["cogniflow-tui"]) or {item["normalized_distribution"]: item["sha256"] for item in records}.get(project.normalized_name) != hashlib.sha256(binary).hexdigest():
|
|
124
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "native wheel executable is not bound to its distribution")
|
|
125
|
+
metadata = archive.read(dist + "METADATA").decode("utf-8")
|
|
126
|
+
wheel = archive.read(dist + "WHEEL").decode("utf-8")
|
|
127
|
+
if f"Name: {project.distribution}\n" not in metadata or f"Version: {project.version}\n" not in metadata or "Root-Is-Purelib: false\n" not in wheel or f"Tag: py3-none-{platform_tag}\n" not in wheel:
|
|
128
|
+
raise ResolverError("ARTIFACT_INVALID", "native wheel metadata is invalid")
|
|
129
|
+
if project.normalized_name == "cogniflow-tui":
|
|
130
|
+
target = "cogniflow_tui:main"
|
|
131
|
+
expected_entry_points = (
|
|
132
|
+
f"[console_scripts]\n{project.entry_point} = {target}\n"
|
|
133
|
+
f"[{project.discovery_group}]\n{project.entry_point} = {target}\n"
|
|
134
|
+
)
|
|
135
|
+
if archive.read(dist + "entry_points.txt").decode("ascii") != expected_entry_points:
|
|
136
|
+
raise ResolverError("ARTIFACT_INVALID", "frontend entry-point metadata is invalid")
|
|
137
|
+
_validate_record(archive, names, dist + "RECORD")
|
|
138
|
+
mode = (archive.getinfo(executable).external_attr >> 16) & 0o777
|
|
139
|
+
if suffix == "" and mode != 0o755:
|
|
140
|
+
raise ResolverError("ARTIFACT_INVALID", "native executable permissions are invalid")
|
|
141
|
+
size_bytes = path.stat().st_size
|
|
142
|
+
except ResolverError:
|
|
143
|
+
raise
|
|
144
|
+
except (zipfile.BadZipFile, KeyError, UnicodeDecodeError, OSError, ValueError) as error:
|
|
145
|
+
raise ResolverError("ARTIFACT_INVALID", "native wheel archive is invalid") from error
|
|
146
|
+
return {"distribution": project.distribution, "normalized_distribution": project.normalized_name, "version": project.version, "filename": path.name, "sha256": hashlib.sha256(raw_wheel).hexdigest(), "size_bytes": size_bytes, "source_project": project.relative, "artifact_kind": "native", "wheel_tags": [f"py3-none-{platform_tag}"], "executable": executable, "discovery_group": project.discovery_group, "entry_point": project.entry_point}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def native_executable_sha256(path: Path, project: NativeProject) -> str:
|
|
150
|
+
"""Return the digest of the validated executable payload in a native wheel."""
|
|
151
|
+
artifact = validate_native_wheel(path, project)
|
|
152
|
+
try:
|
|
153
|
+
with zipfile.ZipFile(path) as archive:
|
|
154
|
+
payload = archive.read(str(artifact["executable"]))
|
|
155
|
+
except (OSError, KeyError, zipfile.BadZipFile) as error:
|
|
156
|
+
raise ResolverError("ARTIFACT_INVALID", "native executable payload is invalid") from error
|
|
157
|
+
return hashlib.sha256(payload).hexdigest()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def native_authority(path: Path, projects: tuple[NativeProject, ...]) -> dict[str, str]:
|
|
161
|
+
"""Read complete executable authority from the authenticated retained MCP wheel."""
|
|
162
|
+
mcp = [project for project in projects if project.normalized_name == "cf-service-mcp-server"]
|
|
163
|
+
if len(mcp) != 1:
|
|
164
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "accepted native authority carrier is not unique")
|
|
165
|
+
project = mcp[0]
|
|
166
|
+
validate_native_wheel(path, project)
|
|
167
|
+
wheel_name = project.normalized_name.replace("-", "_")
|
|
168
|
+
member = f"{wheel_name}-{project.version}.dist-info/NATIVE-AUTHORITY.json"
|
|
169
|
+
try:
|
|
170
|
+
with zipfile.ZipFile(path) as archive:
|
|
171
|
+
raw = archive.read(member)
|
|
172
|
+
value = json.loads(raw)
|
|
173
|
+
except (OSError, KeyError, UnicodeDecodeError, json.JSONDecodeError, zipfile.BadZipFile) as error:
|
|
174
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "accepted native executable authority is invalid") from error
|
|
175
|
+
expected = {item.normalized_name for item in projects}
|
|
176
|
+
records = value.get("executables") if isinstance(value, dict) and value.get("schema") == "stonecastle.native-authority.v1" and set(value) == {"schema", "executables"} else None
|
|
177
|
+
if not isinstance(records, list):
|
|
178
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "accepted native executable authority is invalid")
|
|
179
|
+
authority: dict[str, str] = {}
|
|
180
|
+
for record in records:
|
|
181
|
+
if not isinstance(record, dict) or set(record) != {"normalized_distribution", "sha256"} or not isinstance(record.get("normalized_distribution"), str) or not isinstance(record.get("sha256"), str) or len(record["sha256"]) != 64 or any(char not in "0123456789abcdef" for char in record["sha256"]):
|
|
182
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "accepted native executable authority record is invalid")
|
|
183
|
+
name = record["normalized_distribution"]
|
|
184
|
+
if name in authority:
|
|
185
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "accepted native executable authority is ambiguous")
|
|
186
|
+
authority[name] = record["sha256"]
|
|
187
|
+
if set(authority) != expected or raw != json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8"):
|
|
188
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "accepted native executable authority is incomplete")
|
|
189
|
+
if authority[project.normalized_name] != native_executable_sha256(path, project):
|
|
190
|
+
raise ResolverError("NATIVE_AUTHORITY_INVALID", "accepted native authority carrier does not authenticate itself")
|
|
191
|
+
return authority
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Offline wheel building from isolated source copies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
from .artifacts import validate_wheel
|
|
16
|
+
from .inventory import NativeProject, Project
|
|
17
|
+
from .native import build_natives
|
|
18
|
+
from .model import ResolverError, normalize_distribution
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_DIAGNOSTIC_LIMIT = 8192
|
|
22
|
+
_TRUNCATED = " ...[truncated]"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _safe_diagnostic(value: object) -> str:
|
|
26
|
+
"""Render captured pip output without leaking common credential forms."""
|
|
27
|
+
if isinstance(value, bytes):
|
|
28
|
+
text = value.decode("utf-8", errors="replace")
|
|
29
|
+
elif isinstance(value, str):
|
|
30
|
+
text = value.encode("utf-8", errors="replace").decode("utf-8")
|
|
31
|
+
else:
|
|
32
|
+
text = "" if value is None else "<non-text output>"
|
|
33
|
+
text = re.sub(r"(?i)\b(https?://)[^\s/?#@]+@", r"\1<redacted>@", text)
|
|
34
|
+
text = re.sub(r"(?i)([?&](?:token|access_token|api_key|password|secret|key)=)[^&#\s]+", r"\1<redacted>", text)
|
|
35
|
+
text = re.sub(r"(?i)(\bAuthorization:\s*(?:Bearer|Basic)\s+)[^\s]+", r"\1<redacted>", text)
|
|
36
|
+
text = re.sub(r"(?i)\b((?:token|password|api[_-]?key|secret)\s*[:=]\s*)[^\s]+", r"\1<redacted>", text)
|
|
37
|
+
text = "".join(character if character.isprintable() else " " for character in text)
|
|
38
|
+
if len(text) > _DIAGNOSTIC_LIMIT:
|
|
39
|
+
text = text[:_DIAGNOSTIC_LIMIT - len(_TRUNCATED)] + _TRUNCATED
|
|
40
|
+
return text
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _failure_detail(project: Project, result: subprocess.CompletedProcess[str]) -> str:
|
|
44
|
+
try:
|
|
45
|
+
pip_version = importlib.metadata.version("pip")
|
|
46
|
+
except importlib.metadata.PackageNotFoundError:
|
|
47
|
+
pip_version = "unavailable"
|
|
48
|
+
return (
|
|
49
|
+
f"project={_safe_diagnostic(project.normalized_name)} ({_safe_diagnostic(project.relative)}); "
|
|
50
|
+
f"python={_safe_diagnostic(sys.executable)}; python_version={platform.python_version()}; "
|
|
51
|
+
f"pip_version={pip_version}; exit_code={result.returncode}; "
|
|
52
|
+
f"stdout={_safe_diagnostic(result.stdout)}; stderr={_safe_diagnostic(result.stderr)}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build(projects: tuple[Project, ...], staging: Path, natives: tuple[NativeProject, ...] = ()) -> list[dict[str, object]]:
|
|
57
|
+
wheel_dir = staging / "wheels"
|
|
58
|
+
wheel_dir.mkdir(parents=True)
|
|
59
|
+
artifacts: list[dict[str, object]] = []
|
|
60
|
+
for project in projects:
|
|
61
|
+
source = staging / "sources" / project.normalized_name
|
|
62
|
+
shutil.copytree(project.path, source, symlinks=True, ignore=shutil.ignore_patterns(".venv", ".git", "__pycache__", ".pytest_cache", "build", "dist", "*.egg-info", "*.whl", "*.tar.gz"))
|
|
63
|
+
for candidate in source.rglob("*"):
|
|
64
|
+
if candidate.is_symlink():
|
|
65
|
+
try:
|
|
66
|
+
candidate.resolve().relative_to(source.resolve())
|
|
67
|
+
except ValueError as error:
|
|
68
|
+
raise ResolverError("SOURCE_PROJECT_INVALID", "project symlink escapes source tree") from error
|
|
69
|
+
env = os.environ.copy()
|
|
70
|
+
env.update({"PIP_NO_INDEX": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1", "PIP_CONFIG_FILE": os.devnull, "PYTHONDONTWRITEBYTECODE": "1", "SOURCE_DATE_EPOCH": "315532800", "HOME": str(staging / "home"), "XDG_CACHE_HOME": str(staging / "cache")})
|
|
71
|
+
env.pop("PYTHONPATH", None)
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run([sys.executable, "-m", "pip", "wheel", "--no-deps", "--no-build-isolation", "--no-index", "--disable-pip-version-check", "--wheel-dir", str(wheel_dir), str(source)], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=120, check=False)
|
|
74
|
+
except FileNotFoundError as error:
|
|
75
|
+
raise ResolverError("BUILD_TOOL_UNAVAILABLE", "pip is unavailable in the active interpreter") from error
|
|
76
|
+
except subprocess.TimeoutExpired as error:
|
|
77
|
+
raise ResolverError("BUILD_FAILED", "wheel build timed out") from error
|
|
78
|
+
if result.returncode != 0:
|
|
79
|
+
stderr = result.stderr.decode("utf-8", errors="replace") if isinstance(result.stderr, bytes) else result.stderr or ""
|
|
80
|
+
detail = _failure_detail(project, result)
|
|
81
|
+
if "No module named pip" in stderr or "build backend" in stderr and "not found" in stderr:
|
|
82
|
+
raise ResolverError("BUILD_TOOL_UNAVAILABLE", f"required build tool is unavailable: {detail}")
|
|
83
|
+
raise ResolverError("BUILD_FAILED", f"offline wheel build failed: {detail}")
|
|
84
|
+
wheels = sorted(wheel_dir.glob("*.whl"))
|
|
85
|
+
matching = [wheel for wheel in wheels if project.normalized_name == normalize_distribution(wheel.name.split("-", 1)[0])]
|
|
86
|
+
if len(matching) != 1:
|
|
87
|
+
raise ResolverError("ARTIFACT_INVALID", "build did not produce exactly one wheel")
|
|
88
|
+
artifacts.append(validate_wheel(matching[0], project))
|
|
89
|
+
artifacts.extend(build_natives(natives, staging))
|
|
90
|
+
return artifacts
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Source-only capability provider discovery from indexed TriG semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rdflib import Dataset, Literal, RDF, URIRef
|
|
9
|
+
|
|
10
|
+
from .inventory import Project
|
|
11
|
+
from .model import ResolverError
|
|
12
|
+
|
|
13
|
+
CF = URIRef("https://cogniflow.odea-project.org/cf#")
|
|
14
|
+
SERVICE_OPERATION = CF + "ServiceOperation"
|
|
15
|
+
OPERATION_CAPABILITY = CF + "hasOperationCapability"
|
|
16
|
+
EXECUTION_AFFORDANCE = CF + "hasExecutionAffordance"
|
|
17
|
+
VALUE = RDF.value
|
|
18
|
+
EXECUTOR_PROTOCOL = CF + "hasExecutorProtocol"
|
|
19
|
+
EXECUTOR_MODULE = CF + "hasExecutorModule"
|
|
20
|
+
EXECUTOR_FUNCTION = CF + "hasExecutorFunction"
|
|
21
|
+
PYTHON_EXECUTOR = CF + "PythonFunctionExecutorV1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _single(dataset: Dataset, subject: object, predicate: URIRef, label: str, graph: object) -> object:
|
|
25
|
+
values = {obj for _, _, obj, _ in dataset.quads((subject, predicate, None, graph))}
|
|
26
|
+
if not values:
|
|
27
|
+
values = {obj for _, _, obj, _ in dataset.quads((subject, predicate, None, None))}
|
|
28
|
+
if len(values) != 1:
|
|
29
|
+
raise ResolverError("AFFORDANCE_INVALID", f"{label} must have exactly one value for {subject}")
|
|
30
|
+
return next(iter(values))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _iri(value: object, label: str) -> str:
|
|
34
|
+
if not isinstance(value, URIRef):
|
|
35
|
+
raise ResolverError("CAPABILITY_INVALID", f"{label} must be an IRI")
|
|
36
|
+
return str(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def discover(checkout: Path, projects: tuple[Project, ...], requested: tuple[str, ...] = ()) -> dict[str, dict[str, str]]:
|
|
40
|
+
by_relative = {project.relative: project for project in projects}
|
|
41
|
+
try:
|
|
42
|
+
index = json.loads((checkout / "stonecastle/stonecastle.index.json").read_text(encoding="utf-8"))
|
|
43
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
44
|
+
raise ResolverError("SOURCE_INDEX_INVALID", "semantic index is unreadable") from error
|
|
45
|
+
providers: dict[str, dict[str, str]] = {}
|
|
46
|
+
for item in index.get("files", []):
|
|
47
|
+
if item.get("kind") != "service-semantics" or not isinstance(item.get("path"), str):
|
|
48
|
+
continue
|
|
49
|
+
path = checkout / item["path"]
|
|
50
|
+
relative_project = "/".join(item["path"].split("/")[:2])
|
|
51
|
+
project = by_relative.get(relative_project)
|
|
52
|
+
if project is None:
|
|
53
|
+
continue
|
|
54
|
+
if not path.is_file() or path.is_symlink():
|
|
55
|
+
raise ResolverError("CAPABILITY_INVALID", "service semantic file is not a regular indexed file")
|
|
56
|
+
dataset = Dataset()
|
|
57
|
+
try:
|
|
58
|
+
dataset.parse(path, format="trig")
|
|
59
|
+
except Exception as error:
|
|
60
|
+
raise ResolverError("CAPABILITY_INVALID", f"service semantic TriG is invalid: {item['path']}") from error
|
|
61
|
+
for operation, _, _, graph in dataset.quads((None, RDF.type, SERVICE_OPERATION, None)):
|
|
62
|
+
if not isinstance(operation, URIRef):
|
|
63
|
+
raise ResolverError("CAPABILITY_INVALID", "service operation must be an IRI")
|
|
64
|
+
capability_node = _single(dataset, operation, OPERATION_CAPABILITY, "operation capability", graph)
|
|
65
|
+
try:
|
|
66
|
+
capability = _iri(_single(dataset, capability_node, VALUE, "capability value", graph), "capability")
|
|
67
|
+
except ResolverError:
|
|
68
|
+
if isinstance(capability_node, URIRef) and str(capability_node) in requested:
|
|
69
|
+
raise
|
|
70
|
+
continue
|
|
71
|
+
affordance = _single(dataset, operation, EXECUTION_AFFORDANCE, "execution affordance", graph)
|
|
72
|
+
protocol_node = _single(dataset, affordance, EXECUTOR_PROTOCOL, "executor protocol", graph)
|
|
73
|
+
protocol = _iri(_single(dataset, protocol_node, VALUE, "executor protocol value", graph), "executor protocol")
|
|
74
|
+
if protocol != str(PYTHON_EXECUTOR):
|
|
75
|
+
raise ResolverError("AFFORDANCE_INVALID", "unsupported executor protocol")
|
|
76
|
+
module_node = _single(dataset, affordance, EXECUTOR_MODULE, "executor module", graph)
|
|
77
|
+
function_node = _single(dataset, affordance, EXECUTOR_FUNCTION, "executor function", graph)
|
|
78
|
+
module = _single(dataset, module_node, VALUE, "executor module value", graph)
|
|
79
|
+
function = _single(dataset, function_node, VALUE, "executor function value", graph)
|
|
80
|
+
if not isinstance(module, Literal) or not isinstance(function, Literal) or not module.value or not function.value:
|
|
81
|
+
raise ResolverError("AFFORDANCE_INVALID", "executor module and function must be literals")
|
|
82
|
+
package_roots = [child.name for child in (project.path / "src").iterdir() if child.is_dir() and (child / "__init__.py").is_file()]
|
|
83
|
+
if not any(str(module).startswith(root + ".") for root in package_roots):
|
|
84
|
+
raise ResolverError("AFFORDANCE_INVALID", "executor module is outside owning distribution")
|
|
85
|
+
if capability in providers:
|
|
86
|
+
raise ResolverError("CAPABILITY_AMBIGUOUS", "capability has multiple providers")
|
|
87
|
+
providers[capability] = {"distribution": project.name, "source_project": project.relative, "operation": str(operation), "protocol": protocol, "module": str(module), "function": str(function)}
|
|
88
|
+
for capability in requested:
|
|
89
|
+
if capability not in providers:
|
|
90
|
+
raise ResolverError("CAPABILITY_NOT_FOUND", "requested capability has no provider")
|
|
91
|
+
return {key: providers[key] for key in sorted(providers)}
|