extension for ref resolve, explode, implode, weave, tangle

This commit is contained in:
2026-05-04 02:25:49 +02:00
parent 8203f50fd5
commit 65bfc1aebf
39 changed files with 3959 additions and 25 deletions

View File

@@ -0,0 +1,19 @@
"""Deterministic content class composition."""
from markitect_tool.content_class.engine import (
ClassCompositionResult,
ContentClass,
ContentClassRegistry,
ContentClassResolutionError,
load_content_class_file,
load_content_classes,
)
__all__ = [
"ClassCompositionResult",
"ContentClass",
"ContentClassRegistry",
"ContentClassResolutionError",
"load_content_class_file",
"load_content_classes",
]

View File

@@ -0,0 +1,225 @@
"""Small deterministic content class resolver."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
import yaml
from markitect_tool.diagnostics import Diagnostic
class ContentClassResolutionError(ValueError):
"""Raised when content class definitions cannot be loaded."""
@dataclass(frozen=True)
class ContentClass:
"""A data-defined content class."""
name: str
extends: list[str] = field(default_factory=list)
slots: dict[str, Any] = field(default_factory=dict)
merge_policies: dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return {key: value for key, value in asdict(self).items() if value not in ({}, [], None)}
@dataclass(frozen=True)
class ClassCompositionResult:
"""Resolved content class slots plus diagnostics."""
class_name: str
linearization: list[str]
slots: dict[str, Any]
diagnostics: list[Diagnostic] = field(default_factory=list)
@property
def valid(self) -> bool:
return not any(diagnostic.severity == "error" for diagnostic in self.diagnostics)
def to_dict(self) -> dict[str, Any]:
return {
"valid": self.valid,
"class_name": self.class_name,
"linearization": self.linearization,
"slots": self.slots,
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
}
class ContentClassRegistry:
"""Registry and resolver for content classes."""
def __init__(self, classes: dict[str, ContentClass] | None = None) -> None:
self.classes = classes or {}
def add(self, content_class: ContentClass) -> None:
self.classes[content_class.name] = content_class
def linearize(self, class_name: str) -> list[str]:
if class_name not in self.classes:
raise ContentClassResolutionError(f"Unknown content class `{class_name}`")
return self._linearize(class_name, [])
def compose(self, class_name: str) -> ClassCompositionResult:
diagnostics: list[Diagnostic] = []
try:
linearization = self.linearize(class_name)
except ContentClassResolutionError as exc:
return ClassCompositionResult(
class_name=class_name,
linearization=[],
slots={},
diagnostics=[
Diagnostic(
severity="error",
code="content_class.resolution_error",
message=str(exc),
)
],
)
slots: dict[str, Any] = {}
for name in reversed(linearization):
content_class = self.classes[name]
for slot, value in content_class.slots.items():
policy = content_class.merge_policies.get(slot, "replace")
try:
slots[slot] = _merge_slot(slots.get(slot), value, policy)
except ContentClassResolutionError as exc:
diagnostics.append(
Diagnostic(
severity="error",
code="content_class.merge_conflict",
message=str(exc),
details={"class": name, "slot": slot, "policy": policy},
)
)
return ClassCompositionResult(
class_name=class_name,
linearization=linearization,
slots=slots,
diagnostics=diagnostics,
)
def _linearize(self, class_name: str, stack: list[str]) -> list[str]:
if class_name in stack:
raise ContentClassResolutionError(
"Cyclic content class inheritance: " + " -> ".join(stack + [class_name])
)
content_class = self.classes[class_name]
parent_mros = [
self._linearize(parent, stack + [class_name])
for parent in content_class.extends
if _known_parent(parent, self.classes)
]
missing = [parent for parent in content_class.extends if parent not in self.classes]
if missing:
raise ContentClassResolutionError(
f"Content class `{class_name}` extends unknown class(es): {', '.join(missing)}"
)
return [class_name] + _c3_merge(parent_mros + [list(content_class.extends)])
def load_content_class_file(path: str | Path) -> ContentClassRegistry:
"""Load content class definitions from YAML."""
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ContentClassResolutionError("Content class file must be a mapping")
return load_content_classes(data)
def load_content_classes(data: dict[str, Any]) -> ContentClassRegistry:
"""Load content class definitions from a mapping."""
raw_classes = data.get("classes", data)
if not isinstance(raw_classes, dict):
raise ContentClassResolutionError("Content classes must be a mapping")
classes: dict[str, ContentClass] = {}
for name, raw_class in raw_classes.items():
if not isinstance(raw_class, dict):
raise ContentClassResolutionError(f"Content class `{name}` must be a mapping")
extends = raw_class.get("extends", [])
if isinstance(extends, str):
extends = [extends]
if not isinstance(extends, list):
raise ContentClassResolutionError(f"Content class `{name}` extends must be a list")
slots = raw_class.get("slots", {})
policies = raw_class.get("merge_policies", {})
if not isinstance(slots, dict) or not isinstance(policies, dict):
raise ContentClassResolutionError(
f"Content class `{name}` slots and merge_policies must be mappings"
)
classes[str(name)] = ContentClass(
name=str(name),
extends=[str(parent) for parent in extends],
slots=slots,
merge_policies={str(key): str(value) for key, value in policies.items()},
)
return ContentClassRegistry(classes)
def _c3_merge(sequences: list[list[str]]) -> list[str]:
result: list[str] = []
sequences = [list(sequence) for sequence in sequences if sequence]
while sequences:
candidate = None
for sequence in sequences:
head = sequence[0]
if not any(head in other[1:] for other in sequences):
candidate = head
break
if candidate is None:
raise ContentClassResolutionError("Inconsistent content class precedence order")
result.append(candidate)
sequences = [
[item for item in sequence if item != candidate]
for sequence in sequences
]
sequences = [sequence for sequence in sequences if sequence]
return result
def _merge_slot(existing: Any, value: Any, policy: str) -> Any:
incoming = deepcopy(value)
if existing is None:
return incoming
if policy == "replace":
return incoming
if policy == "append":
return _as_list(existing) + _as_list(incoming)
if policy == "prepend":
return _as_list(incoming) + _as_list(existing)
if policy == "deep_merge":
if not isinstance(existing, dict) or not isinstance(incoming, dict):
raise ContentClassResolutionError("deep_merge requires mapping values")
return _deep_merge(existing, incoming)
if policy == "error_on_conflict":
if existing != incoming:
raise ContentClassResolutionError("slot conflict")
return existing
raise ContentClassResolutionError(f"Unknown merge policy `{policy}`")
def _deep_merge(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
merged = deepcopy(left)
for key, value in right.items():
if isinstance(merged.get(key), dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = deepcopy(value)
return merged
def _as_list(value: Any) -> list[Any]:
return value if isinstance(value, list) else [value]
def _known_parent(parent: str, classes: dict[str, ContentClass]) -> bool:
return parent in classes