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
tangle_cli/api_cli.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""OpenAPI-backed `tangle api` command implementation.
|
|
2
|
+
|
|
3
|
+
The backend exposes a FastAPI OpenAPI schema. Schema cache, operation naming,
|
|
4
|
+
parameter mapping, and HTTP dispatch live in reusable modules so the CLI and
|
|
5
|
+
programmatic client share one behavior. Static commands are registered from the
|
|
6
|
+
checked-in OpenAPI snapshot, while `refresh` can update the dynamic schema cache
|
|
7
|
+
for expansion against a live backend. Commands are generated only when the root
|
|
8
|
+
CLI is being built for an actual `tangle api ...` invocation, so importing this
|
|
9
|
+
module never reads ambient argv, touches the schema cache, or contacts the
|
|
10
|
+
backend.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Annotated, Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
import platformdirs
|
|
24
|
+
from cyclopts import App, Parameter
|
|
25
|
+
|
|
26
|
+
from .args_container import ArgsContainer, ConfigFileError
|
|
27
|
+
from .api_schema import (
|
|
28
|
+
SUPPORTED_METHODS,
|
|
29
|
+
CliParameter,
|
|
30
|
+
OperationCommand,
|
|
31
|
+
cache_path,
|
|
32
|
+
default_cache_dir,
|
|
33
|
+
fetch_schema,
|
|
34
|
+
load_cached_schema,
|
|
35
|
+
load_or_fetch_schema,
|
|
36
|
+
operation_commands,
|
|
37
|
+
refresh_schema,
|
|
38
|
+
write_cached_schema,
|
|
39
|
+
_dedupe_command_name,
|
|
40
|
+
_flatten_schema,
|
|
41
|
+
_is_path_param,
|
|
42
|
+
_is_simple_schema,
|
|
43
|
+
_iter_operation_commands,
|
|
44
|
+
_json_request_body_schema,
|
|
45
|
+
_method_sort_key,
|
|
46
|
+
_normalize_name,
|
|
47
|
+
_operation_command_name,
|
|
48
|
+
_operation_group_name,
|
|
49
|
+
_operation_parameters,
|
|
50
|
+
_path_parts,
|
|
51
|
+
_request_body_parameters,
|
|
52
|
+
_resolve_ref,
|
|
53
|
+
_safe_identifier,
|
|
54
|
+
_same_operation,
|
|
55
|
+
_schema_to_python_type,
|
|
56
|
+
_unwrap_nullable_schema,
|
|
57
|
+
)
|
|
58
|
+
from .api_transport import (
|
|
59
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
60
|
+
_ambient_auth_env_present,
|
|
61
|
+
_env_header_entries,
|
|
62
|
+
_headers_from_env,
|
|
63
|
+
_load_body_argument,
|
|
64
|
+
_normalize_auth_header,
|
|
65
|
+
_normalize_base_url,
|
|
66
|
+
_openapi_url,
|
|
67
|
+
_parse_header_entries,
|
|
68
|
+
_request_headers,
|
|
69
|
+
_urlencode_query,
|
|
70
|
+
default_auth_header,
|
|
71
|
+
default_base_url,
|
|
72
|
+
default_token,
|
|
73
|
+
request_operation,
|
|
74
|
+
)
|
|
75
|
+
from .cli_helpers import api_arg_specs, load_args_or_exit
|
|
76
|
+
from .cli_options import (
|
|
77
|
+
AuthHeaderOption,
|
|
78
|
+
BaseUrlOption,
|
|
79
|
+
ConfigOption,
|
|
80
|
+
HeaderOption,
|
|
81
|
+
TokenOption,
|
|
82
|
+
)
|
|
83
|
+
from .openapi.parser import load_openapi_schema as load_bundled_openapi_schema
|
|
84
|
+
|
|
85
|
+
BodyOption = Annotated[
|
|
86
|
+
str | None,
|
|
87
|
+
Parameter(help="JSON request body, or @path/to/file.json."),
|
|
88
|
+
]
|
|
89
|
+
SchemaSourceOption = Annotated[
|
|
90
|
+
str,
|
|
91
|
+
Parameter(
|
|
92
|
+
help=(
|
|
93
|
+
"OpenAPI schema source for generated API commands: 'auto' merges "
|
|
94
|
+
"checked-in official operations with cached backend extensions "
|
|
95
|
+
"(default); 'official' uses only the checked-in static schema; "
|
|
96
|
+
"'cache' uses only a schema previously written by `tangle api refresh`."
|
|
97
|
+
)
|
|
98
|
+
),
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_app(schema: dict[str, Any] | None = None) -> App:
|
|
103
|
+
"""Build the `tangle api` Cyclopts app.
|
|
104
|
+
|
|
105
|
+
When *schema* is supplied, commands are generated from it. Otherwise the
|
|
106
|
+
checked-in official OpenAPI snapshot is always used, and cached live backend
|
|
107
|
+
operations are merged in as dynamic extensions by default. Official
|
|
108
|
+
definitions win for matching method/path operations.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
api_app = App(
|
|
112
|
+
name="api",
|
|
113
|
+
help="Call Tangle backend API endpoints from the checked-in OpenAPI schema.",
|
|
114
|
+
)
|
|
115
|
+
_register_refresh_command(api_app)
|
|
116
|
+
_register_reset_cache_command(api_app)
|
|
117
|
+
_register_schema_source_option(api_app)
|
|
118
|
+
|
|
119
|
+
schema = schema if schema is not None else _schema_for_current_invocation()
|
|
120
|
+
if schema is not None:
|
|
121
|
+
register_dynamic_commands(api_app, schema)
|
|
122
|
+
|
|
123
|
+
return api_app
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def register_dynamic_commands(api_app: App, schema: dict[str, Any]) -> None:
|
|
127
|
+
"""Attach generated resource groups and endpoint commands to `api_app`."""
|
|
128
|
+
|
|
129
|
+
groups: dict[str, App] = {}
|
|
130
|
+
|
|
131
|
+
for operation in operation_commands(schema):
|
|
132
|
+
group = groups.get(operation.group_name)
|
|
133
|
+
if group is None:
|
|
134
|
+
group = App(
|
|
135
|
+
name=operation.group_name,
|
|
136
|
+
help=f"Call {operation.group_name} API endpoints.",
|
|
137
|
+
)
|
|
138
|
+
_register_schema_source_option(group)
|
|
139
|
+
groups[operation.group_name] = group
|
|
140
|
+
api_app.command(group)
|
|
141
|
+
|
|
142
|
+
command = _make_operation_callable(operation)
|
|
143
|
+
group.command(command, name=operation.command_name)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _register_schema_source_option(app: App) -> None:
|
|
147
|
+
@app.default
|
|
148
|
+
def schema_source_option(*, schema_source: SchemaSourceOption = "auto") -> None:
|
|
149
|
+
"""Select merged, official-only, or raw cached backend schema."""
|
|
150
|
+
|
|
151
|
+
_validate_schema_source(schema_source)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _register_refresh_command(api_app: App) -> None:
|
|
155
|
+
@api_app.command(name="refresh")
|
|
156
|
+
def refresh(
|
|
157
|
+
*,
|
|
158
|
+
base_url: BaseUrlOption = None,
|
|
159
|
+
token: TokenOption = None,
|
|
160
|
+
auth_header: AuthHeaderOption = None,
|
|
161
|
+
header: HeaderOption = None,
|
|
162
|
+
config: ConfigOption = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Fetch /openapi.json and update the local schema cache."""
|
|
165
|
+
|
|
166
|
+
for args in load_args_or_exit(
|
|
167
|
+
config,
|
|
168
|
+
**api_arg_specs(
|
|
169
|
+
base_url=base_url,
|
|
170
|
+
token=token,
|
|
171
|
+
auth_header=auth_header,
|
|
172
|
+
header=header,
|
|
173
|
+
),
|
|
174
|
+
):
|
|
175
|
+
base_url_from_config = base_url is None and "base_url" in args._config
|
|
176
|
+
normalized_base_url = (
|
|
177
|
+
_normalize_base_url(args.base_url) if args.base_url else default_base_url()
|
|
178
|
+
)
|
|
179
|
+
try:
|
|
180
|
+
schema, path = refresh_schema(
|
|
181
|
+
normalized_base_url,
|
|
182
|
+
args.token,
|
|
183
|
+
args.header,
|
|
184
|
+
args.auth_header,
|
|
185
|
+
include_env_credentials=not base_url_from_config,
|
|
186
|
+
)
|
|
187
|
+
except httpx.HTTPStatusError as exc:
|
|
188
|
+
message = f"HTTP {exc.response.status_code} {exc.response.reason_phrase}"
|
|
189
|
+
raise SystemExit(
|
|
190
|
+
f"Failed to fetch {_openapi_url(normalized_base_url)}: {message}"
|
|
191
|
+
) from exc
|
|
192
|
+
except httpx.RequestError as exc:
|
|
193
|
+
raise SystemExit(
|
|
194
|
+
f"Failed to fetch {_openapi_url(normalized_base_url)}: {exc}"
|
|
195
|
+
) from exc
|
|
196
|
+
path_count = len(schema.get("paths", {}))
|
|
197
|
+
print(f"Cached OpenAPI schema for {normalized_base_url}")
|
|
198
|
+
print(f"Path: {path}")
|
|
199
|
+
print(f"OpenAPI paths: {path_count}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _register_reset_cache_command(api_app: App) -> None:
|
|
203
|
+
@api_app.command(name="reset-cache")
|
|
204
|
+
def reset_cache(*, base_url: BaseUrlOption = None, config: ConfigOption = None) -> None:
|
|
205
|
+
"""Delete the cached live OpenAPI schema for a base URL."""
|
|
206
|
+
|
|
207
|
+
for args in load_args_or_exit(config, base_url=(base_url, None)):
|
|
208
|
+
normalized_base_url = (
|
|
209
|
+
_normalize_base_url(args.base_url) if args.base_url else default_base_url()
|
|
210
|
+
)
|
|
211
|
+
path = cache_path(normalized_base_url)
|
|
212
|
+
if path.exists():
|
|
213
|
+
path.unlink()
|
|
214
|
+
print(f"Deleted cached OpenAPI schema for {normalized_base_url}")
|
|
215
|
+
print(f"Path: {path}")
|
|
216
|
+
else:
|
|
217
|
+
print(f"No cached OpenAPI schema for {normalized_base_url}")
|
|
218
|
+
print(f"Path: {path}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _make_operation_callable(operation: OperationCommand):
|
|
222
|
+
"""Create the Python callable Cyclopts registers for one endpoint.
|
|
223
|
+
|
|
224
|
+
Cyclopts introspects function metadata, so we attach a generated signature
|
|
225
|
+
and docstring below. The real function accepts flexible args/kwargs and
|
|
226
|
+
forwards normalized values to the HTTP dispatcher.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
positional_names = [
|
|
230
|
+
parameter.local_name
|
|
231
|
+
for parameter in operation.parameters
|
|
232
|
+
if parameter.location == "path"
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
def command(*args: Any, **values: Any) -> None:
|
|
236
|
+
for name, value in zip(positional_names, args):
|
|
237
|
+
values[name] = value
|
|
238
|
+
_invoke_operation(operation, values)
|
|
239
|
+
|
|
240
|
+
command.__name__ = _safe_function_name(f"{operation.group_name}_{operation.command_name}")
|
|
241
|
+
command.__doc__ = _operation_help(operation)
|
|
242
|
+
command.__signature__ = _operation_signature(operation) # type: ignore[attr-defined]
|
|
243
|
+
return command
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _operation_signature(operation: OperationCommand) -> inspect.Signature:
|
|
247
|
+
"""Build the signature Cyclopts uses for parsing and help output.
|
|
248
|
+
|
|
249
|
+
Path parameters are positional. Query parameters and simple body fields are
|
|
250
|
+
keyword-only options. `--body`, `--header`, `--auth-header`, `--base-url`,
|
|
251
|
+
and `--token` are appended as common generated-command options.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
parameters: list[inspect.Parameter] = []
|
|
255
|
+
|
|
256
|
+
for parameter in operation.parameters:
|
|
257
|
+
if parameter.location != "path":
|
|
258
|
+
continue
|
|
259
|
+
annotation = _annotated_type(parameter.python_type, parameter.description)
|
|
260
|
+
parameters.append(
|
|
261
|
+
inspect.Parameter(
|
|
262
|
+
parameter.local_name,
|
|
263
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
264
|
+
default=None,
|
|
265
|
+
annotation=_optional_type(annotation),
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
for parameter in operation.parameters:
|
|
270
|
+
if parameter.location not in {"query", "body"}:
|
|
271
|
+
continue
|
|
272
|
+
body_field_with_escape_hatch = parameter.location == "body" and operation.has_request_body
|
|
273
|
+
annotation = _annotated_type(
|
|
274
|
+
_optional_type(parameter.python_type)
|
|
275
|
+
if not parameter.required or body_field_with_escape_hatch
|
|
276
|
+
else parameter.python_type,
|
|
277
|
+
parameter.description,
|
|
278
|
+
)
|
|
279
|
+
default = parameter.default if not parameter.required else None
|
|
280
|
+
parameters.append(
|
|
281
|
+
inspect.Parameter(
|
|
282
|
+
parameter.local_name,
|
|
283
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
284
|
+
default=default,
|
|
285
|
+
annotation=annotation,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if operation.has_request_body:
|
|
290
|
+
parameters.append(
|
|
291
|
+
inspect.Parameter(
|
|
292
|
+
"body",
|
|
293
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
294
|
+
default=None,
|
|
295
|
+
annotation=BodyOption,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
parameters.append(
|
|
300
|
+
inspect.Parameter(
|
|
301
|
+
"config",
|
|
302
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
303
|
+
default=None,
|
|
304
|
+
annotation=ConfigOption,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
parameters.append(
|
|
308
|
+
inspect.Parameter(
|
|
309
|
+
"schema_source",
|
|
310
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
311
|
+
default="auto",
|
|
312
|
+
annotation=SchemaSourceOption,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
parameters.append(
|
|
316
|
+
inspect.Parameter(
|
|
317
|
+
"auth_header",
|
|
318
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
319
|
+
default=None,
|
|
320
|
+
annotation=AuthHeaderOption,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
parameters.append(
|
|
324
|
+
inspect.Parameter(
|
|
325
|
+
"header",
|
|
326
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
327
|
+
default=None,
|
|
328
|
+
annotation=HeaderOption,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
parameters.extend(
|
|
333
|
+
[
|
|
334
|
+
inspect.Parameter(
|
|
335
|
+
"base_url",
|
|
336
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
337
|
+
default=None,
|
|
338
|
+
annotation=BaseUrlOption,
|
|
339
|
+
),
|
|
340
|
+
inspect.Parameter(
|
|
341
|
+
"token",
|
|
342
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
343
|
+
default=None,
|
|
344
|
+
annotation=TokenOption,
|
|
345
|
+
),
|
|
346
|
+
]
|
|
347
|
+
)
|
|
348
|
+
return inspect.Signature(parameters=parameters)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _optional_type(python_type: Any) -> Any:
|
|
352
|
+
return python_type | None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _annotated_type(python_type: Any, description: str) -> Any:
|
|
356
|
+
if description:
|
|
357
|
+
return Annotated[python_type, Parameter(help=description)]
|
|
358
|
+
return python_type
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _operation_help(operation: OperationCommand) -> str:
|
|
362
|
+
summary = operation.operation.get("summary") or operation.operation.get("description")
|
|
363
|
+
if summary:
|
|
364
|
+
return str(summary).strip()
|
|
365
|
+
return f"{operation.method} {operation.path}"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _invoke_operation(operation: OperationCommand, values: dict[str, Any]) -> None:
|
|
369
|
+
"""Turn parsed CLI values into an HTTP request and print the response."""
|
|
370
|
+
|
|
371
|
+
config = values.pop("config", None)
|
|
372
|
+
cli_body = values.get("body") if operation.has_request_body else None
|
|
373
|
+
cli_base_url = values.get("base_url")
|
|
374
|
+
for args in _operation_args_from_config(operation, values, config):
|
|
375
|
+
body_from_config = operation.has_request_body and cli_body is None and "body" in args._config
|
|
376
|
+
base_url_from_config = cli_base_url is None and "base_url" in args._config
|
|
377
|
+
_invoke_operation_once(
|
|
378
|
+
operation,
|
|
379
|
+
args.to_dict(),
|
|
380
|
+
allow_body_file_references=not body_from_config,
|
|
381
|
+
include_env_credentials=not base_url_from_config,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _operation_args_from_config(
|
|
386
|
+
operation: OperationCommand,
|
|
387
|
+
values: dict[str, Any],
|
|
388
|
+
config: str | None,
|
|
389
|
+
) -> list[ArgsContainer]:
|
|
390
|
+
specs: dict[str, tuple[Any, ...]] = {}
|
|
391
|
+
for parameter in operation.parameters:
|
|
392
|
+
default = parameter.default if not parameter.required else None
|
|
393
|
+
required = parameter.required and parameter.location != "body"
|
|
394
|
+
specs[parameter.local_name] = (
|
|
395
|
+
parameter.local_name,
|
|
396
|
+
values.get(parameter.local_name, default),
|
|
397
|
+
default,
|
|
398
|
+
False,
|
|
399
|
+
required,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
specs["schema_source"] = (values.get("schema_source", "auto"), "auto")
|
|
403
|
+
if operation.has_request_body:
|
|
404
|
+
specs["body"] = (values.get("body"), None)
|
|
405
|
+
specs.update(
|
|
406
|
+
api_arg_specs(
|
|
407
|
+
base_url=values.get("base_url"),
|
|
408
|
+
token=values.get("token"),
|
|
409
|
+
auth_header=values.get("auth_header"),
|
|
410
|
+
header=values.get("header"),
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
resolved = load_args_or_exit(config, **specs)
|
|
414
|
+
for args in resolved:
|
|
415
|
+
for parameter in operation.parameters:
|
|
416
|
+
if parameter.required or parameter.default is None:
|
|
417
|
+
continue
|
|
418
|
+
if parameter.local_name in args._config:
|
|
419
|
+
continue
|
|
420
|
+
if getattr(args, parameter.local_name, None) == parameter.default:
|
|
421
|
+
setattr(args, parameter.local_name, None)
|
|
422
|
+
return resolved
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _invoke_operation_once(
|
|
426
|
+
operation: OperationCommand,
|
|
427
|
+
values: dict[str, Any],
|
|
428
|
+
*,
|
|
429
|
+
allow_body_file_references: bool = True,
|
|
430
|
+
include_env_credentials: bool = True,
|
|
431
|
+
) -> None:
|
|
432
|
+
_validate_schema_source(values.pop("schema_source", "official"))
|
|
433
|
+
base_url = _normalize_base_url(values.pop("base_url", None) or default_base_url())
|
|
434
|
+
token = values.pop("token", None)
|
|
435
|
+
if token is None and include_env_credentials:
|
|
436
|
+
token = default_token()
|
|
437
|
+
auth_header = values.pop("auth_header", None)
|
|
438
|
+
header_entries = values.pop("header", None)
|
|
439
|
+
body_arg = values.pop("body", None) if operation.has_request_body else None
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
response = request_operation(
|
|
443
|
+
operation,
|
|
444
|
+
values,
|
|
445
|
+
base_url=base_url,
|
|
446
|
+
token=token,
|
|
447
|
+
auth_header=auth_header,
|
|
448
|
+
header_entries=header_entries,
|
|
449
|
+
body=body_arg,
|
|
450
|
+
timeout=DEFAULT_TIMEOUT_SECONDS,
|
|
451
|
+
allow_body_file_references=allow_body_file_references,
|
|
452
|
+
include_env_credentials=include_env_credentials,
|
|
453
|
+
)
|
|
454
|
+
except httpx.HTTPStatusError as exc:
|
|
455
|
+
message = exc.response.text or exc.response.reason_phrase
|
|
456
|
+
print(message, file=sys.stderr)
|
|
457
|
+
raise SystemExit(exc.response.status_code) from exc
|
|
458
|
+
except httpx.RequestError as exc:
|
|
459
|
+
raise SystemExit(f"Failed to call {exc.request.url}: {exc}") from exc
|
|
460
|
+
except TypeError as exc:
|
|
461
|
+
raise SystemExit(str(exc)) from exc
|
|
462
|
+
|
|
463
|
+
if not response.content:
|
|
464
|
+
return
|
|
465
|
+
text = response.text
|
|
466
|
+
if "json" in response.headers.get("Content-Type", "").lower():
|
|
467
|
+
try:
|
|
468
|
+
print(json.dumps(json.loads(text), indent=2, sort_keys=True))
|
|
469
|
+
return
|
|
470
|
+
except json.JSONDecodeError:
|
|
471
|
+
pass
|
|
472
|
+
print(text)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _schema_for_current_invocation() -> dict[str, Any] | None:
|
|
476
|
+
"""Return schema needed to build API commands for this process.
|
|
477
|
+
|
|
478
|
+
Static commands come from the checked-in official OpenAPI snapshot and are
|
|
479
|
+
available on a cold cache. By default, cached live backend operations are
|
|
480
|
+
merged in as extensions without overriding official operations.
|
|
481
|
+
"""
|
|
482
|
+
|
|
483
|
+
api_tail = _api_argv_tail(sys.argv)
|
|
484
|
+
if api_tail is None:
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
first_command = _api_first_command(api_tail)
|
|
488
|
+
if first_command in {"refresh", "reset-cache"}:
|
|
489
|
+
return None
|
|
490
|
+
help_requested = _api_tail_requests_help(api_tail)
|
|
491
|
+
|
|
492
|
+
schema_source = _schema_source_from_argv(api_tail)
|
|
493
|
+
base_url_arg, base_url_source = _base_url_with_source_from_argv(api_tail)
|
|
494
|
+
configured_base_url = base_url_arg or os.environ.get("TANGLE_API_URL")
|
|
495
|
+
include_env_credentials = base_url_source != "config"
|
|
496
|
+
token = _token_from_argv(api_tail, include_env_credentials=include_env_credentials)
|
|
497
|
+
auth_header = _auth_header_from_argv(api_tail, include_env_credentials=include_env_credentials)
|
|
498
|
+
header = _headers_from_argv(api_tail)
|
|
499
|
+
if schema_source == "cache":
|
|
500
|
+
base_url = configured_base_url or default_base_url()
|
|
501
|
+
cached = load_cached_schema(base_url)
|
|
502
|
+
if cached is None:
|
|
503
|
+
raise SystemExit(
|
|
504
|
+
f"No cached OpenAPI schema for {_normalize_base_url(base_url)}. "
|
|
505
|
+
"Run `tangle api refresh` with the same --base-url/--auth-header/--header options, "
|
|
506
|
+
"or install tangle-cli[native] to use the official static schema."
|
|
507
|
+
)
|
|
508
|
+
return cached
|
|
509
|
+
|
|
510
|
+
cache_base_url = _auto_cache_base_url(configured_base_url, help_requested)
|
|
511
|
+
cached = load_cached_schema(cache_base_url) if cache_base_url else None
|
|
512
|
+
try:
|
|
513
|
+
official = load_bundled_openapi_schema()
|
|
514
|
+
except FileNotFoundError as exc:
|
|
515
|
+
if first_command is None:
|
|
516
|
+
return None
|
|
517
|
+
if schema_source == "auto" and cache_base_url:
|
|
518
|
+
try:
|
|
519
|
+
return load_or_fetch_schema(
|
|
520
|
+
cache_base_url,
|
|
521
|
+
token=token,
|
|
522
|
+
header=header,
|
|
523
|
+
auth_header=auth_header,
|
|
524
|
+
include_env_credentials=include_env_credentials,
|
|
525
|
+
)
|
|
526
|
+
except (httpx.HTTPError, RuntimeError, ValueError, json.JSONDecodeError) as fetch_exc:
|
|
527
|
+
raise SystemExit(_missing_official_schema_message()) from fetch_exc
|
|
528
|
+
raise SystemExit(_missing_official_schema_message()) from exc
|
|
529
|
+
|
|
530
|
+
if schema_source == "official":
|
|
531
|
+
return official
|
|
532
|
+
if cached is None:
|
|
533
|
+
return official
|
|
534
|
+
return _merge_official_with_cached_extensions(official, cached)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _auto_cache_base_url(
|
|
538
|
+
configured_base_url: str | None,
|
|
539
|
+
help_requested: bool,
|
|
540
|
+
) -> str | None:
|
|
541
|
+
if configured_base_url:
|
|
542
|
+
return configured_base_url
|
|
543
|
+
if help_requested and _ambient_auth_env_present() and not os.environ.get("TANGLE_API_URL"):
|
|
544
|
+
return None
|
|
545
|
+
return default_base_url()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _api_tail_requests_help(api_tail: list[str]) -> bool:
|
|
549
|
+
skip_next = False
|
|
550
|
+
options_with_values = {
|
|
551
|
+
"--base-url",
|
|
552
|
+
"--api-url",
|
|
553
|
+
"--token",
|
|
554
|
+
"--auth-header",
|
|
555
|
+
"--header",
|
|
556
|
+
"-H",
|
|
557
|
+
"--schema-source",
|
|
558
|
+
"--config",
|
|
559
|
+
}
|
|
560
|
+
for arg in api_tail:
|
|
561
|
+
if skip_next:
|
|
562
|
+
skip_next = False
|
|
563
|
+
continue
|
|
564
|
+
if arg in options_with_values:
|
|
565
|
+
skip_next = True
|
|
566
|
+
continue
|
|
567
|
+
if arg in {"--help", "-h"}:
|
|
568
|
+
return True
|
|
569
|
+
return False
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _missing_official_schema_message() -> str:
|
|
573
|
+
return (
|
|
574
|
+
"Official static Tangle API commands require the native tangle-api "
|
|
575
|
+
"package because the bundled OpenAPI snapshot lives in tangle_api.schema. "
|
|
576
|
+
"Install tangle-cli[native], or run `tangle api refresh` and use "
|
|
577
|
+
"`--schema-source cache` for cached backend operations."
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _merge_official_with_cached_extensions(
|
|
582
|
+
official: dict[str, Any],
|
|
583
|
+
cached: dict[str, Any],
|
|
584
|
+
) -> dict[str, Any]:
|
|
585
|
+
"""Return official schema plus cached-only extension operations.
|
|
586
|
+
|
|
587
|
+
Official operations win for matching method/path pairs. Cached schemas can
|
|
588
|
+
contribute entirely new paths, additional methods on existing paths, and
|
|
589
|
+
component definitions needed by cached-only extension operations.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
merged = json.loads(json.dumps(official))
|
|
593
|
+
cached_paths = cached.get("paths", {}) or {}
|
|
594
|
+
merged_paths = merged.setdefault("paths", {})
|
|
595
|
+
for path, cached_path_item in cached_paths.items():
|
|
596
|
+
if not isinstance(cached_path_item, dict):
|
|
597
|
+
continue
|
|
598
|
+
if path not in merged_paths or not isinstance(merged_paths[path], dict):
|
|
599
|
+
merged_paths[path] = json.loads(json.dumps(cached_path_item))
|
|
600
|
+
continue
|
|
601
|
+
merged_path_item = merged_paths[path]
|
|
602
|
+
for key, value in cached_path_item.items():
|
|
603
|
+
if key.lower() in SUPPORTED_METHODS:
|
|
604
|
+
# Preserve official operation definitions when method/path match.
|
|
605
|
+
merged_path_item.setdefault(key, json.loads(json.dumps(value)))
|
|
606
|
+
elif key not in merged_path_item:
|
|
607
|
+
# Preserve cached-only path-level metadata for cached-only methods.
|
|
608
|
+
merged_path_item[key] = json.loads(json.dumps(value))
|
|
609
|
+
|
|
610
|
+
_merge_missing_dict_keys(merged.setdefault("components", {}), cached.get("components", {}) or {})
|
|
611
|
+
return merged
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _merge_missing_dict_keys(target: dict[str, Any], source: dict[str, Any]) -> None:
|
|
615
|
+
for key, value in source.items():
|
|
616
|
+
if key not in target:
|
|
617
|
+
target[key] = json.loads(json.dumps(value))
|
|
618
|
+
elif isinstance(target[key], dict) and isinstance(value, dict):
|
|
619
|
+
_merge_missing_dict_keys(target[key], value)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _argv_requests_api_schema(argv: list[str]) -> bool:
|
|
623
|
+
api_tail = _api_argv_tail(argv)
|
|
624
|
+
if api_tail is None:
|
|
625
|
+
return False
|
|
626
|
+
first_command = _api_first_command(api_tail)
|
|
627
|
+
return first_command not in {None, "refresh", "reset-cache"}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _argv_dispatches_dynamic_command(argv: list[str]) -> bool:
|
|
631
|
+
api_tail = _api_argv_tail(argv)
|
|
632
|
+
if api_tail is None:
|
|
633
|
+
return False
|
|
634
|
+
first_command = _api_first_command(api_tail)
|
|
635
|
+
return first_command not in {None, "refresh", "reset-cache"}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _api_argv_tail(argv: list[str]) -> list[str] | None:
|
|
639
|
+
"""Return args after the root `api` command, or None for non-API invocations."""
|
|
640
|
+
|
|
641
|
+
args = list(argv[1:])
|
|
642
|
+
for index, arg in enumerate(args):
|
|
643
|
+
if arg == "--":
|
|
644
|
+
if index + 1 < len(args) and args[index + 1] == "api":
|
|
645
|
+
return args[index + 2 :]
|
|
646
|
+
return None
|
|
647
|
+
if arg in {"--help", "-h", "--version"}:
|
|
648
|
+
return None
|
|
649
|
+
if arg.startswith("-"):
|
|
650
|
+
return None
|
|
651
|
+
return args[index + 1 :] if arg == "api" else None
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _api_first_command(api_tail: list[str]) -> str | None:
|
|
656
|
+
skip_next = False
|
|
657
|
+
options_with_values = {
|
|
658
|
+
"--base-url",
|
|
659
|
+
"--api-url",
|
|
660
|
+
"--token",
|
|
661
|
+
"--auth-header",
|
|
662
|
+
"--header",
|
|
663
|
+
"-H",
|
|
664
|
+
"--schema-source",
|
|
665
|
+
"--config",
|
|
666
|
+
}
|
|
667
|
+
for arg in api_tail:
|
|
668
|
+
if skip_next:
|
|
669
|
+
skip_next = False
|
|
670
|
+
continue
|
|
671
|
+
if arg in options_with_values:
|
|
672
|
+
skip_next = True
|
|
673
|
+
continue
|
|
674
|
+
if arg in {"--help", "-h"}:
|
|
675
|
+
return None
|
|
676
|
+
if arg.startswith("--"):
|
|
677
|
+
continue
|
|
678
|
+
return arg
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _schema_source_from_argv(argv: list[str]) -> str:
|
|
683
|
+
value = _option_from_argv(argv, "--schema-source")
|
|
684
|
+
if value is None:
|
|
685
|
+
value = _config_value_from_argv(argv, "schema_source")
|
|
686
|
+
return _validate_schema_source(str(value or "auto"))
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _validate_schema_source(value: str) -> str:
|
|
690
|
+
normalized = value.strip().lower()
|
|
691
|
+
if normalized not in {"auto", "official", "cache"}:
|
|
692
|
+
raise SystemExit("--schema-source must be 'auto', 'official', or 'cache'")
|
|
693
|
+
return normalized
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _schema_fetch_failure_message(base_url: str, exc: Exception) -> str:
|
|
697
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
698
|
+
reason = f"HTTP {exc.response.status_code} {exc.response.reason_phrase}"
|
|
699
|
+
elif isinstance(exc, httpx.RequestError):
|
|
700
|
+
reason = str(exc)
|
|
701
|
+
else:
|
|
702
|
+
reason = exc.__class__.__name__
|
|
703
|
+
return (
|
|
704
|
+
f"No cached OpenAPI schema for {_normalize_base_url(base_url)}, and fetching "
|
|
705
|
+
f"{_openapi_url(base_url)} failed: {reason}. Run `tangle api refresh` "
|
|
706
|
+
"with the same --base-url/--auth-header/--header options, or set "
|
|
707
|
+
"TANGLE_API_URL/TANGLE_API_AUTH_HEADER/TANGLE_API_HEADERS."
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _base_url_from_argv(argv: list[str]) -> str | None:
|
|
712
|
+
value, _source = _base_url_with_source_from_argv(argv)
|
|
713
|
+
return value
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _base_url_with_source_from_argv(argv: list[str]) -> tuple[str | None, str | None]:
|
|
717
|
+
cli_value = _option_from_argv(argv, "--base-url") or _option_from_argv(argv, "--api-url")
|
|
718
|
+
if cli_value is not None:
|
|
719
|
+
return cli_value, "cli"
|
|
720
|
+
config_value = _optional_str(_config_value_from_argv(argv, "base_url"))
|
|
721
|
+
if config_value is not None:
|
|
722
|
+
return config_value, "config"
|
|
723
|
+
return None, None
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _token_from_argv(argv: list[str], *, include_env_credentials: bool = True) -> str | None:
|
|
727
|
+
token = _option_from_argv(argv, "--token") or _optional_str(_config_value_from_argv(argv, "token"))
|
|
728
|
+
if token is None and include_env_credentials:
|
|
729
|
+
token = default_token()
|
|
730
|
+
return token
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _auth_header_from_argv(argv: list[str], *, include_env_credentials: bool = True) -> str | None:
|
|
734
|
+
auth_header = _option_from_argv(argv, "--auth-header") or _optional_str(
|
|
735
|
+
_config_value_from_argv(argv, "auth_header")
|
|
736
|
+
)
|
|
737
|
+
if auth_header is None and include_env_credentials:
|
|
738
|
+
auth_header = default_auth_header()
|
|
739
|
+
return auth_header
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _config_value_from_argv(argv: list[str], key: str) -> Any:
|
|
743
|
+
config_path = _option_from_argv(argv, "--config")
|
|
744
|
+
if config_path is None:
|
|
745
|
+
return None
|
|
746
|
+
try:
|
|
747
|
+
configs = ArgsContainer._load_config_file(config_path)
|
|
748
|
+
except ConfigFileError as exc:
|
|
749
|
+
raise SystemExit(f"Config error: {exc}") from exc
|
|
750
|
+
if not configs:
|
|
751
|
+
return None
|
|
752
|
+
return configs[0].get(key)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _optional_str(value: Any) -> str | None:
|
|
756
|
+
return value if isinstance(value, str) else None
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _headers_from_argv(argv: list[str]) -> list[str]:
|
|
760
|
+
entries: list[str] = []
|
|
761
|
+
for index, arg in enumerate(argv):
|
|
762
|
+
if arg in {"--header", "-H"} and index + 1 < len(argv):
|
|
763
|
+
entries.append(argv[index + 1])
|
|
764
|
+
elif arg.startswith("--header="):
|
|
765
|
+
entries.append(arg.split("=", 1)[1])
|
|
766
|
+
if entries:
|
|
767
|
+
return entries
|
|
768
|
+
|
|
769
|
+
config_header = _config_value_from_argv(argv, "header")
|
|
770
|
+
if isinstance(config_header, list):
|
|
771
|
+
return [str(entry) for entry in config_header]
|
|
772
|
+
if isinstance(config_header, str):
|
|
773
|
+
return [config_header]
|
|
774
|
+
return []
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _option_from_argv(argv: list[str], option: str) -> str | None:
|
|
778
|
+
for index, arg in enumerate(argv):
|
|
779
|
+
if arg == option and index + 1 < len(argv):
|
|
780
|
+
return argv[index + 1]
|
|
781
|
+
if arg.startswith(option + "="):
|
|
782
|
+
return arg.split("=", 1)[1]
|
|
783
|
+
return None
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _safe_function_name(name: str) -> str:
|
|
787
|
+
return re.sub(r"\W+", "_", name).strip("_") or "api_command"
|