zcbot/core/software_contracts.py

298 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""语言无关的专业软件 capability 契约加载与查询。"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path, PurePosixPath
from typing import Any
from jsonschema import Draft202012Validator, FormatChecker
CONTRACT_ROOT = Path(__file__).resolve().parents[1] / "software-contracts"
class SoftwareContractError(ValueError):
pass
@dataclass(frozen=True)
class OutputSpec:
output_id: str
filename: str
media_type: str
relative_path: str
publish: bool
required: bool
@dataclass(frozen=True)
class CapabilityContract:
capability: str
display_name: str
default_enrollment: bool
output_namespace: str
request_schema: dict[str, Any]
input_policy: dict[str, Any]
outputs: dict[str, OutputSpec]
feature_path: tuple[str, ...]
features: dict[str, str]
summary: dict[str, Any]
legacy_runtime: dict[str, Any] | None
def normalize_request(self, request: object) -> tuple[dict[str, Any], str]:
errors = sorted(
Draft202012Validator(
self.request_schema, format_checker=FormatChecker()
).iter_errors(request),
key=lambda item: list(item.absolute_path),
)
if errors:
first = errors[0]
location = ".".join(str(item) for item in first.absolute_path)
prefix = f"{location}: " if location else ""
raise SoftwareContractError(f"invalid {self.capability} request: {prefix}{first.message}")
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
max_bytes = int(self.request_schema.get("x-maxBytes") or 256 * 1024)
if len(encoded.encode("utf-8")) > max_bytes:
raise SoftwareContractError(f"{self.capability} request is too large")
normalized = json.loads(encoded)
return normalized, sha256(encoded.encode("utf-8")).hexdigest()
def input_bindings(self, request: dict[str, Any]) -> list[dict[str, Any]]:
return list(request.get("inputs") or [])
def expected_outputs(self, request: dict[str, Any]) -> dict[str, OutputSpec]:
requested = {
item.get("key")
for item in request.get("outputs") or []
if isinstance(item, dict) and isinstance(item.get("key"), str)
}
expected = {
output_id: spec
for output_id, spec in self.outputs.items()
if spec.required or output_id in requested
}
if requested - self.outputs.keys():
raise SoftwareContractError(f"{self.capability} request contains unknown outputs")
return expected
def output_spec(self, output_id: str) -> OutputSpec:
try:
return self.outputs[output_id]
except KeyError as exc:
raise SoftwareContractError("unsupported output artifact identity") from exc
def feature(self, request: dict[str, Any]) -> str:
value: Any = request
for part in self.feature_path:
if not isinstance(value, dict):
return ""
value = value.get(part)
return value if isinstance(value, str) else ""
def required_adapter_version(self, request: dict[str, Any]) -> str:
return self.features.get(self.feature(request), "0.0.0")
def summarize(self, request: dict[str, Any]) -> dict[str, Any]:
return {
"display_name": self.display_name,
"title": str(_value_at(request, self.summary.get("title_path") or []) or ""),
"formats": [
item.get("format")
for item in request.get("outputs") or []
if isinstance(item, dict)
],
}
def submission_schema(self) -> dict[str, Any]:
definitions = self.request_schema.get("$defs") or {}
properties = _resolve_local_refs(
dict(self.request_schema.get("properties") or {}), definitions
)
properties.pop("schema_version", None)
required = [
item for item in self.request_schema.get("required") or []
if item != "schema_version"
]
return {
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": False,
}
def _value_at(value: object, path: list[str]) -> object:
current = value
for part in path:
if not isinstance(current, dict):
return None
current = current.get(part)
return current
def _resolve_local_refs(value: Any, definitions: dict[str, Any]) -> Any:
if isinstance(value, list):
return [_resolve_local_refs(item, definitions) for item in value]
if not isinstance(value, dict):
return value
if set(value) == {"$ref"} and isinstance(value["$ref"], str):
prefix = "#/$defs/"
if not value["$ref"].startswith(prefix):
raise RuntimeError("software contract contains a non-local schema reference")
name = value["$ref"][len(prefix):]
if name not in definitions:
raise RuntimeError("software contract contains an unknown schema reference")
return _resolve_local_refs(definitions[name], definitions)
return {
key: _resolve_local_refs(item, definitions)
for key, item in value.items()
}
def _load_contract(path: Path) -> CapabilityContract:
raw = json.loads(path.read_text(encoding="utf-8"))
required = {
"capability", "display_name", "default_enrollment", "output_namespace", "request_schema",
"input_policy", "outputs", "feature_path", "features", "summary", "legacy_runtime",
}
if not isinstance(raw, dict) or set(raw) != required:
raise RuntimeError(f"invalid software contract fields: {path.name}")
capability = raw["capability"]
namespace = raw["output_namespace"]
if not isinstance(capability, str) or not re.fullmatch(r"[a-z][a-z0-9_.-]+@v[1-9][0-9]*", capability):
raise RuntimeError(f"invalid software capability: {path.name}")
if not isinstance(namespace, str) or not re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", namespace):
raise RuntimeError(f"invalid output namespace: {path.name}")
outputs: dict[str, OutputSpec] = {}
for output_id, item in raw["outputs"].items():
if not isinstance(item, dict) or set(item) != {
"filename", "media_type", "relative_path", "publish", "required"
}:
raise RuntimeError(f"invalid output spec: {path.name}:{output_id}")
filename = item["filename"]
relative_path = item["relative_path"]
parsed_path = PurePosixPath(relative_path) if isinstance(relative_path, str) else None
if (
not isinstance(output_id, str)
or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", output_id)
or not isinstance(filename, str)
or PurePosixPath(filename).name != filename
or parsed_path is None
or parsed_path.is_absolute()
or ".." in parsed_path.parts
or parsed_path.name != filename
or parsed_path.as_posix() != relative_path
or not isinstance(item["media_type"], str)
or not item["media_type"]
or not isinstance(item["publish"], bool)
or not isinstance(item["required"], bool)
):
raise RuntimeError(f"unsafe output spec: {path.name}:{output_id}")
outputs[output_id] = OutputSpec(output_id=output_id, **item)
if len({item.relative_path for item in outputs.values()}) != len(outputs):
raise RuntimeError(f"duplicate output paths: {path.name}")
Draft202012Validator.check_schema(raw["request_schema"])
return CapabilityContract(
capability=capability,
display_name=raw["display_name"],
default_enrollment=raw["default_enrollment"],
output_namespace=namespace,
request_schema=raw["request_schema"],
input_policy=raw["input_policy"],
outputs=outputs,
feature_path=tuple(raw["feature_path"]),
features=raw["features"],
summary=raw["summary"],
legacy_runtime=raw["legacy_runtime"],
)
_loaded_contracts = [_load_contract(path) for path in sorted(CONTRACT_ROOT.glob("*.json"))]
CONTRACTS = {contract.capability: contract for contract in _loaded_contracts}
if not CONTRACTS or len(CONTRACTS) != len(_loaded_contracts):
raise RuntimeError("software contracts are missing or contain duplicate capabilities")
SUPPORTED_CAPABILITIES = frozenset(CONTRACTS)
DEFAULT_CAPABILITIES = tuple(
item.capability for item in CONTRACTS.values() if item.default_enrollment
)
def get_contract(capability: str) -> CapabilityContract:
try:
return CONTRACTS[capability]
except KeyError as exc:
raise SoftwareContractError("unsupported capability") from exc
def version_at_least(actual: str, required: str) -> bool:
def parts(value: str) -> tuple[int, ...]:
match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?", value.strip())
return tuple(int(item or 0) for item in match.groups(default="0")) if match else (0, 0, 0)
return parts(actual) >= parts(required)
def node_supports_request(
contract: CapabilityContract,
request: dict[str, Any],
runtime: dict[str, Any],
) -> bool:
capability_runtime = (runtime.get("capability_runtime") or {}).get(contract.capability)
if isinstance(capability_runtime, dict):
if capability_runtime.get("health") != "ready":
return False
if int(capability_runtime.get("available_slots") or 0) <= 0:
return False
actual_version = str(capability_runtime.get("adapter_version") or "0.0.0")
advertised_features = capability_runtime.get("features")
feature = contract.feature(request)
if isinstance(advertised_features, list) and feature not in advertised_features:
return False
return version_at_least(actual_version, contract.required_adapter_version(request))
# 兼容尚未升级 capability_runtime 的 Node兼容路径由版本化 contract 声明。
legacy_config = contract.legacy_runtime or {}
if not legacy_config:
return False
legacy = _value_at(runtime, legacy_config.get("detail_path") or [])
assumed_version = str(legacy_config.get("assumed_adapter_version") or "0.0.0")
slots = int(_value_at(runtime, legacy_config.get("slots_path") or []) or 0)
if legacy is None:
return (
slots > 0
and version_at_least(assumed_version, contract.required_adapter_version(request))
)
return (
isinstance(legacy, dict)
and legacy.get("health") == "ready"
and slots > 0
and version_at_least(
str(legacy.get("adapter_version") or "0.0.0"),
contract.required_adapter_version(request),
)
)
def node_available_slots(capability: str, runtime: dict[str, Any]) -> int:
contract = get_contract(capability)
item = (runtime.get("capability_runtime") or {}).get(capability)
if isinstance(item, dict) and item.get("health") == "ready":
return max(0, int(item.get("available_slots") or 0))
legacy_config = contract.legacy_runtime or {}
if not legacy_config:
return 0
legacy = _value_at(runtime, legacy_config.get("detail_path") or [])
slots = max(0, int(_value_at(runtime, legacy_config.get("slots_path") or []) or 0))
if legacy is None:
return slots
if (
isinstance(legacy, dict)
and legacy.get("health") == "ready"
):
return slots
return 0