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.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- 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"]
|