Notebook

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.save and 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.SAFE for 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'}")
Output
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-level DictConfig

Key points:

PartSyntaxEffect
Pathconfigs://examples/mlp.pyResolved via expath using the configs entry-point
Overrides?hps.dim_hidden=512&hps.num_layers=4Applied with apply_overrides after loading
Key / fragment#modelEquivalent 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))
Output
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.")
Output
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))
Output
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:

FormWhen to use
URL stringCompact; good for hardcoded one-liners or CLI-style usage
path + *args + key=When building overrides programmatically
bare pathWhen 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 string
  • laco.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)
Output
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.")
Output
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.")
Output
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}")
Output
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}")
Output
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-commandWhat 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 laco is not on your PATH, use python -m laco.cli as 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
Output
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
Output
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
Output
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)
Output
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.")
Output
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:

  1. Restricted builtins: open, eval, exec, compile, globals, locals, breakpoint are removed from the config file's namespace.
  2. 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)
Output
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()))
Output
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.")
Output
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()
Output
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:

OperationAPI
.pyDictConfiglaco.load("path.py")
.yamlDictConfiglaco.load("path.yaml")
DictConfig → YAML stringlaco.dump(cfg)
DictConfig.yaml filelaco.save(cfg, path)
Load with restrictionslaco.load(path, mode=LoadMode.SAFE)
Apply overridespass as positional strings or use the ?query URL syntax
Select sub-configpass 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.save produce valid YAML that round-trips back through laco.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.SAFE restricts builtins and imports when executing untrusted .py configs.

Next: 06.nested-configs-and-containers.ipynb covers L.OrderedDict, L.repeat, expand_args, and all five container macros.