"""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)