Step Packs: External Steps¶
A step pack is a consumer-owned folder of pipeline steps that lives outside the
pip-installed cursus package and is discovered as if it were native. It lets a team add
its own steps — a new preprocessing step, a proprietary training step, a custom model
evaluation — without forking cursus, vendoring a copy of the package, or editing any file
under cursus/.
This capability landed in 2.8.0. Its central guarantee is the additive invariant:
The steps that ship inside the
cursuspackage are ALWAYS available. An external pack is a strictly additive overlay — it can only ADD steps (and, on a deliberate name clash, shadow one with a warning). It can never remove or replace a package step. With no pack active, the registry and the step catalog are byte-identical to package-only.
If you are new to how cursus turns a DAG into a pipeline, read Compilation and Registry and discovery first — step packs plug into exactly those two subsystems.
The shape of a pack¶
A pack mirrors the layout the package itself uses. It is a directory containing three sibling subfolders:
my_project/
step_pack/
interfaces/ # one <step>.step.yaml per step (the source of truth)
configs/ # config_<step>_step.py — the step's Pydantic config class
scripts/ # <step>.py — the runtime script the step executes
interfaces/*.step.yaml— the unified step interface. Each file carries astep_type, aregistry:block (sagemaker_step_type,description, and the derived config/builder/spec names), and the contract/spec sections. This is the same.step.yamlformat the package ships; see Step interfaces.configs/config_<step>_step.py— a config class that either inherits a known base (BasePipelineConfig,ProcessingStepConfigBase, or PydanticBaseModel) or follows the<Name>Config/<Name>Configurationnaming convention.scripts/<step>.py— the executable body of the step.
A minimal one-step pack, taken from the regression fixture in
tests/step_catalog/test_plugin_pack_additive_invariant.py:
# step_pack/interfaces/additive_probe_step.step.yaml
step_type: AdditiveProbeStep
registry:
sagemaker_step_type: Processing
description: plugin step AdditiveProbeStep
# step_pack/configs/config_additive_probe_step_step.py
from pydantic import BaseModel
class AdditiveProbeStepConfig(BaseModel):
field: str = "x"
Because the layout is exactly the package layout, the same discovery machinery — AST config
scanning, .step.yaml interface loading, registry derivation — works on a pack without any
special cases. A pack is just “another search root.”
The discovery anchor¶
Nothing about a pack is hard-wired to a path. cursus finds it through the caller hook:
the pipeline entry point tells cursus where the project lives, and the pack is derived from
there. There are three ways to supply the anchor, in priority order, all on
PipelineDAGCompiler.
1. Explicit workspace_dirs (highest precedence)¶
Pass the pack root(s) directly. Each must be a directory holding interfaces/ +
configs/ + scripts/.
from cursus.core.compiler.dag_compiler import PipelineDAGCompiler
compiler = PipelineDAGCompiler(
config_path="configs/my_pipeline.json",
workspace_dirs=["/abs/path/to/my_project/step_pack"],
)
workspace_dirs accepts a single str/Path or a list. When present, it wins over any
derived pack.
2. The project-root caller hook + auto-derivation¶
More commonly you give cursus the project, not the pack, and let it derive the pack. Pass
project_root (a project directory) or the self-documenting anchor_file (a file
inside the project — pass __file__):
compiler = PipelineDAGCompiler(
config_path="configs/my_pipeline.json",
anchor_file=__file__, # equivalent to project_root=Path(__file__).parent
)
anchor_file=__file__ and project_root=Path(__file__).parent are equivalent; both are
normalized by resolve_anchor in core/utils/hybrid_path_resolution.py. If both are given
and disagree, project_root wins and a warning is logged
(PipelineDAGCompiler._resolve_project_root).
The same anchor is also the highest-priority “Strategy 0” input for resolving a step’s
source_dir / processing_source_dir across deployment modes — it is pushed process-wide
via set_project_root, so configs resolve against it without needing the
CURSUS_PROJECT_BASE env var or a project_root_folder field. The pack discovery and the
path resolution share one anchor.
3. Config-anchored fallback¶
If you pass neither project_root nor anchor_file, the project root is inferred from
config_path: cursus walks up from the config file, treating a
pipeline_config / pipeline_configs parent directory as the config dir and using its
parent as the project root; otherwise it uses the config file’s own directory.
_derive_step_pack_dir¶
Once the project root is known, PipelineDAGCompiler._derive_step_pack_dir(project_root)
turns it into a pack directory by convention:
# simplified from dag_compiler.py
for candidate in (root / "step_pack", root):
if (candidate / "interfaces").is_dir():
return str(candidate)
return None # no interfaces/ anywhere -> package-only
It checks <project_root>/step_pack first, then <project_root> itself (for a project
whose own interfaces/ sit at its root), and returns the first directory that actually
contains an interfaces/ subfolder. If neither exists, it returns None and cursus stays
package-only. _resolve_workspace_dirs wraps this: explicit workspace_dirs win, otherwise
it falls back to the derived pack.
The upshot: a caller usually needs only the anchor (anchor_file=__file__), and a
conventionally-laid-out pack is found automatically.
What the compiler does with a pack¶
When PipelineDAGCompiler.__init__ resolves a non-empty workspace_dirs, it wires the pack
into every subsystem that needs to know about it:
Register the pack in the registry. For each pack dir it calls
refresh_registry(Path(pack_dir) / "interfaces")(fromcursus.registry.step_names). This merges the pack’s.step.yamlrows into the live step registry so its steps get a registry row (config class, builder name, spec type, SageMaker type).Publish a process-wide default. It calls
set_default_workspace_dirs(workspace_dirs)so that bareStepCatalog()construction sites elsewhere (validation, authoring, execution-doc generation) also see the pack.get_default_workspace_dirs()instep_catalog/step_catalog.pyreturns this list when a catalog is built with no explicitworkspace_dirs.Build a pack-aware catalog. The compiler’s own
StepCatalogis constructed withworkspace_dirs=[Path(d) for d in self.workspace_dirs], so it indexes the pack’s steps alongside the package’s.
The registry refresh (1) and the process-wide default push (2) are each best-effort, wrapped
in try/except so a pack problem there never blocks a compile. The catalog (3) is a plain
StepCatalog(...) construction that builds its index lazily and records any discovery error
in metrics["index_build_error"] rather than raising — so a bad pack step degrades to an
empty/partial index instead of aborting the compile.
Merging into the registry: refresh_registry / merge_pack_registry¶
The registry side is where the additive invariant is enforced. Two functions collaborate.
refresh_registry(pack_interfaces_dir)¶
Defined in cursus/registry/step_names.py, this is the public entry point. Given the pack’s
interfaces/ directory it:
Derives the pack’s registry rows from
pack_interfaces_dir/*.step.yamlviabuild_registry_from_interfaces(interfaces_dir=pack_dir).Drops the interface-less
_EXTRASrows (Base/Processing/HyperparameterPrep) thatbuild_registry_from_interfacesalways seeds — those are package concerns, already present, so they are not “pack” rows.Layers the remaining rows on top of the live package table with
merge_pack_registry.Registers the pack’s
interfaces/with the interface loader (register_pack_interface_dir, best-effort) so builder synthesis can load the plugin step’s.step.yaml.Re-syncs the hybrid manager (
manager.reload_core_registry()) soget_step_names()— and therefore theStepCatalog, which reads names through the manager — sees the plugin steps.Refreshes this module’s own snapshot globals via
_refresh_module_variables()so directSTEP_NAMESreaders pick up the merge too.
It returns a {name: "collision"} dict for pack names that shadowed an existing package
step (empty when every pack step is new). refresh_registry(None) is a no-op returning {};
a non-existent pack dir logs a warning and returns {}.
from cursus.registry.step_names import refresh_registry
collisions = refresh_registry("/abs/path/to/my_project/step_pack/interfaces")
# {} means every pack step was genuinely new
merge_pack_registry(pack_rows) — the in-place, never-replace primitive¶
Defined in cursus/registry/step_names_base.py, this is the low-level merge. The critical
detail is that it mutates STEP_NAMES in place and never reassigns it:
# step_names_base.py
def merge_pack_registry(pack_rows):
collisions = {name: "collision" for name in pack_rows if name in STEP_NAMES}
STEP_NAMES.update(pack_rows) # in place — package rows preserved, pack rows on top
_rebuild_derived()
return collisions
Why in-place? Many modules did from ...step_names_base import STEP_NAMES at import time and
hold a live reference to that dict. Reassigning STEP_NAMES to a new dict would leave those
references pointing at the stale table. Using .update() keeps every import-time reference
live, and _rebuild_derived() regenerates the CONFIG_STEP_REGISTRY / BUILDER_STEP_NAMES
/ SPEC_STEP_TYPES mappings from the mutated table.
Note the asymmetry with build_registry_from_interfaces, which is a REPLACE primitive
(it returns a fresh table). refresh_registry uses that to derive only the pack’s rows,
then routes them through merge_pack_registry so they are layered on top of the package
table — the pack rows never stand alone as the registry. This is what makes package steps
the permanent floor.
Collisions: shadow-with-warning + pack_collisions health¶
A pack step whose canonical name already belongs to a package step is a collision. The policy is plugin-wins: the pack row shadows the package row in the merged table, but the event is recorded and logged rather than silently accepted.
merge_pack_registryreturns the collision names.refresh_registryrecords them into the module-global_pack_collisionsdict and logs aWARNINGrecommending you rename the pack step to avoid shadowing a core step.The collisions are surfaced for monitoring via
get_registry_health():
from cursus.registry.step_names import get_registry_health
health = get_registry_health()
# {
# "hybrid_active": True, # False => static fallback registry in use
# "init_error": None, # stringified exception if the hybrid manager failed
# "pack_collisions": {}, # {name: "collision"} — empty means clean
# }
pack_collisions sits alongside the other registry-health signals (hybrid_active,
init_error) so an operator can detect a silent shadow of a core step. An empty dict means
no pack shadowed a package step.
Even on a collision, every other package step is untouched — the invariant tests assert
set(before).issubset(set(after)) after a deliberate XGBoostTraining clash.
The ordered interface loader (package wins)¶
Registry rows tell the catalog a step exists; the interface loader
(cursus/steps/interfaces/__init__.py) tells it what the step is by loading its
.step.yaml. Packs plug in here through an ordered search.
register_pack_interface_dir(interfaces_dir)appends a pack’sinterfaces/dir to the module-global_pack_interface_dirslist (idempotent;None/missing dirs ignored). It clears the interface cache because a new search root can change what resolves._search_dirs()returns the search roots package-first:[INTERFACES_DIR, *_pack_interface_dirs]._resolve_interface_path(step_name)walks those roots in order and returns the first match, so a package interface always wins on a name clash. Within each root it tries the convention-derived filename first, then a normalized (case/separator-insensitive) scan as a fallback.
# steps/interfaces/__init__.py
def _search_dirs():
"""Ordered interface search roots: the package dir FIRST, then registered packs."""
return [INTERFACES_DIR, *_pack_interface_dirs]
list_available_interfaces() merges names across all roots (deduplicated), and
clear_interface_cache() lets long-running processes pick up edited .step.yaml files.
This package-first ordering is the interface-loader half of the additive invariant, mirroring
the registry half in merge_pack_registry.
External config import by file location¶
A pack’s config classes live in files that are not under the cursus package_root, so
they cannot be imported by a relative dotted module path. This is a direct consequence of the
package-portability principle: cursus imports package components with deployment-agnostic
relative dotted paths (file_path.relative_to(package_root) + importlib.import_module(..., package=__package__)) so the same code works whether cursus is pip-installed, vendored, or
running in a Lambda/container. That relative form only exists for files under the package
root. Before 2.8.0 a config file outside the package was AST-detected and then silently
dropped. ConfigAutoDiscovery in step_catalog/config_discovery.py now handles them.
When _scan_config_directory (and, for hyperparameters, _scan_hyperparams_directory) finds
a config class, it picks the import strategy by file location:
# config_discovery.py — inside _scan_config_directory
relative_module_path = self._file_to_relative_module_path(py_file)
if relative_module_path:
module = importlib.import_module(relative_module_path, package=__package__)
class_type = getattr(module, node.name)
else:
# File is NOT under package_root -> import by file location
class_type = self._import_class_from_file(py_file, node.name)
_file_to_relative_module_path returns None for any file outside package_root, which is
exactly the case for a pack. _import_class_from_file then loads the class via
importlib.util.spec_from_file_location under a unique, path-hashed synthetic module
name:
digest = hashlib.md5(abs_path.encode("utf-8")).hexdigest()[:12]
module_name = f"cursus._pack_configs.{file_path.stem}_{digest}"
The path-derived hash guarantees two packs each shipping, say, config_xgboost_step.py do
not collide in sys.modules, and it is stable so repeated discovery reuses the already-loaded
module. The module is registered in sys.modules before exec_module so intra-module
references resolve during execution, and on failure the partial registration is rolled back
so a later retry re-runs cleanly. A load failure never raises out of discovery — it is logged
and the class is skipped.
Discovery finds a pack’s configs/ via _discover_workspace_configs (and its hyperparams/
via _discover_workspace_hyperparams), each looking for that subfolder directly under a
workspace_dir. The regression test
TestExternalConfigImport.test_external_config_class_is_discovered writes a pack for
ImportProbeStep and asserts that its ImportProbeStepConfig is imported rather than dropped;
a sibling test test_package_config_import_unchanged asserts the package config import path is
unaffected.
End to end¶
Putting the pieces together for a project laid out as my_project/step_pack/:
from cursus.core.compiler.dag_compiler import PipelineDAGCompiler
# 1. anchor_file=__file__ -> project_root = my_project/
# -> _derive_step_pack_dir finds my_project/step_pack (has interfaces/)
compiler = PipelineDAGCompiler(
config_path="configs/my_pipeline.json",
anchor_file=__file__,
)
# On construction, for the derived pack the compiler has already:
# - refresh_registry("my_project/step_pack/interfaces")
# -> merge_pack_registry (in-place, package steps preserved)
# -> register_pack_interface_dir (interface loader, package-first)
# -> reload_core_registry (catalog sees the new step)
# - set_default_workspace_dirs([...]) (bare StepCatalog() also sees the pack)
# - built self.step_catalog with workspace_dirs=[...]
pipeline = compiler.compile(dag)
The pack’s steps now resolve exactly like package steps: they have a registry row, a loadable interface, an importable config class, and a catalog entry. The regression suite locks each guarantee:
Invariant |
Test |
|---|---|
No pack ⇒ registry equals package-only |
|
Pack adds only its own step; all package steps kept |
|
A pack omitting a core step never drops it |
|
Collision shadows with a warning, keeps other steps |
|
External config imported by file location |
|
Catalog indexes pack + package |
|
Bare |
|
Snapshot gate re-derives package-only |
|
Contrast: the removed cursus.workspace module¶
The word “workspace” appears throughout this feature (workspace_dirs,
_discover_workspace_configs, get_workspace_context), but it is not the old
cursus.workspace module. That module (the api/manager/integrator/validator
subsystem) was a dead island — never instantiated on any live path — and has been removed:
there is no src/cursus/workspace/ package today, and core/__init__.py no longer references
it. Its one goal-relevant use, enumerating pipeline projects, is replaced by
core/utils/project_discovery.py and the cursus projects list|show CLI (see the
CLI reference).
The step-pack model deliberately does not resurrect that machinery. The differences:
Removed |
Step packs (2.8.0) |
|---|---|
A separate subsystem (API/manager/integrator/validator) |
No new subsystem — reuses the registry, interface loader, and step catalog |
Dead code, never instantiated |
Wired into |
Aimed at multi-workspace orchestration |
Aims at one thing: adding external steps additively |
— |
Package steps are a permanent floor (additive invariant) |
The load-bearing pieces that were preserved are the workspace_dirs parameter and the
step_catalog/adapters — those are what step packs build on. The workspace-context helpers
still present in registry/step_names.py (set_workspace_context, workspace_context, …)
are a separate, older registry-scoping concern and are orthogonal to step packs.
See also¶
Compilation — where
PipelineDAGCompilerand the caller hook live.Registry and discovery — the step registry that
refresh_registrymerges into.Step interfaces — the
.step.yamlformat a pack ships.Config system — how config classes are discovered and used.
Step catalog — the catalog that indexes pack steps as native.