Read config values from the environment: _env, EnvField, and _select/_env in pipeline cfg (v0.1.20) - #63
Merged
Volv-G merged 1 commit intoSep 25, 2026
Conversation
…r, and _select/_env in pipeline cfg (v0.1.20) *(AI-assisted)*
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
(AI-assisted)
What
Config files can now read values from the environment, and Python pipeline configs get the same
_select/_envresolution as--configfiles. Mechanism only: no existing field is wired to an environment variable._envvalue directive in any--configfile or pipelineconfig.yaml:EnvFieldtier forArgsContainerfield specs:token=EnvField("TANGLE_PROD_TOKEN", (token, None))wraps any existing 1–6-tuple spec unchanged.Pipeline
cfg(@pipeline(config=...)/ defaultconfig.yaml, root and children, including apropagate_configbroadcast) now resolves a root_selectand_env. Before this change a_selectthere was silently treated as an ordinary nested value.How
One shared resolver
resolve_config_document(parsed, source, *, mapping_only=False)inargs_container.pyis the single document-level resolution. It validates the selector tree and every_envdirective without reading the environment (dormant branches and helper sections included), then resolves the root_selectchain. It returns the selected document plus a per-entry_envresolver, so each caller keeps its own shape rules:ArgsContainer._load_config_file: the object / list /_defaults+configsshapes._envis looked up only in the entries and_defaultsit actually returns.cfg.read_cfg_document(used byload_cfg,_load_cfg_and_rawand_read_raw_cfg):mapping_only=True, so every branch must be a mapping and_defaults/configsare ordinary keys._selectmessages and behavior for--configfiles are byte-identical. The only internal change is a keyword-onlymapping_onlyflag threaded through the selector validators._envrules{_env: NAME}or{_env: NAME, default: <scalar>}. Any other sibling key is rejected, underscore-prefixed ones included.NAMEreuses the_select.envname rule through a single_is_env_name._defaults,configsentries). A document or config-entry mapping is never itself a directive, so an_env:helper or anchor key at the top level still works._selectbranch is shape-checked in every environment, but only directives in the selected document are read.defaultfails closed with the variable name, the key path (for exampleconfigs[1].token) and the file. An empty string counts as set.defaultis stringified the same way, so a field has one type whether or not the variable is set. Numbers and booleans use their JSON spelling (10,1.5,true), so JSON-typed fields round-trip the authored value; dates use ISO format.nullis rejected, because it has no honest string form; quote''for an empty default. Maps and lists are rejected too.${VAR}interpolation. YAML alias sharing is resolved once, with linear cost. An_envinside a recursive alias is rejected. Files with no directive skip the resolver entirely.Precedence
CLI > config > env > default, per
ArgsContainerfield. A config value read through_envcounts as config, so it beats anEnvFieldtier. Fields not wrapped inEnvFieldnever read the environment, and no names are mapped automatically. The env string goes through the spec's usual converter (JSON fields, repeatables, enums, typed converters). As before, a CLI value equal to the option default is indistinguishable from an omitted one.Pipeline
cfgorderingResolve
_select/_envfirst, then layer--override,.override_configand the broadcast on top..override_configkey must exist in the child's selected branch.raw_cfg) is the resolved dict and never holds_select/_envnodes.template_fileis fail-closedtemplate_file:is rejected in every candidate branch, dormant ones included, in every environment, not just the selected one. Non-mapping branches are likewise rejected in every environment.cfgcoercion choice (please weigh in)_selectbranch values keep their native YAML types._envvalues stay strings and are not YAML-coerced the way raw--overridestrings are. An environment value is opaque:007stays"007"andnostays"no". This matchesArgsContainer. Authors convert explicitly (int(cfg.limit)) or put typed values in_selectbranches.Compile identity
PipelineCompileKeynever included config content. Within one compile the environment is fixed, so nothing can collide there. Across compiles, however, different selections of the same file produced the same sidecar name (<child>-<hash8>.yaml) for different content.Now, only for a config that uses
_selector_env, a SHA-256 of the resolved config is folded into the key through a reserved"\x00config"fingerprint envelope entry. This applies to the root and to every child that loads its config.The digest is never printed; only
hash8of the whole key reaches a file name.No value echo
Diagnostics name variables and key paths only. The
EnvFieldconversion errors are raisedfrom None.Invalid value for X. Valid values: [...]). Without this, an enum value that arrived through_envwould be echoed.Provisional surface:
ArgsContainer.origin()args.origin(name)returnscli,config,env:NAMEordefault, backed by_originsand excluded fromto_dict(). Upstream had no value-source reporting to extend, so this was added to makeenv:NAMEprovenance observable. It is currently unused and should be treated as provisional: it may be removed or reshaped before anything depends on it.Failure modes
_envbecomes a reserved key in value positions. An existing config value that is a mapping containing an_envkey would now be read as a directive._env, value nesting is capped at 256 levels._envvalues end up wherever the config value goes, including compiled YAML if a pipeline passes them as task constants. The directive keeps secrets out of checked-in files, not out of outputs.$, so a name ending in a newline passes validation (affects_selecttoo). Worth a follow-up withfullmatch.Review focus
resolve_config_documentas the single path, andmapping_onlykeeping the--configbehavior identical._validate_env_directives): linear, cycle-safe, never reads the environment._config_identityand the envelope in_compile_key_for, especially that directive-free configs keep byte-identical keys.Tophatting
The two files add 112 tests.
--configside:_envset / unset / empty / default, stringified defaults,nullrejection, nested maps and lists, all document shapes, JSON configs, selected vs dormant_selectbranches, invalid names, stray siblings, no value echo, the full CLI/config/env/default precedence matrix, JSON and repeatable conversion, and unchanged behavior without directives._select, fail-closed without echo,defaultfallback,_envincfg,--overrideand.override_configprecedence over the selected branch, broadcast of resolved values, identity differing across selections and env values, identical identity for identical values, and unchanged sidecar names for directive-free configs.Checklist
master). ruff clean.git diff --checkclean.uv buildfor both packages. The wheel metadata reportsVersion: 0.1.20, andtest_packagingpasses.pyproject.toml,packages/tangle-cli/src/tangle_cli/__init__.py,tests/test_packaging.py, and theuv.lockeditable self-entry. The lock diff is the two-line version change only;uv lockwas not run._env, the_selectinterplay, precedence,EnvField/origin(), and the pipelinecfgsection.