generated from coulomb/repo-seed
extension for ref resolve, explode, implode, weave, tangle
This commit is contained in:
19
src/markitect_tool/content_class/__init__.py
Normal file
19
src/markitect_tool/content_class/__init__.py
Normal 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",
|
||||
]
|
||||
225
src/markitect_tool/content_class/engine.py
Normal file
225
src/markitect_tool/content_class/engine.py
Normal 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
|
||||
Reference in New Issue
Block a user