Skip to content

Canonical specification

The canonical specification is the stable JSON boundary for Hakowan figures. See the schema guide for format semantics, resolver rules, versioning, and complete examples.

Public functions

Convert a runtime Layer or Figure into a canonical specification.

Source code in hakowan/spec/codec.py
def to_spec(
    value: Layer | Figure,
    *,
    data_ids: DataIds | None = None,
    function_ids: FunctionIds | None = None,
) -> sm.FigureSpec:
    """Convert a runtime Layer or Figure into a canonical specification."""
    _ensure_runtime_nesting(value)
    if isinstance(value, Figure):
        return sm.FigureSpec(
            version="1.1",
            root=_node_to_spec(value.layer, data_ids, function_ids, "root"),
            scene=_scene_to_spec(value.scene),
        )
    if not isinstance(value, Layer):
        raise TypeError(f"Expected Layer or Figure, got {type(value)!r}")
    return sm.FigureSpec(
        version="1.0", root=_node_to_spec(value, data_ids, function_ids, "root")
    )

Build a runtime Layer or Figure from a canonical specification.

Source code in hakowan/spec/codec.py
def from_spec(
    spec: sm.FigureSpec | Mapping[str, Any],
    *,
    data_resolver: DataResolver | None = None,
    function_resolver: FunctionResolver | None = None,
    base_dir: str | Path | None = None,
) -> Layer | Figure:
    """Build a runtime Layer or Figure from a canonical specification."""
    sm._ensure_spec_nesting(spec)
    parsed = (
        spec if isinstance(spec, sm.FigureSpec) else sm.FigureSpec.model_validate(spec)
    )
    directory = Path(base_dir) if base_dir is not None else None
    layer = _node_from_spec(
        parsed.root, data_resolver, function_resolver, directory, "root"
    )
    if parsed.scene is None:
        return layer
    return Figure(layer=layer, scene=_scene_from_spec(parsed.scene, directory))

Parse canonical JSON and build a runtime layer tree.

Source code in hakowan/spec/codec.py
def from_json(
    text: str | bytes,
    *,
    data_resolver: DataResolver | None = None,
    function_resolver: FunctionResolver | None = None,
    base_dir: str | Path | None = None,
) -> Layer | Figure:
    """Parse canonical JSON and build a runtime layer tree."""
    return from_spec(
        sm.FigureSpec.from_json(text),
        data_resolver=data_resolver,
        function_resolver=function_resolver,
        base_dir=base_dir,
    )

Load and validate a canonical specification document.

Source code in hakowan/spec/codec.py
def load_spec(path: str | Path) -> sm.FigureSpec:
    """Load and validate a canonical specification document."""
    return sm.FigureSpec.load(path)

Load a specification and resolve relative resources beside its file.

Source code in hakowan/spec/codec.py
def load_layer(
    path: str | Path,
    *,
    data_resolver: DataResolver | None = None,
    function_resolver: FunctionResolver | None = None,
) -> Layer | Figure:
    """Load a specification and resolve relative resources beside its file."""
    filename = Path(path)
    return from_spec(
        load_spec(filename),
        data_resolver=data_resolver,
        function_resolver=function_resolver,
        base_dir=filename.parent,
    )

Return the documented canonical Hakowan JSON Schema.

Source code in hakowan/spec/model.py
def json_schema() -> dict[str, Any]:
    """Return the documented canonical Hakowan JSON Schema."""
    from .schema_docs import enrich_schema

    result = FigureSpec.model_json_schema(by_alias=True)
    result["$id"] = SCHEMA_URL
    return enrich_schema(result)

Atomically patch a runtime Layer or Figure through its canonical form.

In-memory meshes and callable references are rebound automatically. Explicit identifier and resolver hooks support references introduced by a patch. Schema validation always runs; semantic validation runs by default.

Parameters:

Name Type Description Default
value Layer | Figure

Runtime Layer or Figure to patch without mutation.

required
operations Iterable[PatchOperation | Mapping[str, Any]]

Ordered add, remove, or replace operations.

required
data_ids DataIds | None

Optional stable IDs for existing in-memory meshes.

None
function_ids FunctionIds | None

Optional stable IDs for existing callables.

None
data_resolver DataResolver | None

Resolver for new external data IDs.

None
function_resolver FunctionResolver | None

Resolver for new external function IDs.

None
base_dir str | Path | None

Base directory for relative resource paths.

None
backend BackendName | None

Backend used by semantic validation.

None
strict bool

Promote backend degradations to semantic errors.

False
semantic bool

Run semantic and compile validation when true.

True

Returns:

Type Description
Layer | Figure

A reconstructed Layer or Figure containing all patch operations.

Raises:

Type Description
PatchError

If conversion, an operation, schema validation, or semantic validation fails.

Source code in hakowan/spec/patch.py
def patch(
    value: Layer | Figure,
    operations: Iterable[PatchOperation | Mapping[str, Any]],
    *,
    data_ids: DataIds | None = None,
    function_ids: FunctionIds | None = None,
    data_resolver: DataResolver | None = None,
    function_resolver: FunctionResolver | None = None,
    base_dir: str | Path | None = None,
    backend: BackendName | None = None,
    strict: bool = False,
    semantic: bool = True,
) -> Layer | Figure:
    """Atomically patch a runtime Layer or Figure through its canonical form.

    In-memory meshes and callable references are rebound automatically.
    Explicit identifier and resolver hooks support references introduced by a
    patch. Schema validation always runs; semantic validation runs by default.

    Args:
        value: Runtime Layer or Figure to patch without mutation.
        operations: Ordered add, remove, or replace operations.
        data_ids: Optional stable IDs for existing in-memory meshes.
        function_ids: Optional stable IDs for existing callables.
        data_resolver: Resolver for new external data IDs.
        function_resolver: Resolver for new external function IDs.
        base_dir: Base directory for relative resource paths.
        backend: Backend used by semantic validation.
        strict: Promote backend degradations to semantic errors.
        semantic: Run semantic and compile validation when true.

    Returns:
        A reconstructed Layer or Figure containing all patch operations.

    Raises:
        PatchError: If conversion, an operation, schema validation, or semantic
            validation fails.

    """
    if not isinstance(value, (Layer, Figure)):
        raise TypeError(f"Expected Layer or Figure, got {type(value)!r}")
    try:
        spec, runtime_data, runtime_functions = _runtime_spec_and_resolvers(
            value, data_ids, function_ids, data_resolver, function_resolver
        )
        patched_spec = patch_spec(spec, operations)
        result = from_spec(
            patched_spec,
            data_resolver=runtime_data,
            function_resolver=runtime_functions,
            base_dir=base_dir,
        )
    except PatchError:
        raise
    except (
        KeyError,
        RecursionError,
        SpecConversionError,
        TypeError,
        ValueError,
    ) as exc:
        raise PatchError(PatchFailure("patch.conversion", "", str(exc))) from exc
    if semantic:
        report = validate(result, backend=backend, strict=strict)
        if not report.valid:
            first = report.errors[0]
            raise PatchError(
                PatchFailure(
                    code="patch.semantic",
                    path=first.path,
                    message=first.message,
                ),
                validation_report=report,
            )
    return result

Apply atomic JSON Pointer operations and validate the resulting schema.

Supported operations are add, remove, and replace. All operations run against a private deep copy; failure leaves spec and supplied values unchanged. - appends to an array.

Parameters:

Name Type Description Default
spec FigureSpec | Mapping[str, Any]

Immutable FigureSpec or a canonical specification mapping.

required
operations Iterable[PatchOperation | Mapping[str, Any]]

Ordered patch operations using RFC 6901 pointer paths.

required

Returns:

Type Description
FigureSpec

A newly validated immutable FigureSpec.

Raises:

Type Description
PatchError

If an operation or final schema is invalid.

Source code in hakowan/spec/patch.py
def patch_spec(
    spec: FigureSpec | Mapping[str, Any],
    operations: Iterable[PatchOperation | Mapping[str, Any]],
) -> FigureSpec:
    """Apply atomic JSON Pointer operations and validate the resulting schema.

    Supported operations are ``add``, ``remove``, and ``replace``. All
    operations run against a private deep copy; failure leaves ``spec`` and
    supplied values unchanged. ``-`` appends to an array.

    Args:
        spec: Immutable FigureSpec or a canonical specification mapping.
        operations: Ordered patch operations using RFC 6901 pointer paths.

    Returns:
        A newly validated immutable FigureSpec.

    Raises:
        PatchError: If an operation or final schema is invalid.

    """
    try:
        _ensure_spec_nesting(spec)
        document = copy.deepcopy(
            spec.to_dict() if isinstance(spec, FigureSpec) else dict(spec)
        )
    except (RecursionError, ValueError) as exc:
        raise PatchError(PatchFailure("patch.conversion", "", str(exc))) from exc
    for index, operation in enumerate(operations):
        path = operation.get("path") if isinstance(operation, Mapping) else None
        try:
            if isinstance(operation, Mapping) and "value" in operation:
                _ensure_spec_nesting(operation["value"])
            document = _apply_one(document, operation)
        except (KeyError, IndexError, RecursionError, TypeError, ValueError) as exc:
            raise PatchError(
                PatchFailure(
                    code="patch.operation",
                    path=path if isinstance(path, str) else "",
                    message=str(exc),
                    operation_index=index,
                )
            ) from exc
    try:
        return FigureSpec.model_validate(document)
    except PydanticValidationError as exc:
        error = exc.errors(include_url=False)[0]
        raise PatchError(
            PatchFailure(
                code="patch.schema",
                path=_schema_pointer(error["loc"]),
                message=error["msg"],
            )
        ) from exc

Root model

Bases: SpecModel

Canonical versioned Hakowan visualization specification.

Source code in hakowan/spec/model.py
class FigureSpec(SpecModel):
    """Canonical versioned Hakowan visualization specification."""

    @model_validator(mode="before")
    @classmethod
    def validate_nesting(cls, value: Any) -> Any:
        _ensure_spec_nesting(value)
        return value

    schema_url: Literal["https://hakowan.github.io/hakowan/schema/v1.json"] = Field(
        default="https://hakowan.github.io/hakowan/schema/v1.json", alias="$schema"
    )
    version: Literal["1.0", "1.1"] = "1.1"
    root: NodeSpec
    scene: SceneSettingsSpec | None = None

    @model_validator(mode="after")
    def validate_version_features(self) -> "FigureSpec":
        """Reject fields introduced after the declared schema version."""
        if self.version == "1.0" and self.scene is not None:
            raise ValueError("FigureSpec version 1.0 does not support scene settings.")
        return self

    def to_dict(self) -> dict[str, Any]:
        """Return the complete JSON-safe document, including defaults and nulls."""
        return self.model_dump(mode="json", by_alias=True, exclude_none=False)

    def to_json(self, *, indent: int | None = 2, canonical: bool = False) -> str:
        """Serialize with sorted keys and optional canonical compact formatting."""
        payload = self.to_dict()
        return json.dumps(
            payload,
            indent=None if canonical else indent,
            sort_keys=True,
            separators=(",", ":") if canonical else None,
            allow_nan=False,
        )

    def save(self, path: str | Path, *, indent: int | None = 2) -> None:
        """Write the specification as UTF-8 JSON followed by a newline."""
        Path(path).write_text(self.to_json(indent=indent) + "\n", encoding="utf-8")

    @classmethod
    def from_json(cls, text: str | bytes) -> "FigureSpec":
        """Parse and validate a FigureSpec from JSON text or bytes."""
        return cls.model_validate_json(text)

    @classmethod
    def load(cls, path: str | Path) -> "FigureSpec":
        """Read and validate a FigureSpec from a UTF-8 JSON file."""
        return cls.from_json(Path(path).read_text(encoding="utf-8"))

to_dict()

Return the complete JSON-safe document, including defaults and nulls.

Source code in hakowan/spec/model.py
def to_dict(self) -> dict[str, Any]:
    """Return the complete JSON-safe document, including defaults and nulls."""
    return self.model_dump(mode="json", by_alias=True, exclude_none=False)

to_json(*, indent=2, canonical=False)

Serialize with sorted keys and optional canonical compact formatting.

Source code in hakowan/spec/model.py
def to_json(self, *, indent: int | None = 2, canonical: bool = False) -> str:
    """Serialize with sorted keys and optional canonical compact formatting."""
    payload = self.to_dict()
    return json.dumps(
        payload,
        indent=None if canonical else indent,
        sort_keys=True,
        separators=(",", ":") if canonical else None,
        allow_nan=False,
    )

save(path, *, indent=2)

Write the specification as UTF-8 JSON followed by a newline.

Source code in hakowan/spec/model.py
def save(self, path: str | Path, *, indent: int | None = 2) -> None:
    """Write the specification as UTF-8 JSON followed by a newline."""
    Path(path).write_text(self.to_json(indent=indent) + "\n", encoding="utf-8")

from_json(text) classmethod

Parse and validate a FigureSpec from JSON text or bytes.

Source code in hakowan/spec/model.py
@classmethod
def from_json(cls, text: str | bytes) -> "FigureSpec":
    """Parse and validate a FigureSpec from JSON text or bytes."""
    return cls.model_validate_json(text)

load(path) classmethod

Read and validate a FigureSpec from a UTF-8 JSON file.

Source code in hakowan/spec/model.py
@classmethod
def load(cls, path: str | Path) -> "FigureSpec":
    """Read and validate a FigureSpec from a UTF-8 JSON file."""
    return cls.from_json(Path(path).read_text(encoding="utf-8"))

Boundary errors

Bases: ValueError

Raised when a runtime object cannot cross the canonical spec boundary.

Source code in hakowan/spec/codec.py
class SpecConversionError(ValueError):
    """Raised when a runtime object cannot cross the canonical spec boundary."""

Bases: ValueError

Raised when an expression uses syntax outside the safe allowlist.

Source code in hakowan/spec/expression.py
class ExpressionError(ValueError):
    """Raised when an expression uses syntax outside the safe allowlist."""

Bases: ValueError

Raised when a patch operation, schema, or semantic check fails.

Source code in hakowan/spec/patch.py
class PatchError(ValueError):
    """Raised when a patch operation, schema, or semantic check fails."""

    def __init__(
        self,
        failure: PatchFailure,
        *,
        validation_report: ValidationReport | None = None,
    ) -> None:
        """Initialize an error with its structured failure and validation report."""
        self.failure = failure
        self.validation_report = validation_report
        location = (
            f"operation {failure.operation_index} at {failure.path}"
            if failure.operation_index is not None
            else failure.path
        )
        super().__init__(f"{failure.code}: {location}: {failure.message}")

__init__(failure, *, validation_report=None)

Initialize an error with its structured failure and validation report.

Source code in hakowan/spec/patch.py
def __init__(
    self,
    failure: PatchFailure,
    *,
    validation_report: ValidationReport | None = None,
) -> None:
    """Initialize an error with its structured failure and validation report."""
    self.failure = failure
    self.validation_report = validation_report
    location = (
        f"operation {failure.operation_index} at {failure.path}"
        if failure.operation_index is not None
        else failure.path
    )
    super().__init__(f"{failure.code}: {location}: {failure.message}")

Machine-readable cause of a rejected atomic patch.

Source code in hakowan/spec/patch.py
@dataclass(frozen=True, slots=True)
class PatchFailure:
    """Machine-readable cause of a rejected atomic patch."""

    code: str
    path: str
    message: str
    operation_index: int | None = None

    def to_dict(self) -> dict[str, str | int | None]:
        """Return this failure as a JSON-safe mapping."""
        return {
            "code": self.code,
            "path": self.path,
            "message": self.message,
            "operation_index": self.operation_index,
        }

to_dict()

Return this failure as a JSON-safe mapping.

Source code in hakowan/spec/patch.py
def to_dict(self) -> dict[str, str | int | None]:
    """Return this failure as a JSON-safe mapping."""
    return {
        "code": self.code,
        "path": self.path,
        "message": self.message,
        "operation_index": self.operation_index,
    }

Expression compiler

Compile a safe one-argument expression into a callable.

Available names are value and vector aliases x, y, z. Allowed functions are abs, min, max, isfinite, and norm.

Source code in hakowan/spec/expression.py
def compile_expression(source: str) -> Callable[[Any], Any]:
    """Compile a safe one-argument expression into a callable.

    Available names are ``value`` and vector aliases ``x``, ``y``, ``z``.
    Allowed functions are ``abs``, ``min``, ``max``, ``isfinite``, and ``norm``.
    """
    if len(source) > 1024:
        raise ExpressionError("Expression exceeds the 1024-character limit.")
    try:
        tree = ast.parse(source, mode="eval")
    except (SyntaxError, ValueError) as exc:
        message = exc.msg if isinstance(exc, SyntaxError) else str(exc)
        raise ExpressionError(f"Invalid expression: {message}") from exc

    # Validate immediately so malformed specifications fail before execution.
    _validate(tree)

    def expression(value: Any) -> Any:
        return _evaluate(tree, _context(value))

    expression.__name__ = "hakowan_expression"
    expression.__doc__ = source
    return expression