Skip to content

validate

tit.server.routes.validate

POST /api/validate/{kind} -- field-level config validation, no side effects.

Deserializes config through :func:tit.config_io.deserialize_config for the dataclass that kind maps to (see :func:cls_for), catching exactly what a config dataclass's own __post_init__ (or a bad _type/field shape) can raise, and reports it as {ok, errors: [{path, message}]} -- the UI never re-implements these rules (design rule R1); this route only surfaces them. Exhaustive-search mask paths are also checked for accessibility before a plan or costly leadfield load can proceed.

Kind -> config-class resolution

Most kinds map to exactly one dataclass in :data:tit.config_io.CONFIG_CLASS_REGISTRY (:data:SIMPLE_KIND_CLASS). Two kinds do not: stats (GroupComparisonConfig or CorrelationConfig) and blender (MontageConfig/VectorConfig/RegionConfig/SubcorticalConfig) -- the frozen PipelineConfig union in contracts/openapi.yaml lists all of them under the same kind/path parameter with no other discriminant on the object itself. This module resolves the ambiguity with a config["_type"] key (see :data:AMBIGUOUS_KIND_CLASSES), the same discriminator convention :mod:tit.config_io already uses for union-typed fields (ROIs, electrodes, montages) -- just applied here to the top-level kind/class choice. _type is not a field of any of these dataclasses, so :func:~tit.config_io.deserialize_config silently ignores it (documented behavior: "keys in data that are not fields of cls ... are ignored"), making this additive rather than a contract change. The tag is read exactly as each runner dispatches: blender requires it (tit.blender.__main__ has no default), and stats lets the older mode key win over it (tit.stats.__main__), so the server never plans one class while the runner runs another. NO_SCHEMA_KINDS is empty as of the runners lane registering NiftiAverageConfig/NilearnConfig in :data:~tit.config_io.CONFIG_CLASS_REGISTRY -- kept as a (currently-empty) frozenset rather than removed outright since contracts/generated/config.schema.json has not been regenerated with these two $defs yet (a B4/F1b follow-up: docker exec tit-v3-spike simnibs_python dev/build_schema.py), so the desktop's generated types don't know about them even though this route already validates/plans them for real.

This is a documented design decision by this track (B3), not something contracts/openapi.yaml specifies -- flagged in the B3 report for F1a/the orchestrator in case a future contract revision wants an explicit discriminant instead.

KindNotConfigurable

Bases: ValueError

Raised by :func:cls_for when an ambiguous kind's config names no class it can run.

cls_for

cls_for(kind: str, config_body: dict[str, Any]) -> type | None

Resolve kind (and, for an ambiguous kind, the config's own tag) to a dataclass.

An ambiguous kind is read the way its runner dispatches it: stats by its mode key when present (:data:STATS_MODE_CLASSES), else by _type, else GroupComparisonConfig; blender by _type only.

Parameters

kind : str A :data:ALL_KINDS member (callers should 404 first for anything else). config_body : dict The request's raw config object (read for _type -- and, for stats, mode -- only when kind is ambiguous; never mutated).

Returns

type or None None for a :data:NO_SCHEMA_KINDS member (nothing to validate against yet).

Raises

KindNotConfigurable If kind is ambiguous and _type (or its default) does not name one of its known classes, a blender config has no _type, or a stats config's mode is unknown or contradicts its _type.

Source code in tit/server/routes/validate.py
def cls_for(kind: str, config_body: dict[str, Any]) -> type | None:
    """Resolve *kind* (and, for an ambiguous kind, the config's own tag) to a dataclass.

    An ambiguous kind is read the way its runner dispatches it: ``stats`` by its ``mode`` key
    when present (:data:`STATS_MODE_CLASSES`), else by ``_type``, else
    ``GroupComparisonConfig``; ``blender`` by ``_type`` only.

    Parameters
    ----------
    kind : str
        A :data:`ALL_KINDS` member (callers should 404 first for anything else).
    config_body : dict
        The request's raw ``config`` object (read for ``_type`` -- and, for ``stats``,
        ``mode`` -- only when *kind* is ambiguous; never mutated).

    Returns
    -------
    type or None
        ``None`` for a :data:`NO_SCHEMA_KINDS` member (nothing to validate against yet).

    Raises
    ------
    KindNotConfigurable
        If *kind* is ambiguous and ``_type`` (or its default) does not name one of its
        known classes, a ``blender`` config has no ``_type``, or a ``stats`` config's
        ``mode`` is unknown or contradicts its ``_type``.
    """
    from tit.config_io import resolve_config_class

    if kind in NO_SCHEMA_KINDS:
        return None
    if kind in SIMPLE_KIND_CLASS:
        return resolve_config_class(SIMPLE_KIND_CLASS[kind])

    choices = AMBIGUOUS_KIND_CLASSES.get(kind)
    if choices is not None:
        type_tag = config_body.get("_type")
        if kind == "stats" and config_body.get("mode"):
            type_tag = _stats_mode_class(config_body["mode"], type_tag)
        if not type_tag and kind in TYPE_REQUIRED_KINDS:
            raise KindNotConfigurable(
                f"kind={kind!r} config._type is required, one of {sorted(choices)}: "
                "the runner picks the export by it and has no default"
            )
        type_tag = type_tag or AMBIGUOUS_KIND_DEFAULT[kind]
        registry_key = choices.get(type_tag)
        if registry_key is None:
            raise KindNotConfigurable(
                f"kind={kind!r} config._type must be one of {sorted(choices)}, "
                f"got {type_tag!r}"
            )
        return resolve_config_class(registry_key)

    raise KindNotConfigurable(f"unknown kind: {kind!r}")

refuse_unknown_keys

refuse_unknown_keys(kind: str, config: dict[str, Any]) -> None

Raise :class:ValueError when config has a key /api/validate/{kind} calls unknown.

The submission routes (POST /api/jobs, /api/jobs/groups, rerun, an approved proposal) read configs leniently, as the runners do, so a misspelled or renamed key used to be accepted and silently ignored. This applies the validate route's strictness, with the same envelope (project_dir) and dispatch (stats mode) keys set aside, to that one rule: a config that is invalid for another reason is left to check_job_config, the preflight and the runner (the analyzer and source runners still read their older layouts). Kinds with no config class (tools, project_init) have nothing to check. A pre key from before the FastSurfer stage (tit.pre.config.LEGACY_KEYS) is not unknown here: the runner and POST /api/plan/pre translate it (migrate_legacy_keys), and check_job_config checks the translated config. /api/validate itself still flags it.

Raises

ValueError Naming the unknown key(s) and the class's fields.

Source code in tit/server/routes/validate.py
def refuse_unknown_keys(kind: str, config: dict[str, Any]) -> None:
    """Raise :class:`ValueError` when *config* has a key ``/api/validate/{kind}`` calls unknown.

    The submission routes (``POST /api/jobs``, ``/api/jobs/groups``, rerun, an approved
    proposal) read configs leniently, as the runners do, so a misspelled or renamed key used
    to be accepted and silently ignored. This applies the validate route's strictness, with the
    same envelope (``project_dir``) and dispatch (stats ``mode``) keys set aside, to that one
    rule: a config that is invalid for another reason is left to ``check_job_config``, the
    preflight and the runner (the analyzer and source runners still read their older layouts).
    Kinds with no config class (``tools``, ``project_init``) have nothing to check. A ``pre``
    key from before the FastSurfer stage (``tit.pre.config.LEGACY_KEYS``) is not unknown here:
    the runner and ``POST /api/plan/pre`` translate it (``migrate_legacy_keys``), and
    ``check_job_config`` checks the translated config. ``/api/validate`` itself still flags it.

    Raises
    ------
    ValueError
        Naming the unknown key(s) and the class's fields.
    """
    from tit.config_io import deserialize_config

    if kind not in ALL_KINDS:
        return
    try:
        cls = cls_for(kind, config)
    except KindNotConfigurable:
        return  # refused by the overwrite check's own cls_for, with its own message
    if cls is None:
        return
    ignored = ENVELOPE_KEYS | DISPATCH_KEYS.get(kind, frozenset())
    if kind == "pre":
        from tit.pre.config import LEGACY_KEYS

        ignored |= frozenset(LEGACY_KEYS)
    data = {k: v for k, v in config.items() if k not in ignored}
    try:
        deserialize_config(cls, data, strict=True)
    except (ValueError, TypeError, KeyError) as strict_error:
        try:
            deserialize_config(cls, data)
        except (ValueError, TypeError, KeyError):
            return  # invalid regardless of its keys: not this check's to judge
        # Strict adds only the unknown-key rule, so that is what failed.
        raise ValueError(str(strict_error)) from None