Derive a value the configuration does not contain¶
Some values are not choices. They are consequences of other choices, and writing them by hand is an invitation to write them inconsistently.
The running example on this page: a dataset reads its labels from somewhere, and the model needs to know how many labels there are. Nobody wants to type that number — it is already implied by the labels — but the model configuration cannot be built without it.
Which recipe applies depends on one question only: where does the value live?
The value is elsewhere in the configuration¶
Reference it. An expression is resolved after all sources are merged, so overriding the input on the command line recomputes the output.
See Tutorial 21 for expressions, and Tutorial 22 for what a bare reference means in a file that is mounted somewhere.
The value is in a file confarg can read¶
If the labels sit in a JSON, YAML, TOML or CSV file, the dataset object does not have to be the one
that reads them. Let the configuration read them, and the count is an expression: len is part of
the expression whitelist.
data:
labels: { __include__: labels.json } # ["dog", "cat", ...]
model:
num_labels: ${len(data.labels)}
__include__ contributes any top-level value, not only a mapping, so a JSON list or a CSV column
is a legitimate thing to include. The dataset now receives the labels themselves rather than a path
to them, and num_labels is derived wherever the labels come from.
Two caveats that are easy to trip over:
- An include path cannot be an expression. Includes are resolved while files are being loaded,
before anything is merged, so
__include__: ${labels_file}can never work. To keep the file selectable per run, mount it from outside instead —--config.data.labels labels.jsonon the command line, orMYAPP_CONFIG__DATA__LABELS=labels.jsonin the environment. Either spelling reads its value exactly as__include__does, so a JSON list or a CSV is as welcome there as in the file, and{ "path": "labels.csv", "orient": "columns" }works too. Use the+form (--config.data.labels+) when you mean to add to labels the configuration already has. - A CSV must land on a real field, never in the
locals:namespace. CSV carries no types, so its cells would have nothing to be coerced against.
See Tutorial 11 for includes and Tutorial 10 for mounting a file at a key.
Two sub-configurations must share one value¶
When you compose two stand-alone configurations, they often duplicate a field — one project_id
across two pipeline steps, one batch_size in the data pipeline and in the model. You know they are
one knob; neither of the reused types does.
Declaring a local variable is the reader's tool, in the reader's file. To express it from the application instead, ship a configuration fragment with your package and pass it first, so that every other source still overrides it:
# defaults.yaml, shipped with your application
data:
batch_size: ${batch_size}
model:
batch_size: ${batch_size}
--batch_size 64 now sets both, and --model.batch_size 64 still overrides just the one — the
fragment is the lowest layer, not a rule.
The value only exists at runtime¶
Sometimes nothing can read the labels but your own code: a format confarg does not parse, a
directory to scan, a database to query. Resolution finishes before construction begins, so no
value produced by a constructed object can flow back into an expression. The pipeline will not do
this for you — but it stops in the middle on purpose, and you can do it in three lines.
Take the hardest version: the labels come from a query, and the credentials for that query are themselves configuration.
raw = confarg.merge(Config, argv=argv) # every source merged, ${...} still literal
db = connect(confarg.build(DbConfig, raw["db"])) # resolve and build the db subtree only
raw.setdefault("model", {}).setdefault("num_labels", db.count_labels())
config = confarg.build(Config, raw) # resolve again, now with num_labels present
The credentials are fully merged and resolved by the time you connect, so they can come from a file, the environment or the command line like anything else — and you keep the connection, which you were going to open anyway.
What each step assumes:
confarg.build(DbConfig, raw["db"])is the cheap route, and it is valid as long as the expressions insidedbdo not reach outside it. Absolute references in a merged configuration are anchored at its root, so a subtree lifted out of it can no longer resolve${elsewhere.value}. A relative reference —${.sibling}, one level up per dot — is anchored at the value that wrote it and does survive the lift, which is the reasonmerge()leaves it unresolved. If an absolute one must reach out, resolve the whole document instead withconfarg.resolve(Config, raw)and buildDbConfigfromresolved["db"]— which in turn requires that nothing else in the configuration references the value you have not computed yet.- Inject into the merged dict, not the resolved one, and build from it. Re-resolving is what
makes the injected value visible to other expressions, so
${model.num_labels * 4}elsewhere works. (A resolved dict also aliases the nodes that were referenced in it, so it is meant to be inspected and dumped rather than edited.) setdefaultkeeps every source winning: a user who passes--model.num_labels 3gets 3. Assign instead if the derived value must win, or compare and raise if a disagreement is a configuration that cannot run.
Or keep it out of the configuration¶
Before reaching for any of the above, consider whether the value is configuration at all.
num_labels is a fact about the data, not a knob: nobody chooses it, and offering --model.num_labels
in --help advertises a flag whose only correct value is computed.
A Callable field lets you configure everything that is a choice and supply the rest at the call
site:
Bound arguments are checked against the real signature and built through the same machinery as any
other configuration value, so --model.bind.hidden 1024 still works. See
Tutorial 20.
Which one¶
| Where the value lives | Recipe |
|---|---|
| Elsewhere in the configuration | ${...} |
In a file confarg reads |
__include__ (or --config.<path>+) and derive with ${len(...)} |
| In the application's knowledge of the composition | a fragment shipped with the package, passed first in files= |
| In your code, at runtime | stage it: merge(), build the part you need, inject, build() |
| In your code, and it is not a choice | do not configure it — bind the rest and pass it at the call site |
confarg has no way to call your own function from inside an expression, so a derivation only your
code can compute is staged by the application or not expressed in the configuration at all.