Python API Reference
Comprehensive guide to FlexLock’s Python API for programmatic experiment orchestration.
Core Components
py2cfg: Python to Configuration
Convert Python functions and classes into configuration dictionaries.
Basic Usage
from flexlock import py2cfg
def train(lr=0.01, epochs=10, model=None):
"""Train a model."""
pass
# Convert function to config
cfg = py2cfg(train)
# Result: {'_target_': 'module.train', 'lr': 0.01, 'epochs': 10, 'model': None}
Every parameter with a default is captured — including ones whose default
is None. Override or remove them downstream if you don’t want them in
the run lock.
With Overrides
# Override default values
cfg = py2cfg(train, lr=0.1, epochs=100)
# Result: {'_target_': 'module.train', 'lr': 0.1, 'epochs': 100}
Nested Configurations
class Transformer:
def __init__(self, layers=6, heads=8):
self.layers = layers
self.heads = heads
# Nested py2cfg
cfg = py2cfg(
train,
lr=0.001,
model=py2cfg(Transformer, layers=12, heads=16)
)
# Result:
# {
# '_target_': 'module.train',
# 'lr': 0.001,
# 'epochs': 10,
# 'model': {
# '_target_': 'module.Transformer',
# 'layers': 12,
# 'heads': 16
# }
# }
Positional Arguments
# For functions requiring positional args
cfg = py2cfg(train, model_name, lr=0.01)
# Result: {'_target_': 'module.train', '_args_': ['model_name'], 'lr': 0.01}
Partial Functions
from functools import partial
# Create partial function
train_adam = partial(train, optimizer='adam')
cfg = py2cfg(train_adam, lr=0.01)
# Result: {'_target_': 'module.train', '_partial_': true, 'optimizer': 'adam', 'lr': 0.01}
@flexcli: Command-Line Decorator
Transform a Python function into a CLI-enabled experiment.
Basic Usage
from flexlock import flexcli
@flexcli
def train(lr=0.01, epochs=10, save_dir=None):
"""Train a model."""
print(f"Training with lr={lr}, epochs={epochs}")
print(f"Saving to {save_dir}")
return {"accuracy": 0.95}
if __name__ == "__main__":
train() # CLI mode: parses sys.argv
CLI usage:
python train.py # Uses defaults
python train.py -o lr=0.1 epochs=20 # Override params
With Defaults
@flexcli(lr=0.001, epochs=100)
def train(lr=0.01, epochs=10):
pass
# Decorator defaults override function defaults
With Snapshot Configuration
@flexcli(
_snapshot_=dict(
repos={
'main': '.', # string shorthand for path
'mylib': {'path': 'libs/mylib'}, # explicit dict form
},
data={'input': '${...input_path}'}
)
)
def train(input_path, lr=0.01, save_dir=None):
pass
Enables:
Git repository tracking
Data file hashing
Reproducibility snapshots
Note: When the config has a
_target_key, FlexLock automatically tracks the git repository of the target function’s source file — no explicitreposentry is needed in that case.
Debug Mode
Enable debug mode via the --debug CLI flag or the FLEXLOCK_DEBUG=1 environment variable:
python train.py --debug
FLEXLOCK_DEBUG=1 python train.py
On error, FlexLock drops into an interactive debugger. See Debugging for details.
Project API
The Project class provides high-level orchestration for multi-stage experiments.
Creating a Project
from flexlock import Project
# Load defaults from a Python module
proj = Project(defaults='myproject.config.defaults')
# Or from a file path — bare path infers the `defaults` variable name
proj = Project(defaults='configs/defaults.py')
# Equivalent to: Project(defaults='configs/defaults.py:defaults')
# Or point at a differently-named variable
proj = Project(defaults='configs/experiments.py:hyperparam_grid')
# Or from a pre-built DictConfig / dict
proj = Project(defaults={'train': {'lr': 0.01}})
# Or with no defaults — useful for one-off submissions
proj = Project()
One-off submission without a Project
For a single config that doesn’t need pipeline plumbing:
from flexlock import submit, py2cfg
cfg = py2cfg(train, lr=0.01, save_dir='outputs/train')
result = submit(cfg, slurm_config='configs/slurm_gpu.yaml')
flexlock.submit is a thin wrapper over Project().submit — all kwargs
(sweep, smart_run, overrides, force, etc.) are forwarded.
Python module structure:
# myproject/config/defaults.py
from flexlock import py2cfg
def preprocess(input_dir, output_dir):
pass
def train(data_dir, lr=0.01, save_dir=None):
pass
defaults = dict(
preprocess=py2cfg(preprocess, input_dir='data/', output_dir='processed/'),
train=py2cfg(train, data_dir='processed/', lr=0.01)
)
proj.get(key): Retrieve Configuration
Get a configuration node from the defaults. The returned config is
self-contained: root-scope references (${some_anchor}) are frozen to
concrete values, while resolver calls (${vinc:}, ${latest:},
${run_lock:}) and intra-sub-tree references are preserved for resolution
at submit time.
When the project was built from an import string
(Project("pkg.xps.pipeline_cfg")) and the node is runnable (_target_ +
save_dir), get also records the preset as _preset_
(defaults: pkg.xps:pipeline_cfg, select: <key>), so the run shows up in
flexlock runs / flexlock presets. It never affects caching.
${run:...} references are kept as call strings by get and resolved by
submit, which records the chosen run in the config and its lineage. With
after, unpinned references instead bind at worker stage start.
# Get config by key
train_cfg = proj.get('train')
# Modify before execution
train_cfg.lr = 0.1
train_cfg.batch_size = 64
# train_cfg can be pickled, merged, or shipped to an HPC worker
# without losing root context — every ${root_anchor} has already been
# substituted with its value at the moment of the get() call.
proj.submit(train_cfg, slurm_config='configs/slurm_gpu.yaml')
Anchor-update workflow: if you want a root-level anchor change to
propagate into a stage, update proj.defaults before calling proj.get:
OmegaConf.update(proj.defaults, 'cnf_run_dir', result.save_dir)
eval_cfg = proj.get('cnf_eval_val') # picks up the new anchor
A previously-fetched config will not see anchor updates retroactively —
its root refs are already frozen. Re-call proj.get to refresh.
Returns: DictConfig (OmegaConf)
proj.submit(): Execute Configuration
Execute a configuration, with smart caching and HPC support.
Signature
def submit(
config: DictConfig | str | None = None,
sweep: List[Dict] = None,
sweep_target: str = None,
n_jobs: int = 1,
smart_run: bool = True,
search_dirs: List[str] = None,
wait: bool = True,
pbs_config: str = None,
slurm_config: str = None,
sweep_dir_suffix: bool = False,
match_include: List[str] = None,
match_exclude: List[str] = None,
isolated: bool = False,
force: bool = False,
overrides: dict | List[str] = None,
merge: str | Path | dict = None,
debug: bool = False,
print_config: bool = False,
) -> ExecutionResult | List[ExecutionResult] | None
Parameters:
config: Accepts aDictConfig, a key string (looked up viaproj.get), orNone(useproj.defaults).sweep_target: Dot-path where each sweep item is merged into the base config.Nonemerges items at the root.overrides: Dict ({'lr': 0.01}) or dotlist (['lr=0.01']) merged intoconfigbefore execution, in order. A value"@name"replaces the node with the confignamefromProject.override_module(the module of an import-stringdefaults) or"@pkg.mod.attr"; sweep items accept"@name"values too.merge: Path to a YAML file (or a dict) merged intoconfigbefore execution.overridesis applied aftermerge.debug: Wrap the user function with the post-mortem debugger so exceptions drop into PDB.print_config: Print the resolved config and returnNonewithout executing — useful for inspecting sweep-merged or override-merged configs before launching.sweep_dir_suffix: WhenTrue, nests each sweep run under<save_dir>/sweep_{i:04d}/. DefaultFalse(all sweep runs share the basesave_dir).match_include: Override the git path include-patterns used duringsmart_runcomparison (takes priority over per-repo patterns stored inrun.lock).match_exclude: Override the git path exclude-patterns used duringsmart_runcomparison.
CLI/Python parity
flexlock-run and proj.submit() share the same execution kernel. Equivalences:
CLI flag |
Python kwarg |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
The load_sweep utility is also exported at the package level:
from flexlock import load_sweep
sweep = load_sweep(sweep_file='configs/ablation.yaml')
proj.submit('train', sweep=sweep, sweep_target='lit_module',
slurm_config='configs/slurm_gpu.yaml')
Basic Execution
# Get config and execute
cfg = proj.get('train')
result = proj.submit(cfg)
# Access results
print(result.save_dir) # Where results are saved
print(result.status) # "SUCCESS", "CACHED", "FAILED"
print(result.result) # Return value from function
print(result['accuracy']) # Dict-like access (if result is dict)
Smart Run (Caching)
# First run: executes
result = proj.submit(cfg, smart_run=True) # Runs
# Second run: cache hit
result = proj.submit(cfg, smart_run=True) # ⚡ Skipped! Returns cached result
How it works:
Generates fingerprint (code + data + config)
Searches
search_dirsfor run directories containing bothrun.lock(written before execution) andrun.complete(written after the user function returns successfully)Returns cached result on a match; runs that have only
run.lock(previous attempts interrupted before completion) are skipped, not treated as cache hits
Custom search:
result = proj.submit(
cfg,
smart_run=True,
search_dirs=['outputs/train/', 'archive/old_runs/']
)
Cache markers and force=True
Each completed run leaves two files alongside its outputs:
run.lock— config + provenance, written before execution starts.run.complete— JSON with completion timestamp, written after the user function returns. A cache hit requires both.
To force re-execution while keeping prior outputs in place:
proj.submit(cfg, force=True) # deletes run.complete, runs again into same dir
force=True does not delete save_dir or run.lock — only the
completion marker. The user function overwrites outputs in place.
Migrating from older versions: runs created before this scheme exists
have only run.lock and would be treated as incomplete. Backfill them once:
flexlock migrate-cache-markers outputs/
This writes run.complete for any dir that has run.lock plus at least one
non-hidden output file. To prune stale lock-only dirs instead:
flexlock gc --incomplete outputs/
Parameter Sweep
# Define sweep
sweep = [
dict(lr=0.001, batch_size=32),
dict(lr=0.01, batch_size=64),
dict(lr=0.1, batch_size=128),
]
if __name__ == "__main__":
# n_jobs > 1 spawns multiprocessing workers (see "Parallelism" note
# below) and therefore must be guarded by `if __name__ == "__main__":`
# when the call is at module scope. Without the guard, importing the
# script (which spawn does to bootstrap the children) re-runs the call
# and the process never terminates.
results = proj.submit(cfg, sweep=sweep, n_jobs=3)
# Process results
for i, result in enumerate(results):
print(f"Run {i}: accuracy={result['accuracy']}")
best = max(results, key=lambda r: r['accuracy'])
print(f"Best config: {best.cfg}")
Parallelism note.
n_jobs > 1andisolated=Trueboth use Python’smultiprocessingwith thespawnstart method (chosen to avoid GPU/CUDA fork hazards). Spawn re-imports the launching script in each child, so any module-scopeproj.submit(...)call must be placed underif __name__ == "__main__":. Notebook and REPL contexts are fine.n_jobs=1withoutisolated=Trueruns in-process and does not need the guard.
Per-item save_dir containment. Each sweep item’s save_dir must
nest under the sweep root (taken from the base config’s save_dir, or
the first item’s parent dir). The tasks DB and per-item lineage markers
all live in the sweep root tree. Violating containment raises a
FlexLockValidationError listing the offending items before any work is
queued.
Tracking per-item save_dir in derived paths. Use intra-node
references (relative to the same config node) rather than absolute root
references:
# ✅ Tracks per-item save_dir overrides — recommended
cfg = py2cfg(train,
save_dir='outputs/train',
log_dir='${save_dir}/logs', # intra-node ref
ckpt_dir='${save_dir}/checkpoints',
)
proj.submit(cfg, sweep=[
dict(save_dir='outputs/sweep/a'), # log_dir becomes outputs/sweep/a/logs
dict(save_dir='outputs/sweep/b'),
])
# ❌ Frozen at proj.get() time — won't track sweep overrides
cfg = py2cfg(train,
save_dir='outputs/train',
log_dir='${main.save_dir}/logs', # cross-tree ref
)
Cross-tree refs (${main.save_dir}, ${root_anchor}) are frozen to
concrete values at proj.get() so the sub-node remains self-contained
under pickling and merge. Intra-node refs (${save_dir},
${some_local_key}) are preserved and re-resolve on each sweep item.
Previewing a sweep. Set print_config=True while passing sweep=...
to print each item’s fully-merged config without executing:
proj.submit(cfg, sweep=sweep, print_config=True)
# --- sweep item 0 ---
# lr: 0.001
# batch_size: 32
# ...
Post-sweep chaining
After a sweep, downstream stages typically need to run once per sweep
result, each anchored at the result’s save_dir. proj.submit_chained
packages this loop:
chained = proj.submit_chained(
base_cfg,
sweep=[{'fourier_sigma': s} for s in [1.0, 2.0, 5.0]],
downstream=[
# (stage_key, anchor_wiring)
# anchor_wiring maps anchor name in proj.defaults → result attribute
('encode_val', {'cnf_run_dir': 'save_dir'}),
('cnf_eval_val', {'cnf_run_dir': 'save_dir'}),
],
sweep_kwargs={'slurm_config': 'configs/slurm_gpu.yaml', 'n_jobs': 3},
downstream_kwargs={'slurm_config': 'configs/slurm_gpu.yaml'},
)
# Iterate (sweep_result, downstream_results) pairs:
for sweep_r, downstream_rs in chained:
encode_r, eval_r = downstream_rs
print(sweep_r.save_dir, eval_r['val_score'])
For each sweep item, the parent’s save_dir is written into the named
anchor on proj.defaults, then each downstream stage is fetched via
proj.get(key) (so the new anchor propagates through interpolations)
and submitted. downstream_kwargs defaults to smart_run=False to
avoid stale-cache false hits between iterations — pass
{'smart_run': True} explicitly if you want caching.
HPC Execution (Slurm)
result = proj.submit(
cfg,
slurm_config='slurm.yaml',
wait=True # Block until completion
)
Slurm config:
startup_lines:
- "#SBATCH --job-name=train"
- "#SBATCH --cpus-per-task=8"
- "#SBATCH --mem=32G"
- "#SBATCH --time=04:00:00"
- "module load cuda/11.8"
python_exe: "python"
HPC Execution (PBS)
result = proj.submit(
cfg,
pbs_config='pbs.yaml',
wait=False # Submit and return immediately
)
# Check result later
# (Requires manual polling or separate script)
Async Execution
# Submit without waiting
result = proj.submit(cfg, slurm_config='slurm.yaml', wait=False)
print(result.status) # "SUBMITTED"
# Continue with other work...
proj.exists(): Check for Cached Run
Check if a run with matching configuration exists.
cfg = proj.get('train')
if proj.exists(cfg, search_dirs=['outputs/train/']):
print("Run already exists, using cache")
result = proj.get_result(cfg)
else:
print("No cache found, executing")
result = proj.submit(cfg)
proj.get_result(): Load Cached Results
Load results from a previously completed run.
cfg = proj.get('train')
# Load cached result (raises ValueError if not found)
result = proj.get_result(cfg, search_dirs=['outputs/'])
print(result.save_dir)
print(result.result)
Result loading:
Tries
results.jsonfirstFalls back to
run.lockif availableReturns
ExecutionResultwithstatus="CACHED"
proj.run_stage(): Execute a Pipeline Stage
Convenience method that wraps submit() with automatic search_dirs discovery and save_dir propagation — the two things you’d otherwise have to do manually in every pipeline.
Signature
def run_stage(
cfg: DictConfig,
stage_name: str = None,
smart_run: bool = True,
search_dirs: List[str] = None,
**submit_kwargs,
) -> ExecutionResult
Parameters:
cfg: Stage configuration (DictConfig)stage_name: Name of the stage. IfNone, inferred fromcfg.save_dir(last path component).smart_run: Whether to check for cached runs (defaultTrue)search_dirs: Directories to search for cached runs. IfNone, auto-discovered by scanning sibling experiment directories with the same stage name.**submit_kwargs: Additional arguments forwarded tosubmit()(e.g.,isolated=True)
Basic Usage
proj = Project(defaults='pipeline.defaults')
cfg = proj.get('train')
cfg.save_dir = 'results/exp_001/train'
# Executes the stage, auto-discovers search_dirs, propagates save_dir
result = proj.run_stage(cfg)
# cfg.save_dir is now updated to result.save_dir (useful for downstream stages)
print(cfg.save_dir) # results/exp_001/train
Multi-Stage Pipeline
proj = Project(defaults='pipeline.defaults')
# Stage 1
prep_cfg = proj.get('preprocess')
prep_cfg.save_dir = 'results/exp_001/preprocess'
proj.run_stage(prep_cfg)
# Stage 2 — uses prep_cfg.save_dir (propagated by run_stage)
train_cfg = proj.get('train')
train_cfg.data_dir = prep_cfg.save_dir
train_cfg.save_dir = 'results/exp_001/train'
proj.run_stage(train_cfg, isolated=True) # GPU stage in subprocess
# Save the full pipeline config for reproducibility
proj.save_snapshot('results/exp_001')
How auto-discovery works:
Given save_dir = "results/exp_001/train", run_stage looks at results/*/train/ for all sibling experiment directories with the same stage name. This means re-running a pipeline with different parameters will automatically find cached stages from previous experiments.
proj.save_snapshot(): Save Pipeline Config
Save the current project defaults as pipeline.yaml for reproducibility.
proj.save_snapshot('results/exp_001')
# Creates results/exp_001/pipeline.yaml
The saved pipeline.yaml can later be re-run with:
flexlock-run -c results/exp_001/pipeline.yaml -s train
ExecutionResult
Object returned by proj.submit().
Attributes
result = proj.submit(cfg)
result.save_dir # str: Directory where results are saved
result.status # str: "SUCCESS", "CACHED", "SKIPPED", "FAILED"
result.result # Any: Return value from function
result.cfg # DictConfig: Configuration used
Dict-like Access
If the function returns a dict, access keys as attributes:
def train(...):
return {"accuracy": 0.95, "loss": 0.05}
result = proj.submit(cfg)
print(result.accuracy) # 0.95
print(result['loss']) # 0.05
print(result.get('f1', 0)) # 0 (default)
Advanced Usage
Multi-Stage Pipelines
from pathlib import Path
from flexlock import Project, py2cfg
# Define pipeline
proj = Project(defaults='pipeline.defaults')
# Stage 1: Preprocess
preprocess_cfg = proj.get('preprocess')
preprocess_result = proj.submit(preprocess_cfg)
# Stage 2: Train (depends on Stage 1)
train_cfg = proj.get('train')
train_cfg.data_dir = preprocess_result.save_dir # Use output from Stage 1
train_result = proj.submit(train_cfg)
# Stage 3: Evaluate (depends on Stage 2)
eval_cfg = proj.get('evaluate')
eval_cfg.model_path = Path(train_result.save_dir) / 'model.pth'
eval_result = proj.submit(eval_cfg)
print(f"Final accuracy: {eval_result['accuracy']}")
With smart run:
# Re-run entire pipeline
# Unchanged stages are automatically skipped
preprocess_result = proj.submit(preprocess_cfg) # ⚡ Cached
train_result = proj.submit(train_cfg) # ⚡ Cached
eval_result = proj.submit(eval_cfg) # ⚡ Cached
Sweep with Early Stopping
sweep = [dict(lr=v) for v in [0.001, 0.01, 0.1, 1.0]]
results = []
for i, override in enumerate(sweep):
cfg = proj.get('train')
cfg.merge_with(override)
result = proj.submit(cfg, smart_run=False)
results.append(result)
# Early stop if accuracy > 0.95
if result['accuracy'] > 0.95:
print(f"Found good config at iteration {i}")
break
best = max(results, key=lambda r: r['accuracy'])
Dynamic Configuration
from datetime import datetime
cfg = proj.get('train')
# Dynamic save_dir with timestamp
cfg.save_dir = f"outputs/train_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
# Dynamic data path
cfg.data_dir = "data/latest" if use_latest else "data/stable"
result = proj.submit(cfg)
Conditional Execution
cfg = proj.get('train')
# Only run if not cached
if not proj.exists(cfg):
print("Training model...")
result = proj.submit(cfg, smart_run=False)
else:
print("Using cached model")
result = proj.get_result(cfg)
# Use result
model_path = Path(result.save_dir) / 'model.pth'
Best Practices
1. Always Use save_dir
# Bad: No save_dir
cfg = py2cfg(train, lr=0.01)
# Good: Explicit save_dir
cfg = py2cfg(train, lr=0.01, save_dir='outputs/train_baseline')
2. Use Smart Run by Default
# Let FlexLock handle caching
result = proj.submit(cfg, smart_run=True)
# Only disable for debugging
result = proj.submit(cfg, smart_run=False)
3. Structure Configs Hierarchically
# Good: Nested structure
defaults = dict(
base_dir='outputs/',
preprocess=py2cfg(preprocess, save_dir='${base_dir}/preprocess'),
train=py2cfg(train, save_dir='${base_dir}/train')
)
# Access root variables via interpolation
# OmegaConf resolves ${base_dir} at runtime
4. Track Data Dependencies
cfg = py2cfg(
train,
input_data='data/train.csv',
save_dir='outputs/train',
_snapshot_=dict(
repos={'main': '.'}, # string shorthand
data={'train_data': '${...input_data}'}
)
)
# Now FlexLock tracks:
# - Code version (git tree hash)
# - Data version (file hash)
# - Configuration
Note: Use
_snapshot_=insidepy2cfg. Thesnapshot_config=spelling only works as a parameter to the@flexclidecorator.
To track only specific files within a repo (speeds up smart_run by ignoring irrelevant changes):
_snapshot_=dict(
repos={
'main': {
'path': '.',
'include': ['src/mymodule/**'], # only these paths matter
'exclude': ['tests/**'], # ignore tests
}
}
)
Or resolve the repo path automatically from a Python module name:
_snapshot_=dict(
repos={
'mylib': {'module': 'mylib'} # path resolved via importlib
}
)
5. Use Sweeps for Exploration
# Define parameter grid
param_grid = [
dict(lr=lr, batch_size=bs)
for lr in [0.001, 0.01, 0.1]
for bs in [32, 64, 128]
]
# Execute in parallel
results = proj.submit(
cfg,
sweep=param_grid,
n_jobs=8,
smart_run=True # Skip already-run configs
)
# Analyze results
best = max(results, key=lambda r: r.get('accuracy', 0))
See Also
CLI Reference - Command-line usage
HPC Integration - Slurm/PBS configuration
Debugging - Interactive debugging
Reference - Environment variables and exceptions
Scheduler dependencies
Project.submit(..., after=[job_id], wait=False) and
Project.submit_pipeline(..., after=[job_id], wait=False) accept scheduler
job dependencies with Slurm or PBS. after may also be a colon-separated
string. Returned ExecutionResult.job_id is the newly submitted scheduler ID;
all submitted sweep items or pipeline stages share that ID. It is None for
local or cached results.
With after, unpinned ${run:preset,select} references (including strict
references) bind when each worker stage starts. The selected run directories
are recorded in _snapshot_.prevs and lineage before fingerprinting. Explicit
pins such as ${run:preset,select,0005} still bind at submission. Automatic
controller cache matching is skipped for configs with deferred run references;
explicit save_dir_policy="skip" retains its resume behavior. save_dir,
_target_, and repository paths must be known at submission; use fixed paths
or pinned references there. Without after, run references bind at submission.