API reference¶
This reference is generated from the source docstrings. The most useful names
are re-exported from the top-level shinobi package and documented here;
supporting types live in their home modules below.
Top-level package¶
- class shinobi.DerivedRead(*, member, family)[source]¶
Bases:
ProductModel- Parameters:
member (Literal['file', 'directory'])
family (FamilySpec)
- member: Literal['file', 'directory']¶
- family: FamilySpec¶
- validate_inputs(fields)[source]¶
Reject attribute/index traversal and unknown inputs at construction.
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.DirectoryBundle(*, path)[source]¶
Bases:
ProductModelOne owned local directory; its recursive inventory is execution evidence.
- Parameters:
path (Path)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.ProductFamily(*, resolved=False, members=())[source]¶
Bases:
ProductModel,Generic[T]- Parameters:
resolved (bool)
members (tuple[ProductMember, ...])
- members: tuple[ProductMember[T], ...]¶
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.ProductMember(*, coordinates, value)[source]¶
Bases:
ProductModel,Generic[T]- Parameters:
- coordinates: dict[str, Coordinate]¶
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.FamilySpec(*, root='.', coordinates, rules, min_members=0, max_members=10000)[source]¶
Bases:
ProductModel- Parameters:
- rules: tuple[MemberRule, ...]¶
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.AxisSpec(*, count_input=None, values_input=None, values=None, tokens=None, uppercase=False, split=None, include=None, suffix='{value}', singleton_suffix=None, aggregate=None)[source]¶
Bases:
ProductModel- Parameters:
count_input (str | None)
values_input (str | None)
values (tuple[Annotated[int, Strict(strict=True)] | Annotated[str, Strict(strict=True)], ...] | None)
uppercase (bool)
split (Literal['csv_or_compact'] | None)
include (tuple[Annotated[int, Strict(strict=True)] | Annotated[str, Strict(strict=True)], ...] | None)
suffix (str)
singleton_suffix (str | None)
aggregate (AggregateSpec | None)
- values: tuple[Coordinate, ...] | None¶
- include: tuple[Coordinate, ...] | None¶
- aggregate: AggregateSpec | None¶
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.MemberRule(*, path, coordinates=<factory>, axes=<factory>, captures=<factory>, when=<factory>, when_set=<factory>, required=False, accept_existing=False, min_members=0)[source]¶
Bases:
ProductModel- Parameters:
- coordinates: dict[str, Coordinate]¶
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.Cab(*, name, info=None, inputs_model, outputs_model, backend=None, image=None, venv=None, input_mutability=<factory>, field_meta=<factory>, dataset_accesses=<factory>, derived_reads=<factory>, cache=None, cache_dir=None, sandbox=None, harvest=<factory>, scratch=<factory>, resources=None, command, flavour='binary', policies=<factory>, input_patterns=<factory>, output_patterns=<factory>, wranglers=<factory>)[source]¶
Bases:
ScopeAn atomic step backed by a single command.
- Parameters:
name (str)
info (str | None)
backend (str | None)
image (str | None)
venv (str | None)
input_mutability (dict[str, Mutability])
dataset_accesses (list[DatasetAccess])
derived_reads (dict[str, DerivedRead])
cache (bool | None)
cache_dir (str | None)
sandbox (bool | None)
resources (Resources | None)
command (str)
flavour (str)
policies (Policies)
input_patterns (list[ParamPattern])
output_patterns (list[ParamPattern])
- input_patterns: list[ParamPattern]¶
- output_patterns: list[ParamPattern]¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.DatasetClosure(*, profile='msv2-dataset-closure/v2', requested_root, storage_namespace, root=None, status, message, resources=(), capabilities=None, unsupported_features=())[source]¶
Bases:
BaseModelSerializable observation of an MSv2’s physical CASA-table resources.
- Parameters:
profile (str)
requested_root (Path)
storage_namespace (Path)
root (Path | None)
status (ClosureStatus)
message (str)
resources (tuple[ClosureResource, ...])
capabilities (ClosureCapabilities | None)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- status: ClosureStatus¶
- resources: tuple[ClosureResource, ...]¶
- capabilities: ClosureCapabilities | None¶
- class shinobi.DatasetAccess(*, field, mode, table=DatasetTable.MAIN, columns=None, selection=None, allow_row_count_change=False, allow_schema_change=False, allow_keyword_change=False, allow_subtable_change=(), allow_present_subtable_rewrite=False, root_field=None, reservation=None)[source]¶
Bases:
BaseModelOne field-local MSv2 access declaration attached to a
Scope.fieldnames an input or output field.root_fieldis needed only whenfielditself names a referenced subtable whose parent MS cannot be inferred from its path (notably an externally stored subtable).reservationis a canonicalizable path envelope used when the dataset path is produced dynamically and is therefore unknown during planning.allow_present_subtable_rewritegrants an explicit MAIN writer backing- file rewrites of supported subtables present before execution and still linked at the same path afterward; table schema authority stays explicit.- Parameters:
field (str)
mode (DatasetMode)
table (DatasetTable)
columns (DatasetColumns | None)
selection (DatasetSelection | None)
allow_row_count_change (bool)
allow_schema_change (bool)
allow_keyword_change (bool)
allow_present_subtable_rewrite (bool)
root_field (str | None)
reservation (Path | None)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- mode: DatasetMode¶
- table: DatasetTable¶
- columns: DatasetColumns | None¶
- selection: DatasetSelection | None¶
- exception shinobi.DatasetAccessError[source]¶
Bases:
ShinobiError,ValueErrorA dataset access declaration cannot be resolved safely.
- class shinobi.DatasetBackendCapability(*, schema_version=1, profile='msv2-backend-route/v1', backend, lifecycle, status, namespace_mode, execution_location, inspection_location, recovery_location, reason)[source]¶
Bases:
BaseModelVersioned capability decision for one backend and lifecycle kind.
inspection_locationandrecovery_locationare explicit because a scheduler accepting a job on one host does not prove that its compute node sees the same path or that restoring the submit host repairs what the task changed.- Parameters:
schema_version (Literal[1])
profile (Literal['msv2-backend-route/v1'])
backend (str)
lifecycle (Literal['contained-native-msv2-read/v1', 'contained-native-msv2-mutation/v1'])
status (DatasetBackendStatus)
namespace_mode (DatasetNamespaceMode)
execution_location (str)
inspection_location (str)
recovery_location (str)
reason (str)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- schema_version: Literal[1]¶
- profile: Literal['msv2-backend-route/v1']¶
- lifecycle: Literal['contained-native-msv2-read/v1', 'contained-native-msv2-mutation/v1']¶
- status: DatasetBackendStatus¶
- namespace_mode: DatasetNamespaceMode¶
- class shinobi.DatasetBackendMount(*, source, target, writable, reason)[source]¶
Bases:
BaseModelOne identity bind required in addition to schema-derived mounts.
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.DatasetBackendPlan(*, schema_version=1, capability, storage_namespace, mounts=())[source]¶
Bases:
BaseModelClosed, data-only plan handed to a local backend adapter.
- Parameters:
schema_version (Literal[1])
capability (DatasetBackendCapability)
storage_namespace (Path)
mounts (tuple[DatasetBackendMount, ...])
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- schema_version: Literal[1]¶
- capability: DatasetBackendCapability¶
- mounts: tuple[DatasetBackendMount, ...]¶
- class shinobi.DatasetBackendStatus(*values)[source]¶
-
Evidence state for one concrete backend/lifecycle route.
- TESTED = 'tested'¶
- UNAVAILABLE = 'unavailable'¶
- UNSUPPORTED = 'unsupported'¶
- class shinobi.DatasetColumns(*, read=(), write=(), create=(), remove=())[source]¶
Bases:
BaseModelColumn-level intent retained for validation and provenance.
Column detail deliberately does not relax scheduling: any writer excludes every concurrent access to the associated resolved dataset closure.
NoneonDatasetAccessmeans the columns are unknown and therefore requests the conservative whole-dataset fallback. Entries may format direct string inputs of their own scope, for example"{column}"or"MODEL_{suffix}". Only undecorated field names are allowed; a missing or None input makes the entire access’s columns unknown.- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.DatasetCacheDecision(*, decision, cache_key, identity, dataset_coverage, reason)[source]¶
Bases:
BaseModelWhich identity and fingerprint coverage one leaf’s cache decision used.
- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- decision: Literal['hit', 'miss', 'rejected-hit', 'disabled']¶
- class shinobi.DatasetFallback(*values)[source]¶
-
Why planning conservatively reserves a whole dataset identity.
- UNDECLARED = 'undeclared'¶
- UNKNOWN_COLUMNS = 'unknown-columns'¶
- UNKNOWN_PATH = 'unknown-path'¶
- class shinobi.DatasetLeafAttempt(*, step_path, accesses, cache=None, pre_observations=(), absent_before=(), post_observations=(), mutations=(), auxiliary_mutations=(), outcome='pending', reason='')[source]¶
Bases:
BaseModelOne atomic step’s strict observations under the workflow’s claim.
- Parameters:
step_path (str)
accesses (tuple[ResolvedDatasetAccess, ...])
cache (DatasetCacheDecision | None)
pre_observations (tuple[DatasetObservation, ...])
post_observations (tuple[DatasetObservation, ...])
mutations (tuple[DatasetMutationRecord, ...])
auxiliary_mutations (tuple[AuxiliaryMutationRecord, ...])
outcome (Literal['pending', 'validated', 'committed', 'reused', 'failed', 'refused'])
reason (str)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- accesses: tuple[ResolvedDatasetAccess, ...]¶
- cache: DatasetCacheDecision | None¶
- pre_observations: tuple[DatasetObservation, ...]¶
- post_observations: tuple[DatasetObservation, ...]¶
- mutations: tuple[DatasetMutationRecord, ...]¶
- auxiliary_mutations: tuple[AuxiliaryMutationRecord, ...]¶
- outcome: Literal['pending', 'validated', 'committed', 'reused', 'failed', 'refused']¶
- class shinobi.DatasetLifecycleAttempt(*, schema_version=1, attempt_id, scope, workspace, phase, events, backends, backend_capabilities=(), capability='contained-native-msv2-read/v1', capability_supported, planned_accesses=(), claim=None, pre_observations=(), post_observations=(), absent_roots=(), leaves=(), recovery=(), overwrites=(), outcome='pending', reason)[source]¶
Bases:
BaseModelVersioned durable provenance for one contained strict workflow.
Schema 1 is the historical read-only baseline. Schema 2 adds per-leaf records for readers and writers, and is required for mutation capability.
backend_capabilitiesrecords the versioned namespace/lifecycle decision for every effective backend in the claimed flat workflow.- Parameters:
schema_version (Literal[1, 2])
attempt_id (str)
scope (str)
workspace (Path)
phase (DatasetLifecyclePhase)
events (tuple[DatasetLifecycleEvent, ...])
backend_capabilities (tuple[DatasetBackendCapability, ...])
capability (Literal['contained-native-msv2-read/v1', 'contained-native-msv2-mutation/v1'])
capability_supported (bool)
planned_accesses (tuple[ResolvedDatasetAccess, ...])
claim (WorkspaceOwner | None)
pre_observations (tuple[DatasetObservation, ...])
post_observations (tuple[DatasetObservation, ...])
leaves (tuple[DatasetLeafAttempt, ...])
overwrites (tuple[DatasetOverwrite, ...])
outcome (Literal['pending', 'committed', 'refused', 'failed'])
reason (str)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- schema_version: Literal[1, 2]¶
- phase: DatasetLifecyclePhase¶
- events: tuple[DatasetLifecycleEvent, ...]¶
- backend_capabilities: tuple[DatasetBackendCapability, ...]¶
- capability: Literal['contained-native-msv2-read/v1', 'contained-native-msv2-mutation/v1']¶
- planned_accesses: tuple[ResolvedDatasetAccess, ...]¶
- claim: WorkspaceOwner | None¶
- pre_observations: tuple[DatasetObservation, ...]¶
- post_observations: tuple[DatasetObservation, ...]¶
- leaves: tuple[DatasetLeafAttempt, ...]¶
- overwrites: tuple[DatasetOverwrite, ...]¶
- outcome: Literal['pending', 'committed', 'refused', 'failed']¶
- class shinobi.DatasetLifecyclePhase(*values)[source]¶
-
Durable phases of one strict dataset attempt.
- PLANNED = 'planned'¶
- CLAIMED = 'claimed'¶
- REVALIDATED = 'revalidated'¶
- EXECUTING = 'executing'¶
- VALIDATED = 'validated'¶
- COMMITTED = 'committed'¶
- REFUSED = 'refused'¶
- FAILED = 'failed'¶
- class shinobi.DatasetLifecycleSnapshot(*, accesses, observations, absent_roots=())[source]¶
Bases:
BaseModelOne complete access-plan and on-disk observation.
absent_rootsare CREATE targets, observed as absent: their predecessor is absence by contract.- Parameters:
accesses (tuple[ResolvedDatasetAccess, ...])
observations (tuple[DatasetObservation, ...])
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- accesses: tuple[ResolvedDatasetAccess, ...]¶
- observations: tuple[DatasetObservation, ...]¶
Bases:
ShinobiErrorExecution was refused for a strict dataset annotation.
Only the contained native MSv2 read and mutation lifecycles are executable. This exception keeps every unsupported route from looking enforced while an annotation is only being carried as a path by that execution stack, and it is also the refusal raised before a strict mutation launches when exact predecessor recovery cannot be promised.
- exception shinobi.DatasetLifecycleViolationError[source]¶
Bases:
ShinobiErrorA strict dataset broke its declared postcondition under its claim.
For a reader, the dataset changed; for a writer, the change exceeded what its access contract permits. A writer’s dataset has been rolled back to its exact predecessor when this is raised, unless a note says otherwise.
- class shinobi.DatasetMode(*values)[source]¶
-
How a step uses an MSv2 dataset.
- READ = 'read'¶
- WRITE = 'write'¶
- CREATE = 'create'¶
- class shinobi.DatasetMutationOutcome(*values)[source]¶
-
What finally happened to one strict dataset a leaf wrote or created.
- PENDING = 'pending'¶
- COMMITTED = 'committed'¶
- ROLLED_BACK = 'rolled-back'¶
- PRE_RUN_RESTORED = 'pre-run-restored'¶
- ABSENT_RESTORED = 'absent-restored'¶
- UNTRUSTED = 'untrusted'¶
- REFUSED = 'refused'¶
- class shinobi.DatasetMutationRecord(*, field, element_index=None, mode, root, predecessor_state, predecessor_signature, predecessor_snapshot, successor_state, predecessor_fingerprint=None, successor_signature=None, successor_fingerprint=None, successor_snapshot=None, signature_coverage='MAIN row count; MAIN column, keyword and subtable names; closure membership, table files and storage managers. Cell values, keyword values and visibility data are not examined.', fingerprint_coverage="Relative path, size and mtime of every regular file in the closure's tables. Detects writes through the filesystem, including in-place cell updates; not a content hash, so a deliberately restored mtime is not detected.", outcome=DatasetMutationOutcome.PENDING, restored_state=None)[source]¶
Bases:
BaseModelPredecessor/successor evidence for one strict write or create.
The four identities in the module docstring are separate fields here on purpose; none of them stands in for another.
- Parameters:
field (str)
element_index (int | None)
mode (DatasetMode)
root (Path)
predecessor_state (str | None)
predecessor_signature (str | None)
predecessor_snapshot (Path | None)
successor_state (str)
predecessor_fingerprint (str | None)
successor_signature (str | None)
successor_fingerprint (str | None)
successor_snapshot (Path | None)
signature_coverage (str)
fingerprint_coverage (str)
outcome (DatasetMutationOutcome)
restored_state (str | None)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- mode: DatasetMode¶
- outcome: DatasetMutationOutcome¶
- class shinobi.AuxiliaryMutationRecord(*, schema_version=1, profile='regular-file/v1', address, path, predecessor_state, predecessor_signature, predecessor_fingerprint, predecessor_snapshot, successor_state, successor_signature=None, successor_fingerprint=None, successor_snapshot=None, outcome=DatasetMutationOutcome.PENDING, restored_state=None)[source]¶
Bases:
BaseModelRegular-file participants in the existing strict mutation group.
- Parameters:
schema_version (Literal[1])
profile (Literal['regular-file/v1'])
address (DerivedAddress)
path (Path)
predecessor_state (str)
predecessor_signature (str)
predecessor_fingerprint (str)
predecessor_snapshot (Path)
successor_state (str)
successor_signature (str | None)
successor_fingerprint (str | None)
successor_snapshot (Path | None)
outcome (DatasetMutationOutcome)
restored_state (str | None)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- schema_version: Literal[1]¶
- profile: Literal['regular-file/v1']¶
- address: DerivedAddress¶
- outcome: DatasetMutationOutcome¶
- class shinobi.DatasetNamespaceMode(*values)[source]¶
-
How host paths relate to paths observed by the executor.
- LOCAL = 'local'¶
- IDENTITY_BIND = 'identity-bind'¶
- SHARED_IDENTITY = 'shared-identity'¶
- REMOTE_DELEGATED = 'remote-delegated'¶
- UNPROVEN = 'unproven'¶
- class shinobi.DatasetOverwrite(*, step, field, path, existed, invalidated)[source]¶
Bases:
BaseModelOne CREATE target an explicit
overwritedeleted before planning.- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.DatasetSelection(*, row_ranges=(), data_description_ids=(), field_ids=(), observation_ids=(), scan_numbers=(), array_ids=())[source]¶
Bases:
BaseModelA bounded, data-only row selection.
Half-open
row_rangesand finite ID sets cover the common static selection shapes without admitting TaQL, Python callables, or another expression language. Scheduling remains conservative at dataset scope.- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.DatasetTable(*values)[source]¶
-
MSv2 tables whose closure membership is understood by Shinobi.
- MAIN = 'MAIN'¶
- ANTENNA = 'ANTENNA'¶
- DATA_DESCRIPTION = 'DATA_DESCRIPTION'¶
- DOPPLER = 'DOPPLER'¶
- FEED = 'FEED'¶
- FIELD = 'FIELD'¶
- FLAG_CMD = 'FLAG_CMD'¶
- FREQ_OFFSET = 'FREQ_OFFSET'¶
- HISTORY = 'HISTORY'¶
- OBSERVATION = 'OBSERVATION'¶
- POINTING = 'POINTING'¶
- POLARIZATION = 'POLARIZATION'¶
- PROCESSOR = 'PROCESSOR'¶
- SOURCE = 'SOURCE'¶
- SPECTRAL_WINDOW = 'SPECTRAL_WINDOW'¶
- STATE = 'STATE'¶
- SYSCAL = 'SYSCAL'¶
- WEATHER = 'WEATHER'¶
- class shinobi.ExecContext(scope, raw_inputs, *, backend_override=None, recipe_backend=None, config=None, cache_enabled=False, cache_forced_off=False, cache_dir='', cache_path='', stream=True, pin=False, sandbox_root=None, clear_outputs=True, input_keys=None, budget=None, run_id='', validated_inputs=None, planned_inputs=None, planned_leaf_inputs=None, leaf_inputs=None, dataset_lifecycle=None, publication_gate=None)[source]¶
Bases:
objectLive execution state, created by _dispatch. inputs is a validated snapshot for inspection; the raw caller kwargs are kept separately because MUTABLE fields must reach the backend as the caller’s original objects.
- Parameters:
scope (Scope)
backend_override (str | None)
recipe_backend (str | None)
config (AppConfig | None)
cache_enabled (bool)
cache_forced_off (bool)
cache_dir (str)
cache_path (str)
stream (bool)
pin (bool)
sandbox_root (str | None)
clear_outputs (bool)
budget (Budget | None)
run_id (str)
validated_inputs (BaseModel | None)
planned_inputs (BaseModel | dict[str, Any] | _ProductReservations | None)
planned_leaf_inputs (dict[int, _ProductReservations] | None)
dataset_lifecycle (Any | None)
publication_gate (Callable[[Callable[[], None]], None] | None)
- prepare_inputs()[source]¶
Validated + mutability-processed inputs, with no overrides applied – for a plain-function step’s own function to call the underlying function with (see steps/pyfunc.py’s adapter, and the manual bare-Scope pattern documented on Scope/StepRef). Reuses the already-validated self.inputs snapshot rather than re-validating.
- resolve_backend_name(override=None)[source]¶
Resolve the effective backend name using the standard priority chain. Exposed so orchestration functions (e.g. the pystep adapter) can inspect which backend is active without duplicating the precedence logic.
- validate_contained_inputs(prepared=None)[source]¶
Check every leaf launcher, including native and manual functions.
- workflow_dataset_resources()[source]¶
Every protected resource, including other leaves and planned creates.
- dataset_backend_plan(backend_name, prepared=None)[source]¶
Prepared namespace plan for an atomic leaf under a strict lifecycle.
The outer lifecycle has already resolved and claimed the workflow’s authoritative access set. Resolving this leaf again from the exact prepared values checks that an annotated leaf receives only paths covered by that claim. Every workflow root is then re-asserted read-only for the container, upgraded only for roots this leaf declares write/create access to; an unannotated leaf gets no upgrade. The strict-leaf wrapper uses the same resolution and claim helper for its bookkeeping. Keeping plan construction here gives cab and pystep container launchers one shared adapter.
- import_callable(name, module=None)[source]¶
Import and return a callable by name.
If module is None, looks up name in builtins (e.g.
print,len). Otherwise imports module – which may be a dotted path of any depth ("astropy.io.fits") – and returns getattr(module, name).Useful for pysteps that invoke container-only callables (e.g. CASA tasks) without triggering linter warnings about missing imports on the host.
Callable, not function, and the distinction is not pedantry: what a pystep asks for here is a class about as often as it is a function (
casacore.tables.table,astropy.wcs.WCS,astropy.coordinates.SkyCoord), and numpy hands back neither –numpy.log10is aufuncandnumpy.linspacean_ArrayFunctionDispatcher. The one property all of them share is the one the caller is about to rely on, so that is the one checked.The check is what makes the split point safe. This always returns an attribute of a module, never a module: name the full dotted path in module and the attribute in name. Splitting it at the wrong point asks for a submodule as though it were an attribute of its parent, and whether that raises depends on the package –
("ndimage", "scipy")quietly returns the module on any scipy new enough to expose its subpackages lazily, while("tables", "casacore")and("fits", "astropy.io")raise AttributeError. Version-dependent luck is a bad way to learn the path was wrong, so a non-callable result is a TypeError here rather than a mystery at the first call site. Use import_module when the step wants the module.
- import_func(func, module=None)[source]¶
Deprecated alias for import_callable.
Kept because it is what every existing pystep calls, and renaming a method a container shim lifts by source is not a change worth breaking a caller over. The name was wrong – half of what real pysteps ask for is a class, not a function – but the behaviour is import_callable’s, including its callability check, so there is exactly one implementation to reason about.
- import_module(module)[source]¶
Import and return a module by its full dotted path.
The companion to import_callable for the case where a pystep wants the module itself rather than one of its attributes –
np = ctx.import_module("numpy"),fits = ctx.import_module("astropy.io.fits"). import_callable cannot express this: its one-argument form is a builtins lookup, and its two-argument form ends in a getattr, which does not reach a submodule (import_module does not bind one onto its parent package) – and now rejects the module it would occasionally have returned by accident.Kept as a separate name rather than folded into import_callable’s one-argument form so the return type stays predictable per method instead of varying with whatever the name happens to resolve to.
- Parameters:
module (str)
- Return type:
- run(*, backend=None, **overrides)[source]¶
Run the underlying Cab or Recipe with optional input overrides.
- Parameters:
- Returns:
The step’s StepResult. Also stored on self.outputs.
- Raises:
TypeError – If self.scope is neither a Cab nor a Recipe (a plain-function step must return its result directly instead of calling ctx.run()).
- Return type:
- class shinobi.InputRef(*, field)[source]¶
Bases:
BaseModelWiring source: this sub-step’s input comes from the enclosing Recipe’s own input field field.
- Parameters:
field (str)
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.LoopIteration(*, loop, index, prev_step=None, sentinel_step=None, sentinel_field)[source]¶
Bases:
BaseModelMarks a step as belonging to iteration index of the unrolled loop loop (see Recipe.add_loop). Carried by every step the unrolling produces; it is bookkeeping for the skip decision only – the dependency edges that make the unrolled chain a real DAG are ordinary wiring plus after, never this.
prev_step is the same body step one iteration earlier (so selfcal.4.image passes through selfcal.3.image), and sentinel_step/sentinel_field name the previous iteration’s output whose existence on disk means “the loop has already converged, do no work”. prev_step and sentinel_step are both None for the first iteration, which can never skip – there is nothing before it to pass through or to have converged. sentinel_field is always set: which output carries the signal is a property of the loop, not of one iteration.
- Parameters:
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.LoopRef(name, steps, final)[source]¶
Bases:
objectWhat Recipe.add_loop returns: the loop’s step names, and an outputs proxy resolving to the final iteration’s producers.
Not a StepRef – a loop is not a step. It has already been unrolled into the recipe’s steps by the time this is returned; this is just a handle for wiring whatever comes next.
- property outputs: _LoopOutputsProxy¶
Wiring proxy (definition layer) – NOT runtime values.
- class shinobi.Mutability(*values)[source]¶
-
Whether a step’s input may be changed in place by the step’s own orchestration function without that change propagating back to the caller’s object.
- IMMUTABLE = 'immutable'¶
- MUTABLE = 'mutable'¶
- class shinobi.OutputRef(*, step, field, selection=None)[source]¶
Bases:
BaseModelWiring source: this input (or, in Recipe.output_wiring, the recipe’s own output) comes from step step’s output field field.
- Parameters:
- selection: dict[str, Coordinate] | None¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.Recipe(*, name, info=None, inputs_model, outputs_model, backend=None, image=None, venv=None, input_mutability=<factory>, field_meta=<factory>, dataset_accesses=<factory>, derived_reads=<factory>, cache=None, cache_dir=None, sandbox=None, harvest=<factory>, scratch=<factory>, resources=None, steps=<factory>, output_wiring=<factory>, max_workers=None)[source]¶
Bases:
ScopeA composite step: declared sub-steps with explicit wiring.
The one deliberately mutable Scope: builder methods (add_step, step, set_output) extend steps/output_wiring before first run.
- Parameters:
name (str)
info (str | None)
backend (str | None)
image (str | None)
venv (str | None)
input_mutability (dict[str, Mutability])
dataset_accesses (list[DatasetAccess])
derived_reads (dict[str, DerivedRead])
cache (bool | None)
cache_dir (str | None)
sandbox (bool | None)
resources (Resources | None)
max_workers (int | None)
- property inputs: _InputsProxy¶
Wiring proxy (definition layer) – NOT runtime values.
- property outputs: _OutputsProxy¶
Wiring proxy (definition layer) – NOT runtime values.
- add_step(name, scope, *, scatter=None, resources=None, **kwargs)[source]¶
Add a step. scope is usually a bare Scope/Cab/Recipe, but can also be an already-built StepRef (e.g. from @shinobi.pystep or @shinobi.step) – its func is carried over so the step keeps its orchestration function, not just its schema.
- Parameters:
scatter (list[str] | ScatterSpec | None) – Fields to fan out over. Each must be a field of the step’s inputs_model; at runtime the corresponding value must be a list, and every scattered field must have the same length.
resources (Resources | None) – What this step costs to run, for the scheduler’s admission control. An explicit keyword rather than a kwarg, since **kwargs here is split into wiring and step params and would otherwise swallow it as a constant input value.
name (str)
kwargs (Any)
- Return type:
- add_loop(name, body, *, max_iter, until, carry, index_input=None, **kwargs)[source]¶
Unroll body max_iter times into this recipe, as a declared chain.
The loop is bounded unrolling with short-circuit pass-through: the body’s steps are flattened into this recipe once per iteration and chained by real carry wiring, so the whole thing is an ordinary statically-inspectable DAG – –dryrun renders every iteration and the offload compiler emits a plain dependency chain. The only runtime decision is whether an already-declared step does any work: once an iteration writes the until sentinel file, every later step passes the corresponding previous iteration’s outputs straight through.
A Recipe body is flattened (selfcal.3.image); any other Scope is one step per iteration (selfcal.3).
- Parameters:
name (str) – The loop’s name; every step it creates is prefixed with it.
body (Scope | StepRef) – The loop body. Its outputs must be re-consumable as its inputs (see carry) – a loop body is a fixed point.
max_iter (int) – How many times to unroll. The upper bound on iterations, not the exact count.
until (str) – Name of a path-typed output of body. Once that file exists, the loop has converged. A path (rather than a bool) is what lets the identical predicate serve both local execution and an offloaded sbatch script.
carry (dict[str, str]) – Body output field -> body input field, the loop-carried dependency. Required and explicit: these pairs are the edges between iterations, and inferring them from matching names would make the graph’s shape depend on name coincidence.
index_input (str | None) – Name of a body input to bind to the 1-based iteration number. Without it every iteration resolves to identical values, so a body cannot name its outputs per cycle (a per-cycle image prefix, say) – which real self-calibration does. Steps consuming it must tolerate a changing value: it is the one input that deliberately differs between iterations.
**kwargs (Any) – Iteration 1’s inputs, split into wiring and constants exactly as add_step does. Constants apply to every iteration.
- Returns:
A LoopRef whose outputs proxy resolves to the final iteration.
- Return type:
- step(scope, *, backend=None, scatter=None, resources=None, **kwargs)[source]¶
Decorate a function as a new step appended to this recipe.
- Parameters:
scope (Scope) – The Cab, Recipe, or bare Scope to bind as this step.
backend (str | None) – Backend override for this step.
scatter (list[str] | ScatterSpec | None) – Fields to fan out over (see add_step).
resources (Resources | None) – What this step costs to run (see add_step).
**kwargs (Any) – Split into wiring (InputRef/OutputRef values) and per-step constant params via _split_kwargs.
- Returns:
A decorator that binds the given function, appends the resulting StepRef to self.steps, and returns it.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.RecipeAccessPlan(graph, accesses, reasons)[source]¶
Bases:
objectA graph augmented with deterministic access-hazard edges.
- Parameters:
- accesses: dict[str, tuple[ResolvedDatasetAccess, ...]]¶
- class shinobi.ResolvedDatasetAccess(*, field, element_index=None, declaration, requested_path, root, resources, mode, whole_dataset, path_known, columns_known, fallback=None, closure_status=None, reason)[source]¶
Bases:
BaseModelSerializable resolution of one dataset access declaration.
declarationis the effective copy with concrete column names, orcolumns=Nonewhen input values are unavailable. The scope retains its original templates; existing literal-column records are unchanged.- Parameters:
field (str)
element_index (int | None)
declaration (DatasetAccess)
requested_path (Path | None)
root (Path | None)
mode (DatasetMode)
whole_dataset (bool)
path_known (bool)
columns_known (bool)
fallback (DatasetFallback | None)
closure_status (ClosureStatus | None)
reason (str)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- declaration: DatasetAccess¶
- mode: DatasetMode¶
- fallback: DatasetFallback | None¶
- closure_status: ClosureStatus | None¶
- property address: DatasetAddress¶
- class shinobi.ScatterSpec(*, fields)[source]¶
Bases:
BaseModelDeclaration that a step should fan out over one or more list-typed input fields. Each listed field must be a list at runtime and all listed fields must have the same length. The step is executed once per index, with slice i receiving element i of every scattered field.
The step’s own inputs_model/outputs_model describe one slice. A downstream step sees the scattered step’s outputs gathered into lists (one element per slice), so it can scatter over them in turn or consume the whole list as a gathered result.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.Scope(*, name, info=None, inputs_model, outputs_model, backend=None, image=None, venv=None, input_mutability=<factory>, field_meta=<factory>, dataset_accesses=<factory>, derived_reads=<factory>, cache=None, cache_dir=None, sandbox=None, harvest=<factory>, scratch=<factory>, resources=None)[source]¶
Bases:
BaseModelDefinition: schema, metadata, backend config. Never carries inputs/outputs/func fields – those live in ExecContext/StepRef.
Cab/Recipe are the two execution-aware subclasses ExecContext.run() knows how to run. A bare Scope is also valid – it’s the manual building block for a plain-Python-function step whose own function returns its StepResult directly rather than calling ctx.run(); see StepRef’s docstring and steps/pyfunc.py’s @shinobi.pystep (which automates this pattern from a function’s own signature).
image is optional: when set on a bare Scope (typically via @shinobi.pystep(image=…)), the step’s Python function can be executed inside a container instead of in-process. Cab inherits this field for the same purpose (container backends need it to wrap argv in a runtime invocation).
- Parameters:
- input_mutability: dict[str, Mutability]¶
- dataset_accesses: list[DatasetAccess]¶
- derived_reads: dict[str, DerivedRead]¶
- mutability_of(field)[source]¶
Look up the declared mutability of an input field.
- Parameters:
field (str) – Name of the input field.
- Returns:
The field’s Mutability, defaulting to Mutability.IMMUTABLE if not explicitly declared.
- Return type:
- with_backend(backend)[source]¶
A copy bound to backend, or self unchanged if backend is None. Shared by @shinobi.step and Recipe.step, which both bind a per-step backend override onto a Scope before wrapping it in a StepRef.
- with_resources(resources)[source]¶
A copy bound to resources, or self unchanged if None. Same shape as with_backend, and used the same way: Recipe.add_step, Recipe.step and @shinobi.step bind a per-step footprint onto a Scope before wrapping it in a StepRef, so one shared Cab can be declared cheap in one recipe position and expensive in another without either mutating the Cab itself.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.StepRef(*, name, step, func=None, wiring=<factory>, params=<factory>, scatter=None, after=<factory>, loop=None)[source]¶
Bases:
BaseModelA named, executable binding of a Scope: orchestration function, wiring (meaningful only inside a Recipe), and per-step constants. Returned by @shinobi.step (free-standing) and @recipe.step (appended to recipe.steps). arbitrary_types_allowed is needed only for func.
step is typed as the general Scope (not Cab | Recipe) so it can also hold a bare Scope – the manual, no-magic way to write a plain-Python-function step: build Scope(name=, inputs_model=, outputs_model=) yourself, write a function that always returns its own StepResult (never calls ctx.run(), which only knows how to execute a Cab or Recipe), and wrap it in a StepRef directly. @shinobi.pystep (steps/pyfunc.py) automates exactly this pattern by deriving the Scope’s schema from the function’s own signature. Passing a Cab/Recipe instance here is unaffected – pydantic’s default revalidate_instances=”never” keeps an already-constructed instance’s real subtype, it does not downcast to bare Scope.
- Parameters:
- model_config = {'arbitrary_types_allowed': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- scatter: ScatterSpec | None¶
- loop: LoopIteration | None¶
- shinobi.dataset_backend_capability(backend, *, mutation)[source]¶
Return the explicit capability decision for
backend.Local processes and virtualenvs share the lifecycle controller’s namespace directly. The four local container runtimes use identity bind mounts augmented from the resolved closure plan; daemon-backed runtimes are unavailable when their configuration does not prove a local daemon. The blocking Slurm backend deliberately remains unavailable: it has no validated submit-host-to-executor namespace mapping and does not run observation and recovery at the execution authority.
remoteis a launch surface, not a local backend; the remoteninja runmust make its own decision.- Parameters:
- Return type:
- shinobi.dataset_attempt_path(workspace, attempt_id)[source]¶
Return the durable local attempt-record path.
- shinobi.pystep(*, name=None, info=None, image=None, venv=None, backend=None, cache=None, cache_dir=None, sandbox=None, harvest=None, write_paths=None, dataset_accesses=None, **params)[source]¶
Decorate (or directly call on an existing function, matching @shinobi.step’s precedent: pystep()(existing_func)) a plain, type-hinted function to turn it into a StepRef. See the module docstring for the schema-derivation and outputs rules.
image enables container execution: when set and a container backend is resolved, the function runs inside the specified container image instead of in-process. The function’s source module is mounted into the container so it can be imported by the runner script.
venv enables virtualenv execution: when set (a path or a key into backend.venv.envs) and the venv backend is resolved, the function runs under that venv’s own interpreter, importing the venv’s real packages (unlike the container path, the target’s own package is not stubbed). A scope may carry both image and venv; the resolved backend name decides which is used.
backend sets the default backend for this step (same as on any Scope). With image, this is typically a container backend name like
"docker"or"apptainer"; with venv,"venv".cache/cache_dir set this step’s cache policy, with the same precedence as the corresponding fields on any Scope. In particular,
cache=Falselets a function whose products depend on run-time choices opt out when one static output schema cannot describe them safely.sandbox/harvest opt this step into sandboxed execution and declare extra keep-globs (see shinobi.sandbox and the fields on Scope). Sandboxing applies to the out-of-process paths (container and venv) – an in-process run ignores it (os.chdir is process-global, and recipes run steps on a thread pool), executing in the caller’s cwd as always.
write_paths names the parameters that are destinations this function creates rather than data it reads (ParamMeta.write_path) – the one thing no signature can express, since outputvis: Path returned as the output and vis: Path rewritten in place are the same annotation. Only a named parameter has its stale product cleared before a re-run (sandbox.clear_stale_outputs); everything else is treated as the caller’s data and left alone.
dataset_accesses attaches the same serializable MSv2 access contracts a binary Cab carries on Scope; it does not inspect a path while the function is decorated.
**params are per-call constants, same as @shinobi.step.
- Parameters:
- Return type:
- shinobi.plan_recipe_accesses(recipe, inputs, *, workspace=None, validated_steps=None, validated_inputs=None, overwrite_steps=frozenset({}))[source]¶
Resolve dataset contracts and add backward hazard edges before dispatch.
Explicit wiring/
afteredges determine the stable topological order. Otherwise-unordered access conflicts are then oriented backwards in that order, so the result is deterministic and cannot invent data lineage. Nested recipes and dataset-bearing scatter are refused explicitly in this first bounded planner rather than partially planned.
- shinobi.resolve_dataset_closure(path, *, storage_namespace, max_resources=32, max_entries_per_resource=100000)[source]¶
Resolve an MSv2 into canonical physical CASA-table resources.
Resolution detects changes across its reads, but filesystem observation is not an atomic snapshot. Consumers must use a cooperative immutable or snapshot boundary and revalidate the observation before acting on it.
- shinobi.read_dataset_attempt(path)[source]¶
Read and validate one durable lifecycle attempt record.
- Parameters:
path (Path)
- Return type:
Schema helpers¶
Supporting types used when defining cabs, not re-exported at the top level.
- class shinobi.derived.DerivedAddress(*, name, coordinates)[source]¶
Bases:
ProductModel- Parameters:
- coordinates: dict[str, Coordinate]¶
- property field¶
- property element_index¶
- property label¶
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.steps.schema.ParamMeta(*, nom_de_guerre=None, implicit=None, info=None, positional=False, positional_head=False, repeat_as_tokens=False, dtype=None, choices=None, nullable=None, string_pattern=None, abbreviation=None, write_path=False, writable=None, family=None)[source]¶
Bases:
BaseModelPer-field metadata a plain pydantic model can’t express: the name the underlying tool actually expects (nom_de_guerre), a value always supplied by the cab itself rather than the caller (implicit), human-facing help (info), the cab dtype string (dtype, e.g. “File”/”MS”) for a ParamPattern attr – since a dynamically-named param has no declared field/type annotation for path_fields to inspect, this is how backends know to bind-mount its directory – positional: emitted as a bare value (no –flag), in field-declaration order, after every flagged/pattern-matched arg. positional_head: the same, but emitted before every flagged/ pattern-matched arg instead of after – real cult-cargo’s own cubical.yml names this exact policy (parset: {policies: {positional_head: true}}) for a tool whose own CLI only recognises a leading bare token as a parset file to seed defaults from (CubiCal’s main.py: if len(sys.argv) > 1 and not sys.argv[1][0].startswith(‘-‘): custom_parset_file = sys.argv[1]); killMS’s kMS.py has the identical sys.argv[1]-only check. A plain positional field there would always land after every –flag, which these two tools’ own argv[1]-anchored parset detection can’t see – either raising (“Unexpected number of arguments”, CubiCal) or silently not reading the parset at all (killMS, which never validates leftover-arg count). Setting both positional and positional_head on the same field is nonsensical; positional_head wins if both are set. Head positionals, like tail ones, are emitted in field-declaration order. – and repeat_as_tokens: a list/tuple value is emitted as separate bare argv tokens (after the one flag occurrence, or as separate positional tokens) instead of joined into one comma-separated token – real cult-cargo cabs express this as a per-field policies: {repeat: list} (see e.g. wsclean’s -size <w> <h>/-weight briggs <n>, which need two separate argv tokens, not “4096,4096” as one).
write_path: this string-typed input names a filesystem path the tool writes to. Two shapes, both covered: a stem products are built from (wsclean’s prefix, ddfacet’s Output-Name, which never exists as a file itself), and a complete path written directly (a cache directory, a logfile). It is a declaration of intent only, and deliberately changes nothing about how the value is handled. In particular it does not make the field a path: a path dtype here would be rewritten by sandbox.absolutize_path_inputs and the tool would then write outside the sandbox, which is precisely why the convention types these as strings (see declared_output_dirs). Mounting and pre-creation still come from the output side – a path-typed output whose implicit template names this field, a harvest glob, or a scratch glob.
What it buys is enforcement. That convention previously existed only as prose and authorial discipline: a write path nobody declared a target for produces no error, just products that stay inside the container or outside the harvest. Marking the field lets Cab check that something does declare it (see Cab._write_path_declares_a_write_target).
A path-typed input may be marked too, and there it is the only thing that distinguishes the two identical-looking spellings of an output field that echoes a same-named input.
mstransformtakesoutputvisand creates it;flagdatatakesvisand rewrites the caller’s own data; both declare that one name on inputs_model and on outputs_model, so mutated_path_fields – and any other structural test – sees one shape. Markingoutputvissays which it is, and sandbox.clear_stale_outputs then clears the stale product before a re-run instead of leaving the tool to trip over it. The default (unmarked) is the safe reading: an echoed path is the caller’s data and is never deleted. Note the marker still changes nothing about how the value is handled – a path-typed one is anchored at the workspace by sandbox.absolutize_path_inputs exactly as before, which is what a complete destination path (as opposed to a stem) wants.writable: this path-typed input is one the tool must not modify (writable: false, cult-cargo/scabha’s own key). The container backends bind-mount the directories a read-only input contributes :ro, and re-assert the input itself :ro at its own path when something writable shares that directory (backends.container.bind_dir_modes). None – the default, and every Python-typed pystep input – means unmarked, which is read as writable. Carried onto a declared field’s json_schema_extra by the loaders (readonly_path_fields reads it from there, the same channel abbreviation rides); kept here as well because a ParamPattern attr has no declared model field for readonly_path_fields to inspect, and a dynamically-named input is exactly the case that wants the marker – QuartiCal’s <term>.load_from names a previous run’s gain store, which the step reads and must not write back into.
choices: the field’s allowed values (cult-cargo/classic’s choices key). A loader that sets this also narrows the field’s real annotation on inputs_model/outputs_model to typing.Literal[*choices] (see loaders._modelgen.narrow_choices), so an out-of-set value fails pydantic validation the same way a wrong dtype would – not merely documented in info. Kept here too (rather than only inferred from the model’s own annotation) so a ParamPattern attr – which has no declared model field for a dynamically-matched name – can still carry it.
string_pattern: a literal scalar string field’s validation regex. Static loaders attach it to the generated pydantic annotation, preserving its JSON-schema pattern. Python authors use a constrained model field. It is not a path classifier, a dynamic attr validator, or executable cab code.
abbreviation: a short single-dash CLI alias for the field’s generated –long-flag (cult-cargo/classic’s abbreviation key, e.g. simms’ ascii-sky -> -as). Purely a ninja run convenience – carried onto the field’s json_schema_extra by the loaders so clickutil.build_options can emit the alias; it never affects the argv the tool actually receives (that still uses nom_de_guerre).
On an output field, a string implicit containing {name} placeholders is resolved by steps.dispatch._fill_outputs as a str.format template against the step’s prepared (validated) input values – e.g. implicit=”{prefix}-MFS-image.fits” derives a tool’s output path from its own prefix input, without shinobi ever importing/executing the tool’s own schema-generation code. A plain string with no {…} is used as a literal constant, same as on an input field.
- Parameters:
- implicit: Any¶
- family: FamilySpec | None¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.steps.schema.ParamPattern(*, separator='.', segments)[source]¶
Bases:
BaseModelA family of inputs whose names are <segment><separator><segment>…, e.g. QuartiCal’s K.type/G.time_interval or cubical’s g1-solvable/ g-time-int. Matched as one anchored regex assembled from segments: exactly one segment is attrs (the known, enumerable part, each value with its own ParamMeta – dtype/nom_de_guerre/info); every other segment is a regex (soft shape-validation of a level that can’t be enumerated ahead of time). See AGENTS.md for the motivating tools.
The attrs segment is usually last (cubical/QuartiCal: an unenumerable term name followed by a known attribute, g1.solvable), but doesn’t have to be – wsclean’s dynamic output names are the opposite shape, a known/enumerable image type followed by an open-ended qualifier tail (dirty.per-band, restored.i.per-interval.mfs), so attrs there is the first segment. Only one segment may carry attrs; the rest must all be regex.
A segment regex that should behave as an unconstrained “match anything” level (the old design’s prefix) should be written lazily (.+?, not .+): with more than one registered attr, an eager .+ prefers the shortest attr that completes an overall match, which is wrong when one attr is itself a suffix of another (e.g. “int” vs “time-int” with separator “-”) – .+? tries the shortest prefix first, which is exactly “prefer the longest/most specific attr”.
- Parameters:
separator (str)
segments (list[ParamSegment])
- segments: list[ParamSegment]¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- model_post_init(context, /)¶
This function is meant to behave like a BaseModel method to initialize private attributes.
It takes context as an argument since that’s what pydantic-core passes when calling it.
- Parameters:
self (BaseModel) – The BaseModel instance.
context (Any) – The context.
- Return type:
None
- class shinobi.steps.schema.ParamSegment(*, regex=None, attrs=None)[source]¶
Bases:
BaseModelOne level of a dotted/dashed dynamic-parameter name. A “shape” segment carries only regex – soft validation, no metadata, for a level whose actual values can’t be enumerated at cab-authoring time (e.g. a solver term name like QuartiCal’s K/G). The “meta” segment – always the last one in a ParamPattern – carries attrs: the known, enumerable part, each value with its own ParamMeta.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.steps.schema.Policies(*, prefix='--', replace=<factory>, list_sep=', ', repeat_list=False, key_value=False, repeat=None, explicit_true=False, explicit_false=False, true_token='true', false_token='false')[source]¶
Bases:
BaseModelHow a cab’s parameters are turned into command-line arguments.
key_value/repeat mirror real cult-cargo cab-level policy keys verbatim (e.g. QuartiCal’s policies: {key_value: true, repeat: ‘[]’, prefix: ‘’}): key_value=True means a hydra-style single name=value argv token instead of two tokens (–name, value); repeat=”[]” means a list value formats as one bracketed-literal token (solver.terms=[K,G]) instead of list_sep-joining. Distinct from a per-field ParamMeta.repeat_as_tokens (real per-field policies: {repeat: list}, e.g. wsclean’s bare -size 4096 4096), which is a field-level override and takes precedence when set.
explicit_true/explicit_false also mirror real cult-cargo cab-level policy keys verbatim (e.g. CubiCal’s policies: {explicit_true: true, explicit_false: false}): by default a True boolean value emits as a bare flag (–flag, argparse store_true-style) and False is omitted entirely. Some real CLIs (CubiCal’s own optparse-derived parser among them) instead expect every boolean option to always take an explicit value token – passing a bare flag with no value corrupts parsing of everything after it, since the parser consumes the next token as that flag’s value. explicit_true=True emits –flag true (two tokens, “true”/”false” lowercase) instead of a bare flag when the value is True; explicit_false=True does the same instead of omitting the flag when the value is False. Each direction is independent (CubiCal only needs explicit_true, never explicit_false), and this applies uniformly to declared fields and ParamPattern-matched dynamic ones (e.g. CubiCal’s own per-Jones-term g-solvable).
true_token/false_token are what those two tokens actually say. Lowercase “true”/”false” (the default) is what CubiCal’s parser reads, but it is not universal: DDFacet and killMS share a parset reader (DDFacet.Parset.ReadCFG) that parses “0”/”1” as ints and “True”/”False” as bools, and leaves an unrecognised “false” as a string – which, being non-empty, is truthy. –Mask-Auto false there switches the mask on. So the pair of tokens is a cab-level policy of its own (policies: {explicit_true: true, explicit_false: true, true_token: ‘1’, false_token: ‘0’} for those two), alongside the explicit_* switches rather than instead of them: explicit_* decides whether a value token is emitted, these decide what it reads.
They spell a boolean in every value position, not only after a flag: a key_value cab’s single name=value token, a positional, an element of a joined or bracketed list, one occurrence of a repeated flag. Python’s own str(True) is “True”, so a path that missed this emitted a spelling no policy ever asked for (policies._scalar_token is the one place that decides).
- Parameters:
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.steps.schema.ScatterSpec(*, fields)[source]¶
Bases:
BaseModelDeclaration that a step should fan out over one or more list-typed input fields. Each listed field must be a list at runtime and all listed fields must have the same length. The step is executed once per index, with slice i receiving element i of every scattered field.
The step’s own inputs_model/outputs_model describe one slice. A downstream step sees the scattered step’s outputs gathered into lists (one element per slice), so it can scatter over them in turn or consume the whole list as a gathered result.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- shinobi.steps.schema.path_fields(model)[source]¶
Names of every field of model whose direct-value leaf type is a filesystem path (
pathlib.Path). File-like cab dtypes (File/MS/Directory/URI) map to Path, so this drives both container bind-mounting and the CLI’sclick.Path()mapping. Direct-value containers include concrete and abstract sequences and sets plus tuples.
- shinobi.steps.schema.declared_output_dirs(scope, prepared, *, workspace=None)[source]¶
(directory, source)for every directory the step is declared to write into, resolved before the run from the declarations alone – never from the shape of an input’s value.This is the answer to “where does this tool put its products”, which a field’s dtype cannot express: the established way to declare an output stem is a string-typed input (wsclean’s
prefix, ddfacet’sOutput-Name), deliberately so, because a path dtype would be rewritten by sandbox.absolutize_path_inputs and the tool would then write outside the sandbox. Such an input can say so withParamMeta.write_path, which changes nothing here – this function reads the declarations either way – but lets Cab reject a stem that no declaration names, which is the authoring mistake this convention is otherwise silent about. The write target is instead declared by the output side: a path-typed output field whoseimplicittemplate names the input ("{prefix}-MFS-image.fits"), aharvestglob, or ascratchglob for a write target that is not a product.Knowable pre-run means the parent of each declared_output_paths entry (per path-typed output field, its value taken from a same-named input, its resolved
implicittemplate, or its field default – the same priority as _fill_outputs), then the literal (glob-free) directory prefix of eachharvestandscratchpattern. source describes the declaration (output 'restored_image',harvest pattern '{prefix}-*.fits',scratch pattern '{cache_dir}/*') so callers can name it in an error.A pattern that references a field whose value is None is skipped, not resolved: substituting it would produce a literal
Nonepath segment ("{cache_dir}/*"->"None/*"), which is a real directory name and would have the sandbox create one. An unset optional write target declares nothing, which is the same answer a path-typed output whose value is None already gives.Both relative and absolute directories are returned, order-preserving and de-duplicated;
.and anything containing..is dropped. Execution pre-creates these directories at the tool’s actual cwd, including absolute destinations. The container backend bind-mounts the absolute ones so a product declared outside the workdir reaches the host. Both consumers treat ascratchdirectory exactly like a product’s: it is mounted so the tool’s write lands on the host, and pre-created so the tool doesn’t have to. Only harvest differs – it never rescues ascratchpath out of a sandbox, which is the whole point of the distinction. Best-effort by design – a template that fails to resolve is skipped here, not raised; output filling and harvest report those errors with full context.
- shinobi.steps.schema.declared_output_paths(scope, prepared, *, workspace=None)[source]¶
(path, source)for every path-typed output field the step is declared to produce, resolved before the run from the declarations alone – the products themselves, where declared_output_dirs (built on this) reports only the directories they land in.Knowable pre-run means, per path-typed output field and in declaration order: its value taken from a same-named input, its resolved
implicittemplate, or its field default – the same priority as _fill_outputs. A list-valued field contributes each element. A field that resolves to None contributes nothing (an unset optional product declares no path). source names the declaration (output 'restored_image') so callers can report it.Deliberately not included: harvest/scratch glob patterns. Those describe a family whose member names the tool chooses at run time, rather than concrete products. Bounded families can be proved disjoint from protected datasets before execution by
product_pattern_issue, while harvesting still distinguishes a declared destination from a glob-matched one.Best-effort by design – a template that fails to resolve is skipped here, not raised; output filling reports those errors with full context.
- shinobi.steps.schema.readonly_path_fields(model, field_meta=None)[source]¶
Names of the path_fields explicitly marked
writable: falsein their schema. The container backend bind-mounts the directories these contribute read-only (backends.container.bind_dir_modes). A path field with nowritablemarker (the default, including every Python-typed pystep input) is treated as writable.Two spellings of the same marker, because a field has two places to carry one and a cab author should not have to know which: the loaders (
yaml_cab,worker_schema) put it on the field’sjson_schema_extra, and a Python-authored cab setsParamMeta.writablein field_meta. Either says read-only; pass field_meta (a Scope always has one) so both are seen. Reading only the model would accept-and-drop the Python spelling, which is the failure this marker exists to prevent.Declared fields only. A ParamPattern-matched input has no model field at all, so it declares the marker on its attr’s ParamMeta.writable, which bind_dir_modes reads directly.
- shinobi.steps.schema.mutated_path_fields(scope)[source]¶
Names of the path inputs scope rewrites in place.
Three spellings, all meaning the same thing: the field is declared on outputs_model as well as inputs_model, its input is declared Mutability.MUTABLE (the flag/gaincal/applycal shape, where a cab rewrites its MS without re-declaring it as an output), or a DatasetAccess declares
mode="write"on the input. The last is the strict MSv2 spelling of the second; leaving it out would let the cache key fingerprint a dataset its own step rewrites while the snapshotter ignored it.This lives here, in schema, rather than in either of its two callers, because they must never disagree. cache.compute_cache_key drops these fields from the key; shinobi.snapshots decides what to snapshot from exactly the same set. If one grew a case the other lacked, the cache would key a step on content the snapshotter had already rolled back – the drift is silent and the failure is wrong science, so there is one computation and both import it.
- shinobi.steps.schema.paths_overlap(a, b)[source]¶
Whether two canonical paths can name the same bytes: equal, or one inside the other. Containment is not a nicety here – a Measurement Set is a directory, so
/data/obs.msand/data/obs.ms/CORRECTEDname the same data while comparing unequal.Shared by the two places that compare resolved path values rather than the way each step happened to spell them: offload.slurm’s MutationOrder (which steps must be ordered against each other) and sandbox.clear_stale_outputs (whether a declared output is really an input this step reads). Both are wrong in the same expensive way if they disagree, so there is one predicate.
Datasets¶
- class shinobi.dataset_access.DatasetAddress(field, element_index=None)[source]¶
Bases:
objectTyped field/element identity; labels are for diagnostics only.
- class shinobi.dataset_state.DatasetStateStore(directory='.shinobi/states', *, cache_dir='.shinobi/cache')[source]¶
Bases:
objectA local immutable state store with explicit, recoverable operations.
Paths are local filesystem paths. Source and destination parent directories must exist. Stored state references are deliberately not accepted by Recipe.
cache_diris used only to refuse an unfinished Tier 1 mutation on export.- verify(state_id, representation_id=None)[source]¶
Fully verify contracts, physical bytes and both public logical roots.
- Parameters:
- Return type:
- export(source, *, block_rows=None, lossless_profile=None)[source]¶
Capture a supported contained MS under a shared READ ownership claim.
- Parameters:
block_rows (int | None)
lossless_profile (LosslessProfile | None)
- Return type:
- materialize(state_id, destination, *, representation_id=None, fidelity='exact-logical')[source]¶
Create a fresh native MS after independent validation, never overwrite.
- derive_precision(state_id, policy, *, representation_id=None)[source]¶
Create a separate approximate MSv4 artifact from a verified exact state.
- class shinobi.state_codecs.LosslessProfile¶
Closed, versioned MSv4 lossless codec and chunk layout selection.
- class shinobi.dataset_state.LogicalState¶
Strict
shinobi-logical-state/v1manifest: native state ID, native model, hash/profile/structural/closure contracts, and exact-logical fidelity.
- class shinobi.dataset_state.StateRepresentation¶
Strict
shinobi-state-representation/v1physical inventory, logical bindings, source observation, software versions and export provenance.
- class shinobi.dataset_state.StateResult¶
State and representation IDs,
verifiedflag, fidelity, optional native destination and attempt-record path. Provides Pydantic JSON serialization.
- class shinobi.dataset_state.StateAttempt¶
Versioned durable operation record: ownership, phases, native state, physical representation, staging identities and recovery outcome.
- class shinobi.dataset_state.StateProvenance¶
Closed logical-state, representation, mapping, reconstruction, fidelity and decision evidence embedded by state-attempt schema v2.
- shinobi.dataset_state.read_state_attempt(path)[source]¶
Read and strictly validate a durable reusable-state attempt record.
- Parameters:
- Return type:
- class shinobi.dataset_state.StateError(code, message, *, details=None)[source]¶
Bases:
ShinobiErrorStable refusal code plus any upstream table/column/row diagnostics.
- shinobi.datasets.CasaTab¶
A path annotated with the versioned
casa-table/v1declaration.
- shinobi.datasets.MSv2¶
A path annotated with the versioned
msv2-structural/v1declaration.
- class shinobi.datasets.DatasetType(kind, profile)[source]¶
Bases:
objectSerializable declaration attached to a path annotation.
profileversions the exact structural contract. New requirements must get a new profile rather than silently changing the meaning of an existing annotation.- Parameters:
kind (DatasetKind)
profile (str)
- kind: DatasetKind¶
- class shinobi.datasets.InspectionLimits(max_columns=4096, max_keywords=4096)[source]¶
Bases:
objectBounds on metadata retained by one inspection.
Casacore supplies column and keyword names as metadata. The inspector refuses an object whose metadata exceeds these bounds instead of silently truncating it; column cells are never read at any limit.
- class shinobi.datasets.DatasetDescriptor(*, path, expected, status, message, nrows=None, columns=(), keywords=(), subtables=(), missing_columns=(), missing_primary_data_columns=(), missing_subtables=(), unreadable_subtables=(), missing_subtable_columns=<factory>, unsupported_features=())[source]¶
Bases:
BaseModelSerializable observation produced by explicit structural inspection.
The complete column and keyword name sets are retained (within
InspectionLimits), including optional and custom columns. The descriptor is an observation, not a parameter annotation and not a cache identity.- Parameters:
path (Path)
expected (DatasetType)
status (DatasetStatus)
message (str)
nrows (int | None)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- expected: DatasetType¶
- status: DatasetStatus¶
- class shinobi.datasets.DatasetKind(*values)[source]¶
-
Dataset families understood by the structural inspector.
- CASA_TABLE = 'casa-table'¶
- MEASUREMENT_SET_V2 = 'measurement-set-v2'¶
- class shinobi.datasets.DatasetStatus(*values)[source]¶
-
Outcome categories returned by
inspect_dataset().- MISSING_PATH = 'missing-path'¶
- NOT_A_DIRECTORY = 'not-a-directory'¶
- INSPECTOR_UNAVAILABLE = 'inspector-unavailable'¶
- NOT_A_CASA_TABLE = 'not-a-casa-table'¶
- NOT_A_MEASUREMENT_SET = 'not-a-measurement-set'¶
- INCOMPLETE_MEASUREMENT_SET_V2 = 'incomplete-measurement-set-v2'¶
- UNSUPPORTED = 'unsupported'¶
- VALID = 'valid'¶
- class shinobi.dataset_closure.DatasetClosure(*, profile='msv2-dataset-closure/v2', requested_root, storage_namespace, root=None, status, message, resources=(), capabilities=None, unsupported_features=())[source]
Bases:
BaseModelSerializable observation of an MSv2’s physical CASA-table resources.
- Parameters:
profile (str)
requested_root (Path)
storage_namespace (Path)
root (Path | None)
status (ClosureStatus)
message (str)
resources (tuple[ClosureResource, ...])
capabilities (ClosureCapabilities | None)
- model_config = {'extra': 'forbid', 'frozen': True}
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- profile: str
- requested_root: Path
- storage_namespace: Path
- status: ClosureStatus
- message: str
- resources: tuple[ClosureResource, ...]
- capabilities: ClosureCapabilities | None
- property valid: bool
- class shinobi.dataset_closure.ClosureRequirement(*values)[source]¶
-
Physical requirements of the accepted ordinary-directory profile.
- TABLE_MEMBERS = 'table-members'¶
- PRESERVE_NAMESPACE_PATHS = 'preserve-namespace-paths'¶
- REWRITE_KEYWORD_REFERENCES = 'rewrite-keyword-references'¶
- class shinobi.dataset_closure.ClosureCapabilities(*, copy_requirements, mount_requirements, stage_requirements, materialize_requirements, restore_requirements)[source]¶
Bases:
BaseModelOperation requirements established by the accepted closure profile.
- Parameters:
copy_requirements (tuple[ClosureRequirement, ...])
mount_requirements (tuple[ClosureRequirement, ...])
stage_requirements (tuple[ClosureRequirement, ...])
materialize_requirements (tuple[ClosureRequirement, ...])
restore_requirements (tuple[ClosureRequirement, ...])
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- copy_requirements: tuple[ClosureRequirement, ...]¶
- mount_requirements: tuple[ClosureRequirement, ...]¶
- stage_requirements: tuple[ClosureRequirement, ...]¶
- materialize_requirements: tuple[ClosureRequirement, ...]¶
- restore_requirements: tuple[ClosureRequirement, ...]¶
- class shinobi.dataset_closure.ClosureResource(*, path, namespace_path, members, table_files, external_to_root, storage_managers)[source]¶
Bases:
BaseModelOne canonical physical CASA table in a dataset closure.
- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.dataset_closure.ClosureStatus(*values)[source]¶
-
Outcome of MSv2 physical closure resolution.
- VALID = 'valid'¶
- INVALID_ROOT = 'invalid-root'¶
- MISSING_RESOURCE = 'missing-resource'¶
- UNREADABLE_RESOURCE = 'unreadable-resource'¶
- DANGLING_REFERENCE = 'dangling-reference'¶
- CYCLIC_REFERENCE = 'cyclic-reference'¶
- ESCAPED_NAMESPACE = 'escaped-namespace'¶
- CHANGED_DURING_RESOLUTION = 'changed-during-resolution'¶
- UNSUPPORTED = 'unsupported'¶
- shinobi.dataset_closure.resolve_dataset_closure(path, *, storage_namespace, max_resources=32, max_entries_per_resource=100000)[source]¶
Resolve an MSv2 into canonical physical CASA-table resources.
Resolution detects changes across its reads, but filesystem observation is not an atomic snapshot. Consumers must use a cooperative immutable or snapshot boundary and revalidate the observation before acting on it.
- class shinobi.dataset_access.DatasetAccess(*, field, mode, table=DatasetTable.MAIN, columns=None, selection=None, allow_row_count_change=False, allow_schema_change=False, allow_keyword_change=False, allow_subtable_change=(), allow_present_subtable_rewrite=False, root_field=None, reservation=None)[source]¶
Bases:
BaseModelOne field-local MSv2 access declaration attached to a
Scope.fieldnames an input or output field.root_fieldis needed only whenfielditself names a referenced subtable whose parent MS cannot be inferred from its path (notably an externally stored subtable).reservationis a canonicalizable path envelope used when the dataset path is produced dynamically and is therefore unknown during planning.allow_present_subtable_rewritegrants an explicit MAIN writer backing- file rewrites of supported subtables present before execution and still linked at the same path afterward; table schema authority stays explicit.- Parameters:
field (str)
mode (DatasetMode)
table (DatasetTable)
columns (DatasetColumns | None)
selection (DatasetSelection | None)
allow_row_count_change (bool)
allow_schema_change (bool)
allow_keyword_change (bool)
allow_present_subtable_rewrite (bool)
root_field (str | None)
reservation (Path | None)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- mode: DatasetMode¶
- table: DatasetTable¶
- columns: DatasetColumns | None¶
- selection: DatasetSelection | None¶
- class shinobi.dataset_access.DatasetMode(*values)[source]¶
-
How a step uses an MSv2 dataset.
- READ = 'read'¶
- WRITE = 'write'¶
- CREATE = 'create'¶
- class shinobi.dataset_access.DatasetFallback(*values)[source]¶
-
Why planning conservatively reserves a whole dataset identity.
- UNDECLARED = 'undeclared'¶
- UNKNOWN_COLUMNS = 'unknown-columns'¶
- UNKNOWN_PATH = 'unknown-path'¶
- class shinobi.dataset_access.DatasetTable(*values)[source]¶
-
MSv2 tables whose closure membership is understood by Shinobi.
- MAIN = 'MAIN'¶
- ANTENNA = 'ANTENNA'¶
- DATA_DESCRIPTION = 'DATA_DESCRIPTION'¶
- DOPPLER = 'DOPPLER'¶
- FEED = 'FEED'¶
- FIELD = 'FIELD'¶
- FLAG_CMD = 'FLAG_CMD'¶
- FREQ_OFFSET = 'FREQ_OFFSET'¶
- HISTORY = 'HISTORY'¶
- OBSERVATION = 'OBSERVATION'¶
- POINTING = 'POINTING'¶
- POLARIZATION = 'POLARIZATION'¶
- PROCESSOR = 'PROCESSOR'¶
- SOURCE = 'SOURCE'¶
- SPECTRAL_WINDOW = 'SPECTRAL_WINDOW'¶
- STATE = 'STATE'¶
- SYSCAL = 'SYSCAL'¶
- WEATHER = 'WEATHER'¶
- class shinobi.dataset_access.DatasetColumns(*, read=(), write=(), create=(), remove=())[source]¶
Bases:
BaseModelColumn-level intent retained for validation and provenance.
Column detail deliberately does not relax scheduling: any writer excludes every concurrent access to the associated resolved dataset closure.
NoneonDatasetAccessmeans the columns are unknown and therefore requests the conservative whole-dataset fallback. Entries may format direct string inputs of their own scope, for example"{column}"or"MODEL_{suffix}". Only undecorated field names are allowed; a missing or None input makes the entire access’s columns unknown.- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.dataset_access.DatasetSelection(*, row_ranges=(), data_description_ids=(), field_ids=(), observation_ids=(), scan_numbers=(), array_ids=())[source]¶
Bases:
BaseModelA bounded, data-only row selection.
Half-open
row_rangesand finite ID sets cover the common static selection shapes without admitting TaQL, Python callables, or another expression language. Scheduling remains conservative at dataset scope.- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.dataset_access.ResolvedDatasetAccess(*, field, element_index=None, declaration, requested_path, root, resources, mode, whole_dataset, path_known, columns_known, fallback=None, closure_status=None, reason)[source]¶
Bases:
BaseModelSerializable resolution of one dataset access declaration.
declarationis the effective copy with concrete column names, orcolumns=Nonewhen input values are unavailable. The scope retains its original templates; existing literal-column records are unchanged.- Parameters:
field (str)
element_index (Annotated[int | None, Strict(strict=True), Ge(ge=0)])
declaration (DatasetAccess)
requested_path (Path | None)
root (Path | None)
mode (DatasetMode)
whole_dataset (bool)
path_known (bool)
columns_known (bool)
fallback (DatasetFallback | None)
closure_status (ClosureStatus | None)
reason (str)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- declaration: DatasetAccess¶
- mode: DatasetMode¶
- fallback: DatasetFallback | None¶
- closure_status: ClosureStatus | None¶
- property address: DatasetAddress¶
- class shinobi.dataset_access.RecipeAccessPlan(graph, accesses, reasons)[source]¶
Bases:
objectA graph augmented with deterministic access-hazard edges.
- Parameters:
- accesses: dict[str, tuple[ResolvedDatasetAccess, ...]]¶
- shinobi.dataset_access.resolve_scope_dataset_accesses(scope, values, *, workspace, planned_roots=None, unresolved_inputs=frozenset({}), allow_existing_create=False)[source]¶
Resolve one leaf scope’s MSv2 declarations against concrete values.
Unknown columns and omitted declarations degrade to whole-dataset access. An unknown path is refused unless its declaration supplies a reservation envelope, because otherwise neither ordering nor ownership can cover it. A CREATE target that already exists is refused unless
allow_existing_createis set, which only the explicitoverwritepath does, to locate the target it is about to delete.
- shinobi.dataset_access.plan_recipe_accesses(recipe, inputs, *, workspace=None, validated_steps=None, validated_inputs=None, overwrite_steps=frozenset({}))[source]¶
Resolve dataset contracts and add backward hazard edges before dispatch.
Explicit wiring/
afteredges determine the stable topological order. Otherwise-unordered access conflicts are then oriented backwards in that order, so the result is deterministic and cannot invent data lineage. Nested recipes and dataset-bearing scatter are refused explicitly in this first bounded planner rather than partially planned.
- class shinobi.dataset_backends.DatasetBackendCapability(*, schema_version=1, profile='msv2-backend-route/v1', backend, lifecycle, status, namespace_mode, execution_location, inspection_location, recovery_location, reason)[source]¶
Bases:
BaseModelVersioned capability decision for one backend and lifecycle kind.
inspection_locationandrecovery_locationare explicit because a scheduler accepting a job on one host does not prove that its compute node sees the same path or that restoring the submit host repairs what the task changed.- Parameters:
schema_version (Literal[1])
profile (Literal['msv2-backend-route/v1'])
backend (str)
lifecycle (Literal['contained-native-msv2-read/v1', 'contained-native-msv2-mutation/v1'])
status (DatasetBackendStatus)
namespace_mode (DatasetNamespaceMode)
execution_location (str)
inspection_location (str)
recovery_location (str)
reason (str)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- schema_version: Literal[1]¶
- profile: Literal['msv2-backend-route/v1']¶
- lifecycle: Literal['contained-native-msv2-read/v1', 'contained-native-msv2-mutation/v1']¶
- status: DatasetBackendStatus¶
- namespace_mode: DatasetNamespaceMode¶
- class shinobi.dataset_backends.DatasetBackendMount(*, source, target, writable, reason)[source]¶
Bases:
BaseModelOne identity bind required in addition to schema-derived mounts.
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.dataset_backends.DatasetBackendPlan(*, schema_version=1, capability, storage_namespace, mounts=())[source]¶
Bases:
BaseModelClosed, data-only plan handed to a local backend adapter.
- Parameters:
schema_version (Literal[1])
capability (DatasetBackendCapability)
storage_namespace (Path)
mounts (tuple[DatasetBackendMount, ...])
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- schema_version: Literal[1]¶
- capability: DatasetBackendCapability¶
- mounts: tuple[DatasetBackendMount, ...]¶
- class shinobi.dataset_backends.DatasetBackendStatus(*values)[source]¶
-
Evidence state for one concrete backend/lifecycle route.
- TESTED = 'tested'¶
- UNAVAILABLE = 'unavailable'¶
- UNSUPPORTED = 'unsupported'¶
- class shinobi.dataset_backends.DatasetNamespaceMode(*values)[source]¶
-
How host paths relate to paths observed by the executor.
- LOCAL = 'local'¶
- IDENTITY_BIND = 'identity-bind'¶
- SHARED_IDENTITY = 'shared-identity'¶
- REMOTE_DELEGATED = 'remote-delegated'¶
- UNPROVEN = 'unproven'¶
- shinobi.dataset_backends.dataset_backend_capability(backend, *, mutation)[source]¶
Return the explicit capability decision for
backend.Local processes and virtualenvs share the lifecycle controller’s namespace directly. The four local container runtimes use identity bind mounts augmented from the resolved closure plan; daemon-backed runtimes are unavailable when their configuration does not prove a local daemon. The blocking Slurm backend deliberately remains unavailable: it has no validated submit-host-to-executor namespace mapping and does not run observation and recovery at the execution authority.
remoteis a launch surface, not a local backend; the remoteninja runmust make its own decision.- Parameters:
- Return type:
- shinobi.dataset_backends.plan_dataset_backend(backend, accesses, *, workspace, mutation, leaf_accesses=None)[source]¶
Build the backend-specific namespace plan for resolved accesses.
accessesis the shared workflow closure plan.leaf_accessesis the current leaf’s exact declared subset (and defaults toaccessesfor direct callers). Every existing workflow root is mounted read-only, then only roots written or created by this leaf are upgraded. The lifecycle has already restricted execution to contained pairwise-disjoint closures, but this function validates that invariant again at the adapter boundary: mounting an incomplete closure would turn a policy bug into a successful tool run against the wrong dataset.- Parameters:
backend (str)
accesses (tuple[ResolvedDatasetAccess, ...])
workspace (Path)
mutation (bool)
leaf_accesses (tuple[ResolvedDatasetAccess, ...] | None)
- Return type:
- shinobi.datasets.dataset_fields(model)[source]¶
Backward-compatible name for
dataset_declarations().Unlike its original implementation, this returns qualified paths for declarations in nested models and containers as well as top-level fields.
- Parameters:
- Return type:
- shinobi.datasets.dataset_declarations(model, *, error=<class 'TypeError'>)[source]¶
Find every dataset declaration reachable from a Pydantic model.
Returned keys are qualified field paths.
[]denotes a sequence item and.*a mapping value, for examplegroups.*.members[].ms. Nested models and unions are traversed recursively. An ancestor stack prevents self-referential models from recursing forever without suppressing the same model used independently by two fields.errorselects the exception for declaration conflicts at a loader boundary; ordinary Python callers retain the defaultTypeError.
- shinobi.datasets.inspect_dataset(path, expected=DatasetType(kind=<DatasetKind.MEASUREMENT_SET_V2: 'measurement-set-v2'>, profile='msv2-structural/v1'), *, limits=None)[source]¶
Inspect bounded CASA-table metadata against a named profile.
- Parameters:
expected (DatasetType) – Dataset declaration/profile to check.
limits (InspectionLimits | None) – Maximum metadata-name counts retained in the descriptor.
- Returns:
A serializable descriptor with a precise status. Ordinary invalid datasets are results rather than exceptions; programmer errors such as an unknown profile are reported as
UNSUPPORTED.- Return type:
- shinobi.datasets.inspect_casa_table(path, *, limits=None)[source]¶
Inspect
pathagainstCasaTab’s current profile.- Parameters:
limits (InspectionLimits | None)
- Return type:
- shinobi.datasets.inspect_measurement_set_v2(path, *, limits=None)[source]¶
Inspect
pathagainstMSv2’s current profile.- Parameters:
limits (InspectionLimits | None)
- Return type:
- class shinobi.dataset_lifecycle.DatasetLifecycleEvent(*, phase, observed_at, reason)[source]¶
Bases:
BaseModelOne append-only phase observation in an attempt record.
- Parameters:
phase (DatasetLifecyclePhase)
observed_at (float)
reason (str)
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- phase: DatasetLifecyclePhase¶
- class shinobi.dataset_lifecycle.DatasetFileObservation(*, path, device, inode, mode, size, mtime_ns, ctime_ns)[source]¶
Bases:
BaseModelCheap metadata identity for one declared CASA table member.
- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.dataset_lifecycle.DatasetObservation(*, structural_profile='msv2-structural/v1', closure_profile='msv2-dataset-closure/v2', root, descriptor, closure, files, table_columns=<factory>)[source]¶
Bases:
BaseModelSerializable structural, closure and member observation of one MSv2.
Supported subtable columns are supplemental postcondition evidence; historical observations may omit them, and signature v1 excludes them.
- Parameters:
structural_profile (Literal['msv2-structural/v1'])
closure_profile (Literal['msv2-dataset-closure/v1', 'msv2-dataset-closure/v2'])
root (Path)
descriptor (DatasetDescriptor)
closure (DatasetClosure)
files (tuple[DatasetFileObservation, ...])
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- structural_profile: Literal['msv2-structural/v1']¶
- closure_profile: Literal['msv2-dataset-closure/v1', 'msv2-dataset-closure/v2']¶
- descriptor: DatasetDescriptor¶
- closure: DatasetClosure¶
- files: tuple[DatasetFileObservation, ...]¶
- shinobi.dataset_lifecycle.STRUCTURAL_SIGNATURE_COVERAGE = 'MAIN row count; MAIN column, keyword and subtable names; closure membership, table files and storage managers. Cell values, keyword values and visibility data are not examined.'¶
str(object=’’) -> str str(bytes_or_buffer[, encoding[, errors]]) -> str
Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to ‘strict’.
- shinobi.dataset_lifecycle.structural_signature(observation)[source]¶
The structural identity of one observed MSv2 closure.
Paths are recorded relative to the root and stat metadata is excluded, so an exact snapshot restored at the same path, or the same state observed through an alias, has the same signature. See
STRUCTURAL_SIGNATURE_COVERAGEfor what it does not cover.- Parameters:
observation (DatasetObservation)
- Return type:
- shinobi.dataset_lifecycle.leaf_postcondition_issues(accesses, pre, post)[source]¶
Every way one leaf’s observed change exceeds its declared contract.
Per dataset root: a pure reader must leave the observation identical; a CREATE must produce a valid contained MSv2 carrying its declared columns; a writer may change row count, schema (columns, subtables, closure membership) and MAIN keyword names only where its declarations permit, may add/remove only declared columns when its columns are known, and may touch only the tables it declares or stable present subtables covered by its rewrite grant – unless it was an undeclared whole-dataset writer, for which any table is in scope.
These are structural checks. Which cells a writer changed is not observable here, so declared column writes are recorded, not verified.
- Parameters:
accesses (tuple[ResolvedDatasetAccess, ...])
pre (dict[Path, DatasetObservation])
post (dict[Path, DatasetObservation | None])
- Return type:
- class shinobi.ownership.WorkspaceAccess(*, path, writes)[source]¶
Bases:
BaseModelOne canonical path covered by a workflow ownership claim.
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.ownership.WorkspaceOwner(*, schema_version=1, workflow_id, kind, workspace, acquired_at, submission=None, accesses=(), registry)[source]¶
Bases:
BaseModel- Parameters:
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- schema_version: Literal[1]¶
- kind: Literal['local', 'slurm']¶
- accesses: tuple[WorkspaceAccess, ...]¶
Execution¶
- class shinobi.results.BackendRun(returncode, stdout='', stderr='', image_digest=None, containerized=False, venv=None, venv_digest=None, stdout_dropped=0, stderr_dropped=0, wrangler_lines_dropped=False)[source]¶
Bases:
objectWhat a backend returns after running a command – just the raw run outcome. Wrangling stdout/stderr into structured outputs and filling an outputs_model is the dispatch layer’s job, not the backend’s.
- Parameters:
- class shinobi.results.StepResult(name, returncode, outputs, inputs, stdout='', stderr='', cached=False, skipped=False, kind='cab', backend=None, image=None, image_digest=None, containerized=False, venv=None, venv_digest=None, sandboxed=False, sandbox_path=None, code_digest=None, worker_digest=None, job_id=None, scheduler_state=None, resources=None, sub_results=None, bundle_inventories=None, cache_key=None, output_keys=None)[source]¶
Bases:
objectThe outcome of running a step (a Cab or a Recipe).
outputs is a validated instance of the step’s outputs_model; inputs is a validated instance of the effective (post-override) inputs the step actually ran with. For a Recipe these aggregate from its sub-steps.
- Parameters:
name (str)
returncode (int)
outputs (BaseModel)
inputs (BaseModel)
stdout (str)
stderr (str)
cached (bool)
skipped (bool)
kind (str)
backend (str | None)
image (str | None)
image_digest (str | None)
containerized (bool)
venv (str | None)
venv_digest (str | None)
sandboxed (bool)
sandbox_path (str | None)
code_digest (str | None)
worker_digest (str | None)
job_id (str | None)
scheduler_state (str | None)
resources (Resources | None)
sub_results (dict[str, StepResult] | None)
cache_key (str | None)
output_keys (dict[str, ProvenanceKey | list[ProvenanceKey | None]] | None)
- sub_results: dict[str, StepResult] | None = None¶
- output_keys: dict[str, ProvenanceKey | list[ProvenanceKey | None]] | None = None¶
- provenance_key(field)[source]¶
The cache key identifying whatever produced output field.
A leaf step produces all its outputs in one run, so every field resolves to that step’s own cache_key. A Recipe fans out to output_keys instead – each declared output comes from a distinct sub-step, and invalidating a downstream consumer because some unrelated sub-step re-ran would throw away most of the cache’s value.
None means “no provenance available” (caching disabled, or a step that isn’t cacheable) – callers must treat that as “contribute nothing”, not as a key in its own right.
- shinobi.steps.dispatch.register_step_backend(name, backend)[source]¶
Register a backend instance under name, overriding the real class-based registry. Mainly for tests.
- shinobi.steps.dispatch.get_step_backend(name)[source]¶
Resolve a backend instance by name, checking test overrides first.
Output wranglers: extract structured outputs from a cab’s stdout/stderr by matching each line against the cab’s configured regexes.
Only the PARSE_OUTPUT action is implemented for now – enough to pull
named values out of a tool’s console output. Display-oriented actions from
Stimela 2 (HIGHLIGHT, SUPPRESS, SEVERITY, …) are currently unsupported.
- shinobi.wranglers.apply_wranglers(wranglers, lines)[source]¶
Extract structured output values from a cab’s console output.
- Parameters:
- Returns:
A dict of extracted output field name to typed value, built by applying each matched line’s actions in order.
- Return type:
- shinobi.wranglers.parse_output_action(action)[source]¶
Parse one wrangler action string: (groupname, typename) for a PARSE_OUTPUT:<groupname>:<typename> action, or None for any other (unimplemented) action kind. The one place both apply_wranglers (which populates the field at run time, below) and graph._wrangler_output_fields (which needs the field name statically, for check_offloadable, without running anything) agree on this grammar – so the two can’t silently drift on what a wrangler action means the way they briefly did (one required exactly 3 colon-separated parts, the other tolerated 2+).
Short-circuit semantics for an unrolled loop (see Recipe.add_loop).
A loop is declared, not interpreted: add_loop flattens its body into the recipe max_iter times and chains the copies with real wiring, so the graph is fully inspectable before anything runs. What stays a run-time decision is narrow – whether an already-declared step does any work. This module is that decision, and nothing else.
The rule lives here rather than in the scheduler because two tiers evaluate it: _run_recipe calls should_skip in-process, and the Slurm offload compiler emits the same predicate as a shell [ -f … ] test at the top of each iteration’s script. A convergence signal that is a path is what makes that possible – a bool would have no way to cross a node boundary, and the two tiers would need separate definitions that could drift into running a different number of cycles for the same recipe.
- shinobi.steps.loops.sentinel_exists(value)[source]¶
Whether a convergence sentinel has actually been produced.
- shinobi.steps.loops.sentinel_value(ref, results)[source]¶
The sentinel value this step’s skip decision reads, or None if it cannot skip (the first iteration, or a step outside any loop).
- Parameters:
ref (StepRef) – The step about to be scheduled.
results (dict[str, StepResult]) – Completed steps by name. The sentinel producer is guaranteed to be present: add_loop gives every iteration an edge to it, so the scheduler cannot reach this step first.
- Returns:
The previous iteration’s sentinel output value, or None.
- Return type:
- shinobi.steps.loops.should_skip(ref, results)[source]¶
Whether ref should pass its predecessor’s outputs through instead of running, because an earlier iteration already converged.
- Parameters:
ref (StepRef) – The step about to be scheduled.
results (dict[str, StepResult]) – Completed steps by name.
- Returns:
True if the previous iteration’s sentinel exists on disk.
- Return type:
- shinobi.steps.loops.passthrough_result(ref, prev, inputs)[source]¶
The result of a step that was skipped: the same body step’s outputs from one iteration earlier, handed on unchanged.
Passing the previous outputs object through (rather than re-deriving field by field) is what makes convergence sticky without extra bookkeeping: the sentinel is itself one of those outputs, so every later iteration sees it and skips in turn.
cache_key/output_keys are carried over too. A skipped step produced no new data, so contributing nothing would drop the upstream term from every downstream cache key (see shinobi.cache) and needlessly invalidate work that is genuinely unchanged.
kind deliberately keeps the scope’s real kind – skipped is a separate flag. shinobi.provenance.apply_manifest_pins asserts that a record’s kind still matches the scope’s type, so inventing a “skipped” kind would make any early-converging run unreplayable.
- Parameters:
ref (StepRef) – The skipped step.
prev (StepResult) – The corresponding step’s result from the previous iteration.
inputs (Any) – The validated inputs this step would have run with.
- Returns:
A successful StepResult marked skipped.
- Return type:
Graphs and resources¶
The shared dependency graph of a Recipe’s declared steps.
A Recipe is a declared DAG (see AGENTS.md): its steps list plus the OutputRef wiring between them is the graph. build_graph reads that graph once – validating every wiring reference and rejecting cycles – and returns a RecipeGraph that BOTH consumers depend on:
the executor (shinobi.steps.dispatch._run_recipe) schedules a topological wavefront over these true dependency edges;
the –dryrun renderer (shinobi.dag.graph_nodes) builds its display view on top of the same edges (re-adding a display-only sequential chain between otherwise-independent steps).
Because both go through build_graph, they can never disagree on what depends on what, or on whether the graph is even valid. Validation runs at run time and at dryrun time – not at build time, since a Recipe is deliberately mutable through its builder methods and a forward OutputRef is legitimate mid-construction.
- exception shinobi.graph.RecipeGraphError[source]¶
Bases:
ValueErrorA recipe’s declared graph is invalid: a wiring reference points at a field/step that doesn’t exist, a step name is duplicated, or the dependency edges form a cycle.
- exception shinobi.graph.RecipeNotOffloadableError[source]¶
Bases:
ValueErrorA recipe is a valid graph but cannot be compiled to an external workflow engine and detached: it relies on shinobi’s live, in-process execution semantics. See check_offloadable for the exact rules and AGENTS.md’s “DAG offload” section for why. Such a recipe should run locally (the default ctx.run() path) instead – it is never silently degraded.
- class shinobi.graph.RecipeGraph(names, deps, dependents)[source]¶
Bases:
objectThe validated dependency graph. Node ids are indices into the recipe’s steps list (so id == declaration order).
deps[i] are the ids step i truly depends on (via OutputRef wiring) – no artificial sequential chaining; independent steps have empty deps. dependents is the reverse edge set, precomputed for the scheduler’s in-degree bookkeeping.
- topological_indices()[source]¶
A stable topological order, preferring declaration order.
build_graphhas already established acyclicity. The heap makes a newly-ready lower declaration index outrank an unrelated later node, matching the local scheduler’smax_workers=1order rather than batching whole topological generations.
- shinobi.graph.build_graph(recipe)[source]¶
Validate a Recipe’s wiring and build its dependency graph.
Raises RecipeGraphError on: a duplicate step name; an InputRef to a field not on the recipe’s inputs_model; an OutputRef (in a step’s wiring or in output_wiring) to an unknown step or to a field that is not an output of that step; a wiring field that is not an input of the consuming step; a recipe output field that is not in the recipe’s outputs model; a dependency cycle; or non-Cab derived reads anywhere in the declared recipe tree.
- Parameters:
recipe (Recipe)
- Return type:
- shinobi.graph.check_offloadable(recipe, *, worker=False, allow_dataset_planning=False)[source]¶
Raise RecipeNotOffloadableError (with all disqualifying reasons) unless recipe is a purely declarative DAG that can be compiled to an external engine and detached. A valid graph is a precondition, so this calls build_graph first (which may raise RecipeGraphError) and returns it, so a caller that needs the graph right after checking eligibility (e.g. offload.slurm.compile_slurm) doesn’t have to call build_graph a second time.
worker=Truechecks the experimental worker-bundle graph shape: explicitly identified pysteps and venvs are admitted, and structured outputs can cross nodes in result records. The bundle compiler must additionally validate the serializable schema, code and environment. It does not enable worker submission in the legacy Slurm compiler.allow_dataset_planning=Trueis reserved for a compiler that resolves and records dataset hazards but marks the resulting workflow as non-submittable. It never enables dataset execution.The default rules follow directly from “the cluster runs the graph, shinobi is not in the loop per step” (see AGENTS.md / the design note):
No orchestration function on any step – arbitrary Python run against live upstream outputs can’t be statically compiled, and shipping it would be a code-execution hazard.
Every step is a `Cab` – a bare Scope/nested Recipe step has no single argv to emit.
A MUTABLE input must be a path on a Cab. MUTABLE means pass-by-reference, which for a live Python object is a single-heap concept with no meaning across nodes – but a Cab’s inputs are paths, and a shared filesystem is exactly that reference. The hazard in-place mutation actually poses is ordering, not representation: two steps rewriting the same MS get no graph edge unless one wires from the other’s output, so a cluster would run them concurrently. That is resolved where the real path values are known, by offload.slurm.MutationOrder, which emits the missing edges – so this check no longer has to refuse the whole class. (A MUTABLE input that is not path-typed still has no cross-node meaning and is refused; so is any MUTABLE input on a non-Cab step.)
Only `binary`-flavour cabs – a code-carrying flavour has no argv to compile, and build_argv would refuse it later anyway. Reported here so an unofloadable recipe says so up front rather than failing mid-compile.
Every inter-step `OutputRef` (one step’s input wired from another step’s output) carries a path output that is not wrangler-derived – across nodes, data flows only as shared-filesystem paths, and a wrangler output is never populated when shinobi doesn’t see the step’s stdout. output_wiring (the recipe’s own external outputs) is deliberately not checked: it is a reporting boundary, not inter-node data flow – under detach the caller gets a handle, and a downstream ninja status reconstructs what it can (paths yes, dynamic values best-effort).
- Parameters:
- Return type:
Declared resource footprints, the machine’s real budget, and the admission control that keeps the two consistent.
_run_recipe (steps/dispatch.py) schedules by counting slots: a step is “running” or “not running”, and max_workers bounds how many run at once. That is fine when steps are cheap and interchangeable. It is not fine for radio-astronomy pipelines, where a single wsclean/DDFacet/QuartiCal step is routinely sized to use most of one machine on its own – five of those running concurrently because five slots were free will oversubscribe the CPU and blow through a memory cap, and the step that gets OOM-killed takes its innocent siblings’ shared memory down with it.
So a Scope may declare a Resources footprint, and the scheduler admits work against a Budget instead of a slot count. Three properties are worth stating up front, because they are what make this honest rather than decorative:
An undeclared step is free. Nothing declares a footprint by default, so a recipe that declares nothing is admitted exactly as before. A declaration is only ever as good as its coverage – this models what you tell it, and tells you nothing you did not.
The budget comes from the cgroup, not from `/proc/meminfo`. This is the whole point. A tool that sizes itself from /proc/meminfo sees the host’s memory, not the slice quota it is actually confined to, and cannot self-limit even if it wants to – DDFacet on a 251.7 GiB-capped user slice of a much larger box is the motivating case. detect_budget walks the full cgroup ancestor chain, because the cap that matters is almost never on the leaf cgroup a process is in (see its docstring).
The declaration is not enforcement. Locally, shinobi decides whether to start a step; it cannot hold a running process to its word. Container and cluster backends do enforce it (–memory/–cpus, #SBATCH –mem), which is a real difference – a containerised runaway dies in its own cgroup instead of eating the shared slice. A native or venv step has no such backstop at all. Enforcement is also per dimension: a rootless runtime can only apply the controllers systemd delegated to the session, so see delegated_controllers for what is actually enforceable here, and note that it is a strictly weaker question than what the budget above says you may use.
- shinobi.resources.parse_size(value)[source]¶
Parse a byte size: a bare number, or a number with a unit suffix.
- Parameters:
value (float | str) – An int/float count of bytes, or a string like “200GB”, “250 GiB”, “2T”, “1024”.
- Returns:
The size in bytes.
- Raises:
ValueError – If the string is not a number with an optional known unit suffix, or the size is negative.
- Return type:
- shinobi.resources.format_size(size)[source]¶
Render a byte count for humans, in binary units.
For log lines and error messages only – every backend wants its own grammar (docker takes raw bytes, Slurm megabytes), so this is deliberately not the shared emitter for those.
- class shinobi.resources.Resources(*, cpus=None, memory=None)[source]¶
Bases:
BaseModelA declared resource footprint: what one step needs to run.
Both fields default to None, meaning “undeclared” – which the scheduler reads as free, not as unknown-so-assume-the-worst. Assuming the worst would serialise every undeclared recipe, i.e. punish everyone who has not opted in; free keeps the feature genuinely additive at the cost of being only as complete as your declarations.
cpus also takes “all”, for the very common tool that is simply told to use every core it can see (wsclean’s default, -j 0, nproc). That step is the opposite of free and must not read as undeclared, but the number it stands for is not knowable where recipes are built: a recipe is a static, machine-independent declaration, and probing the builder’s core count would bake one host’s answer into a job that may run on another (and, for a caller whose builder is not allowed to touch the filesystem at all, is not available even in principle). So it stays symbolic until Budget resolves it against the live pool, on the host that is actually admitting the work. See resolved_against.
Shaped so a third dimension (gpus) can be added later without a schema break – but not added speculatively.
- is_empty()[source]¶
True when nothing is declared, i.e. this step costs the scheduler nothing and is admitted on slot count alone.
- Return type:
- property enforceable_cpus: float | None¶
The core count a backend can cap this step at, or None.
None for “all” as well as for undeclared: a runtime limit of “the whole machine” is not a limit, and no backend should invent a number for it – a container flag would be a no-op at best and the building host’s core count at worst, while a batch script would be asking a scheduler for a node size it cannot know. “all” is a claim on the local pool, and the local scheduler is where it is honoured.
- resolved_against(total)[source]¶
This footprint with cpus=”all” replaced by total’s own count.
Resolution has to happen against the very total that admission is decided against, or the two disagree: resolving to anything larger would trip _exceeds_total and be waved through unconstrained – turning the strongest declaration into the weakest, which is the opposite of what it says.
An unconstrained dimension resolves to None (undeclared): if the pool has no CPU total then nothing can exhaust it, so there is nothing for this step to be held back by.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.resources.ResourceBudget(*, cpus='auto', memory='auto')[source]¶
Bases:
BaseModelConfig-side total: how much of the machine a recipe may occupy.
“auto” (the default) detects the real limit, cgroup-aware; “unbounded” disables that dimension; anything else is an explicit value. Note None is deliberately not the “unbounded” spelling – everywhere else in shinobi.config None means “unset / fall back / off”, and quietly inverting that here would be a trap.
- Parameters:
- resolve(root='/')[source]¶
Resolve to concrete totals, detecting only if something asks for it.
- Parameters:
root (str | Path) – Filesystem root to detect against. A test seam; “/” in real use.
- Returns:
(totals, source) – the resolved budget and a short description of where each value came from, for the one log line the scheduler emits. Whether the number came from the cgroup or from /proc/meminfo is exactly the thing worth being able to check.
- Return type:
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- shinobi.resources.delegated_controllers(root='/')[source]¶
Which cgroup v2 controllers this user session may actually apply limits with – i.e. what a rootless container runtime can enforce here.
Detection is the other half of enforcement. detect_budget asks “how much of this machine am I allowed to use”; this asks “which of those dimensions can I hold a child process to”. They are different questions, and on a shared box the answer to the second is routinely some of them: systemd delegates only what Delegate= names, so a session can easily have memory pids and no cpu. A runtime handed –cpus there cannot create cpu.max in its new scope and the container never starts – taking the memory limit, which was perfectly enforceable, down with it.
What is read, and why that one file. A rootless runtime does not put its scope next to the calling process – it asks the systemd user manager to create one, so the scope lands under user@<uid>.service and inherits whatever that cgroup makes available. That is the delegation boundary, and its cgroup.controllers is the honest answer.
The obvious cheaper proxy – this process’s own cgroup – is measurably wrong: on the machine this was developed on, user@1000.service has cpu memory pids and apptainer applies –cpus fine, while the calling shell sits several levels down under app.slice with only memory pids, because that intermediate slice never enabled cpu in its subtree_control. Reading the caller’s cgroup would have dropped a CPU limit that works – silently losing enforcement, which is the one error direction worth engineering against here. Over-estimating merely fails loudly, exactly as it did before this probe existed.
- Parameters:
root (str) – Filesystem root to probe against. A test seam; “/” in real use. Cached per root – delegation does not change within a run.
- Return type:
The caller is not always inside the manager it will ask. An ssh login lands in /user.slice/user-<uid>.slice/session-<n>.scope – a sibling of user@<uid>.service, not a descendant – so the path walk below finds no manager even though one is running and its delegation is sitting there to be read. Returning “couldn’t tell” for that shape means emitting –cpus on a host known not to delegate cpu, which is the abort this probe exists to prevent, and ssh is how batch runs are started. So a session that names its user slice is resolved through _user_manager_beside.
- Parameters:
root (str) – Filesystem root to probe against. A test seam; “/” in real use. Cached per root – delegation does not change within a run.
- Returns:
The delegated controller names, or None when this cannot be determined – no cgroup v2, no unified entry in /proc/self/cgroup, or neither a user manager in this process’s cgroup path nor one beside it (a container, or a session systemd never took charge of). Callers must read None as “assume everything works” and keep failing loudly, never as “nothing works”.
- Return type:
- shinobi.resources.detect_budget(root='/')[source]¶
Detect how much CPU and memory this process may actually use.
Tried in order, most authoritative first: cgroup v2, cgroup v1, then the host’s own totals. The cgroup tiers walk the full ancestor chain (see _walk_limits) and take the tightest limit at any level.
- Parameters:
root (str | Path) – Filesystem root to detect against. A test seam; “/” in real use.
- Returns:
(budget, source), where an unset field means “nothing constrains this dimension”. source names the tier that answered, so the scheduler’s log line can say whether the number is a real cgroup quota or just the host’s size.
- Return type:
- class shinobi.resources.Budget(total)[source]¶
Bases:
objectAdmission control over a shared pool of CPU and memory.
One Budget is created per top-level run and shared by every nested recipe’s scheduler (threaded down like AppConfig). That sharing is the point: caracal-shaped pipelines nest each worker as its own Recipe, so a budget scoped to one _run_recipe invocation would leave every branch free to independently decide it owns the machine – exactly the failure this is meant to stop.
Not a pydantic model: this is live mutable state guarded by one threading.Condition. Every method below takes that lock; nothing reads the totals outside it.
## The wake protocol
A scheduler that is refused must park until something changes, but it cannot park while holding the lock inside try_acquire (it still has futures to reap). So refusal and parking are two separate lock acquisitions – and a release landing in the window between them would be a lost wakeup and a permanent hang. _generation, bumped under the lock by every release, closes that window: try_acquire hands back the generation it decided at, and wait_for_change returns immediately if anything has happened since. This is reachable in the ordinary case (one recipe is refused at the instant a sibling finishes), not a theoretical race.
## Fairness
Admission is strictly FIFO by ticket. Within one recipe, draining in declaration order already prevents starvation; across sibling recipes sharing this budget it does not – four branches running a steady stream of small steps can hold the reserved total above zero indefinitely, and a branch needing 200 of 250 GiB would never be admitted. A refused scheduler takes a ticket, and no later arrival is admitted ahead of it, so current holders drain and the large step runs. This costs some utilisation, and is the same trade “block in declaration order” already makes, extended across recipes.
## Backfill
Blocking on the head costs more than “some” utilisation when the head is large: a 200 GiB step at the front of the queue idles a machine with room for the 2-core steps behind it. try_backfill recovers that without giving the guarantee up, by admitting a trailing unit only when the parked head would still fit alongside it – and alongside every earlier backfill still running – once the work reserved now drains. The head’s start time is therefore unchanged: it waits for exactly the work it was already waiting for, and nothing new.
That test has to be cumulative. Bounding each grant against the total on its own admits two 30 GiB units in a 100 GiB budget behind a 70 GiB head, and the head then cannot start when the original holder finishes. See _leaves_room_for.
## Why this cannot deadlock
A reservation is only ever held by a step that is running; a parked scheduler holds a ticket, which reserves nothing.
A step holding a reservation never re-enters _run_recipe (the scheduler skips demand for nested-Recipe steps), so no reservation can be waiting on itself.
Every running step terminates, and _run_recipe releases its reservation on every exit path including exceptions.
Given those, some reservation always eventually releases and bumps the generation, and FIFO guarantees the oldest waiter is the one served. A demand larger than the whole budget never waits at all – it is granted immediately, since no amount of waiting could ever make it fit.
- Parameters:
total (Resources)
- try_acquire(demand, label='')[source]¶
Reserve demand if it is admissible right now. Never blocks.
- Parameters:
- Returns:
(granted, generation). On refusal the caller should reap any in-flight work and, if it has nothing else to do, park in wait_for_change(generation) – passing back the generation returned here is what makes the wait race-free.
- Return type:
- can_backfill(demand, behind)[source]¶
Whether try_backfill would grant demand right now, without reserving anything.
For deciding whether a candidate is worth the work of preparing – the scheduler resolves a step’s wiring before it has a unit to admit, and that is wasted (and surfaces errors early) for a step that could not be admitted anyway. Advisory by nature: capacity can change before the real try_backfill, which simply refuses then.
- try_backfill(demand, behind, label='')[source]¶
Reserve demand only if doing so cannot delay a unit needing behind. Never blocks, never queues.
Admission blocks on the head of the queue so a large step is never starved by a stream of small ones – but a head that does not fit also stops everything behind it, idling a machine that has room for work the head will not use anyway. This is the escape hatch, and it is deliberately not try_acquire with a flag:
try_acquire refuses on FIFO fairness as well as capacity, and a backfill must keep honouring that (jumping a sibling recipe’s older ticket is exactly the cross-recipe starvation the tickets prevent);
but a try_acquire grant pops the caller’s ticket, and the caller here is the very scheduler thread whose head is parked. It would give up the queue position it is holding on the head’s behalf.
So this grants without ever touching _waiters: no ticket is taken, and none is dropped.
- Parameters:
- Returns:
Whether demand was reserved. On True the caller must later release(demand, backfilled=True).
- Return type:
Note
Only correct when called after this thread’s own try_acquire refusal in the same pass: that refusal is what registers the ticket which makes the fairness check below read as “we are the oldest waiter” rather than “someone is ahead of us”.
- release(demand, *, backfilled=False)[source]¶
Return demand to the budget and wake every parked scheduler.
- Parameters:
demand (Resources) – Exactly what try_acquire or try_backfill was granted for this unit – passed as declared, “all” and all; it is resolved here the same way, against the same total, so what is returned is exactly what was reserved.
backfilled (bool) – Whether this reservation came from try_backfill. Must match how it was granted – the outstanding-backfill total it feeds is what keeps the cumulative admission test honest, and a missed decrement makes backfill quietly refuse everything for the rest of the run.
- Return type:
None
- abandon()[source]¶
Give up this thread’s place in the queue.
A scheduler that stops waiting – because it failed, or finished – must not leave a ticket behind: strict FIFO means a stale ticket from a dead scheduler would block every later arrival forever. Called from _run_recipe’s exit path.
- Return type:
None
Backends¶
Backend abstraction: a backend takes a cab and a resolved argv and runs it somewhere – natively, in a container, on Slurm, …
A backend knows nothing about recipes or output schemas beyond the argv
it’s handed and the cab’s image/command metadata; it only knows
how to execute and how to capture output. Wrangling that output into
structured results is the dispatch layer’s job, so a backend returns a
raw BackendRun (returncode/stdout/stderr), nothing schema-aware.
- class shinobi.backends.Backend[source]¶
Bases:
ABCAbstract base class for execution backends.
- Variables:
name (str) – Registry key used to look up this backend (e.g.
"native","container","slurm").
- abstractmethod run(cab, argv, inputs, *, label='', stream=True, pin=False, cwd=None, dataset_plan=None)[source]¶
Execute argv (as built by shinobi.policies.build_argv) and return a BackendRun. Must not raise on a non-zero exit – that’s reported via BackendRun.returncode / BackendRun.success.
inputsis the prepared inputs dict argv was built from (the one _prepare_inputs produces, so MUTABLE fields are the caller’s own objects by reference). Most backends ignore it, but container backends need it to know which File/MS-valued params to bind-mount.label/streamcontrol live stdout/stderr echo (see shinobi.backends._stream.run_streaming) – only native and container act on them today; slurm accepts and ignores both (it reads output once after the job finishes).cwdis the working directory to run in (shinobi.sandbox passes the step’s sandbox here), defaulting to the process cwd. slurm accepts and ignores it too: it runs in a remote cwd the dispatch layer can’t scope, so a sandboxed Slurm step degrades gracefully to an unsandboxed run (harvest finds the outputs already in the workspace and moves nothing).dataset_planis a closed, data-only strict-dataset namespace plan produced after claim acquisition. Local-process backends need no mounts and ignore it; container adapters consume its identity binds. Scheduler backends currently refuse strict execution beforerun.
- shinobi.backends.register(backend_cls)[source]¶
Register a backend class under its
nameattribute.Intended for use as a class decorator on Backend subclasses.
- shinobi.backends.get_backend(name, **opts)[source]¶
Instantiate a registered backend by name.
- Parameters:
name (str) – Registry key of the backend (e.g.
"native","slurm").**opts – Keyword arguments forwarded to the backend’s constructor.
- Returns:
A new instance of the requested backend.
- Raises:
ValueError – If no backend is registered under
name.- Return type:
- shinobi.backends.registered_backend_names()[source]¶
Every name a backend is registered under, sorted.
The authoritative answer to “is this a real backend?”, for callers that need to check a name rather than instantiate it – the CLI validating –backend before a run starts (shinobi.cli). Not every consumer of a backend name reaches get_backend: the pystep adapter matches the resolved name against CONTAINER_RUNTIMES and otherwise runs the function in-process, so a typo there would go unnoticed rather than raise.
Building argv¶
Turn a cab’s schema + resolved parameter values into a command line.
Operates on the step-model Cab: the parameter values come from an already-validated inputs_model instance (or the prepared dict dispatch builds from it), while per-field naming/implicit metadata comes from the cab’s field_meta, dynamically-named params from input_patterns, and arg formatting from policies.
- shinobi.policies.build_argv(cab, resolved)[source]¶
Build a full argv (starting with the cab’s command) from a resolved parameter dict, according to the cab’s policies and field metadata.
Rejects any non-“binary” flavour before building argv – so a non-executable command can never reach subprocess as argv[0] (see SECURITY.md).
Loaders¶
Load YAML cab definitions in the scabha dialect into shinobi Cab objects.
Lineage. shinobi’s cab schema uses the scabha dialect, also used by Stimela 2.0. It supports the same vocabulary: inputs/outputs with dtype/required/default/info/choices, policies, management.wranglers, image, flavour, command. The loader translates static cab definitions into shinobi Cab objects; recipes are declared separately in Python (see stimela-ninja’s AGENTS.md).
cult-cargo is the largest published library of cabs written in this dialect and is what this loader is usually pointed at, but the dialect is scabha’s and nothing here is specific to that project. shinobi.loaders.worker_schema reads a scabha-derived config dialect through the same shared helpers.
shinobi-native keys. shinobi’s own Cab carries a few things scabha has no vocabulary for, so the dialect accepts them as an extension rather than inventing a second format for cabs authored against shinobi directly. A document using none of them is a plain scabha document, and cult-cargo’s own files remain a readable subset.
Per field, alongside the scabha keys: write_path: true marks a
string-typed input naming a filesystem path the tool writes to – a stem
products are built from, or a complete path written directly (see
ParamMeta.write_path) – and mutable: true marks an input the step may
change in place (Mutability.MUTABLE). Both are registered in
_LEAF_SPEC_KEYS, which matters more than it looks: _is_section tells a
leaf param from a nested CLI section by whether the mapping has any known
param-spec key, so a spec carrying only an unregistered key is read as a
section and the field disappears without a word.
Per cab: cache, sandbox, harvest, scratch and dataset_accesses,
which mirror the Scope fields of the same names. Access mappings are
validated by DatasetAccess; field references use literal sanitized model
names (data.ms becomes data_ms). Strict MSv2/CasaTab dtypes
retain dataset metadata; legacy MS remains Path. Direct
List[MSv2] inputs support reads and explicit joint WRITE declarations, with
compatible same-name ordered passthrough outputs. Other strict containers, mixed unions,
choices and dynamic patterns are refused for executable cabs, and MSv4
is reserved. Current dispatch supports the bounded MSv2 lifecycle only;
CasaTab remains available for declaration and inspection.
image: may also name a key rather than a reference, resolved through the
caller-supplied images mapping (see loads) – the same shape as
package_roots, and for the same reason: shinobi has no manifest and does
not go looking for one. It lets a document say image: WSCLEAN and leave
which reference that is to the deployment that loads it.
Also per cab: input_patterns/output_patterns, families of
dynamically-named params (ParamPattern). A pattern is a separator plus
ordered segments; each segment is either a regex (a level that cannot
be enumerated ahead of time) or attrs (the known level, each attr a param
spec in its own right). ParamPattern’s own validator enforces the real
rule – exactly one segment carries attrs – so the loader only produces
the shape and lets it object, naming the cab and key when it does.
An attr spec is read by the same _param_meta as a declared field, with one
asymmetry: an attr keeps its dtype, a field does not. A declared field’s
dtype is already its model annotation, and repeating it on field_meta would
make every field of every cab differ from its Python-authored equivalent; a
pattern attr has no model field at all, which is the reason
ParamMeta.dtype exists.
Supported scope. This reads static cab definitions and resolves the composition mechanisms below. Expression evaluation and executable schema generation are outside its supported scope; see SECURITY.md for the execution boundary.
Composition mechanisms for sharing and combining scabha cab definitions:
_include: [file, ...]– merges other YAML files in (relative to the including file), most often to pull in a sharedvars:/lib:namespace. Merging is a plain deep-merge; the including file’s own keys win over included ones. Resolved wherever it appears in the document (top level, or nested underinputs:/outputs:, as real cult-cargo’scubical.yml/quartical.ymldo) via the same tree-walkingresolve_directivehelper_usealready relies on._use: dotted.path– deep-merges a dict looked up by dotted path in the fully-merged document (post-_include) into the dict it appears in, with that dict’s own sibling keys taking precedence. Used both for small things (image: {_use: vars.cult-cargo.images, name: breizorro}) and to inherit a cab’s entire command/flavour block.The package-scoped include form (
_include: (pkg.dotted.path)file.yamlor_include: [{(pkg.dotted.path): [file, ...]}]) – searches an installed package’s data directory rather than a relative path. Resolving a dotted package name to a filesystem directory would normally mean importing the package (importlib), but that risks executing arbitrary code from any__init__.pyon the path – shinobi never imports a cab package for any reason (see SECURITY.md’s “never eval()/exec() a cab’s command” boundary, which this extends to “never import a cab package”). Instead, callers passpackage_roots={"cultcargo": Path(...)}toload_file()/loads(): an explicit, caller-supplied mapping from a dotted package prefix to its filesystem directory. A dotted name is resolved against the longest registered prefix, descending the remainder as subdirectories (cultcargo.genesis.cubicalagainst{"cultcargo": Path("/.../cultcargo")}->Path("/.../cultcargo/genesis/cubical")) – the normal package/subpackage-is-a-subdirectory convention, without ever asking Python’s import machinery to confirm it. A package-scoped_includenaming a package with no registered root raises a clearCabLoadError.
The following features are not evaluated by the loader (see SECURITY.md):
Expressions and substitutions. The
=config.x.y/=recipe.ms/${...}/=IFSET(...)language scabha values can contain. Left as literal strings, so a cab carrying one loads with that value verbatim rather than resolved – visible in the built Cab, not silently dropped. ParamMeta.implicit is the one templating shinobi does resolve, and it is plainstr.formatagainst the step’s own validated inputs: no name resolution across steps, no function calls, no conditionals.Conditionals and control flow. Anything whose value depends on evaluating a predicate at load or run time. A cab is a parameter table; branching over it belongs in the Python that calls the step, where it is visible to the reader and to the DAG.
Aliases and propagation. Recipe-to-step parameter propagation is not implemented. shinobi wires steps with typed InputRef/OutputRef objects to declare parameter sources and data dependencies explicitly.
dynamic_schema: dotted.path– a reference to a Python function that would need importing and calling to get a cab’s real schema (real cult-cargo’swsclean.yml/cubical.yml/quartical.ymluse this). Resolving it for real is not just a parsing gap like the above: it means executing arbitrary code named by a cab file at load time. Not implemented, and not worked around here either: a cab usingdynamic_schemaalways loads with a warning and whatever staticinputs:/outputs:are present – silently incomplete unless you notice the warning. The hand-authored, cross-checked static schemas for the three real cabs that need this (wsclean, cubical, quartical) live indosho(the native shinobi cab repository, a sibling project) instead of as a stopgap table in this loader – this loader used to carry one (a small per-cabParamPatterntable read from each cab’s own static data files, e.g. cubical’sschema_JONES_TEMPLATE.yaml), removed once dosho’s real ports superseded it. Seedosho/cabs/wsclean.py/cubical.py/quartical.pyfor that knowledge now, and prefer porting a cab there over reintroducing a table here.
The loader reads static schemas. It does not evaluate
recipe-level expressions or execute dynamic_schema.
- shinobi.loaders.yaml_cab.load_file(path, *, package_roots=None, images=None)[source]¶
Load a YAML cab definition file into Cab instances.
- Parameters:
- Returns:
A dict mapping cab name to its built Cab instance.
- Raises:
CabLoadError – If composition cannot be resolved, including an
_includeor_usecycle. Cycle errors name the closed path.- Return type:
- shinobi.loaders.yaml_cab.loads(text, *, package_roots=None, images=None)[source]¶
Parse cab defs from a YAML string. Supports
_use(resolved against the document itself) and package-scoped_include(resolved against package_roots), but not a plain relative-path_include, since there’s no base directory to resolve a relative file path against.imagesmaps an image key to its full reference, for a document that names images symbolically (image: WSCLEAN) rather than by a baked-in reference. Caller-supplied for the same reasonpackage_rootsis: shinobi has no manifest of its own and will not go looking for one. A cab repository passes its own – dosho’s images.yaml is exactly this – so a deployment’s overrides still decide the reference at load time instead of it being fixed when the document was written.An image string absent from the mapping is left alone, because a literal reference is the older and still-valid form (cult-cargo’s files carry
quay.io/stimela2/...directly). A key that is simply misspelled therefore reaches the runtime as an image name and fails there – loudly, at pull time, which is the safe direction: the alternative rejects every legitimate bare name (ubuntu) to catch a typo.
Load scabha-dialect worker/config schema YAML (as used by caracal2’s caracal/schemas/*_schema.yaml) into a plain pydantic model – without depending on scabha itself.
This is a config schema, not a cab schema: there’s no command, policies, or image here, just nested inputs:/outputs: parameter groups describing what a pipeline worker accepts in its config file. See shinobi.loaders.yaml_cab for the sibling loader that builds executable Cab`s from scabha-dialect YAML – this module deliberately does not reuse `Scope/Cab for the result, since a worker config is never dispatched as a step.
Dialect, as actually used by caracal2 (see its caracal/schemas/):
A param node is a dict. If it has a dtype key, it’s a leaf parameter. Otherwise it’s a group whose values are themselves leaves/groups, nested arbitrarily deep (e.g. crosscal’s rewind_flags.mode) – one rule, no special-casing per file.
dtypes are str/int/float/bool/File and List[<inner>] (bracket syntax; see _modelgen.dtype_to_type).
choices (a list) maps to typing.Literal.
implicit is a template/expression string (“{current.x}-y.json” or “=IFSET(…)”) – left as a raw, unevaluated string, matching loaders.yaml_cab’s policy on cult-cargo’s own expression language. A field with implicit set is never required from the caller, same rule as loaders.yaml_cab._collect.
_include: “(module.path)filename.yaml” – a single package-scoped string (different from cult-cargo’s list-of-plain-paths form), or a plain relative-path string, or a list of either. Resolved recursively (an included file’s own _include resolves relative to its directory). The package-scoped form is resolved against a caller-supplied package_roots={“module.path”: Path(…)} mapping, never by importing the named module – importing it would execute an arbitrary __init__.py named by a config file. Same mechanism, and the same resolve_package_root helper, as loaders.yaml_cab; see SECURITY.md’s “never import a cab package”.
_use: dotted.path or _use: [dotted.path, …] – deep-merges one or more dotted lookups (against the fully _include-resolved document) into the dict it appears in, with that dict’s own sibling keys winning – same convention as loaders.yaml_cab, extended to accept a list.
_each: {<sub-schema>} – a group whose keys are supplied by the config, each value validated against one declared sub-schema, giving dict[str, <SubModel>]. Every other group declares its keys in the schema; this is for the case where the names are the user’s own data (caracal2’s calibration chains: chains: {primary: …, secondary: …, <whatever the user calls the next one>: …}). _key_pattern (a regex, optional) constrains those names – schemas whose keys become step names or output-path components want ‘^[A-Za-z][A-Za-z0-9_]*$’. A default: alongside _each is a mapping of pre-declared entries, validated at load time and rebuilt per instance. _key_pattern, info and default are the only keys an _each group may carry – anything else raises (a misspelt _key_pattern must not silently switch off the validation).
writable (seen in caracal2’s caracal_base.yaml) is carried onto the generated field’s json_schema_extra: a writable: false directory input is bind-mounted read-only by the container backend (see _leaf_field and backends.container.bind_dir_modes). must_exist/path_policies are still dropped (path-behaviour hints with no consumer yet).
- class shinobi.loaders.worker_schema.ConfigSchema(*, name, info=None, inputs_model, outputs_model)[source]¶
Bases:
BaseModelA loaded worker/config schema: just enough to validate and introspect a config section – name, human info, and the pydantic models for its inputs/outputs.
- Parameters:
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- shinobi.loaders.worker_schema.load_worker_schema(path, *, package_roots=None)[source]¶
Load a stimela-classic worker schema (YAML) file into a ConfigSchema.
- Parameters:
package_roots (dict[str, Path] | None) – Mapping of dotted package name to filesystem root, used to resolve package-scoped _include: (module.path)file directives. A package-scoped include naming a package with no registered root raises rather than being resolved by importing it – see resolve_package_root.
- Returns:
The built ConfigSchema, with inputs_model/outputs_model pydantic models generated from the schema’s inputs/outputs.
- Raises:
ConfigLoadError – If the file’s top-level content isn’t a mapping, it has no top-level name, or a package-scoped _include names a package with no entry in package_roots, or composition contains an
_include/_usecycle. Cycle errors name the closed path.- Return type:
Load stimela-classic style parameters.json cab definitions into shinobi Cab objects. This is a different cab schema format from scabha-dialect YAML (see shinobi.loaders.yaml_cab) – useful for exactly the tools cult-cargo doesn’t have a loadable definition for (several CASA tasks, msutils – see AGENTS.md/examples/ninja_selfcal.py for which ones and why).
Classic’s format: one JSON file per cab (e.g. stimela/cargo/cab/casa_mstransform/parameters.json), a top-level task/binary/base/prefix/msdir plus a flat parameters list – unlike cult-cargo, there’s no _include/_use composition to resolve; each file is fully self-contained.
Field mapping (into a generated inputs_model + field_meta):
name -> the model field name, sanitised to a valid identifier if needed (the original kept as a nom_de_guerre).
dtype -> a Python type on the generated model. A param can declare dtype as a list of alternatives (e.g. [“int”, “str”]) for a genuine type union; the first alternative is used and the rest are dropped – narrowing a real union to shinobi’s simpler model, not a bug.
io: “msfile” forces dtype to “MS” (matching shinobi/cult-cargo convention for the main measurement-set parameter), regardless of whatever the raw dtype said (almost always “file” anyway). io: “input”/”output” have no separate shinobi concept – a file-like type alone already drives bind-mounting via path_fields – so they’re otherwise dropped.
required, default, info -> the model field / its ParamMeta.
mapping -> ParamMeta.nom_de_guerre (classic’s own name for the same concept: what the underlying tool actually calls this parameter).
choices -> ParamMeta.choices, and the generated model field’s real annotation is narrowed to typing.Literal[*choices] (see _modelgen.narrow_choices) – an out-of-set value fails pydantic validation, not just a note in info. Also still appended to info as a human-readable parenthetical, for callers that only look at info.
flavour: classic’s CASA-task cabs (base containing “casa”) are not real standalone executables – binary there is a CASA task name (mstransform/listobs/flagdata/…), invoked by wrapping it in a CASA script, not subprocess.run([“mstransform”, …]). These load with flavour=”casa-task” (shinobi’s existing non-executable flavour, UnsupportedFlavourError-guarded in shinobi.policies – see SECURITY.md’s “Never eval()/exec() a cab’s command” section), not “binary”, so they can’t be silently misrun as if they were real binaries. Cabs with any other base (msutils, wsclean, cubical, …) are real CLI tools and load as flavour=”binary”.
image: classic’s base (e.g. “stimela/casa”) is a base-image family name, not a concrete pullable reference – the real tag/version lives in separate tag/version fields (arrays of compatible versions, no single “the” version). base is used as a best-effort image default; override it on the loaded Cab if you need a specific real image.
- shinobi.loaders.stimela_classic.load_file(path)[source]¶
Load a stimela-classic cab definition (JSON) file into a Cab.
- shinobi.loaders.stimela_classic.loads(text)[source]¶
Parse a stimela-classic cab definition from a JSON string.
Cab loaders, plus the public helpers for building a cab’s schema by hand.
The shinobi.loaders.yaml_cab and shinobi.loaders.stimela_classic
submodules load cab definitions from their respective on-disk formats.
build_model (and sanitize_unique) are the same helpers those loaders use
to turn a flat {name: (dtype, required, default)} spec into the pydantic
inputs_model/outputs_model a Cab needs – re-exported here as the
supported way to build those models directly, without hand-writing a pydantic
class. The implementation lives in the internal _modelgen module.
- shinobi.loaders.build_model(name, fields, *, allow_extra=False, choices=None, extras=None, string_patterns=None, nullable=None, error=<class 'ValueError'>)[source]¶
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. extras maps a field name to a json_schema_extra dict carried onto that field (e.g. abbreviation for the CLI); a field absent from extras gets none. Mirrors what worker_schema._leaf_field builds per field, so both scabha-dialect loaders attach field-level hints the same way.
string_patternsadds pydantic regex constraints to literal scalar string fields; constrained defaults are validated when the model is instantiated.nullablesupplies explicit nullability independently of omission; non-nullable defaults also receive Pydantic default validation.
- shinobi.loaders.merge_field_meta(base, override)[source]¶
base updated with override, merging (not replacing) the entry of any field name both declare – see merge_param_meta. The one way a loader is allowed to compose a cab’s input-side and output-side metas, so a cab built from a document and the same cab built in Python cannot disagree about what its fields declare.
- shinobi.loaders.merge_param_meta(base, override)[source]¶
Merge two ParamMeta`s describing one field name, attribute by attribute: `override wins wherever it says something, and base survives wherever override left the default.
This exists for the dual declaration – one name on inputs and on outputs, the echo-the-input-back idiom every MS-producing cab uses. Each side declares what it knows: the output side carries implicit (how the product’s path is derived), the input side carries write_path (whether the tool creates that path or rewrites the caller’s data). Replacing the whole object, as the loaders used to, keeps only the last one collected – so the input’s write_path was silently dropped for precisely the fields it exists to mark, and sandbox.clear_stale_outputs then read every such cab as “the caller’s data, leave it alone”. Silent, and on the half of the schema that decides whether a re-run works at all.
Attribute-wise rather than “the input wins” so the output’s implicit still overrides – that is what the whole-object merge got right, and what every loaded cab already depends on.
Configuration¶
Application configuration: layered defaults < config file < env vars < CLI overrides, all validated by the same pydantic models used everywhere else in shinobi. No OmegaConf/scabha/munch/benedict stack – just pydantic-settings, reusing the validation library the cab schemas already depend on.
- class shinobi.config.VenvConfig(*, default=None, envs=<factory>)[source]¶
Bases:
BaseModelSettings for the venv backend (shinobi.backends.venv).
default is the venv used when a step declares none of its own (a path, or a key into envs); None means “no default”, so a venv-backend step with nothing declared falls back to native. envs maps short names to venv paths so recipes/config can refer to a venv by name rather than a machine-specific absolute path. A venv path is a deployment concern, so these live here (or on a Scope in Python), never in a shared cab repo.
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.BackendConfig(*, default='native', run_as_host_user=True, venv=<factory>)[source]¶
Bases:
BaseModelSettings controlling which execution backend cabs run under.
- Parameters:
default (str)
run_as_host_user (bool)
venv (VenvConfig)
- venv: VenvConfig¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.ExecutionConfig(*, max_workers=1, resources=<factory>, enforce_resources='auto', clear_stale_outputs=True)[source]¶
Bases:
BaseModelSettings controlling recipe step scheduling.
- Parameters:
max_workers (int)
resources (ResourceBudget)
enforce_resources (Literal['auto', 'always', 'never'])
clear_stale_outputs (bool)
- resources: ResourceBudget¶
- enforce_resources: Literal['auto', 'always', 'never']¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.SnapshotConfig(*, mode='auto')[source]¶
Bases:
BaseModelSettings controlling mutation-chain snapshots (shinobi.snapshots).
- Parameters:
mode (Literal['auto', 'copy', 'off'])
- mode: Literal['auto', 'copy', 'off']¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.CacheConfig(*, enabled=False, dir='.shinobi/cache', snapshots=<factory>, content_sample=False)[source]¶
Bases:
BaseModelSettings controlling step-level skip-if-unchanged caching.
- Parameters:
enabled (bool)
dir (str)
snapshots (SnapshotConfig)
content_sample (bool)
- snapshots: SnapshotConfig¶
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.StateConfig(*, dir='.shinobi/states')[source]¶
Bases:
BaseModelExplicit reusable dataset state storage, independent of step caching.
- Parameters:
dir (str)
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.LogConfig(*, dir='.', file=None, level='INFO', stream=True, capture_head_lines=5000, capture_tail_lines=5000)[source]¶
Bases:
BaseModelSettings controlling logging and live output streaming.
- Parameters:
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.SandboxConfig(*, enabled=False, dir='.shinobi/work')[source]¶
Bases:
BaseModelSettings controlling per-step sandbox execution (shinobi.sandbox).
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.ProvenanceConfig(*, enabled=False, dir='.shinobi/runs')[source]¶
Bases:
BaseModelSettings controlling reproducible-run provenance (shinobi.provenance).
- model_config = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.config.AppConfig(_case_sensitive=None, _nested_model_default_partial_update=None, _env_prefix=None, _env_prefix_target=None, _env_file=PosixPath('.'), _env_file_encoding=None, _env_ignore_empty=None, _env_nested_delimiter=None, _env_nested_max_split=None, _env_parse_none_str=None, _env_parse_enums=None, _cli_prog_name=None, _cli_parse_args=None, _cli_settings_source=None, _cli_parse_none_str=None, _cli_hide_none_type=None, _cli_avoid_json=None, _cli_enforce_required=None, _cli_use_class_docs_for_groups=None, _cli_show_env_vars=None, _cli_exit_on_error=None, _cli_prefix=None, _cli_flag_prefix_char=None, _cli_implicit_flags=None, _cli_ignore_unknown_args=None, _cli_kebab_case=None, _cli_shortcuts=None, _secrets_dir=None, _build_sources=None, *, backend=<factory>, execution=<factory>, log=<factory>, cache=<factory>, state=<factory>, provenance=<factory>, sandbox=<factory>)[source]¶
Bases:
BaseSettingsPrecedence, highest to lowest: CLI overrides > env vars (SHINOBI_*) > config file > built-in defaults.
- Parameters:
_case_sensitive (bool | None)
_nested_model_default_partial_update (bool | None)
_env_prefix (str | None)
_env_prefix_target (EnvPrefixTarget | None)
_env_file (DotenvType | None)
_env_file_encoding (str | None)
_env_ignore_empty (bool | None)
_env_nested_delimiter (str | None)
_env_nested_max_split (int | None)
_env_parse_none_str (str | None)
_env_parse_enums (bool | None)
_cli_prog_name (str | None)
_cli_settings_source (CliSettingsSource[Any] | None)
_cli_parse_none_str (str | None)
_cli_hide_none_type (bool | None)
_cli_avoid_json (bool | None)
_cli_enforce_required (bool | None)
_cli_use_class_docs_for_groups (bool | None)
_cli_show_env_vars (bool | None)
_cli_exit_on_error (bool | None)
_cli_prefix (str | None)
_cli_flag_prefix_char (str | None)
_cli_implicit_flags (bool | Literal['dual', 'toggle'] | None)
_cli_ignore_unknown_args (bool | None)
_cli_kebab_case (bool | Literal['all', 'no_enums'] | None)
_secrets_dir (PathType | None)
_build_sources (tuple[tuple[PydanticBaseSettingsSource, ...], dict[str, Any]] | None)
backend (BackendConfig)
execution (ExecutionConfig)
log (LogConfig)
cache (CacheConfig)
state (StateConfig)
provenance (ProvenanceConfig)
sandbox (SandboxConfig)
- model_config = {'arbitrary_types_allowed': True, 'case_sensitive': False, 'cli_avoid_json': False, 'cli_enforce_required': False, 'cli_exit_on_error': True, 'cli_flag_prefix_char': '-', 'cli_hide_none_type': False, 'cli_ignore_unknown_args': False, 'cli_implicit_flags': False, 'cli_kebab_case': False, 'cli_parse_args': None, 'cli_parse_none_str': None, 'cli_prefix': '', 'cli_prog_name': None, 'cli_shortcuts': None, 'cli_show_env_vars': False, 'cli_use_class_docs_for_groups': False, 'enable_decoding': True, 'env_file': None, 'env_file_encoding': None, 'env_ignore_empty': False, 'env_nested_delimiter': '__', 'env_nested_max_split': None, 'env_parse_enums': None, 'env_parse_none_str': None, 'env_prefix': 'SHINOBI_', 'env_prefix_target': 'variable', 'extra': 'forbid', 'json_file': None, 'json_file_encoding': None, 'nested_model_default_partial_update': False, 'protected_namespaces': ('model_validate', 'model_dump', 'settings_customise_sources'), 'secrets_dir': None, 'toml_file': None, 'validate_default': True, 'yaml_config_section': None, 'yaml_file': None, 'yaml_file_encoding': None}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- backend: BackendConfig¶
- execution: ExecutionConfig¶
- cache: CacheConfig¶
- state: StateConfig¶
- provenance: ProvenanceConfig¶
- sandbox: SandboxConfig¶
- classmethod settings_customise_sources(settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings)[source]¶
Set the settings source precedence: init > env vars > YAML file.
- Parameters:
settings_cls – The BaseSettings subclass being configured.
init_settings – Source for values passed directly to __init__.
env_settings – Source for SHINOBI_* environment variables.
dotenv_settings – Unused; .env files are not supported.
file_secret_settings – Unused; secret files are not supported.
- Returns:
The ordered tuple of settings sources pydantic-settings should consult, highest precedence first.
Caching¶
- class shinobi.cache.ProvenanceKey(value, producer_field=None, element_index=None, coordinates=None)[source]¶
Bases:
strA provenance key that also remembers which output field of the producing step it names.
The key alone answers “which state of this path”, which is all the skip cache ever needed. Snapshotting needs one thing more: a step with two mutated outputs produces two states in one run, and both resolve to that step’s single cache_key (see StepResult.provenance_key), so the key by itself cannot tell them apart. (key, producer field) can.
It is a str subclass rather than a pair because these values are hashed into cache keys, in __upstream__ and through combine_keys, and any change to their shape would rewrite every key in every existing manifest. json.dumps serializes a str subclass as the plain string – as value, as list element, and as dict key, under sort_keys and default=str alike – and equality, ordering and hashing against plain str are inherited unchanged. So the field rides along in memory and vanishes at the point of hashing, which is what lets this land without invalidating anything.
producer_field is a class attribute as well as an instance one: copy.deepcopy and pickle reconstruct a str subclass through str.__new__ and then restore state, so an instance can briefly exist without it, and reading it must not raise.
- Parameters:
- Return type:
Exceptions¶
- exception shinobi.exceptions.ShinobiError[source]¶
Bases:
ExceptionBase class for all shinobi errors.
- exception shinobi.exceptions.ParameterError[source]¶
Bases:
ShinobiErrorA step or cab was called with invalid, missing, or unknown parameters, or its outputs could not be validated against the declared schema.
- exception shinobi.exceptions.BackendError[source]¶
Bases:
ShinobiErrorA backend failed to run a cab.
- exception shinobi.exceptions.CabRunError[source]¶
Bases:
ShinobiErrorA step’s underlying command or function exited with a non-zero/failure status.
- exception shinobi.exceptions.StepError[source]¶
Bases:
ShinobiErrorA step failed during execution for a reason not covered by a more specific exception class. The message carries the step/recipe path so the caller can tell which step failed and why.
- exception shinobi.exceptions.CabLoadError[source]¶
Bases:
ShinobiErrorA cab definition file could not be loaded or resolved.
- exception shinobi.exceptions.ConfigLoadError[source]¶
Bases:
ShinobiErrorA worker/config schema file could not be loaded or resolved.
- exception shinobi.exceptions.ReplayError[source]¶
Bases:
ShinobiErrorA run manifest cannot be replayed against the current target.
Bases:
ShinobiErrorExecution was refused for a strict dataset annotation.
Only the contained native MSv2 read and mutation lifecycles are executable. This exception keeps every unsupported route from looking enforced while an annotation is only being carried as a path by that execution stack, and it is also the refusal raised before a strict mutation launches when exact predecessor recovery cannot be promised.
- exception shinobi.exceptions.DatasetLifecycleViolationError[source]¶
Bases:
ShinobiErrorA strict dataset broke its declared postcondition under its claim.
For a reader, the dataset changed; for a writer, the change exceeded what its access contract permits. A writer’s dataset has been rolled back to its exact predecessor when this is raised, unless a note says otherwise.
- exception shinobi.exceptions.UnsupportedFlavourError[source]¶
Bases:
ShinobiErrorA cab’s flavour isn’t one shinobi knows how to execute.
Cabs with a non-“binary” flavour (cult-cargo’s “python-code”/ “casa-task”/etc.) have a command that is not an executable name – it may be inline Python/shell source, or a dotted reference to a function to import and call. shinobi never treats that as code to eval()/exec(): every backend shells out via subprocess with a list argv, so a non-executable command would otherwise just fail obscurely (subprocess trying to exec a multi-line string as a program path). This is raised deliberately, before that happens.
Product declaration helpers¶
- class shinobi.products.AggregateSpec(*, value, suffix, only_multiple=False)[source]¶
Bases:
ProductModel- Parameters:
- value: Coordinate¶
- model_config = {'extra': 'forbid', 'frozen': True}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class shinobi.products.ProductModel[source]¶
Internal closed, frozen base for framework product values and declarations.
- shinobi.products.T¶
Type parameter for File and DirectoryBundle members.
- shinobi.products.Coordinate¶
Strict integer or string coordinate; bool values are rejected.