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_schema.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""OpenAPI schema cache and operation mapping utilities for Tangle APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import keyword
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import platformdirs
|
|
15
|
+
|
|
16
|
+
from .api_transport import (
|
|
17
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
18
|
+
_normalize_base_url,
|
|
19
|
+
_openapi_url,
|
|
20
|
+
_request_headers,
|
|
21
|
+
default_base_url,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
SUPPORTED_METHODS = {"get", "post", "put", "patch", "delete"}
|
|
25
|
+
_HTTP_METHOD_NAMES = {
|
|
26
|
+
"get": "get",
|
|
27
|
+
"post": "create",
|
|
28
|
+
"put": "update",
|
|
29
|
+
"patch": "update",
|
|
30
|
+
"delete": "delete",
|
|
31
|
+
}
|
|
32
|
+
_METHOD_PRIORITY = {
|
|
33
|
+
"get": 0,
|
|
34
|
+
"post": 1,
|
|
35
|
+
"put": 2,
|
|
36
|
+
"patch": 3,
|
|
37
|
+
"delete": 4,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class CliParameter:
|
|
43
|
+
"""Normalized OpenAPI parameter/body field for CLI and client dispatch."""
|
|
44
|
+
|
|
45
|
+
original_name: str
|
|
46
|
+
local_name: str
|
|
47
|
+
location: Literal["path", "query", "body"]
|
|
48
|
+
python_type: Any
|
|
49
|
+
required: bool = False
|
|
50
|
+
default: Any = None
|
|
51
|
+
description: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class OperationCommand:
|
|
56
|
+
"""Normalized OpenAPI operation ready for CLI or programmatic dispatch."""
|
|
57
|
+
|
|
58
|
+
group_name: str
|
|
59
|
+
command_name: str
|
|
60
|
+
method: str
|
|
61
|
+
path: str
|
|
62
|
+
operation: dict[str, Any]
|
|
63
|
+
parameters: tuple[CliParameter, ...]
|
|
64
|
+
has_request_body: bool
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def operation_name(self) -> str:
|
|
68
|
+
return f"{self.group_name}.{self.command_name}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def default_cache_dir() -> Path:
|
|
72
|
+
"""Return the OpenAPI schema cache directory.
|
|
73
|
+
|
|
74
|
+
``TANGLE_CLI_CACHE_DIR`` is an explicit cache directory override for tests
|
|
75
|
+
and automation. Otherwise platformdirs selects the OS-appropriate user
|
|
76
|
+
cache directory and OpenAPI files live in an ``openapi`` subdirectory.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
import os
|
|
80
|
+
|
|
81
|
+
configured = os.environ.get("TANGLE_CLI_CACHE_DIR")
|
|
82
|
+
if configured:
|
|
83
|
+
return Path(configured).expanduser()
|
|
84
|
+
return Path(platformdirs.user_cache_dir("tangle-cli", "TangleML")) / "openapi"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cache_path(base_url: str | None = None) -> Path:
|
|
88
|
+
"""Return the schema cache file for a base URL."""
|
|
89
|
+
|
|
90
|
+
normalized = _normalize_base_url(base_url or default_base_url())
|
|
91
|
+
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
92
|
+
return default_cache_dir() / f"schema-{digest}.json"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_cached_schema(base_url: str | None = None) -> dict[str, Any] | None:
|
|
96
|
+
"""Load a previously fetched schema without touching the network."""
|
|
97
|
+
|
|
98
|
+
path = cache_path(base_url)
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return None
|
|
101
|
+
with path.open("r", encoding="utf-8") as f:
|
|
102
|
+
return json.load(f)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def write_cached_schema(schema: dict[str, Any], base_url: str | None = None) -> Path:
|
|
106
|
+
"""Atomically write a schema cache file and return its path."""
|
|
107
|
+
|
|
108
|
+
path = cache_path(base_url)
|
|
109
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
111
|
+
with tmp_path.open("w", encoding="utf-8") as f:
|
|
112
|
+
json.dump(schema, f, indent=2, sort_keys=True)
|
|
113
|
+
f.write("\n")
|
|
114
|
+
tmp_path.replace(path)
|
|
115
|
+
return path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fetch_schema(
|
|
119
|
+
base_url: str | None = None,
|
|
120
|
+
token: str | None = None,
|
|
121
|
+
header: list[str] | str | None = None,
|
|
122
|
+
auth_header: str | None = None,
|
|
123
|
+
headers: dict[str, str] | None = None,
|
|
124
|
+
include_env_credentials: bool = True,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
"""Fetch ``/openapi.json``, applying bearer and custom auth headers."""
|
|
127
|
+
|
|
128
|
+
base_url = _normalize_base_url(base_url or default_base_url())
|
|
129
|
+
response = httpx.get(
|
|
130
|
+
_openapi_url(base_url),
|
|
131
|
+
headers=_request_headers(
|
|
132
|
+
token,
|
|
133
|
+
header,
|
|
134
|
+
auth_header,
|
|
135
|
+
headers,
|
|
136
|
+
include_env_credentials=include_env_credentials,
|
|
137
|
+
),
|
|
138
|
+
timeout=DEFAULT_TIMEOUT_SECONDS,
|
|
139
|
+
)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
payload = response.text
|
|
142
|
+
schema = json.loads(payload)
|
|
143
|
+
if not isinstance(schema, dict) or "paths" not in schema:
|
|
144
|
+
raise RuntimeError("OpenAPI response did not contain a paths object")
|
|
145
|
+
return schema
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def refresh_schema(
|
|
149
|
+
base_url: str | None = None,
|
|
150
|
+
token: str | None = None,
|
|
151
|
+
header: list[str] | str | None = None,
|
|
152
|
+
auth_header: str | None = None,
|
|
153
|
+
headers: dict[str, str] | None = None,
|
|
154
|
+
include_env_credentials: bool = True,
|
|
155
|
+
) -> tuple[dict[str, Any], Path]:
|
|
156
|
+
"""Fetch and cache the latest schema for a backend."""
|
|
157
|
+
|
|
158
|
+
base_url = _normalize_base_url(base_url or default_base_url())
|
|
159
|
+
schema = fetch_schema(
|
|
160
|
+
base_url,
|
|
161
|
+
token,
|
|
162
|
+
header,
|
|
163
|
+
auth_header,
|
|
164
|
+
headers,
|
|
165
|
+
include_env_credentials=include_env_credentials,
|
|
166
|
+
)
|
|
167
|
+
path = write_cached_schema(schema, base_url)
|
|
168
|
+
return schema, path
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def load_or_fetch_schema(
|
|
172
|
+
base_url: str | None = None,
|
|
173
|
+
token: str | None = None,
|
|
174
|
+
header: list[str] | str | None = None,
|
|
175
|
+
auth_header: str | None = None,
|
|
176
|
+
headers: dict[str, str] | None = None,
|
|
177
|
+
include_env_credentials: bool = True,
|
|
178
|
+
) -> dict[str, Any]:
|
|
179
|
+
"""Use a cached schema when available, otherwise fetch once and cache it."""
|
|
180
|
+
|
|
181
|
+
cached = load_cached_schema(base_url)
|
|
182
|
+
if cached is not None:
|
|
183
|
+
return cached
|
|
184
|
+
schema, _ = refresh_schema(
|
|
185
|
+
base_url,
|
|
186
|
+
token,
|
|
187
|
+
header,
|
|
188
|
+
auth_header,
|
|
189
|
+
headers,
|
|
190
|
+
include_env_credentials=include_env_credentials,
|
|
191
|
+
)
|
|
192
|
+
return schema
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def operation_commands(schema: dict[str, Any]) -> list[OperationCommand]:
|
|
196
|
+
"""Return normalized operations with deterministic collision handling applied."""
|
|
197
|
+
|
|
198
|
+
operations: list[OperationCommand] = []
|
|
199
|
+
used_names: dict[str, dict[str, OperationCommand]] = {}
|
|
200
|
+
for operation in _iter_operation_commands(schema):
|
|
201
|
+
group_names = used_names.setdefault(operation.group_name, {})
|
|
202
|
+
command_name = _dedupe_command_name(operation.command_name, group_names, operation)
|
|
203
|
+
if command_name != operation.command_name:
|
|
204
|
+
operation = replace(operation, command_name=command_name)
|
|
205
|
+
operations.append(operation)
|
|
206
|
+
return operations
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def operation_map(schema: dict[str, Any]) -> dict[str, OperationCommand]:
|
|
210
|
+
"""Return operations keyed by canonical ``group.command`` name."""
|
|
211
|
+
|
|
212
|
+
return {operation.operation_name: operation for operation in operation_commands(schema)}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def operation_aliases(operation_name: str) -> set[str]:
|
|
216
|
+
"""Return Python-friendly aliases for a canonical operation name."""
|
|
217
|
+
|
|
218
|
+
aliases = {operation_name}
|
|
219
|
+
aliases.add(operation_name.replace("-", "_"))
|
|
220
|
+
aliases.add(operation_name.replace("_", "-"))
|
|
221
|
+
if "." in operation_name:
|
|
222
|
+
group, command = operation_name.split(".", 1)
|
|
223
|
+
aliases.add(f"{group.replace('-', '_')}.{command.replace('-', '_')}")
|
|
224
|
+
aliases.add(f"{group.replace('_', '-')}.{command.replace('_', '-')}")
|
|
225
|
+
return aliases
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve_operation(
|
|
229
|
+
operations: dict[str, OperationCommand], operation_name: str
|
|
230
|
+
) -> OperationCommand:
|
|
231
|
+
"""Resolve canonical or Python-friendly operation names."""
|
|
232
|
+
|
|
233
|
+
candidates = [operation_name, operation_name.replace("_", "-"), operation_name.replace("-", "_")]
|
|
234
|
+
if "." in operation_name:
|
|
235
|
+
group, command = operation_name.split(".", 1)
|
|
236
|
+
candidates.extend(
|
|
237
|
+
[
|
|
238
|
+
f"{group.replace('_', '-')}.{command.replace('_', '-')}",
|
|
239
|
+
f"{group.replace('-', '_')}.{command.replace('-', '_')}",
|
|
240
|
+
]
|
|
241
|
+
)
|
|
242
|
+
for candidate in candidates:
|
|
243
|
+
if candidate in operations:
|
|
244
|
+
return operations[candidate]
|
|
245
|
+
aliases: dict[str, OperationCommand] = {}
|
|
246
|
+
for name, operation in operations.items():
|
|
247
|
+
for alias in operation_aliases(name):
|
|
248
|
+
aliases.setdefault(alias, operation)
|
|
249
|
+
if operation_name in aliases:
|
|
250
|
+
return aliases[operation_name]
|
|
251
|
+
raise KeyError(f"Unknown Tangle API operation: {operation_name}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _iter_operation_commands(schema: dict[str, Any]) -> list[OperationCommand]:
|
|
255
|
+
"""Convert OpenAPI path/method entries into normalized operation specs."""
|
|
256
|
+
|
|
257
|
+
operations: list[OperationCommand] = []
|
|
258
|
+
paths = schema.get("paths", {})
|
|
259
|
+
if not isinstance(paths, dict):
|
|
260
|
+
return operations
|
|
261
|
+
|
|
262
|
+
for path, path_item in sorted(paths.items()):
|
|
263
|
+
if not isinstance(path_item, dict):
|
|
264
|
+
continue
|
|
265
|
+
path_level_parameters = path_item.get("parameters") or []
|
|
266
|
+
for method, operation in sorted(path_item.items(), key=_method_sort_key):
|
|
267
|
+
method_lower = method.lower()
|
|
268
|
+
if method_lower not in SUPPORTED_METHODS or not isinstance(operation, dict):
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
group_name = _operation_group_name(operation, path)
|
|
272
|
+
command_name = _operation_command_name(method_lower, path, group_name)
|
|
273
|
+
parameters = _operation_parameters(
|
|
274
|
+
schema, path_level_parameters, operation, path
|
|
275
|
+
)
|
|
276
|
+
has_request_body, body_parameters = _request_body_parameters(
|
|
277
|
+
schema, operation, {p.local_name for p in parameters}
|
|
278
|
+
)
|
|
279
|
+
operations.append(
|
|
280
|
+
OperationCommand(
|
|
281
|
+
group_name=group_name,
|
|
282
|
+
command_name=command_name,
|
|
283
|
+
method=method_lower.upper(),
|
|
284
|
+
path=path,
|
|
285
|
+
operation=operation,
|
|
286
|
+
parameters=tuple(parameters + body_parameters),
|
|
287
|
+
has_request_body=has_request_body,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
return operations
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _operation_group_name(operation: dict[str, Any], path: str) -> str:
|
|
295
|
+
"""Choose the CLI/client group from the resource path, falling back to tags."""
|
|
296
|
+
|
|
297
|
+
for part in _path_parts(path):
|
|
298
|
+
if part != "api" and not _is_path_param(part):
|
|
299
|
+
return _normalize_name(part)
|
|
300
|
+
|
|
301
|
+
tags = operation.get("tags")
|
|
302
|
+
if isinstance(tags, list) and tags:
|
|
303
|
+
return _normalize_name(str(tags[0]))
|
|
304
|
+
return "api"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _operation_command_name(method: str, path: str, group_name: str) -> str:
|
|
308
|
+
"""Derive a readable command name from HTTP method and path shape."""
|
|
309
|
+
|
|
310
|
+
parts = _path_parts(path)
|
|
311
|
+
parts_without_api = parts[1:] if parts and parts[0] == "api" else parts
|
|
312
|
+
|
|
313
|
+
resource_index = None
|
|
314
|
+
for index, part in enumerate(parts_without_api):
|
|
315
|
+
if _normalize_name(part) == group_name:
|
|
316
|
+
resource_index = index
|
|
317
|
+
break
|
|
318
|
+
|
|
319
|
+
if resource_index is None:
|
|
320
|
+
for index, part in enumerate(parts_without_api):
|
|
321
|
+
if not _is_path_param(part):
|
|
322
|
+
resource_index = index
|
|
323
|
+
break
|
|
324
|
+
|
|
325
|
+
remainder = (
|
|
326
|
+
parts_without_api[resource_index + 1 :]
|
|
327
|
+
if resource_index is not None
|
|
328
|
+
else parts_without_api
|
|
329
|
+
)
|
|
330
|
+
path_param_count = sum(1 for part in remainder if _is_path_param(part))
|
|
331
|
+
static_segments = [_normalize_name(part) for part in remainder if not _is_path_param(part)]
|
|
332
|
+
|
|
333
|
+
if static_segments:
|
|
334
|
+
return "-".join(static_segments)
|
|
335
|
+
|
|
336
|
+
if path_param_count == 0:
|
|
337
|
+
return "list" if method == "get" else _HTTP_METHOD_NAMES.get(method, method)
|
|
338
|
+
|
|
339
|
+
return _HTTP_METHOD_NAMES.get(method, method)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _operation_parameters(
|
|
343
|
+
schema: dict[str, Any],
|
|
344
|
+
path_level_parameters: list[Any],
|
|
345
|
+
operation: dict[str, Any],
|
|
346
|
+
path: str,
|
|
347
|
+
) -> list[CliParameter]:
|
|
348
|
+
"""Collect OpenAPI path/query params for CLI positionals and client kwargs."""
|
|
349
|
+
|
|
350
|
+
parameters: list[CliParameter] = []
|
|
351
|
+
used_names: set[str] = {"base_url", "token", "auth_header", "header", "headers", "body"}
|
|
352
|
+
operation_parameters = list(path_level_parameters) + list(operation.get("parameters") or [])
|
|
353
|
+
|
|
354
|
+
for parameter in operation_parameters:
|
|
355
|
+
parameter = _resolve_ref(schema, parameter)
|
|
356
|
+
if not isinstance(parameter, dict):
|
|
357
|
+
continue
|
|
358
|
+
location = parameter.get("in")
|
|
359
|
+
if location not in {"path", "query"}:
|
|
360
|
+
continue
|
|
361
|
+
original_name = str(parameter.get("name") or "value")
|
|
362
|
+
required = bool(parameter.get("required") or location == "path")
|
|
363
|
+
parameter_schema = _unwrap_nullable_schema(
|
|
364
|
+
schema, parameter.get("schema") or {}
|
|
365
|
+
)
|
|
366
|
+
default = parameter_schema.get("default") if isinstance(parameter_schema, dict) else None
|
|
367
|
+
description = str(parameter.get("description") or "")
|
|
368
|
+
local_name = _safe_identifier(original_name, used_names, location)
|
|
369
|
+
parameters.append(
|
|
370
|
+
CliParameter(
|
|
371
|
+
original_name=original_name,
|
|
372
|
+
local_name=local_name,
|
|
373
|
+
location=location, # type: ignore[arg-type]
|
|
374
|
+
python_type=_schema_to_python_type(schema, parameter_schema),
|
|
375
|
+
required=required,
|
|
376
|
+
default=default,
|
|
377
|
+
description=description,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
for original_name in re.findall(r"{([^}]+)}", path):
|
|
382
|
+
if any(p.location == "path" and p.original_name == original_name for p in parameters):
|
|
383
|
+
continue
|
|
384
|
+
local_name = _safe_identifier(original_name, used_names, "path")
|
|
385
|
+
parameters.append(
|
|
386
|
+
CliParameter(
|
|
387
|
+
original_name=original_name,
|
|
388
|
+
local_name=local_name,
|
|
389
|
+
location="path",
|
|
390
|
+
python_type=str,
|
|
391
|
+
required=True,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return parameters
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _request_body_parameters(
|
|
399
|
+
schema: dict[str, Any], operation: dict[str, Any], used_names: set[str] | None = None
|
|
400
|
+
) -> tuple[bool, list[CliParameter]]:
|
|
401
|
+
"""Expose simple JSON object body fields as CLI options/client kwargs."""
|
|
402
|
+
|
|
403
|
+
request_body = _resolve_ref(schema, operation.get("requestBody") or {})
|
|
404
|
+
if not isinstance(request_body, dict) or not request_body:
|
|
405
|
+
return False, []
|
|
406
|
+
|
|
407
|
+
body_schema = _json_request_body_schema(schema, request_body)
|
|
408
|
+
if not body_schema:
|
|
409
|
+
return True, []
|
|
410
|
+
|
|
411
|
+
body_schema = _flatten_schema(schema, body_schema)
|
|
412
|
+
properties = body_schema.get("properties") or {}
|
|
413
|
+
if not isinstance(properties, dict):
|
|
414
|
+
return True, []
|
|
415
|
+
|
|
416
|
+
required_fields = set(body_schema.get("required") or [])
|
|
417
|
+
used_names = set(used_names or set()) | {"base_url", "token", "auth_header", "header", "headers", "body"}
|
|
418
|
+
parameters: list[CliParameter] = []
|
|
419
|
+
for original_name, property_schema in sorted(properties.items()):
|
|
420
|
+
property_schema = _flatten_schema(schema, property_schema)
|
|
421
|
+
if not _is_simple_schema(schema, property_schema):
|
|
422
|
+
continue
|
|
423
|
+
local_name = _safe_identifier(str(original_name), used_names, "body")
|
|
424
|
+
default = property_schema.get("default") if isinstance(property_schema, dict) else None
|
|
425
|
+
parameters.append(
|
|
426
|
+
CliParameter(
|
|
427
|
+
original_name=str(original_name),
|
|
428
|
+
local_name=local_name,
|
|
429
|
+
location="body",
|
|
430
|
+
python_type=_schema_to_python_type(schema, property_schema),
|
|
431
|
+
required=str(original_name) in required_fields,
|
|
432
|
+
default=default,
|
|
433
|
+
description=str(property_schema.get("description") or ""),
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
return True, parameters
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _json_request_body_schema(
|
|
440
|
+
schema: dict[str, Any], request_body: dict[str, Any]
|
|
441
|
+
) -> dict[str, Any] | None:
|
|
442
|
+
"""Return the JSON media-type schema for a request body, if any."""
|
|
443
|
+
|
|
444
|
+
content = request_body.get("content") or {}
|
|
445
|
+
if not isinstance(content, dict):
|
|
446
|
+
return None
|
|
447
|
+
media = content.get("application/json")
|
|
448
|
+
if media is None:
|
|
449
|
+
media = next(
|
|
450
|
+
(
|
|
451
|
+
value
|
|
452
|
+
for key, value in content.items()
|
|
453
|
+
if key == "application/*+json" or key.endswith("+json")
|
|
454
|
+
),
|
|
455
|
+
None,
|
|
456
|
+
)
|
|
457
|
+
if not isinstance(media, dict):
|
|
458
|
+
return None
|
|
459
|
+
media_schema = media.get("schema")
|
|
460
|
+
if not isinstance(media_schema, dict):
|
|
461
|
+
return None
|
|
462
|
+
return _resolve_ref(schema, media_schema)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _flatten_schema(schema: dict[str, Any], value: Any) -> dict[str, Any]:
|
|
466
|
+
"""Merge simple ``allOf`` object schemas so body fields can become options."""
|
|
467
|
+
|
|
468
|
+
value = _unwrap_nullable_schema(schema, value)
|
|
469
|
+
if not isinstance(value, dict):
|
|
470
|
+
return {}
|
|
471
|
+
if "allOf" not in value:
|
|
472
|
+
return value
|
|
473
|
+
|
|
474
|
+
flattened: dict[str, Any] = {k: v for k, v in value.items() if k != "allOf"}
|
|
475
|
+
properties: dict[str, Any] = {}
|
|
476
|
+
required: list[str] = []
|
|
477
|
+
for item in value.get("allOf") or []:
|
|
478
|
+
item = _flatten_schema(schema, item)
|
|
479
|
+
properties.update(item.get("properties") or {})
|
|
480
|
+
required.extend(item.get("required") or [])
|
|
481
|
+
properties.update(flattened.get("properties") or {})
|
|
482
|
+
required.extend(flattened.get("required") or [])
|
|
483
|
+
if properties:
|
|
484
|
+
flattened["properties"] = properties
|
|
485
|
+
if required:
|
|
486
|
+
flattened["required"] = sorted(set(required))
|
|
487
|
+
return flattened
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _is_simple_schema(schema_doc: dict[str, Any], schema: Any) -> bool:
|
|
491
|
+
"""Return true for scalar/list types safe to expose as CLI options."""
|
|
492
|
+
|
|
493
|
+
schema = _unwrap_nullable_schema(schema_doc, schema)
|
|
494
|
+
if not isinstance(schema, dict):
|
|
495
|
+
return False
|
|
496
|
+
schema_type = schema.get("type")
|
|
497
|
+
if schema_type in {"string", "integer", "number", "boolean"}:
|
|
498
|
+
return True
|
|
499
|
+
if schema_type == "array":
|
|
500
|
+
return _is_simple_schema(schema_doc, schema.get("items") or {})
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _schema_to_python_type(schema_doc: dict[str, Any], schema: Any) -> Any:
|
|
505
|
+
"""Map a small OpenAPI schema subset to Python annotations for Cyclopts."""
|
|
506
|
+
|
|
507
|
+
schema = _unwrap_nullable_schema(schema_doc, schema)
|
|
508
|
+
if not isinstance(schema, dict):
|
|
509
|
+
return str
|
|
510
|
+
schema_type = schema.get("type")
|
|
511
|
+
if schema_type == "integer":
|
|
512
|
+
return int
|
|
513
|
+
if schema_type == "number":
|
|
514
|
+
return float
|
|
515
|
+
if schema_type == "boolean":
|
|
516
|
+
return bool
|
|
517
|
+
if schema_type == "array":
|
|
518
|
+
return list[_schema_to_python_type(schema_doc, schema.get("items") or {})]
|
|
519
|
+
return str
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _method_sort_key(item: tuple[str, Any]) -> tuple[int, str]:
|
|
523
|
+
method = item[0].lower()
|
|
524
|
+
return (_METHOD_PRIORITY.get(method, 100), method)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _path_parts(path: str) -> list[str]:
|
|
528
|
+
return [part for part in path.strip("/").split("/") if part]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _is_path_param(part: str) -> bool:
|
|
532
|
+
return part.startswith("{") and part.endswith("}")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _normalize_name(value: str) -> str:
|
|
536
|
+
"""Normalize OpenAPI tag/path text to kebab-case CLI names."""
|
|
537
|
+
|
|
538
|
+
value = value.strip().replace("_", "-").replace(" ", "-")
|
|
539
|
+
value = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value)
|
|
540
|
+
value = re.sub(r"[^A-Za-z0-9-]+", "-", value)
|
|
541
|
+
value = re.sub(r"-+", "-", value).strip("-").lower()
|
|
542
|
+
return value or "api"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _safe_identifier(original: str, used_names: set[str], prefix: str) -> str:
|
|
546
|
+
"""Convert OpenAPI parameter names into unique Python identifiers."""
|
|
547
|
+
|
|
548
|
+
name = _normalize_name(original).replace("-", "_")
|
|
549
|
+
if not name or name[0].isdigit() or keyword.iskeyword(name):
|
|
550
|
+
name = f"{prefix}_{name or 'value'}"
|
|
551
|
+
candidate = name
|
|
552
|
+
suffix = 2
|
|
553
|
+
while candidate in used_names:
|
|
554
|
+
candidate = f"{name}_{suffix}"
|
|
555
|
+
suffix += 1
|
|
556
|
+
used_names.add(candidate)
|
|
557
|
+
return candidate
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _dedupe_command_name(
|
|
561
|
+
command_name: str,
|
|
562
|
+
used_names: dict[str, OperationCommand],
|
|
563
|
+
operation: OperationCommand,
|
|
564
|
+
) -> str:
|
|
565
|
+
"""Avoid command collisions within a resource group."""
|
|
566
|
+
|
|
567
|
+
existing = used_names.get(command_name)
|
|
568
|
+
if existing is None or _same_operation(existing, operation):
|
|
569
|
+
used_names[command_name] = operation
|
|
570
|
+
return command_name
|
|
571
|
+
|
|
572
|
+
method_prefix = operation.method.lower()
|
|
573
|
+
candidate = f"{method_prefix}-{command_name}"
|
|
574
|
+
existing = used_names.get(candidate)
|
|
575
|
+
if existing is None or _same_operation(existing, operation):
|
|
576
|
+
used_names[candidate] = operation
|
|
577
|
+
return candidate
|
|
578
|
+
|
|
579
|
+
path_suffix = "-".join(_normalize_name(part) for part in _path_parts(operation.path))
|
|
580
|
+
candidate = f"{method_prefix}-{path_suffix}"
|
|
581
|
+
suffix = 2
|
|
582
|
+
while candidate in used_names and not _same_operation(used_names[candidate], operation):
|
|
583
|
+
candidate = f"{method_prefix}-{path_suffix}-{suffix}"
|
|
584
|
+
suffix += 1
|
|
585
|
+
used_names[candidate] = operation
|
|
586
|
+
return candidate
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _same_operation(left: OperationCommand, right: OperationCommand) -> bool:
|
|
590
|
+
return left.method == right.method and left.path == right.path
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _unwrap_nullable_schema(schema: dict[str, Any], value: Any) -> Any:
|
|
594
|
+
"""Resolve refs and reduce nullable unions to their non-null schema."""
|
|
595
|
+
|
|
596
|
+
value = _resolve_ref(schema, value)
|
|
597
|
+
if not isinstance(value, dict):
|
|
598
|
+
return value
|
|
599
|
+
|
|
600
|
+
schema_type = value.get("type")
|
|
601
|
+
if isinstance(schema_type, list):
|
|
602
|
+
non_null_types = [item for item in schema_type if item != "null"]
|
|
603
|
+
if len(non_null_types) == 1:
|
|
604
|
+
value = {**value, "type": non_null_types[0]}
|
|
605
|
+
|
|
606
|
+
for union_key in ("anyOf", "oneOf"):
|
|
607
|
+
variants = value.get(union_key)
|
|
608
|
+
if not isinstance(variants, list):
|
|
609
|
+
continue
|
|
610
|
+
for variant in variants:
|
|
611
|
+
variant = _resolve_ref(schema, variant)
|
|
612
|
+
if not isinstance(variant, dict) or variant.get("type") == "null":
|
|
613
|
+
continue
|
|
614
|
+
metadata = {k: v for k, v in value.items() if k not in {union_key, "type"}}
|
|
615
|
+
return {**variant, **metadata}
|
|
616
|
+
return value
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _resolve_ref(schema: dict[str, Any], value: Any) -> Any:
|
|
620
|
+
"""Resolve local OpenAPI ``$ref`` pointers; leave unsupported refs untouched."""
|
|
621
|
+
|
|
622
|
+
if not isinstance(value, dict) or "$ref" not in value:
|
|
623
|
+
return value
|
|
624
|
+
ref = value["$ref"]
|
|
625
|
+
if not isinstance(ref, str) or not ref.startswith("#/"):
|
|
626
|
+
return value
|
|
627
|
+
current: Any = schema
|
|
628
|
+
for part in ref[2:].split("/"):
|
|
629
|
+
part = part.replace("~1", "/").replace("~0", "~")
|
|
630
|
+
if not isinstance(current, dict):
|
|
631
|
+
return value
|
|
632
|
+
current = current.get(part)
|
|
633
|
+
return current if current is not None else value
|