Skip to content

Per-command TANGLE_ROOT_CONFIG layered beneath --config; remove ArgsContainer.origin() (v0.1.24) - #67

Merged
Volv-G merged 1 commit into
masterfrom
piforge/tangle-pipeline-crud/root-config-layered-default-conf-a85e500
Sep 25, 2026
Merged

Volv-G merged 1 commit into
masterfrom
piforge/tangle-pipeline-crud/root-config-layered-default-conf-a85e500

Conversation

@Volv-G

@Volv-G Volv-G commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

(AI-assisted)

What

  1. TANGLE_ROOT_CONFIG names a per-command root config. The entry for the running command is layered beneath that command's --config.
  2. ArgsContainer.origin() is removed (it was provisional and unused). ⚠️ Breaking for anyone who adopted it since 0.1.20.

How

Command selector shape

# $TANGLE_ROOT_CONFIG
_shared: &search {annotations: {team: search}}   # underscore keys: YAML anchor helpers only
commands:
  "tangle sdk pipeline-runs submit": {<<: *search, base_url: https://api.example}
  "tangle-deploy pipeline-run submit": *search
  • The top level must be commands. Besides it, only underscore-prefixed helper keys (for YAML anchors) may appear there. Anything else is an error: a missing commands, another top-level key, a non-mapping entry, or a key that duplicates another after normalization.
  • Each entry is a single config object. Only the running command's entry applies. A command with no entry gets no root config, and entries for other commands or CLIs are ignored. A key that closely matches the running command logs one warning naming both keys, never a value.
  • _select / _env resolve in this order:
    • a document-level _select can choose between whole commands documents;
    • after the command's entry is picked, the entry may itself be a _select;
    • its _env values are read only for the running command, but every directive in the file is structure-checked in every environment.

Explicit command identity

  • API: ArgsContainer.load(config, *, command=...) and load_config(config, *, command=...) take the full command path explicitly, program first. Without command= (a library call), the root config is ignored and not even read.
  • tangle: the dispatcher builds the path as Cyclopts resolves the command line (program, then groups, then leaf), excluding options and arguments. Each level is canonicalized to its first registered name, so aliases resolve to the real command. The path travels to load_args_or_exit / load_config_or_exit through private plumbing in cli_helpers, and those helpers pass it as command=. The tangle api schema bootstrap passes tangle api <group> <operation> from the leading tokens.
  • normalize_command(path, aliases=COMMAND_ALIASES): collapses whitespace and rewrites alias or deprecated prefixes (longest whole-token prefix, repeated until nothing changes, and bounded). The default table maps tangle-cli to tangle, and downstream CLIs pass their own table. Matching is otherwise exact and case-sensitive.

Precedence and merge

  • Precedence: CLI > --config > TANGLE_ROOT_CONFIG > EnvField env tier > default.
  • Merge:
    • Mappings deep-merge key by key, with --config winning at each leaf.
    • Lists, scalars and type changes replace wholesale.
    • A CLI value still replaces the whole field.
    • For _defaults + configs, the order is root < _defaults (still a shallow merge within its file) < entry.
    • Strict scalar typing (0.1.21) applies to the merged value.

null rules

  • Root file: null is rejected anywhere, and the error names the key path.
  • --config layered over an active root entry: a null mapping value means absent, at any depth.
    • At a key the root provides, it unsets the inherited value, so annotations: {team: null} removes just team.
    • At a key the root doesn't set, it is ignored.
    • Either way, resolution falls through to the env tier, then the default. null items inside lists are values and are kept.
  • Without an active root entry, --config null keeps today's meaning. Scoping it this way keeps the feature opt-in and byte-identical for existing configs.
    • An audit found no explicit --config nulls in the Tangle CLI or Discovery tangle-deploy configs and tests.
    • Applying the rule always would silently change base_url: null credential isolation, and the default-restoring behavior of typed fields.

base_url, paths, direct readers

  • base_url is allowed in an entry. It counts as config for credential isolation: ambient environment credentials are not sent to a URL that came from config.
  • args.config_source(name) names the file that supplied a field (top-level-key granularity), so a relative path inherited from the root file resolves against the root file's directory, never the --config directory.
  • load_config(config_path, *, command=...) returns LoadedConfig (.values, .sources) for code that reads config without ArgsContainer.load. load_config_or_exit and the tangle api pre-parse now use it. _load_config_file still reads exactly one file.
  • Out of scope: pipeline config.yaml files and TaskEnv.from_config are never layered.
  • Errors fail closed:
    • An unset or empty TANGLE_ROOT_CONFIG is a no-op.
    • A set path that is missing, not a file, unreadable, malformed, or contains null is an error naming the variable and the path, never a value.

origin() removal (breaking)

ArgsContainer.origin() and its _origins bookkeeping are gone. It was flagged provisional in #63 and nothing in Tangle CLI or Discovery calls it. For provenance of file-backed values, use config_source().

Failure modes

  • A generic commands key in a shared file only works for commands you list, and a mistyped key silently applies nothing (mitigated by the near-miss warning).
  • Discovery tangle-deploy needs to pass command= before its commands see root entries (follow-up after the pin).

Review focus

  • The null-as-absent handling in _deep_merge, and its scoping to an active root entry.
  • Command-path accumulation and canonicalization in cli._command_identity.
  • Lazy _env reads per command, and the root-file error prefixing.

Tophatting

uv run --frozen pytest tests/test_root_config.py

These are 57 tests on real ArgsContainer.load outputs, covering:

  • matching vs. non-matching commands, both CLIs, and normalization and aliases (including Cyclopts aliases and cyclic tables);
  • the missing or malformed selector, and _select at both levels;
  • deep-merge per command, and all three null cases;
  • no command identity, and a direct app call that bypasses the dispatcher;
  • the launcher end to end through tangle sdk secrets delete;
  • relative-path sources, errors without value echo, load_config_or_exit and the tangle api pre-parse;
  • credential isolation, and the pipeline cfg path staying untouched.

Mutation-checked: 21 deliberate breaks, plus 4 no-leak and 2 provenance mutations, are all caught.

Checklist

  • Full suite green locally on Python 3.12 and 3.13: 1902 passed.
  • pyright: no new errors (the one reported in tests/conftest.py:62 exists on master). ruff clean. git diff --check clean.
  • uv build for both packages. The wheel is 0.1.24 and test_packaging passes.
  • Version bumped to 0.1.24 in pyproject.toml, __init__.py, tests/test_packaging.py, and the uv.lock editable self-entry (a two-line lock diff; uv lock not run).
  • README: the per-command root config section, the load_config/config_source guidance, and the origin() mention removed.

@Volv-G
Volv-G requested a review from Ark-kun as a code owner September 25, 2026 21:21
@Volv-G
Volv-G merged commit f2e4fa3 into master Sep 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant