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,720 @@
1
+ #!/usr/bin/env python3
2
+ """Foundry Connectivity CLI - 20 canonical Connectivity API v2 operations.
3
+
4
+ Exposes exactly 20 public ``foundry_sdk.v2.connectivity`` operations as CLI
5
+ subcommands across the Connection, FileImport, TableImport, and VirtualTable
6
+ client paths:
7
+
8
+ - connection (7): create, get, get-configuration, get-configuration-batch,
9
+ update-export-settings, update-secrets, upload-custom-jdbc-drivers
10
+ - file-import (6): create, delete, execute, get, list, replace
11
+ - table-import (6): create, delete, execute, get, list, replace
12
+ - virtual-table (1): create
13
+
14
+ ``file-import list`` and ``table-import list`` return SDK ``ResourceIterator``
15
+ objects and implement the ADR-003 cursor-paged pattern via PaginationHelper
16
+ with ``--page-size``/``--page-token``/``--all``/``--max-pages``.
17
+
18
+ Usage: pal-found-connectivity <resource> <operation> [options]
19
+ Output: JSON/TOON on stdout, NDJSON diagnostics on stderr (ADR-004, ADR-005).
20
+ Exit codes per ADR-001 taxonomy.
21
+ Access control: AccessControlGuard before client construction and file
22
+ effects (SRS 4.2, ADR-007). Retry: transient conditions only per ADR-002.
23
+ Tracing: SDK-native B3 via invocation_scope; include_attribution=False
24
+ (DESIGN-017; this namespace is outside FR-ATTR-4 scope).
25
+ Secrets: update-secrets and update-export-settings inputs are never echoed
26
+ to stdout or stderr.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import asyncio
33
+ import json
34
+ import logging
35
+ import sys
36
+ from pathlib import Path
37
+ from typing import Any, NoReturn
38
+
39
+ from pal_found_cli.common.access_control_guard import (
40
+ AccessControlError,
41
+ AccessControlGuard,
42
+ )
43
+ from pal_found_cli.common.async_client_factory import AsyncClientFactory
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 two cursor-paged commands (DESIGN-017 paging contract). Both SDK list
71
+ # methods return core.ResourceIterator and accept page_size/page_token.
72
+ PAGINATED_OPS: frozenset[tuple[str, str]] = frozenset(
73
+ {
74
+ ("file_import", "list"),
75
+ ("table_import", "list"),
76
+ }
77
+ )
78
+
79
+ # Bounded binary upload size cap for JDBC driver files (aligned with FR-DL
80
+ # limits; the standard upload bound used across namespaces).
81
+ _UPLOAD_MAX_BYTES = 16 * 1024 * 1024
82
+
83
+
84
+ class CLIInputError(ValueError):
85
+ """A locally generated input error whose message never echoes values."""
86
+
87
+ exit_code = EXIT_USER_INPUT
88
+
89
+
90
+ class _ArgumentParser(argparse.ArgumentParser):
91
+ """Raise parser failures into the structured error boundary."""
92
+
93
+ def error(self, message: str) -> NoReturn:
94
+ raise CLIInputError("Invalid command input")
95
+
96
+
97
+ def _op(
98
+ resource: str,
99
+ operation: str,
100
+ client_path: tuple[str, ...],
101
+ method: str,
102
+ positional: tuple[str, ...] = (),
103
+ required: tuple[str, ...] = (),
104
+ optional: tuple[str, ...] = (),
105
+ *,
106
+ json_args: frozenset[str] = frozenset(),
107
+ list_json_args: frozenset[str] = frozenset(),
108
+ ) -> OperationSpec:
109
+ """Build one operation specification entry."""
110
+ return {
111
+ "resource": resource,
112
+ "operation": operation,
113
+ "client_path": client_path,
114
+ "method": method,
115
+ "positional": positional,
116
+ "required": required,
117
+ "optional": optional,
118
+ "json_args": json_args,
119
+ "list_json_args": list_json_args,
120
+ }
121
+
122
+
123
+ OP_SPECS: tuple[OperationSpec, ...] = (
124
+ _op(
125
+ "connection",
126
+ "create",
127
+ ("Connection",),
128
+ "create",
129
+ required=("configuration", "display_name", "parent_folder_rid", "worker"),
130
+ json_args=frozenset({"configuration", "worker"}),
131
+ ),
132
+ _op(
133
+ "connection",
134
+ "get",
135
+ ("Connection",),
136
+ "get",
137
+ ("connection_rid",),
138
+ ),
139
+ _op(
140
+ "connection",
141
+ "get_configuration",
142
+ ("Connection",),
143
+ "get_configuration",
144
+ ("connection_rid",),
145
+ ),
146
+ _op(
147
+ "connection",
148
+ "get_configuration_batch",
149
+ ("Connection",),
150
+ "get_configuration_batch",
151
+ required=("body",),
152
+ list_json_args=frozenset({"body"}),
153
+ ),
154
+ _op(
155
+ "connection",
156
+ "update_export_settings",
157
+ ("Connection",),
158
+ "update_export_settings",
159
+ ("connection_rid",),
160
+ required=("export_settings",),
161
+ json_args=frozenset({"export_settings"}),
162
+ ),
163
+ _op(
164
+ "connection",
165
+ "update_secrets",
166
+ ("Connection",),
167
+ "update_secrets",
168
+ ("connection_rid",),
169
+ required=("secrets",),
170
+ json_args=frozenset({"secrets"}),
171
+ ),
172
+ _op(
173
+ "connection",
174
+ "upload_custom_jdbc_drivers",
175
+ ("Connection",),
176
+ "upload_custom_jdbc_drivers",
177
+ ("connection_rid",),
178
+ required=("file", "file_name"),
179
+ ),
180
+ _op(
181
+ "file_import",
182
+ "create",
183
+ ("Connection", "FileImport"),
184
+ "create",
185
+ ("connection_rid",),
186
+ required=("dataset_rid", "display_name", "file_import_filters", "import_mode"),
187
+ optional=("branch_name", "subfolder"),
188
+ list_json_args=frozenset({"file_import_filters"}),
189
+ ),
190
+ _op(
191
+ "file_import",
192
+ "delete",
193
+ ("Connection", "FileImport"),
194
+ "delete",
195
+ ("connection_rid", "file_import_rid"),
196
+ ),
197
+ _op(
198
+ "file_import",
199
+ "execute",
200
+ ("Connection", "FileImport"),
201
+ "execute",
202
+ ("connection_rid", "file_import_rid"),
203
+ ),
204
+ _op(
205
+ "file_import",
206
+ "get",
207
+ ("Connection", "FileImport"),
208
+ "get",
209
+ ("connection_rid", "file_import_rid"),
210
+ ),
211
+ _op(
212
+ "file_import",
213
+ "list",
214
+ ("Connection", "FileImport"),
215
+ "list",
216
+ ("connection_rid",),
217
+ optional=("page_size", "page_token"),
218
+ ),
219
+ _op(
220
+ "file_import",
221
+ "replace",
222
+ ("Connection", "FileImport"),
223
+ "replace",
224
+ ("connection_rid", "file_import_rid"),
225
+ required=("display_name", "file_import_filters", "import_mode"),
226
+ optional=("subfolder",),
227
+ list_json_args=frozenset({"file_import_filters"}),
228
+ ),
229
+ _op(
230
+ "table_import",
231
+ "create",
232
+ ("Connection", "TableImport"),
233
+ "create",
234
+ ("connection_rid",),
235
+ required=("config", "dataset_rid", "display_name", "import_mode"),
236
+ optional=("allow_schema_changes", "branch_name"),
237
+ json_args=frozenset({"config"}),
238
+ ),
239
+ _op(
240
+ "table_import",
241
+ "delete",
242
+ ("Connection", "TableImport"),
243
+ "delete",
244
+ ("connection_rid", "table_import_rid"),
245
+ ),
246
+ _op(
247
+ "table_import",
248
+ "execute",
249
+ ("Connection", "TableImport"),
250
+ "execute",
251
+ ("connection_rid", "table_import_rid"),
252
+ ),
253
+ _op(
254
+ "table_import",
255
+ "get",
256
+ ("Connection", "TableImport"),
257
+ "get",
258
+ ("connection_rid", "table_import_rid"),
259
+ ),
260
+ _op(
261
+ "table_import",
262
+ "list",
263
+ ("Connection", "TableImport"),
264
+ "list",
265
+ ("connection_rid",),
266
+ optional=("page_size", "page_token"),
267
+ ),
268
+ _op(
269
+ "table_import",
270
+ "replace",
271
+ ("Connection", "TableImport"),
272
+ "replace",
273
+ ("connection_rid", "table_import_rid"),
274
+ required=("config", "display_name", "import_mode"),
275
+ optional=("allow_schema_changes",),
276
+ json_args=frozenset({"config"}),
277
+ ),
278
+ _op(
279
+ "virtual_table",
280
+ "create",
281
+ ("Connection", "VirtualTable"),
282
+ "create",
283
+ ("connection_rid",),
284
+ required=("config", "name", "parent_rid"),
285
+ optional=("markings",),
286
+ json_args=frozenset({"config"}),
287
+ list_json_args=frozenset({"markings"}),
288
+ ),
289
+ )
290
+
291
+ OPERATION_BY_RESOURCE: dict[str, dict[str, OperationSpec]] = {}
292
+ for _spec in OP_SPECS:
293
+ OPERATION_BY_RESOURCE.setdefault(_spec["resource"], {})[_spec["operation"]] = _spec
294
+
295
+ # Structured JSON arguments across the catalog use the ``-json`` flag suffix.
296
+ _JSON_ARG_NAMES: frozenset[str] = frozenset(
297
+ name
298
+ for spec in OP_SPECS
299
+ for name in (*spec["json_args"], *spec["list_json_args"])
300
+ )
301
+
302
+ # Integer-typed SDK options.
303
+ _INT_ARG_NAMES: frozenset[str] = frozenset({"page_size"})
304
+
305
+ # Boolean-typed SDK options registered as store_true flags.
306
+ _BOOLEAN_ARG_NAMES: frozenset[str] = frozenset({"allow_schema_changes"})
307
+
308
+ # Flag-name overrides: the CLI flag stays stable even when the SDK keyword
309
+ # argument name differs. `file-import create/replace` keep `--filters-json`
310
+ # but dispatch to the SDK's keyword-only `file_import_filters` argument.
311
+ _FLAG_NAME_OVERRIDES: dict[str, str] = {"file_import_filters": "filters"}
312
+
313
+
314
+ def _common_parser(*, paginated: bool) -> _ArgumentParser:
315
+ """Build operation-level common options."""
316
+ parser = _ArgumentParser(add_help=False)
317
+ parser.add_argument("--timeout", type=int, default=None)
318
+ parser.add_argument("--format", choices=("json", "toon", "auto"), default="auto")
319
+ parser.add_argument("--pretty", action="store_true")
320
+ if paginated:
321
+ parser.add_argument("--page-size", type=int, default=None, dest="page_size")
322
+ parser.add_argument("--page-token", default=None, dest="page_token")
323
+ parser.add_argument("--all", action="store_true", dest="all_pages")
324
+ parser.add_argument("--max-pages", type=int, default=None, dest="max_pages")
325
+ return parser
326
+
327
+
328
+ def _kebab(name: str) -> str:
329
+ """Convert snake_case names to CLI kebab-case."""
330
+ return name.replace("_", "-")
331
+
332
+
333
+ def _add_kwarg(
334
+ parser: argparse.ArgumentParser, arg_name: str, *, required: bool
335
+ ) -> None:
336
+ """Add one named operation argument."""
337
+ if arg_name in _JSON_ARG_NAMES:
338
+ flag_name = _FLAG_NAME_OVERRIDES.get(arg_name, arg_name)
339
+ flag = "--" + _kebab(flag_name) + "-json"
340
+ elif arg_name in _BOOLEAN_ARG_NAMES:
341
+ flag = "--" + _kebab(arg_name)
342
+ parser.add_argument(flag, action="store_true", default=None, dest=arg_name)
343
+ return
344
+ else:
345
+ flag = "--" + _kebab(arg_name)
346
+ kwargs: dict[str, Any] = {"required": required, "default": None, "dest": arg_name}
347
+ if arg_name in _INT_ARG_NAMES:
348
+ kwargs["type"] = int
349
+ parser.add_argument(flag, **kwargs)
350
+
351
+
352
+ def build_parser() -> argparse.ArgumentParser:
353
+ """Build argparse parser with all 20 Connectivity operations."""
354
+ parser = _ArgumentParser(
355
+ prog="pal-found-connectivity",
356
+ description="Foundry Connectivity CLI - 20 Connectivity API v2 operations",
357
+ )
358
+ subparsers = parser.add_subparsers(dest="resource", help="Resource type")
359
+
360
+ for resource in sorted(OPERATION_BY_RESOURCE):
361
+ res_parser = subparsers.add_parser(_kebab(resource))
362
+ op_sub = res_parser.add_subparsers(dest="operation")
363
+ for operation, spec in sorted(OPERATION_BY_RESOURCE[resource].items()):
364
+ is_paginated = (resource, operation) in PAGINATED_OPS
365
+ op_parser = op_sub.add_parser(
366
+ _kebab(operation), parents=[_common_parser(paginated=is_paginated)]
367
+ )
368
+ for arg_name in spec["positional"]:
369
+ op_parser.add_argument(arg_name)
370
+ for arg_name in spec["required"]:
371
+ _add_kwarg(op_parser, arg_name, required=True)
372
+ for arg_name in spec["optional"]:
373
+ if arg_name in {"page_size", "page_token"} and is_paginated:
374
+ continue
375
+ _add_kwarg(op_parser, arg_name, required=False)
376
+
377
+ return parser
378
+
379
+
380
+ def _spec_for(resource: str, operation: str) -> OperationSpec:
381
+ """Return one catalog operation specification."""
382
+ try:
383
+ return OPERATION_BY_RESOURCE[resource][operation]
384
+ except KeyError as exc:
385
+ raise CLIInputError(f"Unknown operation: {resource}.{operation}") from exc
386
+
387
+
388
+ def _get_client(root_client: Any, client_path: tuple[str, ...]) -> Any:
389
+ """Resolve the exact public nested Connectivity SDK resource."""
390
+ client = root_client.connectivity
391
+ for attribute in client_path:
392
+ client = getattr(client, attribute)
393
+ return client
394
+
395
+
396
+ def _required_text(value: Any, *, field: str) -> str:
397
+ """Validate a non-empty string without echoing its value."""
398
+ if not isinstance(value, str) or not value.strip():
399
+ raise CLIInputError(f"{field} must not be empty")
400
+ return value
401
+
402
+
403
+ def _decode_json(value: str, *, field: str) -> Any:
404
+ try:
405
+ return json.loads(value)
406
+ except json.JSONDecodeError as exc:
407
+ raise CLIInputError(f"{field} must contain valid JSON") from exc
408
+
409
+
410
+ def _parse_json_object(value: str, *, field: str) -> dict[str, Any]:
411
+ """Decode one JSON object."""
412
+ result = _decode_json(value, field=field)
413
+ if not isinstance(result, dict):
414
+ raise CLIInputError(f"{field} must be a JSON object")
415
+ return result
416
+
417
+
418
+ def _parse_json_list(value: str, *, field: str) -> list[Any]:
419
+ """Decode one JSON array."""
420
+ result = _decode_json(value, field=field)
421
+ if not isinstance(result, list):
422
+ raise CLIInputError(f"{field} must be a JSON array")
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_import_mode(value: str, *, field: str) -> str:
434
+ """Validate a FileImportMode/TableImportMode literal without echoing."""
435
+ if not isinstance(value, str) or not value.strip():
436
+ raise CLIInputError(f"{field} must not be empty")
437
+ return value
438
+
439
+
440
+ def _read_file_bounded(path_value: str, *, field: str) -> bytes:
441
+ """Read a local file for binary upload with a hard size bound."""
442
+ path = Path(path_value)
443
+ if not path.is_file():
444
+ raise CLIInputError(f"{field} must reference an existing file")
445
+ size = path.stat().st_size
446
+ if size > _UPLOAD_MAX_BYTES:
447
+ raise CLIInputError(f"{field} exceeds the maximum upload size")
448
+ return path.read_bytes()
449
+
450
+
451
+ def _validate_jdbc_file_name(value: str) -> str:
452
+ """Validate that a JDBC driver file name ends with .jar (DESIGN-017)."""
453
+ name = _required_text(value, field="file_name")
454
+ if not name.endswith(".jar"):
455
+ raise CLIInputError("file-name must end with .jar")
456
+ return name
457
+
458
+
459
+ def _validate_inputs(spec: OperationSpec, args: argparse.Namespace) -> None:
460
+ """Validate scalars and decode JSON arguments before client creation."""
461
+ for name in (*spec["positional"], *spec["required"]):
462
+ value = getattr(args, name, None)
463
+ if isinstance(value, str):
464
+ _required_text(value, field=name)
465
+ for name in spec["json_args"]:
466
+ value = getattr(args, name, None)
467
+ if value is not None:
468
+ setattr(args, name, _parse_json_object(value, field=name))
469
+ for name in spec["list_json_args"]:
470
+ value = getattr(args, name, None)
471
+ if value is not None:
472
+ setattr(args, name, _parse_json_list(value, field=name))
473
+ if spec["method"] == "upload_custom_jdbc_drivers":
474
+ _validate_jdbc_file_name(getattr(args, "file_name", "") or "")
475
+
476
+
477
+ def _model_to_dict(value: Any) -> Any:
478
+ """Convert SDK models to JSON-compatible values."""
479
+ if value is None:
480
+ return None
481
+ if isinstance(value, list):
482
+ return [_model_to_dict(item) for item in value]
483
+ if isinstance(value, dict):
484
+ return {key: _model_to_dict(item) for key, item in value.items()}
485
+ for method_name in ("to_dict", "model_dump", "dict"):
486
+ method = getattr(value, method_name, None)
487
+ if callable(method):
488
+ result = method()
489
+ if isinstance(result, dict):
490
+ return {key: _model_to_dict(item) for key, item in result.items()}
491
+ return value
492
+
493
+
494
+ def _build_kwargs(
495
+ spec: OperationSpec, args: argparse.Namespace, timeout: int
496
+ ) -> dict[str, Any]:
497
+ """Build SDK keyword arguments, omitting absent optional values.
498
+
499
+ For cursor-paged operations the page_size/page_token come from the
500
+ PaginationHelper path and are intentionally omitted here. ``--file``
501
+ on upload_custom_jdbc_drivers is consumed separately and never forwarded.
502
+ """
503
+ is_paginated = (spec["resource"], spec["operation"]) in PAGINATED_OPS
504
+ kwargs: dict[str, Any] = {}
505
+ for name in (*spec["required"], *spec["optional"]):
506
+ if name == "file":
507
+ continue
508
+ if name in {"page_size", "page_token"} and is_paginated:
509
+ continue
510
+ value = getattr(args, name, None)
511
+ if value is not None:
512
+ kwargs[name] = value
513
+ kwargs["request_timeout"] = timeout
514
+ return kwargs
515
+
516
+
517
+ async def _invoke(
518
+ spec: OperationSpec,
519
+ client: Any,
520
+ args: argparse.Namespace,
521
+ timeout: int,
522
+ ) -> Any:
523
+ """Invoke one non-paginated SDK operation."""
524
+ positional = [getattr(args, name) for name in spec["positional"]]
525
+ if spec["method"] == "upload_custom_jdbc_drivers":
526
+ positional.append(getattr(args, "_file_bytes"))
527
+ return await getattr(client, spec["method"])(
528
+ *positional, **_build_kwargs(spec, args, timeout)
529
+ )
530
+
531
+
532
+ async def _fetch_raw_page(
533
+ method: Any,
534
+ *,
535
+ page_size: int,
536
+ page_token: str | None,
537
+ request_timeout: int | None,
538
+ positional: tuple[Any, ...],
539
+ extra_kwargs: dict[str, Any],
540
+ ) -> dict[str, Any]:
541
+ """Fetch exactly one decoded SDK page via with_raw_response."""
542
+ raw_response = await method(
543
+ *positional,
544
+ page_size=page_size,
545
+ page_token=page_token,
546
+ request_timeout=request_timeout,
547
+ **extra_kwargs,
548
+ )
549
+ page = raw_response.decode()
550
+ return {
551
+ "items": list(getattr(page, "data", None) or []),
552
+ "next_page_token": getattr(page, "next_page_token", None),
553
+ }
554
+
555
+
556
+ def _resolve_pagination_flags(args: argparse.Namespace) -> int:
557
+ """Resolve the effective page batch size from --all/--max-pages."""
558
+ if getattr(args, "all_pages", False):
559
+ return HARD_MAX_BATCH_PAGES
560
+ return getattr(args, "max_pages", None) or 1
561
+
562
+
563
+ async def _paginate_operation(
564
+ spec: OperationSpec,
565
+ client: Any,
566
+ args: argparse.Namespace,
567
+ timeout: int,
568
+ ) -> tuple[list[Any], PaginationHelper]:
569
+ """Collect a bounded number of actual server pages."""
570
+ raw_method = getattr(getattr(client, "with_raw_response"), spec["method"])
571
+ batch_pages = _resolve_pagination_flags(args)
572
+ helper = PaginationHelper(
573
+ page_size=getattr(args, "page_size", None),
574
+ page_token=getattr(args, "page_token", None),
575
+ batch_pages=batch_pages,
576
+ )
577
+ positional = tuple(getattr(args, name) for name in spec["positional"])
578
+ extra_kwargs: dict[str, Any] = {}
579
+ for name in spec["optional"]:
580
+ if name in {"page_size", "page_token"}:
581
+ continue
582
+ value = getattr(args, name, None)
583
+ if value is not None:
584
+ extra_kwargs[name] = value
585
+
586
+ async def fetch_page(**page_kwargs: Any) -> dict[str, Any]:
587
+ return await _fetch_raw_page(
588
+ raw_method,
589
+ page_size=page_kwargs["page_size"],
590
+ page_token=page_kwargs.get("page_token"),
591
+ request_timeout=timeout,
592
+ positional=positional,
593
+ extra_kwargs=extra_kwargs,
594
+ )
595
+
596
+ return await helper.paginate(fetch_page), helper
597
+
598
+
599
+ def _serialize_error(exception: BaseException) -> int:
600
+ """Write one privacy-safe ADR-001 error envelope to stdout."""
601
+ serializer = ErrorSerializer()
602
+ status = sdk_http_status(exception)
603
+ declared = getattr(exception, "exit_code", None)
604
+ sdk_exit = sdk_exception_exit_code(exception)
605
+ if isinstance(declared, int) and 1 <= declared <= 9:
606
+ exit_code = declared
607
+ elif sdk_exit is not None:
608
+ exit_code = sdk_exit
609
+ elif status == 401:
610
+ exit_code = EXIT_AUTH
611
+ elif status == 403:
612
+ exit_code = EXIT_PERMISSION_DENIED
613
+ elif status == 404:
614
+ exit_code = EXIT_NOT_FOUND
615
+ elif status == 429:
616
+ exit_code = EXIT_RATE_LIMIT
617
+ elif isinstance(exception, ConfigurationError):
618
+ exit_code = EXIT_CONFIGURATION
619
+ elif isinstance(exception, (TypeError, ValueError)):
620
+ exit_code = EXIT_USER_INPUT
621
+ elif isinstance(exception, TimeoutError):
622
+ exit_code = EXIT_TIMEOUT
623
+ else:
624
+ exit_code = EXIT_SERVER_ERROR
625
+
626
+ safe = isinstance(
627
+ exception,
628
+ (CLIInputError, ConfigurationError, TimeoutError),
629
+ ) or declared in {EXIT_ACCESS_CONTROL, EXIT_CONFIGURATION}
630
+ message = str(exception) if safe else "Connectivity operation failed"
631
+ envelope = serializer.create_error_envelope(
632
+ exit_code, message, type(exception).__name__, serializer.call_id
633
+ )
634
+ if isinstance(status, int):
635
+ envelope["http_status"] = status
636
+ sys.stdout.write(json.dumps(envelope, default=str) + "\n")
637
+ sys.stdout.flush()
638
+ logger.error(
639
+ "Connectivity operation failed",
640
+ extra={
641
+ "call_id": serializer.call_id,
642
+ "exception_type": type(exception).__name__,
643
+ "http_status": status,
644
+ },
645
+ )
646
+ return exit_code
647
+
648
+
649
+ async def main() -> int:
650
+ """Run one stateless Foundry Connectivity invocation."""
651
+ parser = build_parser()
652
+ try:
653
+ args = parser.parse_args()
654
+ if not args.resource or not getattr(args, "operation", None):
655
+ raise CLIInputError("a Connectivity operation is required")
656
+ except CLIInputError as exc:
657
+ return _serialize_error(exc)
658
+
659
+ try:
660
+ cfg = ConfigLoader()
661
+ cfg.load()
662
+ LogSetup.configure(log_level=cfg.log_level)
663
+
664
+ resource = args.resource.replace("-", "_")
665
+ operation = args.operation.replace("-", "_")
666
+ spec = _spec_for(resource, operation)
667
+ _validate_inputs(spec, args)
668
+ timeout = _validate_timeout(
669
+ args.timeout if args.timeout is not None else cfg.timeout_s
670
+ )
671
+ AccessControlGuard(
672
+ cfg,
673
+ "CONNECTIVITY",
674
+ metadata_allowlist_path=str(_METADATA_ALLOWLIST_PATH),
675
+ ).check(resource, operation)
676
+
677
+ if spec["method"] == "upload_custom_jdbc_drivers":
678
+ # Bounded file read after the access-control decision and before
679
+ # any client construction (DESIGN-017 binary handling contract).
680
+ file_bytes = _read_file_bounded(
681
+ getattr(args, "file", "") or "", field="file"
682
+ )
683
+ setattr(args, "_file_bytes", file_bytes)
684
+
685
+ factory = AsyncClientFactory()
686
+ helper: PaginationHelper | None = None
687
+ with factory.invocation_scope(cfg, include_attribution=False):
688
+ root_client = factory.create(cfg, include_attribution=False)
689
+ client = _get_client(root_client, spec["client_path"])
690
+ retry_handler = RetryHandler(timeout_s=timeout)
691
+ if (resource, operation) in PAGINATED_OPS:
692
+ result, helper = await retry_handler.execute(
693
+ _paginate_operation, spec, client, args, timeout
694
+ )
695
+ else:
696
+ result = await retry_handler.execute(
697
+ _invoke, spec, client, args, timeout
698
+ )
699
+
700
+ formatter = OutputFormatter(format_setting=args.format, pretty=args.pretty)
701
+ print(formatter.format(_model_to_dict(result)))
702
+ if helper is not None:
703
+ helper.emit_metadata()
704
+ return EXIT_SUCCESS
705
+ except AccessControlError as exc:
706
+ ErrorSerializer().serialize(exc)
707
+ return EXIT_ACCESS_CONTROL
708
+ except asyncio.CancelledError:
709
+ return _serialize_error(TimeoutError("Operation cancelled"))
710
+ except Exception as exc:
711
+ return _serialize_error(exc)
712
+
713
+
714
+ def console_main() -> int:
715
+ """Run the async CLI through one event-loop boundary."""
716
+ return asyncio.run(main())
717
+
718
+
719
+ if __name__ == "__main__":
720
+ raise SystemExit(console_main())