"""Shared helpers for turning a loader's flat parameter specs into a
pydantic model class (the `inputs_model`/`outputs_model` a `Cab` needs),
plus the generic YAML-composition primitives (`_include`/`_use` deep-merge)
every loader dialect builds its own resolution order on top of.
Cab dtypes are strings (cult-cargo/stimela-classic convention): scalar
names (`str`/`int`/`float`/`bool`), file-like names (`File`/`MS`/
`Directory`/`URI`, all mapped to `pathlib.Path` so `path_fields` picks
them up for bind-mounting), `list:<inner>` (cult-cargo/classic colon
syntax) and `List[<inner>]` (newer bracket syntax seen in both newer
cult-cargo cabs and caracal2's scabha-dialect config schemas) for lists,
and `Tuple[<a>, <b>, ...]`/`Union[<a>, <b>, ...]` (both bracket syntax,
nestable inside each other and inside `List[...]`) for tuples and unions.
"""
from __future__ import annotations
import functools
import keyword
import operator
import re
from pathlib import Path
from typing import Any, Callable, Literal
from pydantic import ConfigDict, create_model
def sanitize(name: str) -> str:
"""Turn a cab parameter name into a valid Python identifier (pydantic
field names must be identifiers). Non-identifier characters -- hyphens,
dots, etc. common in cult-cargo/classic param names -- become
underscores; a leading digit or a keyword is prefixed. The loader keeps
the original name as a ``nom_de_guerre`` so the built argv still uses it.
"""
cleaned = re.sub(r"\W", "_", name)
if cleaned and cleaned[0].isdigit():
cleaned = f"_{cleaned}"
if keyword.iskeyword(cleaned):
cleaned = f"{cleaned}_"
return cleaned
[docs]
def sanitize_unique(name: str, seen: dict[str, str]) -> str:
"""Like `sanitize`, but raises if two distinct raw names collide on the
same sanitized identifier. `seen` maps a sanitized field name to the
first raw name that produced it -- share one `seen` dict across a
single cab's parameter list.
"""
field = sanitize(name)
if field in seen and seen[field] != name:
raise ValueError(
f"parameter names {seen[field]!r} and {name!r} both sanitize to "
f"{field!r} -- rename one to avoid a silent collision"
)
seen[field] = name
return field
_SCALAR_TYPES: dict[str, type] = {
"str": str,
"string": str,
"int": int,
"integer": int,
"float": float,
"double": float,
"bool": bool,
"boolean": bool,
}
_FILE_LIKE = {"file", "ms", "directory", "dir", "uri", "url"}
def is_file_dtype(dtype: str) -> bool:
"""Whether a cab dtype string (e.g. from `ParamMeta.dtype`) is
file-like. The single source of truth for that classification, shared
with `dtype_to_type` below and with backends that need to recognise a
dynamically pattern-matched param (no declared field/type annotation
for `path_fields` to inspect) as needing a bind mount.
"""
return str(dtype).strip().lower() in _FILE_LIKE
_BRACKET_LIST_RE = re.compile(r"^list\[(?P<inner>.+)\]$", re.IGNORECASE)
_TUPLE_RE = re.compile(r"^tuple\[(?P<inner>.+)\]$", re.IGNORECASE)
_UNION_RE = re.compile(r"^union\[(?P<inner>.+)\]$", re.IGNORECASE)
def _split_top_level(spec: str) -> list[str]:
"""Split a bracket-inner spec (`"int, int"`, `"str, List[int]"`) on
top-level commas only -- commas nested inside a `[...]` (e.g. inside a
`List[...]`/`Tuple[...]`/`Union[...]` argument) don't split.
"""
parts = []
depth = 0
current: list[str] = []
for ch in spec:
if ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if ch == "," and depth == 0:
parts.append("".join(current).strip())
current = []
else:
current.append(ch)
if current:
parts.append("".join(current).strip())
return parts
def dtype_to_type(dtype: str) -> Any:
"""Map a cab dtype string to a Python type. File-like dtypes become
`pathlib.Path`; `list:<inner>` or `List[<inner>]` becomes
`list[<inner>]`; `Tuple[<a>, <b>, ...]` becomes `tuple[<a>, <b>, ...]`;
`Union[<a>, <b>, ...]` becomes `<a> | <b> | ...`; anything unrecognised
falls back to `str`.
"""
dtype = str(dtype).strip()
lower = dtype.lower()
if lower.startswith("list:"):
return list[dtype_to_type(dtype[5:])]
if m := _BRACKET_LIST_RE.match(dtype):
return list[dtype_to_type(m.group("inner"))]
if m := _TUPLE_RE.match(dtype):
items = tuple(dtype_to_type(p) for p in _split_top_level(m.group("inner")))
return tuple[items] if items else tuple
if m := _UNION_RE.match(dtype):
items = [dtype_to_type(p) for p in _split_top_level(m.group("inner"))]
if items:
return functools.reduce(operator.or_, items)
return str
if is_file_dtype(dtype):
return Path
return _SCALAR_TYPES.get(lower, str)
def validate_choices(choices: Any, *, error: type[Exception]) -> list[Any] | None:
"""Normalise a raw `choices:` value to a plain list, or `None` if it
wasn't given -- the one place both `cultcargo._collect` and
`worker_schema._leaf_field` check the "must be a list" shape, so the
error message and the shape rule can't drift between the two scabha-
dialect loaders.
"""
if not choices:
return None
if not isinstance(choices, (list, tuple)):
raise error(f"'choices' must be a list, got {choices!r}")
return list(choices)
def narrow_choices(py_type: Any, choices: list[Any] | None) -> Any:
"""Narrow `py_type` to `typing.Literal[*choices]` when `choices` is a
non-empty list -- shinobi's one enum-like schema mechanism (shared by
`build_model` below and `worker_schema._leaf_field`), so an out-of-set
value fails real pydantic validation instead of only being documented
in a field's `info` text.
"""
if not choices:
return py_type
return Literal[tuple(choices)]
def required_field_spec(py_type: Any, required: bool, default: Any) -> tuple[Any, Any]:
"""`(annotation, default)` for a `pydantic.create_model`/`Field` slot: a
required field with no default is `(py_type, ...)`; everything else is
Optional with its default (or None), so callers can omit it. The single
source of truth for this rule -- shared by `build_model` below and by
any loader (e.g. `worker_schema._leaf_field`) building one field at a
time instead of a whole model in one call.
"""
if required and default is None:
return (py_type, ...)
return (py_type | None, default)
[docs]
def build_model(
name: str,
fields: dict[str, tuple[str, bool, Any]],
*,
allow_extra: bool = False,
choices: dict[str, list[Any]] | None = None,
) -> type:
"""Create a pydantic model class named `name`.
`fields` maps a field name to `(dtype, required, default)`. See
`required_field_spec` for the required/default rule applied to each.
`choices` maps a field name to its allowed values (see
`narrow_choices`) -- omitted or absent for a field means its plain
`dtype`-derived type applies unchanged.
"""
choices = choices or {}
definitions: dict[str, tuple[Any, Any]] = {
field_name: required_field_spec(narrow_choices(dtype_to_type(dtype), choices.get(field_name)), required, default)
for field_name, (dtype, required, default) in fields.items()
}
config = ConfigDict(extra="allow") if allow_extra else None
return create_model(name, __config__=config, **definitions)
def deep_merge(base: Any, override: Any) -> Any:
"""Recursively merge two YAML-parsed values: dict keys are merged
key-by-key (recursing into nested dicts), with `override`'s value
winning on any key present in both; any other type just takes
`override` outright. Shared by every loader's `_include`/`_use`
composition (cult-cargo's "including/using file's own keys win"
convention).
"""
if isinstance(base, dict) and isinstance(override, dict):
merged = dict(base)
for key, value in override.items():
merged[key] = deep_merge(merged[key], value) if key in merged else value
return merged
return override
def get_path(root: dict[str, Any], dotted: str, *, error: type[Exception]) -> Any:
"""Look up a dotted path (`a.b.c`) in a nested dict, raising `error`
with a clear message if any segment is missing.
"""
node: Any = root
for part in dotted.split("."):
if not isinstance(node, dict) or part not in node:
raise error(f"path '{dotted}' not found (stuck at '{part}')")
node = node[part]
return node
def resolve_use(node: Any, root: dict[str, Any], *, error: type[Exception]) -> Any:
"""Resolve every `_use: dotted.path` directive in `node` (a `_use`
target that itself has a `_use` resolves too, recursively) by dotted
lookup against `root` (the fully `_include`-resolved document) --
`cultcargo`'s and `worker_schema`'s dialects agree on this directive
(deep-merge the target in, with the dict's own sibling keys winning),
differing only in which exception type reports a bad dotted path.
"""
def entry_to_dict(dotted: str) -> Any:
"""Resolve the `_use` target at `dotted`, recursing into its own `_use`.
Args:
dotted: Dotted path into `root` naming the `_use` target.
Returns:
The target node with its own `_use` directives resolved.
"""
return resolve_directive(get_path(root, dotted, error=error), "_use", entry_to_dict)
return resolve_directive(node, "_use", entry_to_dict)
# Leaf-descriptor keys both scabha-dialect loaders (`cultcargo`, a *cab*
# schema, and `worker_schema`, a *config* schema) agree mean "this is a
# leaf parameter, not a nested group" -- a loader that recognises extra
# keys of its own (e.g. cultcargo's `nom_de_guerre`/`mkdir`) extends this
# set rather than re-listing the shared ones.
COMMON_LEAF_KEYS = {
"info",
"dtype",
"default",
"required",
"implicit",
"choices",
"policies",
"writable",
"must_exist",
"path_policies",
}
def resolve_directive(node: Any, key: str, entry_to_dict: Callable[[Any], Any]) -> Any:
"""Walk a nested dict/list structure. Wherever a dict has `key`, pop it
(a single entry, or a list of entries), turn each entry into a fully
resolved dict via `entry_to_dict`, `deep_merge` them together in order,
then `deep_merge` the result under the dict's own remaining keys (which
win on any conflict). This is the shared "resolve a composition
directive" shape behind both `_include` and `_use` in every loader
dialect -- `entry_to_dict` is responsible for whatever an entry means
(a dotted lookup, a file path, ...) and for any further recursion its
own result needs; this walker only handles the tree traversal and merge.
"""
if isinstance(node, list):
return [resolve_directive(item, key, entry_to_dict) for item in node]
if not isinstance(node, dict):
return node
node = {k: resolve_directive(v, key, entry_to_dict) for k, v in node.items()}
if key in node:
spec = node.pop(key)
entries = spec if isinstance(spec, list) else [spec]
merged: dict[str, Any] = {}
for entry in entries:
merged = deep_merge(merged, entry_to_dict(entry))
node = deep_merge(merged, node)
return node