重构 改为标准化输出
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
"""Versioned runtime operation contracts and dynamic fragment schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from jsonschema.exceptions import SchemaError
|
||||
|
||||
|
||||
class OperationContractError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
SEMANTIC_PREFLIGHT_NAMES = frozenset({
|
||||
"sketch_workplane",
|
||||
"profile_non_self_intersecting",
|
||||
"host_face_exists",
|
||||
"hole_positions_on_host_plane",
|
||||
"cut_exit_distance",
|
||||
"requires_active_solid",
|
||||
"revolve_axis_on_sketch",
|
||||
"reference_plane_nonzero_normal",
|
||||
"reference_axis_nonzero_direction",
|
||||
"selected_edges_exist",
|
||||
"source_features_exist",
|
||||
"mirror_plane_exists",
|
||||
})
|
||||
|
||||
|
||||
def canonical_hash(value: dict[str, Any]) -> str:
|
||||
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _is_closed(schema: Any) -> bool:
|
||||
if not isinstance(schema, dict):
|
||||
return False
|
||||
if schema.get("type") == "object" and schema.get("additionalProperties") is not False:
|
||||
return False
|
||||
for name in ("properties", "$defs", "definitions"):
|
||||
values = schema.get(name)
|
||||
if isinstance(values, dict) and not all(_is_closed(value) for value in values.values()):
|
||||
return False
|
||||
for name in ("items", "additionalItems"):
|
||||
value = schema.get(name)
|
||||
if isinstance(value, dict) and not _is_closed(value):
|
||||
return False
|
||||
for name in ("oneOf", "anyOf", "allOf"):
|
||||
values = schema.get(name)
|
||||
if values is not None and (not isinstance(values, list) or not all(_is_closed(value) for value in values)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_operation_contract(contract: dict[str, Any]) -> None:
|
||||
required = {
|
||||
"atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy",
|
||||
"server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers",
|
||||
}
|
||||
if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}:
|
||||
raise OperationContractError("Operation contract has unknown or missing fields")
|
||||
if not isinstance(contract["atomic_id"], str) or not contract["atomic_id"]:
|
||||
raise OperationContractError("Operation contract atomic_id is invalid")
|
||||
if not isinstance(contract["contract_version"], str) or not contract["contract_version"]:
|
||||
raise OperationContractError("Operation contract version is missing")
|
||||
shape = contract["fragment_shape"]
|
||||
if not isinstance(shape, dict) or set(shape) != {"sketch", "params", "selector_tokens"}:
|
||||
raise OperationContractError("Operation contract fragment_shape is invalid")
|
||||
if shape["sketch"] not in {"required", "forbidden"} or shape["params"] != "required_object" or shape["selector_tokens"] not in {"required", "forbidden"}:
|
||||
raise OperationContractError("Operation contract fragment_shape is unsupported")
|
||||
params = contract["author_params_schema"]
|
||||
if not _is_closed(params):
|
||||
raise OperationContractError("Operation author_params_schema must recursively close all objects")
|
||||
try:
|
||||
Draft202012Validator.check_schema(params)
|
||||
except SchemaError as error:
|
||||
raise OperationContractError("Operation author_params_schema is invalid JSON Schema") from error
|
||||
selector = contract["selector_policy"]
|
||||
if not isinstance(selector, dict) or set(selector) != {"slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
|
||||
raise OperationContractError("Operation selector_policy is invalid")
|
||||
if shape["selector_tokens"] == "forbidden":
|
||||
if any(selector[key] not in {None, 0, False, ""} for key in selector):
|
||||
raise OperationContractError("Selector policy must be empty when selectors are forbidden")
|
||||
else:
|
||||
if not isinstance(selector["slot"], str) or not selector["slot"] or selector["token_kind"] not in {"face", "edge", "plane", "axis", "body"}:
|
||||
raise OperationContractError("Selector policy has no concrete slot/token kind")
|
||||
if not isinstance(selector["min_items"], int) or not isinstance(selector["max_items"], int) or not 1 <= selector["min_items"] <= selector["max_items"] <= 64 or selector["snapshot_bound"] is not True:
|
||||
raise OperationContractError("Selector policy bounds are invalid")
|
||||
if not isinstance(contract["server_injected_paths"], list) or not all(isinstance(item, str) and item for item in contract["server_injected_paths"]):
|
||||
raise OperationContractError("Operation server injection paths are invalid")
|
||||
injected = contract["server_injected_paths"]
|
||||
expected_injected = [selector["slot"]] if shape["selector_tokens"] == "required" else []
|
||||
if injected != expected_injected:
|
||||
raise OperationContractError("Operation selector policy and server injection paths disagree")
|
||||
if shape["selector_tokens"] == "required" and selector["slot"].startswith("params."):
|
||||
injected_param = selector["slot"].removeprefix("params.")
|
||||
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
|
||||
if injected_param in properties:
|
||||
raise OperationContractError("Server-injected selector must not be author-owned")
|
||||
reference = contract["reference_policy"]
|
||||
if not isinstance(reference, dict) or reference.get("mode") not in {"none", "snapshot_bound"}:
|
||||
raise OperationContractError("Operation reference policy is invalid")
|
||||
if reference["mode"] == "none" and set(reference) != {"mode"}:
|
||||
raise OperationContractError("Reference policy must be empty when references are forbidden")
|
||||
if reference["mode"] == "snapshot_bound":
|
||||
if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
|
||||
raise OperationContractError("Snapshot-bound reference policy is invalid")
|
||||
if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True:
|
||||
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
|
||||
reference_name = reference["slot"].removeprefix("params.")
|
||||
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
|
||||
reference_schema = properties.get(reference_name)
|
||||
required_params = params.get("required") if isinstance(params.get("required"), list) else []
|
||||
if (
|
||||
not isinstance(reference_schema, dict)
|
||||
or reference_schema.get("type") != "array"
|
||||
or not isinstance(reference_schema.get("items"), dict)
|
||||
or reference_name not in required_params
|
||||
):
|
||||
raise OperationContractError("Snapshot-bound reference slot must be a required author array")
|
||||
if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]):
|
||||
raise OperationContractError("Operation semantic preflight is invalid")
|
||||
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES:
|
||||
raise OperationContractError("Operation semantic preflight names are unknown or duplicated")
|
||||
if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]):
|
||||
raise OperationContractError("Operation candidate_verifiers are invalid")
|
||||
if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]):
|
||||
raise OperationContractError("Operation candidate verifiers are duplicated")
|
||||
|
||||
|
||||
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> dict[str, Any]:
|
||||
"""Build the one-operation schema exposed for one pending action."""
|
||||
validate_operation_contract(contract)
|
||||
shape = contract["fragment_shape"]
|
||||
params = deepcopy(contract["author_params_schema"])
|
||||
reference = contract["reference_policy"]
|
||||
if reference["mode"] == "snapshot_bound":
|
||||
slot = str(reference["slot"]).removeprefix("params.")
|
||||
items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None
|
||||
if not isinstance(items, dict):
|
||||
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
|
||||
items.clear()
|
||||
items.update({"enum": reference_tokens or []})
|
||||
feature_properties: dict[str, Any] = {
|
||||
"atomic_id": {"const": contract["atomic_id"]},
|
||||
"params": params,
|
||||
}
|
||||
feature_required = ["atomic_id", "params"]
|
||||
if shape["selector_tokens"] == "required":
|
||||
policy = contract["selector_policy"]
|
||||
feature_properties["selector_tokens"] = {
|
||||
"type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"],
|
||||
"maxItems": policy["max_items"], "uniqueItems": True,
|
||||
"description": (
|
||||
"Required author input. Copy the opaque selector token returned by the current topology "
|
||||
"snapshot here; do not omit it and do not put a host face in params. The server resolves this "
|
||||
"token into the host selector after schema validation."
|
||||
),
|
||||
}
|
||||
feature_required.append("selector_tokens")
|
||||
feature = {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"One atomic feature. selector_tokens, when present, is an author-supplied topology token array "
|
||||
"rather than a server-filled params field."
|
||||
),
|
||||
"properties": feature_properties,
|
||||
"required": feature_required,
|
||||
"additionalProperties": False,
|
||||
}
|
||||
properties: dict[str, Any] = {"feature": feature}
|
||||
required = ["feature"]
|
||||
if shape["sketch"] == "required":
|
||||
properties["sketch"] = _sketch_schema()
|
||||
required.insert(0, "sketch")
|
||||
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False}
|
||||
Draft202012Validator.check_schema(schema)
|
||||
return schema
|
||||
|
||||
|
||||
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None) -> list[dict[str, str]]:
|
||||
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens)
|
||||
return [
|
||||
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
|
||||
for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message))
|
||||
]
|
||||
|
||||
|
||||
def _point(size: int) -> dict[str, Any]:
|
||||
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
|
||||
|
||||
|
||||
def _sketch_schema() -> dict[str, Any]:
|
||||
point2 = _point(2)
|
||||
point3 = _point(3)
|
||||
workplane = {"type": "object", "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, "required": ["origin_mm", "x_dir", "normal"], "additionalProperties": False}
|
||||
profile = {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False},
|
||||
{"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False},
|
||||
]
|
||||
}
|
||||
return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False}
|
||||
Reference in New Issue
Block a user