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.
@@ -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)}