tangle-cli 0.0.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,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