tangle-cli 0.0.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,461 @@
1
+ """HTTP transport helpers shared by the OpenAPI CLI and programmatic client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import sys
9
+ import urllib.parse
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import httpx
14
+
15
+ DEFAULT_API_URL = "http://localhost:8000"
16
+ DEFAULT_TIMEOUT_SECONDS = 30.0
17
+ _HEADER_NAME_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
18
+ _MISSING = object()
19
+ _SENSITIVE_HEADER_NAMES = {"authorization", "cloud-auth", "cookie", "x-api-key"}
20
+ _SENSITIVE_KEY_RE = re.compile(
21
+ r"(authorization|authentication|(^|[-_])auth($|[-_])|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential|pre[-_]?signed[-_]?url|signed[-_]?url)",
22
+ re.IGNORECASE,
23
+ )
24
+ _REDACTED = "<redacted>"
25
+ _REDACTED_DOCUMENT = "<redacted document>"
26
+ _OPAQUE_DOCUMENT_KEY_NAMES = {
27
+ "component_yaml",
28
+ "dockerfile",
29
+ "manifest",
30
+ "pipeline_yaml",
31
+ "text",
32
+ "yaml",
33
+ }
34
+
35
+
36
+ def tangle_verbose_enabled() -> bool:
37
+ value = os.environ.get("TANGLE_VERBOSE")
38
+ if value is None:
39
+ return False
40
+ return value.strip().lower() in {"1", "true", "yes", "on"}
41
+
42
+
43
+ def _redact_headers(headers: dict[str, Any] | None) -> dict[str, Any]:
44
+ redacted: dict[str, Any] = {}
45
+ for name, value in (headers or {}).items():
46
+ normalized_name = name.lower()
47
+ redacted[name] = (
48
+ _REDACTED
49
+ if normalized_name in _SENSITIVE_HEADER_NAMES or _SENSITIVE_KEY_RE.search(name)
50
+ else value
51
+ )
52
+ return redacted
53
+
54
+
55
+ def _redact_sensitive_values(value: Any, key: str | None = None) -> Any:
56
+ if key and _SENSITIVE_KEY_RE.search(key):
57
+ return _REDACTED
58
+ if key and key.lower() in _OPAQUE_DOCUMENT_KEY_NAMES and isinstance(value, str) and value:
59
+ return _REDACTED_DOCUMENT
60
+ if isinstance(value, dict):
61
+ return {str(k): _redact_sensitive_values(v, str(k)) for k, v in value.items()}
62
+ if isinstance(value, list):
63
+ return [_redact_sensitive_values(item) for item in value]
64
+ return value
65
+
66
+
67
+ def _safe_json_text(value: Any) -> str:
68
+ redacted = _redact_sensitive_values(value)
69
+ try:
70
+ return json.dumps(redacted, indent=2, sort_keys=True, default=str)
71
+ except TypeError:
72
+ return str(redacted)
73
+
74
+
75
+ def _content_to_text(content: bytes | str | None) -> str:
76
+ if content is None:
77
+ return "<empty>"
78
+ if isinstance(content, bytes):
79
+ if not content:
80
+ return "<empty>"
81
+ text = content.decode("utf-8", errors="replace")
82
+ else:
83
+ text = content
84
+ if not text:
85
+ return "<empty>"
86
+ try:
87
+ parsed = json.loads(text)
88
+ except Exception:
89
+ return text
90
+ return _safe_json_text(parsed)
91
+
92
+
93
+ def log_http_exchange(
94
+ logger: Any,
95
+ *,
96
+ method: str,
97
+ url: str,
98
+ request_headers: dict[str, Any] | None = None,
99
+ request_body: Any = None,
100
+ response_status: int | None = None,
101
+ response_headers: dict[str, Any] | None = None,
102
+ response_body: bytes | str | None = None,
103
+ ) -> None:
104
+ """Log a redacted HTTP exchange for TANGLE_VERBOSE diagnostics."""
105
+
106
+ emit = getattr(logger, "info", None)
107
+ if not callable(emit):
108
+ emit = lambda message: print(message, file=sys.stderr, flush=True)
109
+ emit(f"[tangle-api] request: {method} {url}")
110
+ emit(f"[tangle-api] request headers: {_safe_json_text(_redact_headers(request_headers))}")
111
+ if isinstance(request_body, (bytes, str)) or request_body is None:
112
+ request_body_text = _content_to_text(request_body)
113
+ else:
114
+ request_body_text = _safe_json_text(request_body)
115
+ emit(f"[tangle-api] request body: {request_body_text}")
116
+ if response_status is not None:
117
+ emit(f"[tangle-api] response status: {response_status}")
118
+ if response_headers is not None:
119
+ emit(f"[tangle-api] response headers: {_safe_json_text(_redact_headers(response_headers))}")
120
+ if response_body is not None:
121
+ emit(f"[tangle-api] response body: {_content_to_text(response_body)}")
122
+
123
+
124
+ def default_base_url() -> str:
125
+ configured_url = os.environ.get("TANGLE_API_URL")
126
+ if configured_url:
127
+ return _normalize_base_url(configured_url)
128
+ if _ambient_auth_env_present():
129
+ raise SystemExit(
130
+ "TANGLE_API_URL is required when Tangle auth environment variables "
131
+ f"are set; refusing to send credentials to default {DEFAULT_API_URL}"
132
+ )
133
+ return _normalize_base_url(DEFAULT_API_URL)
134
+
135
+
136
+ def _ambient_auth_env_present() -> bool:
137
+ return any(
138
+ os.environ.get(name)
139
+ for name in (
140
+ "TANGLE_API_AUTH_HEADER",
141
+ "TANGLE_AUTH_HEADER",
142
+ "TANGLE_API_HEADERS",
143
+ "TANGLE_API_TOKEN",
144
+ )
145
+ )
146
+
147
+
148
+ def default_token() -> str | None:
149
+ return os.environ.get("TANGLE_API_TOKEN") or None
150
+
151
+
152
+ def default_auth_header() -> str | None:
153
+ return os.environ.get("TANGLE_API_AUTH_HEADER") or os.environ.get("TANGLE_AUTH_HEADER") or None
154
+
155
+
156
+ def _normalize_base_url(base_url: str) -> str:
157
+ base_url = base_url.strip().rstrip("/")
158
+ if base_url.endswith("/openapi.json"):
159
+ base_url = base_url[: -len("/openapi.json")]
160
+ return base_url.rstrip("/")
161
+
162
+
163
+ def _openapi_url(base_url: str) -> str:
164
+ base_url = base_url.strip().rstrip("/")
165
+ if base_url.endswith("/openapi.json"):
166
+ return base_url
167
+ return urllib.parse.urljoin(base_url + "/", "openapi.json")
168
+
169
+
170
+ def _request_headers(
171
+ token: str | None,
172
+ cli_header_entries: list[str] | str | None,
173
+ cli_auth_header: str | None,
174
+ extra_headers: dict[str, str] | None = None,
175
+ *,
176
+ include_env_credentials: bool = True,
177
+ ) -> dict[str, str]:
178
+ """Build request headers without printing or otherwise exposing secrets.
179
+
180
+ Precedence, lowest to highest:
181
+ default Accept header, ``TANGLE_API_HEADERS``, auth env vars,
182
+ bearer token, explicit auth header, CLI/header entries, explicit mapping.
183
+ """
184
+
185
+ headers = {"Accept": "application/json"}
186
+ if include_env_credentials:
187
+ headers.update(_headers_from_env())
188
+ env_auth_header = default_auth_header()
189
+ if env_auth_header:
190
+ headers["Authorization"] = _normalize_auth_header(
191
+ env_auth_header, "TANGLE_API_AUTH_HEADER"
192
+ )
193
+ token = token or default_token()
194
+ if token:
195
+ headers["Authorization"] = f"Bearer {token}"
196
+ if cli_auth_header:
197
+ headers["Authorization"] = _normalize_auth_header(cli_auth_header, "--auth-header")
198
+ headers.update(_parse_header_entries(_header_entries(cli_header_entries), "--header"))
199
+ if extra_headers:
200
+ for name, value in extra_headers.items():
201
+ _validate_header(name, str(value), "headers")
202
+ headers[name] = str(value)
203
+ return headers
204
+
205
+
206
+ def _normalize_auth_header(raw: str, source: str) -> str:
207
+ """Accept either an Authorization value or ``Authorization: value``."""
208
+
209
+ value = raw.strip()
210
+ if value.lower().startswith("authorization:"):
211
+ value = value.split(":", 1)[1].strip()
212
+ if not value or "\n" in value or "\r" in value:
213
+ raise SystemExit(f"Invalid {source}; expected an authorization header value")
214
+ return value
215
+
216
+
217
+ def _headers_from_env() -> dict[str, str]:
218
+ raw = os.environ.get("TANGLE_API_HEADERS")
219
+ if not raw or not raw.strip():
220
+ return {}
221
+ return _parse_header_entries(_env_header_entries(raw), "TANGLE_API_HEADERS")
222
+
223
+
224
+ def _env_header_entries(raw: str) -> list[str]:
225
+ """Parse env headers as JSON object/list or newline-separated entries."""
226
+
227
+ raw = raw.strip()
228
+ if raw[0] in "[{":
229
+ try:
230
+ parsed = json.loads(raw)
231
+ except json.JSONDecodeError as exc:
232
+ raise SystemExit("Invalid TANGLE_API_HEADERS JSON") from exc
233
+ if isinstance(parsed, dict):
234
+ return [f"{name}: {value}" for name, value in parsed.items()]
235
+ if isinstance(parsed, list) and all(isinstance(item, str) for item in parsed):
236
+ return parsed
237
+ raise SystemExit("TANGLE_API_HEADERS must be a JSON object or string list")
238
+ return [line.strip() for line in raw.splitlines() if line.strip()]
239
+
240
+
241
+ def _header_entries(entries: list[str] | str | None) -> list[str]:
242
+ if entries is None:
243
+ return []
244
+ if isinstance(entries, str):
245
+ return [entries]
246
+ return list(entries)
247
+
248
+
249
+ def _parse_header_entries(entries: list[str], source: str) -> dict[str, str]:
250
+ headers: dict[str, str] = {}
251
+ for entry in entries:
252
+ if ":" in entry:
253
+ name, value = entry.split(":", 1)
254
+ elif "=" in entry:
255
+ name, value = entry.split("=", 1)
256
+ else:
257
+ raise SystemExit(f"Invalid {source} entry; expected 'Name: value'")
258
+ name = name.strip()
259
+ value = value.strip()
260
+ _validate_header(name, value, source)
261
+ headers[name] = value
262
+ return headers
263
+
264
+
265
+ def _validate_header(name: str, value: str, source: str) -> None:
266
+ if not name or not _HEADER_NAME_RE.fullmatch(name) or "\n" in value or "\r" in value:
267
+ raise SystemExit(f"Invalid {source} header name or value")
268
+
269
+
270
+ def request_operation(
271
+ operation: Any,
272
+ values: dict[str, Any],
273
+ *,
274
+ base_url: str | None = None,
275
+ token: str | None = None,
276
+ auth_header: str | None = None,
277
+ header_entries: list[str] | str | None = None,
278
+ headers: dict[str, str] | None = None,
279
+ body: Any = _MISSING,
280
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
281
+ allow_body_file_references: bool = False,
282
+ include_env_credentials: bool = True,
283
+ ) -> httpx.Response:
284
+ """Dispatch one normalized OpenAPI operation as an HTTP request.
285
+
286
+ ``values`` contains operation params using either generated Python names or
287
+ original OpenAPI names. The returned response has already had
288
+ ``raise_for_status()`` applied, matching the generated CLI behavior.
289
+ """
290
+
291
+ method, url, request_headers, content = build_operation_request(
292
+ operation,
293
+ values,
294
+ base_url=base_url,
295
+ token=token,
296
+ auth_header=auth_header,
297
+ header_entries=header_entries,
298
+ headers=headers,
299
+ body=body,
300
+ allow_body_file_references=allow_body_file_references,
301
+ include_env_credentials=include_env_credentials,
302
+ )
303
+ response = httpx.request(
304
+ method,
305
+ url,
306
+ content=content,
307
+ headers=request_headers,
308
+ timeout=timeout,
309
+ )
310
+ if tangle_verbose_enabled():
311
+ log_http_exchange(
312
+ None,
313
+ method=method,
314
+ url=url,
315
+ request_headers=request_headers,
316
+ request_body=content,
317
+ response_status=response.status_code,
318
+ response_headers=dict(response.headers),
319
+ response_body=response.text,
320
+ )
321
+ response.raise_for_status()
322
+ return response
323
+
324
+
325
+ def build_operation_request(
326
+ operation: Any,
327
+ values: dict[str, Any],
328
+ *,
329
+ base_url: str | None = None,
330
+ token: str | None = None,
331
+ auth_header: str | None = None,
332
+ header_entries: list[str] | str | None = None,
333
+ headers: dict[str, str] | None = None,
334
+ body: Any = _MISSING,
335
+ allow_body_file_references: bool = False,
336
+ include_env_credentials: bool = True,
337
+ ) -> tuple[str, str, dict[str, str], bytes | None]:
338
+ """Build method, URL, headers, and body bytes for an operation."""
339
+
340
+ base_url = _normalize_base_url(base_url or default_base_url())
341
+ path = operation.path
342
+ query: dict[str, Any] = {}
343
+ body_fields: dict[str, Any] = {}
344
+ remaining = dict(values)
345
+
346
+ for parameter in operation.parameters:
347
+ if parameter.local_name in remaining:
348
+ value = remaining.pop(parameter.local_name)
349
+ elif parameter.original_name in remaining:
350
+ value = remaining.pop(parameter.original_name)
351
+ else:
352
+ if parameter.location == "path" and parameter.required:
353
+ raise TypeError(f"Missing required path parameter: {parameter.local_name}")
354
+ if parameter.location in {"query", "body"} and parameter.required:
355
+ # A required body field can also be satisfied by the generic body.
356
+ if parameter.location == "body" and body is not _MISSING and body is not None:
357
+ continue
358
+ raise TypeError(f"Missing required parameter: {parameter.local_name}")
359
+ continue
360
+ if value is None:
361
+ continue
362
+ if parameter.location == "path":
363
+ path = path.replace(
364
+ "{" + parameter.original_name + "}",
365
+ urllib.parse.quote(str(value), safe=""),
366
+ )
367
+ elif parameter.location == "query":
368
+ query[parameter.original_name] = value
369
+ elif parameter.location == "body":
370
+ body_fields[parameter.original_name] = value
371
+
372
+ if remaining:
373
+ names = ", ".join(sorted(remaining))
374
+ raise TypeError(f"Unexpected parameter(s) for {operation.group_name}.{operation.command_name}: {names}")
375
+
376
+ url = _join_operation_url(base_url, path)
377
+ if query:
378
+ url = f"{url}?{_urlencode_query(query)}"
379
+
380
+ request_body = None
381
+ if operation.has_request_body:
382
+ if body is _MISSING:
383
+ body = None
384
+ request_body = (
385
+ _coerce_body_argument(
386
+ body, allow_file_references=allow_body_file_references
387
+ )
388
+ if body is not None
389
+ else None
390
+ )
391
+ if body_fields:
392
+ if request_body is None:
393
+ request_body = {}
394
+ if not isinstance(request_body, dict):
395
+ raise TypeError("body must be a JSON object when body field parameters are used")
396
+ request_body.update(body_fields)
397
+
398
+ request_headers = _request_headers(
399
+ token,
400
+ header_entries,
401
+ auth_header,
402
+ headers,
403
+ include_env_credentials=include_env_credentials,
404
+ )
405
+ content = _body_to_content(request_body)
406
+ if content is not None and "Content-Type" not in request_headers:
407
+ request_headers["Content-Type"] = "application/json"
408
+ return operation.method, url, request_headers, content
409
+
410
+
411
+ def _join_operation_url(base_url: str, path: str) -> str:
412
+ """Join a schema path to ``base_url`` without allowing origin changes."""
413
+
414
+ parsed_path = urllib.parse.urlparse(path)
415
+ if parsed_path.scheme or parsed_path.netloc:
416
+ raise ValueError(f"OpenAPI operation path must be relative: {path!r}")
417
+ return urllib.parse.urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
418
+
419
+
420
+ def _urlencode_query(query: dict[str, Any]) -> str:
421
+ """Encode query params, preserving repeated values for list options."""
422
+
423
+ items: list[tuple[str, Any]] = []
424
+ for key, value in query.items():
425
+ if isinstance(value, (list, tuple)):
426
+ items.extend((key, item) for item in value)
427
+ else:
428
+ items.append((key, value))
429
+ return urllib.parse.urlencode(items, doseq=True)
430
+
431
+
432
+ def _load_body_argument(body: str) -> Any:
433
+ """Parse a CLI ``--body`` value; leading ``@`` reads JSON from a file."""
434
+
435
+ if body.startswith("@"):
436
+ body = Path(body[1:]).expanduser().read_text(encoding="utf-8")
437
+ try:
438
+ return json.loads(body)
439
+ except json.JSONDecodeError as exc:
440
+ raise SystemExit(f"Invalid JSON body: {exc}") from exc
441
+
442
+
443
+ def _coerce_body_argument(body: Any, *, allow_file_references: bool = False) -> Any:
444
+ if not isinstance(body, str):
445
+ return body
446
+ if allow_file_references:
447
+ return _load_body_argument(body)
448
+ try:
449
+ return json.loads(body)
450
+ except json.JSONDecodeError:
451
+ return body
452
+
453
+
454
+ def _body_to_content(request_body: Any) -> bytes | None:
455
+ if request_body is None:
456
+ return None
457
+ if isinstance(request_body, bytes):
458
+ return request_body
459
+ if isinstance(request_body, bytearray):
460
+ return bytes(request_body)
461
+ return json.dumps(request_body).encode("utf-8")
@@ -0,0 +1,244 @@
1
+ """CLI argument resolution with optional YAML/JSON config files.
2
+
3
+ This module provides generic config-file behavior shared by Tangle CLI
4
+ commands: load one or more config objects, merge each with parsed CLI
5
+ arguments, and keep explicit CLI values higher precedence than config values.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Any, cast
15
+
16
+ import yaml
17
+
18
+ from tangle_cli.logger import Logger, get_default_logger
19
+ from tangle_cli.utils import apply_defaults
20
+
21
+
22
+ class ConfigFileError(Exception):
23
+ """Raised when there is an error loading or resolving a config file."""
24
+
25
+
26
+ class ArgsContainer:
27
+ """Container for resolved CLI arguments with config-file defaults."""
28
+
29
+ def __init__(self, resolved: dict[str, Any], raw_config: dict[str, Any]):
30
+ self._config = raw_config
31
+ for key, value in resolved.items():
32
+ setattr(self, key, value)
33
+
34
+ def __getattr__(self, name: str) -> Any:
35
+ raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
36
+
37
+ def get(self, key: str, cli_value: Any = None, cli_default: Any = None) -> Any:
38
+ """Return a resolved value while preserving explicit CLI precedence."""
39
+
40
+ if cli_value != cli_default:
41
+ return cli_value
42
+ if key in self._config:
43
+ return self._config[key]
44
+ return cli_value
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ """Return resolved public values as a dictionary."""
48
+
49
+ return {key: value for key, value in vars(self).items() if key != "_config"}
50
+
51
+ @staticmethod
52
+ def _load_config_file(
53
+ config_path: str | Path | None,
54
+ logger: Logger | None = None,
55
+ ) -> list[dict[str, Any]]:
56
+ """Load a YAML/JSON config file as a list of config dictionaries.
57
+
58
+ Supported shapes are a single object, a list of objects, or an object
59
+ with ``_defaults`` and ``configs`` where defaults are applied to each
60
+ config entry. Other top-level keys are ignored, which lets YAML files
61
+ use anchors/shared helper sections.
62
+ """
63
+
64
+ log = logger or get_default_logger()
65
+ if config_path is None:
66
+ return [{}]
67
+
68
+ path = Path(config_path)
69
+ if not path.exists():
70
+ raise ConfigFileError(f"Config file not found: {config_path}")
71
+
72
+ try:
73
+ with path.open(encoding="utf-8") as f:
74
+ if path.suffix in (".yaml", ".yml"):
75
+ parsed = yaml.safe_load(f)
76
+ if parsed is None:
77
+ return [{}]
78
+ else:
79
+ parsed = json.load(f)
80
+ except (OSError, json.JSONDecodeError, yaml.YAMLError) as exc:
81
+ raise ConfigFileError(f"Error loading config file: {exc}") from exc
82
+
83
+ if isinstance(parsed, dict):
84
+ parsed_dict = cast(dict[str, Any], parsed)
85
+ if "configs" in parsed_dict:
86
+ defaults = parsed_dict.get("_defaults", {})
87
+ configs_list = parsed_dict.get("configs", [])
88
+ if not isinstance(defaults, dict):
89
+ raise ConfigFileError(
90
+ f"_defaults must be an object, got {type(defaults).__name__}"
91
+ )
92
+ if not isinstance(configs_list, list):
93
+ raise ConfigFileError(
94
+ f"configs must be a list, got {type(configs_list).__name__}"
95
+ )
96
+ for index, item in enumerate(configs_list):
97
+ if not isinstance(item, dict):
98
+ raise ConfigFileError(
99
+ "configs entry "
100
+ f"{index} must be an object, got {type(item).__name__}"
101
+ )
102
+ merged = apply_defaults(configs_list, defaults)
103
+ assert isinstance(merged, list)
104
+ log.info(f"Loaded config: {path} ({len(merged)} configs with defaults)")
105
+ return merged
106
+ log.info(f"Loaded config: {path} (1 config)")
107
+ return [parsed_dict]
108
+
109
+ if isinstance(parsed, list):
110
+ for index, item in enumerate(cast(list[Any], parsed)):
111
+ if not isinstance(item, dict):
112
+ raise ConfigFileError(
113
+ "Config file entry "
114
+ f"{index} must be an object, got {type(item).__name__}"
115
+ )
116
+ configs = cast(list[dict[str, Any]], parsed)
117
+ log.info(f"Loaded config: {path} ({len(configs)} configs)")
118
+ return configs
119
+
120
+ raise ConfigFileError(
121
+ "Config file must contain an object or list of objects, "
122
+ f"got {type(parsed).__name__}"
123
+ )
124
+
125
+ @staticmethod
126
+ def _make_json_converter(field_name: str) -> Callable[[Any], Any]:
127
+ """Create a converter that accepts parsed JSON or JSON text."""
128
+
129
+ def convert(value: Any) -> Any:
130
+ if value is None:
131
+ return None
132
+ if isinstance(value, (dict, list)):
133
+ return cast(Any, value)
134
+ if isinstance(value, str):
135
+ if value in ("", "{}", "[]", "null"):
136
+ return None
137
+ try:
138
+ return json.loads(value)
139
+ except json.JSONDecodeError as exc:
140
+ raise ConfigFileError(f"Invalid JSON for {field_name}: {exc}") from exc
141
+ raise ConfigFileError(
142
+ f"{field_name} must be a dict, list, or JSON string, "
143
+ f"got {type(value).__name__}"
144
+ )
145
+
146
+ return convert
147
+
148
+ @staticmethod
149
+ def _make_enum_converter(field_name: str, enum_type: type[Enum]) -> Callable[[Any], Any]:
150
+ """Create a converter that accepts enum values by string."""
151
+
152
+ def convert(value: Any) -> Any:
153
+ if isinstance(value, str):
154
+ try:
155
+ return enum_type(value)
156
+ except ValueError as exc:
157
+ valid_values = [member.value for member in enum_type]
158
+ raise ConfigFileError(
159
+ f"Invalid value '{value}' for {field_name}. "
160
+ f"Valid values: {valid_values}"
161
+ ) from exc
162
+ return value
163
+
164
+ return convert
165
+
166
+ @staticmethod
167
+ def _resolve(config: dict[str, Any], **kwargs: Any) -> ArgsContainer:
168
+ """Resolve CLI args against a single config dict.
169
+
170
+ Field specs can be:
171
+ - ``(cli_value,)``: required field, config key is parameter name;
172
+ - ``(cli_value, default)``: optional field;
173
+ - ``(cli_value, default, converter)``: optional with converter;
174
+ - ``(config_key, cli_value, default, is_json)``: explicit key;
175
+ - ``(config_key, cli_value, default, is_json, required)``;
176
+ - ``(config_key, cli_value, default, is_json, required, converter)``.
177
+ """
178
+
179
+ resolved: dict[str, Any] = {}
180
+ required_fields: list[str] = []
181
+
182
+ for param_name, spec in kwargs.items():
183
+ converter = None
184
+ default_value = None
185
+ if len(spec) == 1:
186
+ (cli_value,) = spec
187
+ config_key = param_name
188
+ required_fields.append(param_name)
189
+ elif len(spec) == 2:
190
+ cli_value, default_value = spec
191
+ config_key = param_name
192
+ elif len(spec) == 3:
193
+ cli_value, default_value, converter = spec
194
+ config_key = param_name
195
+ elif len(spec) == 4:
196
+ config_key, cli_value, default_value, is_json = spec
197
+ if is_json:
198
+ converter = ArgsContainer._make_json_converter(param_name)
199
+ elif len(spec) == 5:
200
+ config_key, cli_value, default_value, is_json, required = spec
201
+ if is_json:
202
+ converter = ArgsContainer._make_json_converter(param_name)
203
+ if required:
204
+ required_fields.append(param_name)
205
+ else:
206
+ config_key, cli_value, default_value, is_json, required, converter = spec
207
+ if is_json:
208
+ converter = ArgsContainer._make_json_converter(param_name)
209
+ if required:
210
+ required_fields.append(param_name)
211
+
212
+ if converter is None and isinstance(default_value, Enum):
213
+ converter = ArgsContainer._make_enum_converter(param_name, type(default_value))
214
+
215
+ if cli_value is not None and cli_value != default_value:
216
+ value = cli_value
217
+ elif config_key in config:
218
+ value = config[config_key]
219
+ else:
220
+ value = cli_value
221
+
222
+ resolved[param_name] = converter(value) if converter and value is not None else value
223
+
224
+ for field_name in required_fields:
225
+ if resolved.get(field_name) is None:
226
+ raise ConfigFileError(
227
+ f"{field_name} is required (via CLI argument or config file)"
228
+ )
229
+
230
+ return ArgsContainer(resolved, config)
231
+
232
+ @staticmethod
233
+ def load(
234
+ config_path: str | Path | None,
235
+ logger: Logger | None = None,
236
+ **kwargs: Any,
237
+ ) -> list[ArgsContainer]:
238
+ """Load a config file and resolve CLI args against each config entry."""
239
+
240
+ configs = ArgsContainer._load_config_file(config_path, logger=logger)
241
+ return [ArgsContainer._resolve(config, **kwargs) for config in configs]
242
+
243
+
244
+ __all__ = ["ArgsContainer", "ConfigFileError"]