pal-found-cli 0.1.2__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 (89) hide show
  1. pal_found_cli/__init__.py +1 -0
  2. pal_found_cli/_version.py +24 -0
  3. pal_found_cli/admin/__init__.py +2 -0
  4. pal_found_cli/admin/scripts/__init__.py +2 -0
  5. pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
  6. pal_found_cli/aip_agents/__init__.py +1 -0
  7. pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
  8. pal_found_cli/aip_agents/scripts/__init__.py +1 -0
  9. pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
  10. pal_found_cli/audit/__init__.py +1 -0
  11. pal_found_cli/audit/metadata-allow-list.md +4 -0
  12. pal_found_cli/audit/scripts/__init__.py +1 -0
  13. pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
  14. pal_found_cli/checkpoints/__init__.py +9 -0
  15. pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
  16. pal_found_cli/checkpoints/scripts/__init__.py +1 -0
  17. pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
  18. pal_found_cli/common/__init__.py +56 -0
  19. pal_found_cli/common/access_control_guard.py +591 -0
  20. pal_found_cli/common/async_client_factory.py +161 -0
  21. pal_found_cli/common/auth_provider.py +82 -0
  22. pal_found_cli/common/binary_download_handler.py +288 -0
  23. pal_found_cli/common/config_loader.py +388 -0
  24. pal_found_cli/common/error_serializer.py +412 -0
  25. pal_found_cli/common/log_setup.py +241 -0
  26. pal_found_cli/common/metadata-allow-list.md +169 -0
  27. pal_found_cli/common/output_formatter.py +267 -0
  28. pal_found_cli/common/pagination_helper.py +212 -0
  29. pal_found_cli/common/retry.py +617 -0
  30. pal_found_cli/common/sdk_error_utils.py +112 -0
  31. pal_found_cli/common/session_manager.py +461 -0
  32. pal_found_cli/common/tracing_provider.py +107 -0
  33. pal_found_cli/connectivity/__init__.py +9 -0
  34. pal_found_cli/connectivity/metadata-allow-list.md +24 -0
  35. pal_found_cli/connectivity/scripts/__init__.py +1 -0
  36. pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
  37. pal_found_cli/data_health/__init__.py +9 -0
  38. pal_found_cli/data_health/metadata-allow-list.md +10 -0
  39. pal_found_cli/data_health/scripts/__init__.py +1 -0
  40. pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
  41. pal_found_cli/datasets/__init__.py +1 -0
  42. pal_found_cli/datasets/scripts/__init__.py +1 -0
  43. pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
  44. pal_found_cli/filesystem/__init__.py +2 -0
  45. pal_found_cli/filesystem/scripts/__init__.py +2 -0
  46. pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
  47. pal_found_cli/functions/__init__.py +2 -0
  48. pal_found_cli/functions/scripts/__init__.py +2 -0
  49. pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
  50. pal_found_cli/language_models/__init__.py +1 -0
  51. pal_found_cli/language_models/metadata-allow-list.md +6 -0
  52. pal_found_cli/language_models/scripts/__init__.py +1 -0
  53. pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
  54. pal_found_cli/media_sets/__init__.py +9 -0
  55. pal_found_cli/media_sets/metadata-allow-list.md +23 -0
  56. pal_found_cli/media_sets/scripts/__init__.py +1 -0
  57. pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
  58. pal_found_cli/models/__init__.py +5 -0
  59. pal_found_cli/models/metadata-allow-list.md +27 -0
  60. pal_found_cli/models/scripts/__init__.py +1 -0
  61. pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
  62. pal_found_cli/ontologies/__init__.py +1 -0
  63. pal_found_cli/ontologies/scripts/__init__.py +1 -0
  64. pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
  65. pal_found_cli/orchestration/__init__.py +9 -0
  66. pal_found_cli/orchestration/metadata-allow-list.md +24 -0
  67. pal_found_cli/orchestration/scripts/__init__.py +1 -0
  68. pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
  69. pal_found_cli/sql_queries/__init__.py +9 -0
  70. pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
  71. pal_found_cli/sql_queries/scripts/__init__.py +1 -0
  72. pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
  73. pal_found_cli/streams/__init__.py +9 -0
  74. pal_found_cli/streams/metadata-allow-list.md +19 -0
  75. pal_found_cli/streams/scripts/__init__.py +1 -0
  76. pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
  77. pal_found_cli/third_party_applications/__init__.py +9 -0
  78. pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
  79. pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
  80. pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
  81. pal_found_cli/widgets/__init__.py +9 -0
  82. pal_found_cli/widgets/metadata-allow-list.md +12 -0
  83. pal_found_cli/widgets/scripts/__init__.py +1 -0
  84. pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
  85. pal_found_cli-0.1.2.dist-info/METADATA +74 -0
  86. pal_found_cli-0.1.2.dist-info/RECORD +89 -0
  87. pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
  88. pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
  89. pal_found_cli-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,714 @@
1
+ #!/usr/bin/env python3
2
+ """Foundry Models CLI - 23 canonical Models API v2 operations.
3
+
4
+ Exposes exactly 23 public ``foundry_sdk.v2.models`` operations as CLI
5
+ subcommands across live deployments, models, versions, experiments,
6
+ experiment content, Model Studio, config versions, runs, and trainers:
7
+
8
+ - live-deployment (1): transform-json
9
+ - model (3): create, get, promote-version
10
+ - model-version (3): create, get, list
11
+ - experiment (2): get, search
12
+ - experiment-series (2): json, parquet
13
+ - experiment-artifact-table (2): json, parquet
14
+ - model-studio (3): create, get, launch
15
+ - model-studio-config-version (4): create, get, latest, list
16
+ - model-studio-run (1): list
17
+ - model-studio-trainer (2): get, list
18
+
19
+ Usage: pal-found-models <resource> <operation> [options]
20
+ Output: JSON/TOON on stdout, NDJSON diagnostics on stderr (ADR-004, ADR-005).
21
+ Exit codes per ADR-001 taxonomy.
22
+ Access control: AccessControlGuard before client and file effects (SRS 4.2,
23
+ ADR-007). Retry: transient conditions only per ADR-002.
24
+ Tracing: SDK-native B3 via invocation_scope; include_attribution=False.
25
+ Downloads: bounded atomic persistence via BinaryDownloadHandler (DESIGN-005).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import asyncio
32
+ import json
33
+ import logging
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Any, NoReturn
37
+
38
+ from pal_found_cli.common.access_control_guard import (
39
+ AccessControlError,
40
+ AccessControlGuard,
41
+ )
42
+ from pal_found_cli.common.async_client_factory import AsyncClientFactory
43
+ from pal_found_cli.common.binary_download_handler import BinaryDownloadHandler
44
+ from pal_found_cli.common.config_loader import ConfigLoader, ConfigurationError
45
+ from pal_found_cli.common.error_serializer import (
46
+ EXIT_ACCESS_CONTROL,
47
+ EXIT_AUTH,
48
+ EXIT_CONFIGURATION,
49
+ EXIT_NOT_FOUND,
50
+ EXIT_PERMISSION_DENIED,
51
+ EXIT_RATE_LIMIT,
52
+ EXIT_SERVER_ERROR,
53
+ EXIT_SUCCESS,
54
+ EXIT_TIMEOUT,
55
+ EXIT_USER_INPUT,
56
+ ErrorSerializer,
57
+ )
58
+ from pal_found_cli.common.log_setup import LogSetup
59
+ from pal_found_cli.common.output_formatter import OutputFormatter
60
+ from pal_found_cli.common.pagination_helper import HARD_MAX_BATCH_PAGES, PaginationHelper
61
+ from pal_found_cli.common.retry import RetryHandler
62
+ from pal_found_cli.common.sdk_error_utils import sdk_exception_exit_code, sdk_http_status
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+ OperationSpec = dict[str, Any]
67
+
68
+ _METADATA_ALLOWLIST_PATH = Path(__file__).resolve().parents[1] / "metadata-allow-list.md"
69
+
70
+ # The four cursor-paged commands (DESIGN-013 paging contract).
71
+ PAGINATED_OPS: frozenset[tuple[str, str]] = frozenset(
72
+ {
73
+ ("experiment", "search"),
74
+ ("model_version", "list"),
75
+ ("model_studio_config_version", "list"),
76
+ ("model_studio_run", "list"),
77
+ }
78
+ )
79
+
80
+ # The three file-producing streamed download commands (DESIGN-013).
81
+ DOWNLOAD_OPS: frozenset[tuple[str, str]] = frozenset(
82
+ {
83
+ ("experiment_series", "parquet"),
84
+ ("experiment_artifact_table", "json"),
85
+ ("experiment_artifact_table", "parquet"),
86
+ }
87
+ )
88
+
89
+
90
+ class CLIInputError(ValueError):
91
+ """A locally generated input error whose message never echoes values."""
92
+
93
+ exit_code = EXIT_USER_INPUT
94
+
95
+
96
+ class _ArgumentParser(argparse.ArgumentParser):
97
+ """Raise parser failures into the structured error boundary."""
98
+
99
+ def error(self, message: str) -> NoReturn:
100
+ raise CLIInputError("Invalid command input")
101
+
102
+
103
+ def _op(
104
+ resource: str,
105
+ operation: str,
106
+ client_path: tuple[str, ...],
107
+ method: str,
108
+ positional: tuple[str, ...] = (),
109
+ required: tuple[str, ...] = (),
110
+ optional: tuple[str, ...] = (),
111
+ *,
112
+ json_args: frozenset[str] = frozenset(),
113
+ list_json_args: frozenset[str] = frozenset(),
114
+ ) -> OperationSpec:
115
+ """Build one operation specification entry."""
116
+ return {
117
+ "resource": resource,
118
+ "operation": operation,
119
+ "client_path": client_path,
120
+ "method": method,
121
+ "positional": positional,
122
+ "required": required,
123
+ "optional": optional,
124
+ "json_args": json_args,
125
+ "list_json_args": list_json_args,
126
+ }
127
+
128
+
129
+ OP_SPECS: tuple[OperationSpec, ...] = (
130
+ _op(
131
+ "live_deployment",
132
+ "transform_json",
133
+ ("LiveDeployment",),
134
+ "transform_json",
135
+ ("live_deployment_rid",),
136
+ required=("input",),
137
+ json_args=frozenset({"input"}),
138
+ ),
139
+ _op("model", "create", ("Model",), "create", required=("name", "parent_folder_rid")),
140
+ _op("model", "get", ("Model",), "get", ("model_rid",)),
141
+ _op(
142
+ "model",
143
+ "promote_version",
144
+ ("Model",),
145
+ "promote_version",
146
+ ("model_rid",),
147
+ required=("source_model_version_rid",),
148
+ ),
149
+ _op(
150
+ "model_version",
151
+ "create",
152
+ ("Model", "Version"),
153
+ "create",
154
+ ("model_rid",),
155
+ required=(
156
+ "backing_repositories",
157
+ "conda_requirements",
158
+ "model_api",
159
+ "model_files",
160
+ ),
161
+ list_json_args=frozenset({"backing_repositories", "conda_requirements"}),
162
+ json_args=frozenset({"model_api", "model_files"}),
163
+ ),
164
+ _op(
165
+ "model_version",
166
+ "get",
167
+ ("Model", "Version"),
168
+ "get",
169
+ ("model_rid", "model_version_rid"),
170
+ ),
171
+ _op(
172
+ "model_version",
173
+ "list",
174
+ ("Model", "Version"),
175
+ "list",
176
+ ("model_rid",),
177
+ optional=("page_size", "page_token"),
178
+ ),
179
+ _op(
180
+ "experiment",
181
+ "get",
182
+ ("Model", "Experiment"),
183
+ "get",
184
+ ("model_rid", "experiment_rid"),
185
+ ),
186
+ _op(
187
+ "experiment",
188
+ "search",
189
+ ("Model", "Experiment"),
190
+ "search",
191
+ ("model_rid",),
192
+ optional=("order_by", "where", "page_size", "page_token"),
193
+ json_args=frozenset({"order_by", "where"}),
194
+ ),
195
+ _op(
196
+ "experiment_series",
197
+ "json",
198
+ ("Model", "Experiment", "Series"),
199
+ "json",
200
+ ("model_rid", "experiment_rid", "experiment_series_name"),
201
+ optional=("offset", "page_size"),
202
+ ),
203
+ _op(
204
+ "experiment_series",
205
+ "parquet",
206
+ ("Model", "Experiment", "Series"),
207
+ "parquet",
208
+ ("model_rid", "experiment_rid", "experiment_series_name"),
209
+ required=("output",),
210
+ ),
211
+ _op(
212
+ "experiment_artifact_table",
213
+ "json",
214
+ ("Model", "Experiment", "ArtifactTable"),
215
+ "json",
216
+ ("model_rid", "experiment_rid", "experiment_artifact_table_name"),
217
+ required=("output",),
218
+ optional=("offset", "page_size"),
219
+ ),
220
+ _op(
221
+ "experiment_artifact_table",
222
+ "parquet",
223
+ ("Model", "Experiment", "ArtifactTable"),
224
+ "parquet",
225
+ ("model_rid", "experiment_rid", "experiment_artifact_table_name"),
226
+ required=("output",),
227
+ ),
228
+ _op(
229
+ "model_studio",
230
+ "create",
231
+ ("ModelStudio",),
232
+ "create",
233
+ required=("name", "parent_folder_rid"),
234
+ ),
235
+ _op("model_studio", "get", ("ModelStudio",), "get", ("model_studio_rid",)),
236
+ _op("model_studio", "launch", ("ModelStudio",), "launch", ("model_studio_rid",)),
237
+ _op(
238
+ "model_studio_config_version",
239
+ "create",
240
+ ("ModelStudio", "ConfigVersion"),
241
+ "create",
242
+ ("model_studio_rid",),
243
+ required=("name", "resources", "trainer_id", "worker_config"),
244
+ optional=("changelog",),
245
+ json_args=frozenset({"resources", "worker_config"}),
246
+ ),
247
+ _op(
248
+ "model_studio_config_version",
249
+ "get",
250
+ ("ModelStudio", "ConfigVersion"),
251
+ "get",
252
+ ("model_studio_rid", "model_studio_config_version_version"),
253
+ ),
254
+ _op(
255
+ "model_studio_config_version",
256
+ "latest",
257
+ ("ModelStudio", "ConfigVersion"),
258
+ "latest",
259
+ ("model_studio_rid",),
260
+ ),
261
+ _op(
262
+ "model_studio_config_version",
263
+ "list",
264
+ ("ModelStudio", "ConfigVersion"),
265
+ "list",
266
+ ("model_studio_rid",),
267
+ optional=("page_size", "page_token"),
268
+ ),
269
+ _op(
270
+ "model_studio_run",
271
+ "list",
272
+ ("ModelStudio", "Run"),
273
+ "list",
274
+ ("model_studio_rid",),
275
+ optional=("config_version", "page_size", "page_token"),
276
+ ),
277
+ _op(
278
+ "model_studio_trainer",
279
+ "get",
280
+ ("ModelStudioTrainer",),
281
+ "get",
282
+ ("model_studio_trainer_trainer_id",),
283
+ optional=("version",),
284
+ ),
285
+ _op(
286
+ "model_studio_trainer",
287
+ "list",
288
+ ("ModelStudioTrainer",),
289
+ "list",
290
+ ),
291
+ )
292
+
293
+ OPERATION_BY_RESOURCE: dict[str, dict[str, OperationSpec]] = {}
294
+ for _spec in OP_SPECS:
295
+ OPERATION_BY_RESOURCE.setdefault(_spec["resource"], {})[_spec["operation"]] = _spec
296
+
297
+ # Structured JSON arguments across the catalog use the ``-json`` flag suffix.
298
+ _JSON_ARG_NAMES: frozenset[str] = frozenset(
299
+ name
300
+ for spec in OP_SPECS
301
+ for name in (*spec["json_args"], *spec["list_json_args"])
302
+ )
303
+
304
+ # Integer-typed optional arguments (service slicing on series/artifact JSON
305
+ # and cursor page size for the four paginated commands).
306
+ _INT_ARG_NAMES: frozenset[str] = frozenset({"offset", "page_size"})
307
+
308
+
309
+ def _common_parser(*, paginated: bool) -> _ArgumentParser:
310
+ """Build operation-level common options."""
311
+ parser = _ArgumentParser(add_help=False)
312
+ parser.add_argument("--timeout", type=int, default=None)
313
+ parser.add_argument("--format", choices=("json", "toon", "auto"), default="auto")
314
+ parser.add_argument("--pretty", action="store_true")
315
+ if paginated:
316
+ parser.add_argument("--page-size", type=int, default=None, dest="page_size")
317
+ parser.add_argument("--page-token", default=None, dest="page_token")
318
+ parser.add_argument("--all", action="store_true", dest="all_pages")
319
+ parser.add_argument("--max-pages", type=int, default=None, dest="max_pages")
320
+ return parser
321
+
322
+
323
+ def _kebab(name: str) -> str:
324
+ """Convert snake_case names to CLI kebab-case."""
325
+ return name.replace("_", "-")
326
+
327
+
328
+ def _add_kwarg(
329
+ parser: argparse.ArgumentParser, arg_name: str, *, required: bool
330
+ ) -> None:
331
+ """Add one named operation argument.
332
+
333
+ Structured JSON arguments use the ``-json`` suffix convention, which the
334
+ ``-`` in the flag is preserved via ``--<kebab>-json`` while the parser
335
+ stores the decoded value under the plain snake_case attribute name.
336
+ """
337
+ if arg_name in _JSON_ARG_NAMES:
338
+ flag = "--" + _kebab(arg_name) + "-json"
339
+ else:
340
+ flag = "--" + _kebab(arg_name)
341
+ kwargs: dict[str, Any] = {"required": required, "default": None, "dest": arg_name}
342
+ if arg_name in _INT_ARG_NAMES:
343
+ kwargs["type"] = int
344
+ parser.add_argument(flag, **kwargs)
345
+
346
+
347
+ def build_parser() -> argparse.ArgumentParser:
348
+ """Build argparse parser with all 23 Models operations."""
349
+ parser = _ArgumentParser(
350
+ prog="pal-found-models",
351
+ description="Foundry Models CLI - 23 Models API v2 operations",
352
+ )
353
+ subparsers = parser.add_subparsers(dest="resource", help="Resource type")
354
+
355
+ for resource in sorted(OPERATION_BY_RESOURCE):
356
+ res_parser = subparsers.add_parser(_kebab(resource))
357
+ op_sub = res_parser.add_subparsers(dest="operation")
358
+ for operation, spec in sorted(OPERATION_BY_RESOURCE[resource].items()):
359
+ is_paginated = (resource, operation) in PAGINATED_OPS
360
+ op_parser = op_sub.add_parser(
361
+ _kebab(operation), parents=[_common_parser(paginated=is_paginated)]
362
+ )
363
+ for arg_name in spec["positional"]:
364
+ op_parser.add_argument(arg_name)
365
+ for arg_name in spec["required"]:
366
+ _add_kwarg(op_parser, arg_name, required=True)
367
+ for arg_name in spec["optional"]:
368
+ # Cursor-paged operations get --page-size/--page-token from the
369
+ # shared paginated parser; service-slicing --page-size on series
370
+ # and artifact-table JSON must be registered here instead.
371
+ if arg_name in {"page_size", "page_token"} and is_paginated:
372
+ continue
373
+ _add_kwarg(op_parser, arg_name, required=False)
374
+
375
+ return parser
376
+
377
+
378
+ def _spec_for(resource: str, operation: str) -> OperationSpec:
379
+ """Return one catalog operation specification."""
380
+ try:
381
+ return OPERATION_BY_RESOURCE[resource][operation]
382
+ except KeyError as exc:
383
+ raise CLIInputError(f"Unknown operation: {resource}.{operation}") from exc
384
+
385
+
386
+ def _get_client(root_client: Any, client_path: tuple[str, ...]) -> Any:
387
+ """Resolve the exact public nested Models SDK resource."""
388
+ client = root_client.models
389
+ for attribute in client_path:
390
+ client = getattr(client, attribute)
391
+ return client
392
+
393
+
394
+ def _required_text(value: Any, *, field: str) -> str:
395
+ """Validate a non-empty string without echoing its value."""
396
+ if not isinstance(value, str) or not value.strip():
397
+ raise CLIInputError(f"{field} must not be empty")
398
+ return value
399
+
400
+
401
+ def _decode_json(value: str, *, field: str) -> Any:
402
+ try:
403
+ return json.loads(value)
404
+ except json.JSONDecodeError as exc:
405
+ raise CLIInputError(f"{field} must contain valid JSON") from exc
406
+
407
+
408
+ def _parse_json_object(value: str, *, field: str) -> dict[str, Any]:
409
+ """Decode one JSON object."""
410
+ result = _decode_json(value, field=field)
411
+ if not isinstance(result, dict):
412
+ raise CLIInputError(f"{field} must be a JSON object")
413
+ return result
414
+
415
+
416
+ def _parse_json_string_list(value: str, *, field: str) -> list[str]:
417
+ """Decode one JSON array containing only strings."""
418
+ result = _decode_json(value, field=field)
419
+ if not isinstance(result, list) or not all(
420
+ isinstance(item, str) for item in result
421
+ ):
422
+ raise CLIInputError(f"{field} must be a JSON array of strings")
423
+ return result
424
+
425
+
426
+ def _validate_timeout(value: int) -> int:
427
+ """Validate the effective per-attempt timeout from ADR-002."""
428
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 3600:
429
+ raise CLIInputError("timeout must be between 1 and 3600 seconds")
430
+ return value
431
+
432
+
433
+ def _validate_inputs(spec: OperationSpec, args: argparse.Namespace) -> None:
434
+ """Validate scalars and decode JSON arguments before client creation."""
435
+ for name in (*spec["positional"], *spec["required"]):
436
+ value = getattr(args, name, None)
437
+ if isinstance(value, str):
438
+ _required_text(value, field=name)
439
+ for name in spec["json_args"]:
440
+ value = getattr(args, name, None)
441
+ if value is not None:
442
+ setattr(args, name, _parse_json_object(value, field=name))
443
+ for name in spec["list_json_args"]:
444
+ value = getattr(args, name, None)
445
+ if value is not None:
446
+ setattr(args, name, _parse_json_string_list(value, field=name))
447
+
448
+
449
+ def _model_to_dict(value: Any) -> Any:
450
+ """Convert SDK models to JSON-compatible values."""
451
+ if value is None:
452
+ return None
453
+ if isinstance(value, list):
454
+ return [_model_to_dict(item) for item in value]
455
+ if isinstance(value, dict):
456
+ return {key: _model_to_dict(item) for key, item in value.items()}
457
+ for method_name in ("to_dict", "model_dump", "dict"):
458
+ method = getattr(value, method_name, None)
459
+ if callable(method):
460
+ result = method()
461
+ if isinstance(result, dict):
462
+ return {key: _model_to_dict(item) for key, item in result.items()}
463
+ return value
464
+
465
+
466
+ def _build_kwargs(
467
+ spec: OperationSpec, args: argparse.Namespace, timeout: int
468
+ ) -> dict[str, Any]:
469
+ """Build SDK keyword arguments, omitting absent optional values.
470
+
471
+ For cursor-paged operations the page_size/page_token come from the
472
+ PaginationHelper path and are intentionally omitted here. For all other
473
+ operations (including service-slicing --offset/--page-size on series and
474
+ artifact-table JSON) every provided value is forwarded exactly once.
475
+ """
476
+ is_paginated = (spec["resource"], spec["operation"]) in PAGINATED_OPS
477
+ kwargs: dict[str, Any] = {}
478
+ for name in (*spec["required"], *spec["optional"]):
479
+ if name in {"page_size", "page_token"} and is_paginated:
480
+ continue
481
+ value = getattr(args, name, None)
482
+ if value is not None:
483
+ kwargs[name] = value
484
+ kwargs["request_timeout"] = timeout
485
+ return kwargs
486
+
487
+
488
+ async def _invoke(
489
+ spec: OperationSpec,
490
+ client: Any,
491
+ args: argparse.Namespace,
492
+ timeout: int,
493
+ ) -> Any:
494
+ """Invoke one non-paginated, non-downloaded SDK operation."""
495
+ positional = [getattr(args, name) for name in spec["positional"]]
496
+ return await getattr(client, spec["method"])(*positional, **_build_kwargs(spec, args, timeout))
497
+
498
+
499
+ async def _fetch_raw_page(
500
+ method: Any,
501
+ *,
502
+ page_size: int,
503
+ page_token: str | None,
504
+ request_timeout: int | None,
505
+ positional: tuple[Any, ...],
506
+ extra_kwargs: dict[str, Any],
507
+ ) -> dict[str, Any]:
508
+ """Fetch exactly one decoded SDK page via with_raw_response."""
509
+ raw_response = await method(
510
+ *positional,
511
+ page_size=page_size,
512
+ page_token=page_token,
513
+ request_timeout=request_timeout,
514
+ **extra_kwargs,
515
+ )
516
+ page = raw_response.decode()
517
+ return {
518
+ "items": list(getattr(page, "data", None) or []),
519
+ "next_page_token": getattr(page, "next_page_token", None),
520
+ }
521
+
522
+
523
+ def _resolve_pagination_flags(args: argparse.Namespace) -> int:
524
+ """Resolve the effective page batch size from --all/--max-pages."""
525
+ if getattr(args, "all_pages", False):
526
+ return HARD_MAX_BATCH_PAGES
527
+ return getattr(args, "max_pages", None) or 1
528
+
529
+
530
+ async def _paginate_operation(
531
+ spec: OperationSpec,
532
+ client: Any,
533
+ args: argparse.Namespace,
534
+ timeout: int,
535
+ ) -> tuple[list[Any], PaginationHelper]:
536
+ """Collect a bounded number of actual server pages."""
537
+ raw_method = getattr(getattr(client, "with_raw_response"), spec["method"])
538
+ batch_pages = _resolve_pagination_flags(args)
539
+ helper = PaginationHelper(
540
+ page_size=getattr(args, "page_size", None),
541
+ page_token=getattr(args, "page_token", None),
542
+ batch_pages=batch_pages,
543
+ )
544
+ positional = tuple(getattr(args, name) for name in spec["positional"])
545
+ extra_kwargs: dict[str, Any] = {}
546
+ for name in spec["optional"]:
547
+ if name in {"page_size", "page_token"}:
548
+ continue
549
+ value = getattr(args, name, None)
550
+ if value is not None:
551
+ extra_kwargs[name] = value
552
+
553
+ async def fetch_page(**page_kwargs: Any) -> dict[str, Any]:
554
+ return await _fetch_raw_page(
555
+ raw_method,
556
+ page_size=page_kwargs["page_size"],
557
+ page_token=page_kwargs.get("page_token"),
558
+ request_timeout=timeout,
559
+ positional=positional,
560
+ extra_kwargs=extra_kwargs,
561
+ )
562
+
563
+ return await helper.paginate(fetch_page), helper
564
+
565
+
566
+ async def _download_operation(
567
+ spec: OperationSpec,
568
+ client: Any,
569
+ args: argparse.Namespace,
570
+ timeout: int,
571
+ cfg: ConfigLoader,
572
+ ) -> dict[str, Any]:
573
+ """Stream one bounded download through BinaryDownloadHandler."""
574
+ method = getattr(getattr(client, "with_streaming_response"), spec["method"])
575
+ positional = [getattr(args, name) for name in spec["positional"]]
576
+ kwargs: dict[str, Any] = {}
577
+ for name in spec["optional"]:
578
+ value = getattr(args, name, None)
579
+ if value is not None:
580
+ kwargs[name] = value
581
+ response_context = method(*positional, request_timeout=timeout, **kwargs)
582
+ async with response_context as response:
583
+ result = await BinaryDownloadHandler(config=cfg).save(
584
+ response.aiter_bytes(),
585
+ original_filename=getattr(args, "output", None),
586
+ namespace="models",
587
+ operation=f"{spec['resource']}.{spec['method']}",
588
+ content_length=None,
589
+ content_encoding=None,
590
+ mime_type=None,
591
+ )
592
+ return result.to_dict()
593
+
594
+
595
+ def _serialize_error(exception: BaseException) -> int:
596
+ """Write one privacy-safe ADR-001 error envelope to stdout."""
597
+ serializer = ErrorSerializer()
598
+ status = sdk_http_status(exception)
599
+ declared = getattr(exception, "exit_code", None)
600
+ sdk_exit = sdk_exception_exit_code(exception)
601
+ if isinstance(declared, int) and 1 <= declared <= 9:
602
+ exit_code = declared
603
+ elif sdk_exit is not None:
604
+ exit_code = sdk_exit
605
+ elif status == 401:
606
+ exit_code = EXIT_AUTH
607
+ elif status == 403:
608
+ exit_code = EXIT_PERMISSION_DENIED
609
+ elif status == 404:
610
+ exit_code = EXIT_NOT_FOUND
611
+ elif status == 429:
612
+ exit_code = EXIT_RATE_LIMIT
613
+ elif isinstance(exception, ConfigurationError):
614
+ exit_code = EXIT_CONFIGURATION
615
+ elif isinstance(exception, (TypeError, ValueError)):
616
+ exit_code = EXIT_USER_INPUT
617
+ elif isinstance(exception, TimeoutError):
618
+ exit_code = EXIT_TIMEOUT
619
+ else:
620
+ exit_code = EXIT_SERVER_ERROR
621
+
622
+ safe = isinstance(
623
+ exception,
624
+ (CLIInputError, ConfigurationError, TimeoutError),
625
+ ) or declared in {EXIT_ACCESS_CONTROL, EXIT_CONFIGURATION}
626
+ message = str(exception) if safe else "Models operation failed"
627
+ envelope = serializer.create_error_envelope(
628
+ exit_code, message, type(exception).__name__, serializer.call_id
629
+ )
630
+ if isinstance(status, int):
631
+ envelope["http_status"] = status
632
+ sys.stdout.write(json.dumps(envelope, default=str) + "\n")
633
+ sys.stdout.flush()
634
+ logger.error(
635
+ "Models operation failed",
636
+ extra={
637
+ "call_id": serializer.call_id,
638
+ "exception_type": type(exception).__name__,
639
+ "http_status": status,
640
+ },
641
+ )
642
+ return exit_code
643
+
644
+
645
+ async def main() -> int:
646
+ """Run one stateless Foundry Models invocation."""
647
+ parser = build_parser()
648
+ try:
649
+ args = parser.parse_args()
650
+ if not args.resource or not getattr(args, "operation", None):
651
+ raise CLIInputError("a Models operation is required")
652
+ except CLIInputError as exc:
653
+ return _serialize_error(exc)
654
+
655
+ try:
656
+ cfg = ConfigLoader()
657
+ cfg.load()
658
+ LogSetup.configure(log_level=cfg.log_level)
659
+
660
+ resource = args.resource.replace("-", "_")
661
+ operation = args.operation.replace("-", "_")
662
+ spec = _spec_for(resource, operation)
663
+ _validate_inputs(spec, args)
664
+ timeout = _validate_timeout(
665
+ args.timeout if args.timeout is not None else cfg.timeout_s
666
+ )
667
+ AccessControlGuard(
668
+ cfg,
669
+ "MODELS",
670
+ metadata_allowlist_path=str(_METADATA_ALLOWLIST_PATH),
671
+ ).check(resource, operation)
672
+
673
+ factory = AsyncClientFactory()
674
+ helper: PaginationHelper | None = None
675
+ with factory.invocation_scope(cfg, include_attribution=False):
676
+ root_client = factory.create(cfg, include_attribution=False)
677
+ client = _get_client(root_client, spec["client_path"])
678
+ retry_handler = RetryHandler(timeout_s=timeout)
679
+ if (resource, operation) in PAGINATED_OPS:
680
+ result, helper = await retry_handler.execute(
681
+ _paginate_operation, spec, client, args, timeout
682
+ )
683
+ elif (resource, operation) in DOWNLOAD_OPS:
684
+ result = await retry_handler.execute(
685
+ _download_operation, spec, client, args, timeout, cfg
686
+ )
687
+ else:
688
+ result = await retry_handler.execute(
689
+ _invoke, spec, client, args, timeout
690
+ )
691
+
692
+ formatter = OutputFormatter(
693
+ format_setting=args.format, pretty=args.pretty
694
+ )
695
+ print(formatter.format(_model_to_dict(result)))
696
+ if helper is not None:
697
+ helper.emit_metadata()
698
+ return EXIT_SUCCESS
699
+ except AccessControlError as exc:
700
+ ErrorSerializer().serialize(exc)
701
+ return EXIT_ACCESS_CONTROL
702
+ except asyncio.CancelledError:
703
+ return _serialize_error(TimeoutError("Operation cancelled"))
704
+ except Exception as exc:
705
+ return _serialize_error(exc)
706
+
707
+
708
+ def console_main() -> int:
709
+ """Run the async CLI through one event-loop boundary."""
710
+ return asyncio.run(main())
711
+
712
+
713
+ if __name__ == "__main__":
714
+ raise SystemExit(console_main())