Define a Step Pack¶
A step pack is a folder of your own pipeline steps that lives outside the
installed cursus package. You author the step artifacts once, drop them in a
directory, point the compiler at that directory, and Cursus discovers your steps as
if they were built in — no fork, no vendored copy, no edit to the package source.
This is the task-oriented recipe. It walks the whole loop end to end:
Lay out the pack directory (
interfaces/+configs/+scripts/).Point the compiler at it — explicitly (
workspace_dirs) or by anchor (anchor_file).Verify discovery — via
get_registry_health()and thecursusCLI.Scaffold a pack-ready project with the
project.initMCP tool.
For why packs work the way they do — the additive invariant, the registry-merge mechanics, interface-resolution ordering — read the companion concept page, Step packs. For authoring a single step’s three artifacts in depth, see the Step pack tutorial.
Prerequisite: you should already be able to compile a DAG with
PipelineDAGCompiler. See DAG and compilation.
Step 1 — Lay out the pack directory¶
A pack is a directory holding three subdirectories, one per discovery source. The
minimum layout for a single custom step named AcmeScoring:
my_project/
└── step_pack/
├── interfaces/
│ └── acme_scoring.step.yaml # the step interface (registry + contract + spec)
├── configs/
│ └── config_acme_scoring_step.py # class AcmeScoringConfig(...)
└── scripts/
└── acme_scoring.py # the step's entry-point script
Each subdirectory maps to exactly one discovery source:
Subdirectory |
Cursus scans for |
Discovered by |
|---|---|---|
|
|
|
|
|
|
|
the step’s entry-point script |
|
|
|
|
You do not ship a per-step builder module — under the current design builders are
synthesized from the interface. So interfaces/ + configs/ + scripts/ is the
complete set of files you author.
The interface file (interfaces/*.step.yaml)¶
The .step.yaml is the single source of truth for the step. Its step_type is the
canonical (PascalCase) step name, and its registry: block is what gets merged into
the step-name registry:
# step_pack/interfaces/acme_scoring.step.yaml
step_type: AcmeScoring
node_type: internal
registry:
sagemaker_step_type: Processing # required — no fallback
description: Acme domain scoring step
contract:
entry_point: acme_scoring.py
inputs:
DATA:
path: /opt/ml/processing/input/data
required: true
outputs:
scored_data:
path: /opt/ml/processing/output
sagemaker_step_type has no default: if you omit it the loader raises a
ValueError naming the file, so a misconfigured pack fails loudly instead of being
silently dropped. The config_class (AcmeScoringConfig) and builder_step_name
(AcmeScoringStepBuilder) are derived from the step name by convention.
The config class (configs/*.py)¶
A class following the <Name>Config convention (or inheriting a known base). Because
the file is not under the package root, ConfigAutoDiscovery finds it with AST
parsing and imports it by file location under a unique, path-hashed synthetic
module name — so two packs that each ship a config_..._step.py never collide in
sys.modules:
# step_pack/configs/config_acme_scoring_step.py
from cursus.core.base.config_base import BasePipelineConfig
class AcmeScoringConfig(BasePipelineConfig):
threshold: float = 0.5
ConfigAutoDiscovery.discover_config_classes() matches a class when it either
inherits BasePipelineConfig / ProcessingStepConfigBase / BaseModel, or its
name ends in Config / Configuration. Hyperparameter classes (in an optional
hyperparams/ dir) match by inheriting ModelHyperparameters / BaseModel or by a
Hyperparameters / Hyperparams name suffix.
The script (scripts/*.py)¶
The entry-point named in the interface’s contract.entry_point. It is indexed as the
step’s script component and used at build time.
Step 2 — Point the compiler at the pack¶
PipelineDAGCompiler gives you two ways to attach a pack. Precedence, top to bottom:
explicit workspace_dirs > derived from project_root > derived from anchor_file >
package-only.
Option A — explicit workspace_dirs¶
Pass one directory (or a list). Each entry is a pack root — the directory that holds
interfaces/ + configs/ + scripts/:
from cursus.core.compiler.dag_compiler import PipelineDAGCompiler
compiler = PipelineDAGCompiler(
config_path="pipeline_config/config_NA.json",
workspace_dirs="/abs/path/to/my_project/step_pack", # str, Path, or list
)
When workspace_dirs is given it wins over any auto-derivation. On construction the
compiler:
Calls
refresh_registry(<pack>/interfaces)for each pack, merging the pack’s.step.yamlrows into the step registry (add-only — see Step 3).Builds the
StepCatalogwithworkspace_dirs=[...]so the pack’s components are indexed as native.Pushes the dirs as the process-level default via
set_default_workspace_dirs(...), so a bareStepCatalog()created elsewhere (validation, exec-doc generation) also sees the plugin steps.
Option B — derived from anchor_file / project_root¶
Most pipelines already pass the caller hook — anchor_file=__file__ (or
project_root=Path(__file__).parent) — so Cursus can resolve docker source_dir
paths against the project folder. That same anchor doubles as the pack anchor. Omit
workspace_dirs and the compiler derives a pack from the resolved project root:
compiler = PipelineDAGCompiler(
config_path="pipeline_config/config_NA.json",
anchor_file=__file__, # project folder = this file's directory
)
The derivation (_derive_step_pack_dir) checks, in order:
<project_root>/step_pack— if it has aninterfaces/subdir, that is the pack.<project_root>itself — if it has aninterfaces/subdir.
The first candidate that actually contains an interfaces/ directory wins; if
neither does, discovery is package-only. This is why, in a scaffolded project,
dropping a step_pack/interfaces/ folder next to your run_pipeline.py is enough —
the anchor_file=__file__ you already pass makes the pack discoverable with no extra
argument.
Note on paths: pass absolute paths (or an
anchor_file=__file__that resolves to one).workspace_dirsentries are expanded and resolved to absolute paths internally, but relative strings are resolved against the current working directory, which is easy to get wrong.
Referencing the step from your DAG¶
Your DAG node name resolves to the pack step’s config the same way any node does. Name the node after the step (or after its config), then preview before compiling:
from cursus.api.dag.base_dag import PipelineDAG
dag = PipelineDAG()
dag.add_node("acme_scoring")
# ... add the rest of your nodes and edges ...
preview = compiler.preview_resolution(dag)
print(preview.node_config_map) # {"acme_scoring": "AcmeScoringConfig", ...}
pipeline = compiler.compile(dag)
node_config_map maps each DAG node to the config class name
(type(config).__name__, e.g. AcmeScoringConfig) that the resolver matched it
to — not the canonical step type. An unresolved node maps to the literal string
"UNRESOLVED".
Because discovery is additive, this exact code compiles unchanged whether or not the pack is present. With no pack the compiler is package-only and behaves as before.
Step 3 — Verify discovery¶
You can confirm a pack was picked up without compiling a full pipeline. Use whichever surface fits — a quick Python check, or the CLI.
The registry merge, and collisions¶
When a pack is attached, refresh_registry(pack_interfaces_dir) merges its rows into
the live registry add-only: package steps are always present, and a pack step
whose name already exists in the package shadows it with a WARNING (plugin-wins)
rather than replacing anything else. It returns a dict of collisions:
from cursus.registry.step_names import refresh_registry, get_step_names
collisions = refresh_registry("/abs/path/to/my_project/step_pack/interfaces")
# {} means every pack step is new; {"XGBoostTraining": "collision"} means a shadow
names = get_step_names()
assert "AcmeScoring" in names # your pack step has a registry row
assert "XGBoostTraining" in names # package steps are still there
You normally don’t call refresh_registry yourself — the compiler does it — but any
collision it records is also readable later through the registry health report:
from cursus.registry.step_names import get_registry_health
health = get_registry_health()
print(health["pack_collisions"]) # {} when clean; {"XGBoostTraining": "collision"} on a clash
print(health["hybrid_active"]) # False (+ health["init_error"]) if the registry fell back
Wire pack_collisions into monitoring so a pack silently shadowing a core step never
goes unnoticed. The fix for a reported collision is simply to rename the pack step so
it no longer clashes with a package name.
The step catalog¶
The pack step is indexed as a native catalog entry:
from cursus.step_catalog.step_catalog import StepCatalog
catalog = StepCatalog(workspace_dirs=["/abs/path/to/my_project/step_pack"])
assert catalog.get_step_info("AcmeScoring") is not None
assert catalog.get_step_info("XGBoostTraining") is not None # package step still indexed
And the out-of-package config was imported by file location:
assert "AcmeScoringConfig" in catalog.config_discovery.discover_config_classes()
From the CLI¶
The cursus CLI reads the same catalog and registry. These commands are handy for a
quick eyeball check (see the full CLI reference):
# List catalog steps (your pack step appears in the list)
cursus catalog list
# Inspect one step: workspace, components, framework
cursus catalog show AcmeScoring
# Show which component files were discovered for the step
cursus catalog components AcmeScoring
# Show the step's I/O connection view (inputs, outputs, property refs)
cursus steps io AcmeScoring
# Registry view, including any name conflicts
cursus registry list-steps --conflicts-only
The bare
cursus catalog ...commands build a package-scoped catalog. To have the CLI itself see a pack you must run inside a process where the pack dirs are the process default (e.g. after a compile that attached them). The most reliable discovery check for a fresh pack is the Python snippet above with an explicitworkspace_dirs=[...], or thepreview_resolutioncall in Step 2.
Step 4 — Scaffold a pack-ready project¶
The project.init MCP tool lays down a phase-0 project whose entry files already
pass anchor_file=__file__. That anchor is exactly what the compiler uses to derive a
step_pack/, so a scaffolded project is pack-ready out of the box.
// project.init arguments
{ "name": "secure_delivery", "framework": "xgboost" }
// -> projects/secure_delivery_xgboost/
project.init requires name (snake_case base name) and framework (one of
xgboost, pytorch, lightgbmmt, bedrock); optional target_dir (default
projects) and overwrite. It writes a fixed skeleton — a region-agnostic
run_pipeline.py, the @MODSTemplate deployment class that loads
pipeline_config/dag.json, a generate_config.py skeleton, an empty dag.json
stub, the folder tree with per-folder READMEs, and a root README.md action-item
ledger. Both generated entry files build the compiler with the caller hook:
from cursus.core.compiler.dag_compiler import PipelineDAGCompiler
compiler = PipelineDAGCompiler(
config_path=self.config_path,
sagemaker_session=self.sagemaker_session,
role=self.execution_role,
anchor_file=__file__, # doubles as the step-pack anchor
)
To make a scaffolded project host a pack, create step_pack/interfaces/ (plus
configs/, scripts/) beside the generated run_pipeline.py and drop your three
artifacts in — the pack is then picked up with no further wiring.
For a brand-new step type that does not yet exist in the registry, the scaffold’s
ledger points you at the /cursus-author-step workflow to author the .step.yaml +
config + script — the very artifacts you then place into your pack.
End-to-end bring-up¶
project.init is scaffold-only. To bring a whole project up (scaffold → seed/author a
DAG → generate config), use project.bring_up, which returns the invocation for the
cursus-new-project orchestrator. That workflow composes:
Scaffold —
cursus-init-project(the phase-0 skeleton + ledger).SeedDAG — for
dag_source="catalog", recommend + load a shared catalog DAG intopipeline_config/dag.json; fordag_source="manual", stop for a human to author the DAG.GateDAG — refuse to proceed on an empty/invalid DAG.
Configure —
cursus-configure-pipelinefillsgenerate_config.pyand writesconfig_<region>.json.
See the MCP tools reference for project.init
and project.bring_up, and the Pipeline catalog
for the DAGs the catalog branch can seed.
Troubleshooting¶
Symptom |
Likely cause / fix |
|---|---|
Pack step never appears in the registry |
The compiler didn’t attach the pack. Confirm |
|
The DAG node name doesn’t match the step or its config. Rename the node, or add an explicit config key / |
Interface loads but the config class is missing |
Config file naming/base doesn’t match |
|
The |
A package step disappeared after adding a pack |
It can’t — packs are add-only. If a pack step shadowed a package name, check |
|
The workspace-aware registry manager fell back to the static registry; read |
Reference¶
Compiler —
cursus.core.compiler.dag_compiler.PipelineDAGCompiler(workspace_dirs,anchor_file,project_root).Registry merge / health —
cursus.registry.step_names.refresh_registry,get_registry_health,get_step_names.Config / hyperparameter discovery —
cursus.step_catalog.config_discovery.ConfigAutoDiscovery.Catalog —
cursus.step_catalog.step_catalog.StepCatalog,set_default_workspace_dirs. See the step catalog reference.Scaffolding — the
project.init/project.bring_upMCP tools; see the MCP tools reference.Concept & deep dive — Step packs · Step pack tutorial · Registry and discovery.