Skip to content

Commit 52cab47

Browse files
authored
Add environment-selected config branches (v0.1.11) (#54)
A `--config` file could describe only one deployment. Teams targeting dev and prod kept parallel files that drifted, or wrapped the CLI in a shell script that picked a path from an environment variable. Neither is reviewable: the selection logic lives outside the config the reviewer reads. Support a top-level `_select` node in the shared loader, so one file can carry every environment it serves: _select: env: TANGLE_ENV cases: dev: base_url: https://api.dev prod: _defaults: base_url: https://api.prod configs: - filter: active - filter: finished The selected branch is a complete config document -- a single object, a list of objects, or `_defaults` + `configs` -- and then feeds the existing loader unchanged, so every downstream command inherits the feature with no call-site change. A branch may itself be another `_select`, which composes independent dimensions such as environment and region. Selection is deliberately unforgiving, because a config that quietly picks the wrong deployment is worse than one that refuses to load: * Matching is exact against `os.environ[NAME]`: no trimming, case folding, or interpolation. `" dev"` and `"DEV"` do not match `dev`. * An unset variable or an unmatched value is an error. There is no implicit default and no implicit production branch. * Fallback exists only where it is authored, via an explicit `default` sibling of `cases`. An exact case match always wins over it, and a case literally named `default` stays an ordinary exact-match case. * The raw environment value is never echoed. Diagnostics list only the configured case names, which are themselves validated as printable and bounded and are rendered through a sanitizer, so a hostile variable cannot inject control characters into a log. Validation runs before the environment is read, so a malformed selector fails identically whichever deployment a reader is testing. Every immediate case and the `default` branch are checked as complete config documents using the same shape rules the loader applies to a whole file, recursing through nested selectors. Only environment *lookups* stay lazy: a dormant branch is shape-checked but never demands its own variable be set. At a selector node only `_select` and other underscore-prefixed helper keys may appear, which keeps YAML anchor holders usable while refusing an ordinary sibling that a reader would reasonably expect to be merged into the result. `_select` accepts only `env`, `cases`, and `default`; `else`, `fallback`, and `defaults` are rejected rather than silently ignored. Nesting is capped at 32 levels, which also terminates a self-referential YAML alias. Because anchors let one node be reached through many edges, structural validation memoizes per load, keyed by node identity and the greatest depth already proven clean -- success at some depth implies success anywhere shallower, while a deeper occurrence is revalidated so the cap stays exact. Without it a compact anchored file was exponential: a 1.4 KB 18-level alias DAG took 3.3s and is now 2ms. `_select` is the only newly reserved key and is an exact name, not a prefix, so `_selector` remains an ordinary ignored helper key. A config without `_select` is unchanged byte for byte and in behavior. Assisted-By: devx/01a092bb-921e-7186-97f7-1a9db9766e5d
1 parent d359f65 commit 52cab47

9 files changed

Lines changed: 1733 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ API-backed commands commonly accept these options. Explicit CLI options win over
5757
| `--token`, `TANGLE_API_TOKEN` | Bearer token shorthand. |
5858
| `--auth-header`, `TANGLE_API_AUTH_HEADER`, `TANGLE_AUTH_HEADER` | Full `Authorization` value such as `Bearer ...` or `Basic ...`. |
5959
| `-H`, `--header`, `TANGLE_API_HEADERS` | Extra headers. Repeatable as CLI flags; env accepts a JSON object or newline-separated `Name: value` entries. |
60-
| `--config` | YAML/JSON defaults. Many commands accept a single object, a list of objects, or `_defaults` + `configs`. |
60+
| `--config` | YAML/JSON defaults. Many commands accept a single object, a list of objects, or `_defaults` + `configs`, optionally wrapped in a top-level `_select` environment selector. |
6161
| `--log-type` | SDK progress logs: `console`, `none`, or `file`. Logs go to stderr or a temp log file so structured stdout stays parseable. |
6262
| `TANGLE_VERBOSE=1` | Redacted HTTP request/response diagnostics only. This is separate from normal progress logging. |
6363
| `--ca-bundle` | Global CLI flag: path to a PEM CA bundle used as the TLS trust store for every transport. Overrides `TANGLE_API_CA_BUNDLE`. Place before the subcommand. |
@@ -230,6 +230,56 @@ uv run tangle sdk pipeline-runs submit --config submit.yaml
230230

231231
For generated `tangle api` commands, config keys use generated CLI parameter names such as `base_url`, `schema_source`, `body`, and endpoint parameters like `limit`, `filter`, or `id`.
232232

233+
### Environment-selected configs (`_select`)
234+
235+
Any command that accepts `--config` can pick one of several config documents from an environment variable by making `_select` the top-level node:
236+
237+
```yaml
238+
_shared: &shared
239+
log_type: none
240+
241+
_select:
242+
env: TANGLE_ENV
243+
cases:
244+
dev:
245+
<<: *shared
246+
base_url: https://api.dev
247+
prod:
248+
_defaults:
249+
<<: *shared
250+
base_url: https://api.prod
251+
configs:
252+
- filter: active
253+
- filter: finished
254+
```
255+
256+
The selected branch is a complete config document — a single object, a list of objects, or `_defaults` + `configs` — and is then loaded exactly as if it had been written on its own. A branch may itself be another `_select` node, which composes multiple dimensions (for example environment and region).
257+
258+
Selection fails closed by default. To opt into a fallback, author an explicit `default` branch:
259+
260+
```yaml
261+
_select:
262+
env: TANGLE_ENV
263+
cases:
264+
prod:
265+
base_url: https://api.prod
266+
default:
267+
base_url: https://api.dev
268+
```
269+
270+
With `default`, an unset variable or a value matching no case resolves to that branch; an exact case match always wins over it. Without `default`, both remain errors. `default` is a sibling of `cases`, not an entry inside it: a case *named* `default` stays an ordinary exact-match case. Like any branch, `default` must be a complete valid config shape and may itself be another `_select`; an inner selector does not inherit the outer `default`.
271+
272+
Rules:
273+
274+
- `_select` is the only newly reserved key, and it is an exact key name rather than a prefix. Configs without `_select` are unchanged byte-for-byte and semantically.
275+
- At a `_select` node, only `_select` and other underscore-prefixed helper keys (YAML anchor holders such as `_shared`) may appear; ordinary sibling keys are rejected.
276+
- `_select` accepts only `env`, `cases`, and the optional `default`. There are no aliases: `else`, `fallback`, and `defaults` are rejected.
277+
- The selector shape, the `env` name, every `cases` key, and every case and `default` branch are validated before the environment is read, so a malformed selector fails identically in every environment. Branches are checked as complete config documents with the same rules the loader applies to a whole file, and nested selectors are validated recursively.
278+
- Only environment *lookups* are lazy. A dormant branch is fully shape-checked, but its `env` variable is never required unless that branch is actually selected. Command-specific field names and types are still validated later, against the selected branch only.
279+
- Selector nesting is capped at 32 levels, which also stops a self-referential YAML alias. A node shared by several anchors is validated once, so anchor-heavy files stay fast.
280+
- Matching uses `os.environ[NAME]` exactly: case sensitive, with no trimming, case folding, or interpolation.
281+
- Fallback exists only where it is authored. Without `default`, an unset variable or an unmatched value is an error; there is never an implicit default or implicit production branch. The raw environment value is never echoed — diagnostics list only the configured case names.
282+
233283
## API schema cache and dynamic commands
234284

235285
Refresh the local schema cache for a live backend with:
@@ -305,7 +355,7 @@ uv run tangle sdk published-components deprecate sha256:old --superseded-by sha2
305355

306356
`publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined.
307357

308-
There is no separate OSS `publish-all` command. To publish multiple components, pass a YAML/JSON config list, or `_defaults` + `configs`, to the same `published-components publish` command; the command aggregates results and exits nonzero if any component errors.
358+
There is no separate OSS `publish-all` command. To publish multiple components, pass a YAML/JSON config list, or `_defaults` + `configs`, to the same `published-components publish` command; the command aggregates results and exits nonzero if any component errors. A top-level `_select` node can choose between such documents per environment (see [Environment-selected configs](#environment-selected-configs-_select)).
309359

310360
```yaml
311361
_defaults:

‎packages/tangle-cli/src/tangle_cli/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,6 @@
1414
try:
1515
__version__ = metadata_version("tangle-cli")
1616
except PackageNotFoundError:
17-
__version__ = "0.1.10"
17+
__version__ = "0.1.11"
1818

1919
__all__ = ["TangleDynamicDiscoveryClient", "__version__"]

0 commit comments

Comments
 (0)