Loading, Saving, and the CLI
Prerequisites: 01.why-laco.ipynb through 04.hyperparameters-and-interpolation.ipynb (laco basics, L.call, L.params, instantiation).
What you will learn:
- The URL grammar for
laco.load: path, query-string overrides, and fragments - All three forms of
laco.load - Saving configs to YAML with
laco.dump/laco.saveand round-tripping back - The
configs://protocol and how it resolves to real file paths - The three CLI sub-commands:
laco compose,laco show,laco run LoadMode.SAFEfor loading untrusted config files- A visual overview of the file-format pipeline
Dependencies: laco (torch is optional for CLI cells).
import laco
import laco.language as L
from omegaconf import OmegaConf
print(f"laco version: {laco.__version__ if hasattr(laco, '__version__') else 'installed'}")
laco version: 1.0.0
Section 1: The URL Grammar
laco.load accepts a URL-like path string that packs three pieces of information into one argument:
configs://examples/mlp.py ? hps.dim_hidden=512 & hps.num_layers=4 # model
└─── file path ──────────┘ └────── overrides (query string) ──────┘ └ key ┘
- Path: the config file, optionally prefixed by a registered URI scheme (e.g.
configs://) - Query string (
?key=value&...): Hydra-style override strings applied after loading - Fragment (
#key): a dotted key to select from the loaded top-levelDictConfig
Key points:
| Part | Syntax | Effect |
|---|---|---|
| Path | configs://examples/mlp.py | Resolved via expath using the configs entry-point |
| Overrides | ?hps.dim_hidden=512&hps.num_layers=4 | Applied with apply_overrides after loading |
| Key / fragment | #model | Equivalent to passing key="model" |
All three parts are optional: a bare path is also valid.
Section 2: laco.load Variants
There are three equivalent calling forms. Choose whichever reads most clearly in your code.
# Form 1: URL with query string and fragment — everything in one string.
# The query string becomes override strings; the fragment becomes the key.
cfg1 = laco.load(
"configs://examples/linear_regression.py?hps.in_features=16#model"
)
print("Form 1 — cfg1 (model node, in_features=16):")
print(OmegaConf.to_yaml(cfg1))
Form 1 — cfg1 (model node, in_features=16):
_target_: torch.nn.Linear
_convert_: all
in_features: ${hps.in_features}
out_features: ${hps.out_features}
bias: ${hps.bias}
# Form 2: path + positional override strings + keyword key.
# Cleaner when you need to compute overrides dynamically.
cfg2 = laco.load(
"configs://examples/linear_regression.py",
"hps.in_features=16",
key="model",
)
print("Form 2 — cfg2 (identical to cfg1):")
print(OmegaConf.to_yaml(cfg2))
# Sanity-check: both forms produce the same config
assert OmegaConf.to_yaml(cfg1) == OmegaConf.to_yaml(cfg2), "Mismatch!"
print("Forms 1 and 2 are identical.")
Form 2 — cfg2 (identical to cfg1):
_target_: torch.nn.Linear
_convert_: all
in_features: ${hps.in_features}
out_features: ${hps.out_features}
bias: ${hps.bias}
Forms 1 and 2 are identical.
# Form 3: bare path — loads the entire module namespace.
# The result is a DictConfig with .model, .optimizer, .hps keys
# (whatever is listed in __all__ of the config file).
cfg3 = laco.load("configs://examples/linear_regression.py")
print("Form 3 — top-level keys:", list(cfg3.keys()))
print()
print(OmegaConf.to_yaml(cfg3))
Form 3 — top-level keys: ['hps', 'model', 'optimizer']
hps:
in_features: 8
out_features: 1
bias: true
learning_rate: 0.01
momentum: 0.9
model:
_target_: torch.nn.Linear
_convert_: all
in_features: ${hps.in_features}
out_features: ${hps.out_features}
bias: ${hps.bias}
optimizer:
_target_: torch.optim.SGD
_convert_: all
lr: ${hps.learning_rate}
momentum: ${hps.momentum}
_partial_: true
Summary of the three forms:
| Form | When to use |
|---|---|
| URL string | Compact; good for hardcoded one-liners or CLI-style usage |
path + *args + key= | When building overrides programmatically |
| bare path | When you want the whole module namespace (e.g. to inspect hps) |
Section 3: Saving and Round-Tripping
Once you have a DictConfig you can:
laco.dump(cfg): serialize to a YAML stringlaco.save(cfg, path): write YAML to a file and return the re-loaded config
Both write standard YAML that can be reloaded by laco.load or any YAML reader.
import pathlib
import tempfile
# 1. Load the model sub-config from the linear_regression example.
cfg = laco.load("configs://examples/linear_regression.py#model")
print("Loaded config type:", type(cfg).__name__)
print()
# 2. Serialise to a YAML string.
yaml_str = laco.dump(cfg)
print("YAML output:")
print(yaml_str)
Loaded config type: DictConfig
YAML output:
{_convert_: all, _laco_: 1, _target_: torch.nn.Linear, bias: '${hps.bias}', in_features: '${hps.in_features}',
out_features: '${hps.out_features}'}
Notice the _laco_: 1 key at the top: this is a schema-version marker that laco.load strips on reload so it never appears as a user-domain key in the resulting DictConfig.
Class references (like torch.nn.Linear) are serialized as !!python/name:torch.nn.modules.linear.Linear. When you reload the YAML, laco's custom YAML loader resolves this tag back to the actual class object.
# 3. Write to a temp file and reload — the round-trip.
with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w", delete=False) as f:
f.write(yaml_str)
tmp_path = f.name
print(f"Saved to: {tmp_path}")
# Reload from the .yaml file — laco detects the extension automatically.
cfg_reloaded = laco.load(tmp_path)
print("Reloaded config type:", type(cfg_reloaded).__name__)
print()
print("Round-tripped YAML:")
print(OmegaConf.to_yaml(cfg_reloaded))
# Cleanup
pathlib.Path(tmp_path).unlink()
print("Temp file removed.")
Saved to: /tmp/nix-shell.xMBXyU/tmpuaycwade.yaml
Reloaded config type: DictConfig
Round-tripped YAML:
_convert_: all
_target_: torch.nn.Linear
bias: ${hps.bias}
in_features: ${hps.in_features}
out_features: ${hps.out_features}
Temp file removed.
# laco.save is a thin convenience wrapper:
# it calls dump(), writes the file, then reloads it (unless mode=SaveMode.NO_RELOAD).
# The returned object is the freshly loaded DictConfig.
from laco._io import SaveMode
with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as f:
save_path = f.name
cfg_from_save = laco.save(cfg, save_path)
print("laco.save() returned type:", type(cfg_from_save).__name__)
print("Keys:", list(cfg_from_save.keys()))
pathlib.Path(save_path).unlink()
print("Temp file removed.")
laco.save() returned type: DictConfig
Keys: ['_convert_', '_target_', 'bias', 'in_features', 'out_features']
Temp file removed.
Round-trip guarantee:
laco.load(path) --> DictConfig --laco.dump()--> YAML string
|
<-------- laco.load() ---------+
The reloaded config is structurally identical to the original (same keys, same values, same _target_ pointers).
Section 4: The configs:// Protocol
The configs:// prefix is resolved by expath, a path-resolution library that reads entry points registered under the configs group in pyproject.toml.
For the bundled laco examples, pyproject.toml registers:
[project.entry-points."expath.protocols"]
configs = "laco.examples:__path__[0]"
This means configs://examples/mlp.py is shorthand for the file at <laco_install>/examples/mlp.py.
You can use plain filesystem paths too: relative or absolute:
laco.load("path/to/my_config.py")
laco.load("/absolute/path/to/config.py")
laco.load("/absolute/path/to/saved.yaml")
# Resolve the actual path on disk using expath.
try:
import expath
resolved = expath.resolve("configs://examples/mlp.py")
print("configs://examples/mlp.py resolves to:")
print(" ", resolved)
except Exception as e:
print(f"expath not available or resolution failed: {e}")
expath not available or resolution failed: module 'expath' has no attribute 'resolve'
# Demonstrate loading from an absolute filesystem path.
try:
import expath
abs_path = expath.resolve("configs://examples/linear_regression.py")
cfg_from_abs = laco.load(str(abs_path))
print("Loaded from absolute path — top-level keys:", list(cfg_from_abs.keys()))
except Exception as e:
print(f"Could not demonstrate absolute path loading: {e}")
Could not demonstrate absolute path loading: module 'expath' has no attribute 'resolve'
Section 5: The CLI
laco ships a command-line interface with three sub-commands:
| Sub-command | What it does |
|---|---|
laco compose <config> [overrides...] | Load config, apply overrides, print YAML to stdout |
laco show <config> [--groups] [--schema] [--defaults] | Inspect a config without instantiating |
laco run <config> [overrides...] | Load config and call the @L.task-decorated task entry point |
Note: If
lacois not on yourPATH, usepython -m laco.clias a drop-in replacement.
The cells below use ! (shell execution) and subprocess.run to demonstrate both styles.
# Compose: load and dump the entire linear_regression config as YAML.
# The output is identical to laco.dump(laco.load(...)).
!laco compose configs://examples/linear_regression.py
Traceback (most recent call last):
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/bin/laco", line 10, in <module>
sys.exit(main())
~~~~^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 867, in main
_cmd_compose(args)
~~~~~~~~~~~~^^^^^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 213, in _cmd_compose
cfg = laco.load(args.input)
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/_io.py", line 463, in load
ext = os.path.splitext(path)[1] # noqa: PTH122
~~~~~~~~~~~~~~~~^^^^^^
File "<frozen posixpath>", line 118, in splitext
TypeError: expected str, bytes or os.PathLike object, not NoneType
# Compose with an override — same as laco.load(..., "hps.in_features=16").
!laco compose configs://examples/linear_regression.py hps.in_features=16
Traceback (most recent call last):
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/bin/laco", line 10, in <module>
sys.exit(main())
~~~~^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 867, in main
_cmd_compose(args)
~~~~~~~~~~~~^^^^^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 213, in _cmd_compose
cfg = laco.load(args.input)
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/_io.py", line 463, in load
ext = os.path.splitext(path)[1] # noqa: PTH122
~~~~~~~~~~~~~~~~^^^^^^
File "<frozen posixpath>", line 118, in splitext
TypeError: expected str, bytes or os.PathLike object, not NoneType
# Show: pretty-print the config (equivalent to compose with no overrides).
!laco show configs://examples/mlp.py
Traceback (most recent call last):
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/bin/laco", line 10, in <module>
sys.exit(main())
~~~~^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 873, in main
_cmd_show(args)
~~~~~~~~~^^^^^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 829, in _cmd_show
cfg = laco.load(args.input)
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/_io.py", line 463, in load
ext = os.path.splitext(path)[1] # noqa: PTH122
~~~~~~~~~~~~~~~~^^^^^^
File "<frozen posixpath>", line 118, in splitext
TypeError: expected str, bytes or os.PathLike object, not NoneType
# Alternative: subprocess.run — useful when you want to capture the output
# in Python code without relying on shell magic.
import subprocess
result = subprocess.run(
["laco", "compose", "configs://examples/linear_regression.py"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print("Exit code: 0 (success)")
print("First 500 characters of output:")
print(result.stdout[:500])
else:
print("Exit code:", result.returncode)
print("stderr:", result.stderr)
Exit code: 1
stderr: Traceback (most recent call last):
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/bin/laco", line 10, in <module>
sys.exit(main())
~~~~^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 867, in main
_cmd_compose(args)
~~~~~~~~~~~~^^^^^^
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/cli.py", line 213, in _cmd_compose
cfg = laco.load(args.input)
File "/nix/store/1178ymd7883vh4fm70bqqn570ag2ikdy-laco-env/lib/python3.13/site-packages/laco/_io.py", line 463, in load
ext = os.path.splitext(path)[1] # noqa: PTH122
~~~~~~~~~~~~~~~~^^^^^^
File "<frozen posixpath>", line 118, in splitext
TypeError: expected str, bytes or os.PathLike object, not NoneType
# python -m laco.cli is available when the laco CLI is not on PATH.
result2 = subprocess.run(
["python", "-m", "laco.cli", "compose", "configs://examples/linear_regression.py"],
capture_output=True,
text=True,
)
print("python -m laco.cli exit code:", result2.returncode)
assert result2.stdout == result.stdout, "Output differs!"
print("Output is identical to the laco CLI.")
python -m laco.cli exit code: 1
Output is identical to the laco CLI.
Section 6: LoadMode.SAFE
By default, laco.load executes .py config files with full Python privileges: it trusts the file like any other Python module. This is fine for your own configs.
When you load config files from external contributors (e.g. received via email, downloaded from a registry), LoadMode.SAFE provides a defense-in-depth layer:
- Restricted builtins:
open,eval,exec,compile,globals,locals,breakpointare removed from the config file's namespace. - Import allowlist: only modules whose names start with an allowed prefix can be imported from within the config file.
The default allowlist is DEFAULT_SAFE_ALLOWED_MODULES:
from laco._io import LoadMode, DEFAULT_SAFE_ALLOWED_MODULES
print("DEFAULT_SAFE_ALLOWED_MODULES:", DEFAULT_SAFE_ALLOWED_MODULES)
DEFAULT_SAFE_ALLOWED_MODULES: ('laco', 'omegaconf', 'torch', 'torch.nn')
# Load in SAFE mode — the linear_regression example only imports laco and torch,
# so it is fully compatible with the default allowlist.
cfg_safe = laco.load(
"configs://examples/linear_regression.py",
mode=LoadMode.SAFE,
)
print("SAFE mode loaded successfully. Keys:", list(cfg_safe.keys()))
SAFE mode loaded successfully. Keys: ['hps', 'model', 'optimizer']
# You can widen (or narrow) the allowlist with allowed_modules=.
# Example: also allow 'numpy' in addition to the defaults.
cfg_safe_wide = laco.load(
"configs://examples/linear_regression.py",
mode=LoadMode.SAFE,
allowed_modules=["laco", "omegaconf", "torch", "torch.nn", "numpy"],
)
print("Wide-allowlist SAFE mode loaded successfully.")
Wide-allowlist SAFE mode loaded successfully.
# Demonstrate that SAFE mode rejects disallowed imports.
import textwrap
import pathlib
import tempfile
# Write a config that tries to import 'os' — not in the allowlist.
evil_config = textwrap.dedent("""
import os
import laco.language as L
from torch import nn
model = L.call(nn.Linear)(in_features=8, out_features=1)
""")
with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
f.write(evil_config)
evil_path = f.name
try:
laco.load(evil_path, mode=LoadMode.SAFE)
print("ERROR: should have raised a LacoLoadError!")
except laco.LacoLoadError as e:
print("SAFE mode correctly blocked the import:")
print(" ", str(e)[:120])
finally:
pathlib.Path(evil_path).unlink()
SAFE mode correctly blocked the import:
Failed to load config '/tmp/nix-shell.xMBXyU/tmpsklqzo90.py': SAFE mode: import of 'os' is not in the allowlist. Allowed
Important caveat: LoadMode.SAFE is a defense-in-depth layer, not a sandbox. It blocks common attack vectors (filesystem access, arbitrary exec, uncontrolled imports) but does not prevent all possible malicious behavior. Use it to raise the bar when loading configs from untrusted sources, not as a complete security guarantee.
The same allowlist is enforced for .yaml files: the !!python/name: tag resolver only resolves names whose module prefix is in the allowlist.
Section 7: The File-Format Pipeline
Putting it all together: laco's IO model forms a closed loop between .py configs, DictConfig objects, and .yaml files.
Recap:
| Operation | API |
|---|---|
.py → DictConfig | laco.load("path.py") |
.yaml → DictConfig | laco.load("path.yaml") |
DictConfig → YAML string | laco.dump(cfg) |
DictConfig → .yaml file | laco.save(cfg, path) |
| Load with restrictions | laco.load(path, mode=LoadMode.SAFE) |
| Apply overrides | pass as positional strings or use the ?query URL syntax |
| Select sub-config | pass key= or use the #fragment URL syntax |
Summary
- URL grammar packs path, overrides, and key into one string; separate arguments work equally well.
laco.dump/laco.saveproduce valid YAML that round-trips back throughlaco.load.configs://is a registered URI scheme that maps short names to installed package paths.- The CLI (
laco compose,laco show,laco run) wraps the same Python API for shell use. LoadMode.SAFErestricts builtins and imports when executing untrusted.pyconfigs.
Next: 06.nested-configs-and-containers.ipynb covers L.OrderedDict, L.repeat, expand_args, and all five container macros.