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
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"