Source code for openrig.conventions.conventions

"""Rigging conventions loader for OpenRig.

Reads ``conventions/config.json``, validates the values, and exposes
``load_conventions()`` for file-based loading and ``RiggingConventions``
for direct programmatic construction.

Exported symbols (consumed by ``__init__.py``):
    - ``ConventionsError``: raised on any load or validation failure.
    - ``RiggingConventions``: frozen dataclass holding the active defaults.
    - ``load_conventions(path)``: reads a JSON file and returns an instance.
"""

import json
from dataclasses import dataclass
from pathlib import Path
from typing import TypeGuard

from openrig.constants import Axis, RotateOrder, Side

# ---------------------------------------------------------------------------
# Exception
# ---------------------------------------------------------------------------


[docs] class ConventionsError(Exception): """Raised when rigging conventions cannot be loaded or are invalid."""
# --------------------------------------------------------------------------- # Dataclass # ---------------------------------------------------------------------------
[docs] @dataclass(frozen=True) class RiggingConventions: """Active rigging conventions for a project. Construct directly for programmatic configuration or testing. Use ``load_conventions()`` or ``get_conventions()`` for file-based loading. Attributes: aim_axis: The axis joints and controls aim along. up_axis: The axis used as the up vector. side_axis: The axis used for the side direction. default_rotate_order: The default rotate order for joints and controls. default_side: The default side token when none is specified. """ aim_axis: Axis up_axis: Axis side_axis: Axis default_rotate_order: RotateOrder default_side: Side
# --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _is_dict(value: object) -> TypeGuard[dict[object, object]]: return isinstance(value, dict) def _parse_conventions(data: dict[object, object]) -> RiggingConventions: """Validates and converts a raw config dict into a ``RiggingConventions``. Args: data: Raw config dict, e.g. from a parsed JSON file. Returns: A validated ``RiggingConventions`` instance. Raises: ConventionsError: If any field is missing or holds an invalid value. """ raw_aim = data.get("aim_axis") if not isinstance(raw_aim, str): raise ConventionsError( f"'aim_axis' must be a string, got: {type(raw_aim).__name__!r}." ) try: aim_axis = Axis(raw_aim) except ValueError: valid = [a.value for a in Axis] raise ConventionsError( f"'aim_axis' value {raw_aim!r} is not a valid Axis. " f"Valid values: {valid!r}." ) from None raw_up = data.get("up_axis") if not isinstance(raw_up, str): raise ConventionsError( f"'up_axis' must be a string, got: {type(raw_up).__name__!r}." ) try: up_axis = Axis(raw_up) except ValueError: valid = [a.value for a in Axis] raise ConventionsError( f"'up_axis' value {raw_up!r} is not a valid Axis. " f"Valid values: {valid!r}." ) from None raw_side_axis = data.get("side_axis") if not isinstance(raw_side_axis, str): raise ConventionsError( f"'side_axis' must be a string, got: {type(raw_side_axis).__name__!r}." ) try: side_axis = Axis(raw_side_axis) except ValueError: valid = [a.value for a in Axis] raise ConventionsError( f"'side_axis' value {raw_side_axis!r} is not a valid Axis. " f"Valid values: {valid!r}." ) from None raw_ro = data.get("default_rotate_order") if not isinstance(raw_ro, str): raise ConventionsError( "'default_rotate_order' must be a string, " f"got: {type(raw_ro).__name__!r}." ) rotate_order_map = {ro.as_string: ro for ro in RotateOrder} if raw_ro not in rotate_order_map: raise ConventionsError( f"'default_rotate_order' value {raw_ro!r} is not valid. " f"Valid values: {sorted(rotate_order_map.keys())!r}." ) default_rotate_order = rotate_order_map[raw_ro] raw_default_side = data.get("default_side") if not isinstance(raw_default_side, str): raise ConventionsError( "'default_side' must be a string, " f"got: {type(raw_default_side).__name__!r}." ) try: default_side = Side(raw_default_side) except ValueError: valid_sides = [s.value for s in Side] raise ConventionsError( f"'default_side' value {raw_default_side!r} is not a valid Side. " f"Valid values: {valid_sides!r}." ) from None return RiggingConventions( aim_axis=aim_axis, up_axis=up_axis, side_axis=side_axis, default_rotate_order=default_rotate_order, default_side=default_side, ) # --------------------------------------------------------------------------- # Public loader # ---------------------------------------------------------------------------
[docs] def load_conventions(path: Path | str | None = None) -> RiggingConventions: """Loads rigging conventions from a JSON file. Falls back to the package-level default (``conventions/config.json``) when no path is provided. Args: path: Path to a JSON config file, or ``None`` to use the package default. Returns: A validated ``RiggingConventions`` instance. Raises: ConventionsError: If the file is missing, malformed, or contains invalid values. """ config_path = ( Path(path) if path is not None else Path(__file__).parent / "config.json" ) if not config_path.exists(): raise ConventionsError( f"Conventions config not found: '{config_path}'. " "Ensure 'config.json' exists in the openrig/conventions directory." ) try: with open(config_path, encoding="utf-8") as fh: raw: object = json.load(fh) except json.JSONDecodeError as exc: raise ConventionsError( f"Failed to parse '{config_path}' as JSON: {exc}" ) from exc except OSError as exc: raise ConventionsError(f"Could not read '{config_path}': {exc}") from exc if not _is_dict(raw): raise ConventionsError( f"Expected a JSON object at the root of '{config_path}', " f"got {type(raw).__name__!r}." ) return _parse_conventions(raw)