Skip to content

config_io

tit.config_io

JSON serialisation for config dataclasses.

Provides helpers to serialise typed config dataclasses (with Enum fields, nested dataclasses, and union-typed ROI/electrode specs) to JSON files and read them back.

Public API

serialize_config Convert a config dataclass to a JSON-serialisable dict (with project_dir injection). write_config_json Serialise a config dataclass to a temporary JSON file. read_config_json Read a JSON config file and return the parsed dict. deserialize_config Rebuild a config dataclass instance from its serialized dict form -- the symmetric inverse of serialize_config. json_schema Build a draft 2020-12 JSON Schema for a config dataclass, with the same _type discriminator conventions serialize_config / deserialize_config use.

Examples

from tit.config_io import write_config_json, read_config_json path = write_config_json(my_flex_config, prefix="flex") data = read_config_json(path)

See Also

tit.opt.config : FlexConfig and ExConfig dataclasses. tit.sim.config : Montage dataclass.

serialize_config

serialize_config(config: Any) -> dict[str, Any]

Convert a config dataclass to a JSON-serialisable dict.

Handles Enum fields (via .value), nested dataclasses (recursed), union-typed ROI / electrode specs (injects a _type discriminator), and None values (preserved as JSON null).

Also injects project_dir from the active :class:~tit.paths.PathManager so that subprocess entry points can initialise their own singleton.

Parameters

config : dataclass instance Any config dataclass (e.g., FlexConfig, ExConfig).

Returns

dict JSON-serialisable dictionary representation of config.

See Also

write_config_json : Serialise and write to a temp file in one step. read_config_json : Read a JSON config back into a dict.

Source code in tit/config_io.py
def serialize_config(config: Any) -> dict[str, Any]:
    """Convert a config dataclass to a JSON-serialisable dict.

    Handles Enum fields (via ``.value``), nested dataclasses (recursed),
    union-typed ROI / electrode specs (injects a ``_type`` discriminator),
    and *None* values (preserved as JSON ``null``).

    Also injects ``project_dir`` from the active :class:`~tit.paths.PathManager`
    so that subprocess entry points can initialise their own singleton.

    Parameters
    ----------
    config : dataclass instance
        Any config dataclass (e.g., ``FlexConfig``, ``ExConfig``).

    Returns
    -------
    dict
        JSON-serialisable dictionary representation of *config*.

    See Also
    --------
    write_config_json : Serialise and write to a temp file in one step.
    read_config_json : Read a JSON config back into a dict.
    """
    data = _serialize(config)
    # Inject project_dir for subprocess entry points
    from tit.paths import get_path_manager

    data["project_dir"] = get_path_manager().project_dir
    return data

write_config_json

write_config_json(config: Any, prefix: str = 'config') -> str

Serialise a config dataclass to a temporary JSON file.

Parameters

config : dataclass instance Config object to serialise. prefix : str, optional Filename prefix for the temp file. Default is "config".

Returns

str Absolute path to the created JSON file.

See Also

serialize_config : Convert to dict without writing to disk. read_config_json : Read a JSON config file.

Source code in tit/config_io.py
def write_config_json(config: Any, prefix: str = "config") -> str:
    """Serialise a config dataclass to a temporary JSON file.

    Parameters
    ----------
    config : dataclass instance
        Config object to serialise.
    prefix : str, optional
        Filename prefix for the temp file.  Default is ``"config"``.

    Returns
    -------
    str
        Absolute path to the created JSON file.

    See Also
    --------
    serialize_config : Convert to dict without writing to disk.
    read_config_json : Read a JSON config file.
    """
    data = serialize_config(config)
    fd, path = tempfile.mkstemp(prefix=f"{prefix}_", suffix=".json")
    with os.fdopen(fd, "w") as f:
        json.dump(data, f, indent=2)
    return path

read_config_json

read_config_json(path: str) -> dict[str, Any]

Read a JSON config file and return the parsed dict.

Parameters

path : str Path to the JSON file.

Returns

dict Parsed JSON contents.

See Also

write_config_json : Create a config JSON file from a dataclass.

Source code in tit/config_io.py
def read_config_json(path: str) -> dict[str, Any]:
    """Read a JSON config file and return the parsed dict.

    Parameters
    ----------
    path : str
        Path to the JSON file.

    Returns
    -------
    dict
        Parsed JSON contents.

    See Also
    --------
    write_config_json : Create a config JSON file from a dataclass.
    """
    with open(path) as f:
        return json.load(f)

resolve_config_class

resolve_config_class(name: str) -> type

Import and return the class registered under name.

Parameters

name : str A key of :data:CONFIG_CLASS_REGISTRY.

Returns

type The resolved dataclass.

Raises

KeyError If name is not registered.

Source code in tit/config_io.py
def resolve_config_class(name: str) -> type:
    """Import and return the class registered under *name*.

    Parameters
    ----------
    name : str
        A key of :data:`CONFIG_CLASS_REGISTRY`.

    Returns
    -------
    type
        The resolved dataclass.

    Raises
    ------
    KeyError
        If *name* is not registered.
    """
    dotted = CONFIG_CLASS_REGISTRY[name]
    module_path, class_name = dotted.rsplit(".", 1)
    module = importlib.import_module(module_path)
    return getattr(module, class_name)

deserialize_config

deserialize_config(cls: type, data: dict[str, Any], *, strict: bool = False) -> Any

Rebuild a config dataclass instance from its serialized dict form.

The symmetric inverse of :func:serialize_config: walks cls's own dataclasses.fields() (resolving each field's type -- including forward-referenced nested-class unions such as FlexConfig.roi -- with typing.get_type_hints) and converts every value back into whatever serialize_config produced it from: nested dataclasses (a single required type, or a union disambiguated by _type), lists of dataclasses, Enum members, tuples (including tuples nested inside lists, e.g. Montage.electrode_pairs), dict values whose declared key type isn't str (JSON object keys are always strings, so e.g. SimulationConfig.tissue_conductivities: dict[int, float] round-trips as {"1": 2.5} and is coerced back to {1: 2.5}), and Optional values. By default, keys in data that are not fields of cls (e.g. "project_dir", which the runners pop themselves -- see tit.sim.__main__.main -- or "_type" on an ambiguous kind's top-level config, see tit.server.routes.validate) are silently ignored; pass strict to change that.

Parameters

cls : type A dataclass, e.g. one of :data:CONFIG_CLASS_REGISTRY's values. data : dict Parsed JSON as produced by :func:serialize_config (directly, or round-tripped through :func:write_config_json / :func:read_config_json). strict : bool, optional If True, raise instead of silently ignoring a key in data that is not one of cls's own fields (recursively, for every nested dataclass reached along the way) -- catching a misspelled or renamed field at validation time instead of it becoming a silent no-op. "_type" is never flagged as unknown even in strict mode (every discriminated dataclass and every ambiguous top-level kind uses it without it being a real field). Off by default: a caller with its own legacy flat-dict layout (e.g. tit.analyzer.__main__'s pre-AnalyzerConfig fallback) is unaffected unless it opts in.

Returns

Any A cls instance.

Raises

TypeError If cls is not a dataclass. ValueError If data carries a _type that does not match cls itself, if a union-typed field's value is missing its _type tag or carries one not registered for that union's members, or (only when strict is True) if data carries a key that is not one of cls's fields.

See Also

serialize_config : The inverse conversion. json_schema : Describes the same shape as a JSON Schema, including the additionalProperties: false this function's strict mode enforces at runtime.

Source code in tit/config_io.py
def deserialize_config(cls: type, data: dict[str, Any], *, strict: bool = False) -> Any:
    """Rebuild a config dataclass instance from its serialized dict form.

    The symmetric inverse of :func:`serialize_config`: walks *cls*'s own
    ``dataclasses.fields()`` (resolving each field's type -- including
    forward-referenced nested-class unions such as ``FlexConfig.roi`` --
    with ``typing.get_type_hints``) and converts every value back into
    whatever ``serialize_config`` produced it from: nested dataclasses
    (a single required type, or a union disambiguated by ``_type``), lists
    of dataclasses, ``Enum`` members, tuples (including tuples nested
    inside lists, e.g. ``Montage.electrode_pairs``), dict values whose declared
    key type isn't ``str`` (JSON object keys are always strings, so e.g.
    ``SimulationConfig.tissue_conductivities: dict[int, float]`` round-trips
    as ``{"1": 2.5}`` and is coerced back to ``{1: 2.5}``), and ``Optional``
    values. By default, keys in *data* that are not fields of *cls* (e.g.
    ``"project_dir"``, which the runners pop themselves -- see
    ``tit.sim.__main__.main`` -- or ``"_type"`` on an ambiguous kind's
    top-level config, see ``tit.server.routes.validate``) are silently
    ignored; pass *strict* to change that.

    Parameters
    ----------
    cls : type
        A dataclass, e.g. one of :data:`CONFIG_CLASS_REGISTRY`'s values.
    data : dict
        Parsed JSON as produced by :func:`serialize_config` (directly, or
        round-tripped through :func:`write_config_json` /
        :func:`read_config_json`).
    strict : bool, optional
        If True, raise instead of silently ignoring a key in *data* that
        is not one of *cls*'s own fields (recursively, for every nested
        dataclass reached along the way) -- catching a misspelled or
        renamed field at validation time instead of it becoming a silent
        no-op. ``"_type"`` is never flagged as unknown even in strict mode
        (every discriminated dataclass and every ambiguous top-level kind
        uses it without it being a real field). Off by default: a caller
        with its own legacy flat-dict layout (e.g.
        ``tit.analyzer.__main__``'s pre-``AnalyzerConfig`` fallback) is
        unaffected unless it opts in.

    Returns
    -------
    Any
        A *cls* instance.

    Raises
    ------
    TypeError
        If *cls* is not a dataclass.
    ValueError
        If *data* carries a ``_type`` that does not match *cls* itself, if
        a union-typed field's value is missing its ``_type`` tag or
        carries one not registered for that union's members, or (only
        when *strict* is True) if *data* carries a key that is not one of
        *cls*'s fields.

    See Also
    --------
    serialize_config : The inverse conversion.
    json_schema : Describes the same shape as a JSON Schema, including the
        ``additionalProperties: false`` this function's *strict* mode
        enforces at runtime.
    """
    if not is_dataclass(cls):
        raise TypeError(f"{cls!r} is not a dataclass")

    type_tag = data.get("_type")
    if type_tag is not None:
        expected = _discriminator_for(cls)
        if expected is not None and type_tag != expected:
            raise ValueError(
                f"_type={type_tag!r} does not match {cls.__name__} "
                f"(expected {expected!r})"
            )

    if strict:
        known = {fld.name for fld in fields(cls)}
        unknown = sorted(set(data) - known - {"_type"})
        if unknown:
            raise ValueError(
                f"{cls.__name__}: unknown key(s) {unknown}; expected one of "
                f"{sorted(known)}"
            )

    hints = typing.get_type_hints(cls)
    kwargs: dict[str, Any] = {}
    for fld in fields(cls):
        if not fld.init or fld.name not in data:
            continue
        kwargs[fld.name] = _deserialize_value(
            data[fld.name], hints.get(fld.name), strict=strict
        )
    return cls(**kwargs)

json_schema

json_schema(cls: type) -> dict[str, Any]

Build a draft 2020-12 JSON Schema for a config dataclass.

Wraps pydantic.TypeAdapter(cls).json_schema() with this project's discriminated-union conventions, so the result matches exactly what :func:serialize_config emits and :func:deserialize_config accepts:

  • every reachable class serialize_config tags with a _type discriminator (:data:_TYPE_DISCRIMINATED / :data:_TYPE_DISCRIMINATED_BY_NAME) gets "_type": {"const": ...} injected into its properties and required list -- whether that class is cls itself (e.g. json_schema(Montage)) or a nested $defs entry (e.g. FlexConfig's SphericalROI/AtlasROI/ SubcorticalROI);
  • every anyOf whose members are all $refs to such discriminated classes (plus an optional null branch for Optional[...]) is rewritten to oneOf + discriminator: {"propertyName": "_type"};
  • $defs entries whose bare name collides with another registered class's (:data:_COLLIDING_DEFS_NAMES: AtlasROI on three classes, PoolElectrodes/BucketElectrodes on two, Subject on two) are renamed to "<cls.__name__><bare name>", so merging many classes' schemas into one document (see dev/build_schema.py) can never let one definition silently clobber another's under the same key;
  • every fixed-shape object schema (the top level and each $defs entry with its own "properties") gets "additionalProperties": false (:func:_close_object_schemas), matching what :func:deserialize_config's strict=True rejects at runtime -- a misspelled or renamed field fails schema validation (e.g. at POST /api/validate/{kind}) instead of silently vanishing. A free-form mapping field (e.g. SimulationConfig.tissue_conductivities: dict[int, float]) has no "properties" of its own and is left open.
Parameters

cls : type A dataclass, e.g. one of :data:CONFIG_CLASS_REGISTRY's values.

Returns

dict A JSON Schema document: cls's own shape at the top level, nested types under $defs.

See Also

deserialize_config : Accepts exactly the data this schema validates.

Source code in tit/config_io.py
def json_schema(cls: type) -> dict[str, Any]:
    """Build a draft 2020-12 JSON Schema for a config dataclass.

    Wraps ``pydantic.TypeAdapter(cls).json_schema()`` with this project's
    discriminated-union conventions, so the result matches exactly what
    :func:`serialize_config` emits and :func:`deserialize_config` accepts:

    - every reachable class ``serialize_config`` tags with a ``_type``
      discriminator (:data:`_TYPE_DISCRIMINATED` /
      :data:`_TYPE_DISCRIMINATED_BY_NAME`) gets ``"_type": {"const": ...}``
      injected into its properties and required list -- whether that class
      is *cls* itself (e.g. ``json_schema(Montage)``) or a nested ``$defs``
      entry (e.g. ``FlexConfig``'s ``SphericalROI``/``AtlasROI``/
      ``SubcorticalROI``);
    - every ``anyOf`` whose members are all ``$ref``s to such discriminated
      classes (plus an optional ``null`` branch for ``Optional[...]``) is
      rewritten to ``oneOf`` + ``discriminator: {"propertyName": "_type"}``;
    - ``$defs`` entries whose bare name collides with another registered
      class's (:data:`_COLLIDING_DEFS_NAMES`: ``AtlasROI`` on three
      classes, ``PoolElectrodes``/``BucketElectrodes`` on two, ``Subject``
      on two) are renamed to ``"<cls.__name__><bare name>"``, so merging
      many classes' schemas into one document
      (see ``dev/build_schema.py``) can never let one definition silently
      clobber another's under the same key;
    - every fixed-shape object schema (the top level and each ``$defs``
      entry with its own ``"properties"``) gets
      ``"additionalProperties": false`` (:func:`_close_object_schemas`),
      matching what :func:`deserialize_config`'s ``strict=True`` rejects
      at runtime -- a misspelled or renamed field fails schema validation
      (e.g. at ``POST /api/validate/{kind}``) instead of silently
      vanishing. A free-form mapping field (e.g.
      ``SimulationConfig.tissue_conductivities: dict[int, float]``) has no
      ``"properties"`` of its own and is left open.

    Parameters
    ----------
    cls : type
        A dataclass, e.g. one of :data:`CONFIG_CLASS_REGISTRY`'s values.

    Returns
    -------
    dict
        A JSON Schema document: *cls*'s own shape at the top level, nested
        types under ``$defs``.

    See Also
    --------
    deserialize_config : Accepts exactly the data this schema validates.
    """
    from pydantic import TypeAdapter

    schema = TypeAdapter(cls).json_schema()
    nested = _iter_nested_dataclasses(cls)

    own_type = _discriminator_for(cls)
    if own_type is not None:
        _inject_type_property(schema, own_type)

    defs = schema.get("$defs", {})
    for name, member in nested.items():
        member_type = _discriminator_for(member)
        if member_type is not None and name in defs:
            _inject_type_property(defs[name], member_type)

    rename = {
        name: f"{cls.__name__}{name}"
        for name in nested
        if name in _COLLIDING_DEFS_NAMES and name in defs
    }
    if rename:
        for old, new in rename.items():
            defs[new] = defs.pop(old)
        _rename_refs(schema, rename)

    _close_object_schemas(schema)
    _rewrite_discriminated_unions(schema)
    return schema