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,615 @@
1
+ #!/usr/bin/env python3
2
+ """Foundry Streams CLI - 15 canonical Streams API v2 operations.
3
+
4
+ Exposes exactly 15 public ``foundry_sdk.v2.streams`` operations as CLI
5
+ subcommands across the Dataset, Stream, and Subscriber client paths:
6
+
7
+ - dataset (1): create
8
+ - stream (7): create, get, get-end-offsets, get-records,
9
+ publish-binary-record, publish-record, publish-records, reset
10
+ - subscriber (7): create, commit-offsets, delete, get-read-position,
11
+ read-records, reset-offsets
12
+
13
+ Record-reading operations implement the ADR-003 batch-response pattern:
14
+ retrieve up to ``--max-records`` records then exit; no persistent streaming
15
+ or progressive stdout emission.
16
+
17
+ Usage: pal-found-streams <resource> <operation> [options]
18
+ Output: JSON/TOON on stdout, NDJSON diagnostics on stderr (ADR-004, ADR-005).
19
+ Exit codes per ADR-001 taxonomy.
20
+ Access control: AccessControlGuard before client construction and file
21
+ effects (SRS 4.2, ADR-007). Retry: transient conditions only per ADR-002.
22
+ Tracing: SDK-native B3 via invocation_scope; include_attribution=False.
23
+ Timeouts: FOUNDRY_AGENTIC_CLI_STREAMS_TIMEOUT_S (default 120s) per ADR-003.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import asyncio
30
+ import json
31
+ import logging
32
+ import sys
33
+ from pathlib import Path
34
+ from typing import Any, NoReturn
35
+
36
+ from pal_found_cli.common.access_control_guard import (
37
+ AccessControlError,
38
+ AccessControlGuard,
39
+ )
40
+ from pal_found_cli.common.async_client_factory import AsyncClientFactory
41
+ from pal_found_cli.common.config_loader import ConfigLoader, ConfigurationError
42
+ from pal_found_cli.common.error_serializer import (
43
+ EXIT_ACCESS_CONTROL,
44
+ EXIT_AUTH,
45
+ EXIT_CONFIGURATION,
46
+ EXIT_NOT_FOUND,
47
+ EXIT_PERMISSION_DENIED,
48
+ EXIT_RATE_LIMIT,
49
+ EXIT_SERVER_ERROR,
50
+ EXIT_SUCCESS,
51
+ EXIT_TIMEOUT,
52
+ EXIT_USER_INPUT,
53
+ ErrorSerializer,
54
+ )
55
+ from pal_found_cli.common.log_setup import LogSetup
56
+ from pal_found_cli.common.output_formatter import OutputFormatter
57
+ from pal_found_cli.common.retry import RetryHandler
58
+ from pal_found_cli.common.sdk_error_utils import sdk_exception_exit_code, sdk_http_status
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+ OperationSpec = dict[str, Any]
63
+
64
+ _METADATA_ALLOWLIST_PATH = (
65
+ Path(__file__).resolve().parents[1] / "metadata-allow-list.md"
66
+ )
67
+
68
+ # Streams namespace per-call timeout (ADR-002/ADR-003).
69
+ _STREAMS_TIMEOUT_ENV = "FOUNDRY_AGENTIC_CLI_STREAMS_TIMEOUT_S"
70
+ _STREAMS_DEFAULT_TIMEOUT_S = 120
71
+
72
+ # ADR-003 batch-read bounds.
73
+ _GET_RECORDS_MAX_RECORDS = 10_000
74
+ _READ_RECORDS_MAX_RECORDS = 1_000
75
+ _DEFAULT_MAX_RECORDS = 100
76
+
77
+
78
+ class CLIInputError(ValueError):
79
+ """A locally generated input error whose message never echoes values."""
80
+
81
+ exit_code = EXIT_USER_INPUT
82
+
83
+
84
+ class _ArgumentParser(argparse.ArgumentParser):
85
+ """Raise parser failures into the structured error boundary."""
86
+
87
+ def error(self, message: str) -> NoReturn:
88
+ raise CLIInputError("Invalid command input")
89
+
90
+
91
+ def _op(
92
+ resource: str,
93
+ operation: str,
94
+ client_path: tuple[str, ...],
95
+ method: str,
96
+ positional: tuple[str, ...] = (),
97
+ required: tuple[str, ...] = (),
98
+ optional: tuple[str, ...] = (),
99
+ *,
100
+ json_args: frozenset[str] = frozenset(),
101
+ list_json_args: frozenset[str] = frozenset(),
102
+ ) -> OperationSpec:
103
+ """Build one operation specification entry."""
104
+ return {
105
+ "resource": resource,
106
+ "operation": operation,
107
+ "client_path": client_path,
108
+ "method": method,
109
+ "positional": positional,
110
+ "required": required,
111
+ "optional": optional,
112
+ "json_args": json_args,
113
+ "list_json_args": list_json_args,
114
+ }
115
+
116
+
117
+ OP_SPECS: tuple[OperationSpec, ...] = (
118
+ _op(
119
+ "dataset",
120
+ "create",
121
+ ("Dataset",),
122
+ "create",
123
+ required=("name", "parent_folder_rid", "schema"),
124
+ optional=("branch_name", "compressed", "partitions_count", "stream_type"),
125
+ json_args=frozenset({"schema"}),
126
+ ),
127
+ _op(
128
+ "stream",
129
+ "create",
130
+ ("Dataset", "Stream"),
131
+ "create",
132
+ ("dataset_rid",),
133
+ required=("branch_name", "schema"),
134
+ optional=("compressed", "partitions_count", "stream_type"),
135
+ json_args=frozenset({"schema"}),
136
+ ),
137
+ _op(
138
+ "stream",
139
+ "get",
140
+ ("Dataset", "Stream"),
141
+ "get",
142
+ ("dataset_rid", "stream_branch_name"),
143
+ ),
144
+ _op(
145
+ "stream",
146
+ "get_end_offsets",
147
+ ("Dataset", "Stream"),
148
+ "get_end_offsets",
149
+ ("dataset_rid", "stream_branch_name"),
150
+ optional=("view_rid",),
151
+ ),
152
+ _op(
153
+ "stream",
154
+ "get_records",
155
+ ("Dataset", "Stream"),
156
+ "get_records",
157
+ ("dataset_rid", "stream_branch_name"),
158
+ required=("partition_id",),
159
+ optional=("start_offset", "view_rid"),
160
+ ),
161
+ _op(
162
+ "stream",
163
+ "publish_binary_record",
164
+ ("Dataset", "Stream"),
165
+ "publish_binary_record",
166
+ ("dataset_rid", "stream_branch_name"),
167
+ required=("file",),
168
+ optional=("view_rid",),
169
+ ),
170
+ _op(
171
+ "stream",
172
+ "publish_record",
173
+ ("Dataset", "Stream"),
174
+ "publish_record",
175
+ ("dataset_rid", "stream_branch_name"),
176
+ required=("record",),
177
+ optional=("view_rid",),
178
+ json_args=frozenset({"record"}),
179
+ ),
180
+ _op(
181
+ "stream",
182
+ "publish_records",
183
+ ("Dataset", "Stream"),
184
+ "publish_records",
185
+ ("dataset_rid", "stream_branch_name"),
186
+ required=("records",),
187
+ optional=("view_rid",),
188
+ list_json_args=frozenset({"records"}),
189
+ ),
190
+ _op(
191
+ "stream",
192
+ "reset",
193
+ ("Dataset", "Stream"),
194
+ "reset",
195
+ ("dataset_rid", "stream_branch_name"),
196
+ optional=("schema", "compressed", "partitions_count", "stream_type"),
197
+ json_args=frozenset({"schema"}),
198
+ ),
199
+ _op(
200
+ "subscriber",
201
+ "create",
202
+ ("Dataset", "Stream", "Subscriber"),
203
+ "create",
204
+ ("dataset_rid", "stream_branch_name"),
205
+ required=("subscriber_id",),
206
+ optional=("read_position",),
207
+ json_args=frozenset({"read_position"}),
208
+ ),
209
+ _op(
210
+ "subscriber",
211
+ "commit_offsets",
212
+ ("Dataset", "Stream", "Subscriber"),
213
+ "commit_offsets",
214
+ ("dataset_rid", "stream_branch_name", "subscriber_subscriber_id"),
215
+ required=("offsets",),
216
+ optional=("view_rid",),
217
+ json_args=frozenset({"offsets"}),
218
+ ),
219
+ _op(
220
+ "subscriber",
221
+ "delete",
222
+ ("Dataset", "Stream", "Subscriber"),
223
+ "delete",
224
+ ("dataset_rid", "stream_branch_name", "subscriber_subscriber_id"),
225
+ ),
226
+ _op(
227
+ "subscriber",
228
+ "get_read_position",
229
+ ("Dataset", "Stream", "Subscriber"),
230
+ "get_read_position",
231
+ ("dataset_rid", "stream_branch_name", "subscriber_subscriber_id"),
232
+ optional=("view_rid",),
233
+ ),
234
+ _op(
235
+ "subscriber",
236
+ "read_records",
237
+ ("Dataset", "Stream", "Subscriber"),
238
+ "read_records",
239
+ ("dataset_rid", "stream_branch_name", "subscriber_subscriber_id"),
240
+ optional=("auto_commit", "partition_ids", "view_rid"),
241
+ list_json_args=frozenset({"partition_ids"}),
242
+ ),
243
+ _op(
244
+ "subscriber",
245
+ "reset_offsets",
246
+ ("Dataset", "Stream", "Subscriber"),
247
+ "reset_offsets",
248
+ ("dataset_rid", "stream_branch_name", "subscriber_subscriber_id"),
249
+ required=("position",),
250
+ json_args=frozenset({"position"}),
251
+ ),
252
+ )
253
+
254
+ OPERATION_BY_RESOURCE: dict[str, dict[str, OperationSpec]] = {}
255
+ for _spec in OP_SPECS:
256
+ OPERATION_BY_RESOURCE.setdefault(_spec["resource"], {})[_spec["operation"]] = _spec
257
+
258
+ # Structured JSON arguments across the catalog use the ``-json`` flag suffix.
259
+ _JSON_ARG_NAMES: frozenset[str] = frozenset(
260
+ name
261
+ for spec in OP_SPECS
262
+ for name in (*spec["json_args"], *spec["list_json_args"])
263
+ )
264
+
265
+ # Integer-typed SDK options.
266
+ _INT_ARG_NAMES: frozenset[str] = frozenset({"partitions_count"})
267
+
268
+ # Boolean-typed SDK options registered as store_true flags.
269
+ _BOOLEAN_ARG_NAMES: frozenset[str] = frozenset({"compressed", "auto_commit"})
270
+
271
+ # Batch-read commands: (resource, operation) -> max allowed records.
272
+ BATCH_READ_OPS: dict[tuple[str, str], int] = {
273
+ ("stream", "get_records"): _GET_RECORDS_MAX_RECORDS,
274
+ ("subscriber", "read_records"): _READ_RECORDS_MAX_RECORDS,
275
+ }
276
+
277
+
278
+ def _common_parser() -> _ArgumentParser:
279
+ """Build operation-level common options."""
280
+ parser = _ArgumentParser(add_help=False)
281
+ parser.add_argument("--timeout", type=int, default=None)
282
+ parser.add_argument("--format", choices=("json", "toon", "auto"), default="auto")
283
+ parser.add_argument("--pretty", action="store_true")
284
+ return parser
285
+
286
+
287
+ def _kebab(name: str) -> str:
288
+ """Convert snake_case names to CLI kebab-case."""
289
+ return name.replace("_", "-")
290
+
291
+
292
+ def _add_kwarg(
293
+ parser: argparse.ArgumentParser, arg_name: str, *, required: bool
294
+ ) -> None:
295
+ """Add one named operation argument."""
296
+ if arg_name in _JSON_ARG_NAMES:
297
+ flag = "--" + _kebab(arg_name) + "-json"
298
+ elif arg_name in _BOOLEAN_ARG_NAMES:
299
+ flag = "--" + _kebab(arg_name)
300
+ parser.add_argument(flag, action="store_true", default=None, dest=arg_name)
301
+ return
302
+ else:
303
+ flag = "--" + _kebab(arg_name)
304
+ kwargs: dict[str, Any] = {"required": required, "default": None, "dest": arg_name}
305
+ if arg_name in _INT_ARG_NAMES:
306
+ kwargs["type"] = int
307
+ parser.add_argument(flag, **kwargs)
308
+
309
+
310
+ def build_parser() -> argparse.ArgumentParser:
311
+ """Build argparse parser with all 15 Streams operations."""
312
+ parser = _ArgumentParser(
313
+ prog="pal-found-streams",
314
+ description="Foundry Streams CLI - 15 Streams API v2 operations",
315
+ )
316
+ subparsers = parser.add_subparsers(dest="resource", help="Resource type")
317
+
318
+ for resource in sorted(OPERATION_BY_RESOURCE):
319
+ res_parser = subparsers.add_parser(_kebab(resource))
320
+ op_sub = res_parser.add_subparsers(dest="operation")
321
+ for operation, spec in sorted(OPERATION_BY_RESOURCE[resource].items()):
322
+ op_parser = op_sub.add_parser(
323
+ _kebab(operation), parents=[_common_parser()]
324
+ )
325
+ for arg_name in spec["positional"]:
326
+ op_parser.add_argument(arg_name)
327
+ for arg_name in spec["required"]:
328
+ _add_kwarg(op_parser, arg_name, required=True)
329
+ for arg_name in spec["optional"]:
330
+ _add_kwarg(op_parser, arg_name, required=False)
331
+ if (resource, operation) in BATCH_READ_OPS:
332
+ op_parser.add_argument(
333
+ "--max-records",
334
+ type=int,
335
+ default=_DEFAULT_MAX_RECORDS,
336
+ dest="max_records",
337
+ help=(
338
+ "Maximum number of records to read in one batch "
339
+ "(ADR-003)."
340
+ ),
341
+ )
342
+
343
+ return parser
344
+
345
+
346
+ def _spec_for(resource: str, operation: str) -> OperationSpec:
347
+ """Return one catalog operation specification."""
348
+ try:
349
+ return OPERATION_BY_RESOURCE[resource][operation]
350
+ except KeyError as exc:
351
+ raise CLIInputError(f"Unknown operation: {resource}.{operation}") from exc
352
+
353
+
354
+ def _get_client(root_client: Any, client_path: tuple[str, ...]) -> Any:
355
+ """Resolve the exact public nested Streams SDK resource."""
356
+ client = root_client.streams
357
+ for attribute in client_path:
358
+ client = getattr(client, attribute)
359
+ return client
360
+
361
+
362
+ def _required_text(value: Any, *, field: str) -> str:
363
+ """Validate a non-empty string without echoing its value."""
364
+ if not isinstance(value, str) or not value.strip():
365
+ raise CLIInputError(f"{field} must not be empty")
366
+ return value
367
+
368
+
369
+ def _decode_json(value: str, *, field: str) -> Any:
370
+ try:
371
+ return json.loads(value)
372
+ except json.JSONDecodeError as exc:
373
+ raise CLIInputError(f"{field} must contain valid JSON") from exc
374
+
375
+
376
+ def _parse_json_object(value: str, *, field: str) -> dict[str, Any]:
377
+ """Decode one JSON object."""
378
+ result = _decode_json(value, field=field)
379
+ if not isinstance(result, dict):
380
+ raise CLIInputError(f"{field} must be a JSON object")
381
+ return result
382
+
383
+
384
+ def _parse_json_list(value: str, *, field: str) -> list[Any]:
385
+ """Decode one JSON array."""
386
+ result = _decode_json(value, field=field)
387
+ if not isinstance(result, list):
388
+ raise CLIInputError(f"{field} must be a JSON array")
389
+ return result
390
+
391
+
392
+ def _validate_timeout(value: int) -> int:
393
+ """Validate the effective per-attempt timeout from ADR-002."""
394
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 3600:
395
+ raise CLIInputError("timeout must be between 1 and 3600 seconds")
396
+ return value
397
+
398
+
399
+ def _read_max_records(args: argparse.Namespace, spec: OperationSpec) -> int:
400
+ """Validate and return the effective batch size for a batch-read op."""
401
+ maximum = BATCH_READ_OPS[(spec["resource"], spec["operation"])]
402
+ value = int(getattr(args, "max_records", _DEFAULT_MAX_RECORDS))
403
+ if not 1 <= value <= maximum:
404
+ raise CLIInputError(
405
+ f"max-records must be between 1 and {maximum} for "
406
+ f"{spec['resource']} {spec['operation']}"
407
+ )
408
+ return value
409
+
410
+
411
+ def _read_file_bounded(path_value: str, *, field: str) -> bytes:
412
+ """Read a local file for binary publish with a hard size bound."""
413
+ path = Path(path_value)
414
+ if not path.is_file():
415
+ raise CLIInputError(f"{field} must reference an existing file")
416
+ maximum = 16 * 1024 * 1024 # 16 MiB publish cap (aligned with FR-DL).
417
+ size = path.stat().st_size
418
+ if size > maximum:
419
+ raise CLIInputError(f"{field} exceeds the maximum publish size")
420
+ return path.read_bytes()
421
+
422
+
423
+ def _validate_inputs(spec: OperationSpec, args: argparse.Namespace) -> None:
424
+ """Validate scalars and decode JSON arguments before client creation.
425
+
426
+ Local validation that runs before any access-control or client work:
427
+ scalar checks, JSON decoding, and batch-read bounds (ADR-003). The
428
+ bounded binary-file read for ``publish_binary_record`` happens after
429
+ the access-control decision (DESIGN-016) and before client creation.
430
+ """
431
+ for name in (*spec["positional"], *spec["required"]):
432
+ value = getattr(args, name, None)
433
+ if isinstance(value, str):
434
+ _required_text(value, field=name)
435
+ for name in spec["json_args"]:
436
+ value = getattr(args, name, None)
437
+ if value is not None:
438
+ setattr(args, name, _parse_json_object(value, field=name))
439
+ for name in spec["list_json_args"]:
440
+ value = getattr(args, name, None)
441
+ if value is not None:
442
+ setattr(args, name, _parse_json_list(value, field=name))
443
+ if (spec["resource"], spec["operation"]) in BATCH_READ_OPS:
444
+ _read_max_records(args, spec)
445
+
446
+
447
+ def _model_to_dict(value: Any) -> Any:
448
+ """Convert SDK models to JSON-compatible values."""
449
+ if value is None:
450
+ return None
451
+ if isinstance(value, list):
452
+ return [_model_to_dict(item) for item in value]
453
+ if isinstance(value, dict):
454
+ return {key: _model_to_dict(item) for key, item in value.items()}
455
+ for method_name in ("to_dict", "model_dump", "dict"):
456
+ method = getattr(value, method_name, None)
457
+ if callable(method):
458
+ result = method()
459
+ if isinstance(result, dict):
460
+ return {key: _model_to_dict(item) for key, item in result.items()}
461
+ return value
462
+
463
+
464
+ def _build_kwargs(
465
+ spec: OperationSpec, args: argparse.Namespace, timeout: int
466
+ ) -> dict[str, Any]:
467
+ """Build SDK keyword arguments, omitting absent optional values.
468
+
469
+ Batch-read commands map ``--max-records`` onto the SDK ``limit``
470
+ argument (ADR-003). ``--file`` on publish_binary_record is consumed
471
+ separately and never forwarded.
472
+ """
473
+ kwargs: dict[str, Any] = {}
474
+ for name in (*spec["required"], *spec["optional"]):
475
+ if name == "file":
476
+ continue
477
+ value = getattr(args, name, None)
478
+ if value is not None:
479
+ kwargs[name] = value
480
+ if (spec["resource"], spec["operation"]) in BATCH_READ_OPS:
481
+ kwargs["limit"] = _read_max_records(args, spec)
482
+ kwargs["request_timeout"] = timeout
483
+ return kwargs
484
+
485
+
486
+ async def _invoke(
487
+ spec: OperationSpec,
488
+ client: Any,
489
+ args: argparse.Namespace,
490
+ timeout: int,
491
+ ) -> Any:
492
+ """Invoke one SDK operation with bounded local validation first."""
493
+ positional = [getattr(args, name) for name in spec["positional"]]
494
+ if spec["method"] == "publish_binary_record":
495
+ positional.append(getattr(args, "_file_bytes"))
496
+ return await getattr(client, spec["method"])(
497
+ *positional, **_build_kwargs(spec, args, timeout)
498
+ )
499
+
500
+
501
+ def _serialize_error(exception: BaseException) -> int:
502
+ """Write one privacy-safe ADR-001 error envelope to stdout."""
503
+ serializer = ErrorSerializer()
504
+ status = sdk_http_status(exception)
505
+ declared = getattr(exception, "exit_code", None)
506
+ sdk_exit = sdk_exception_exit_code(exception)
507
+ if isinstance(declared, int) and 1 <= declared <= 9:
508
+ exit_code = declared
509
+ elif sdk_exit is not None:
510
+ exit_code = sdk_exit
511
+ elif status == 401:
512
+ exit_code = EXIT_AUTH
513
+ elif status == 403:
514
+ exit_code = EXIT_PERMISSION_DENIED
515
+ elif status == 404:
516
+ exit_code = EXIT_NOT_FOUND
517
+ elif status == 429:
518
+ exit_code = EXIT_RATE_LIMIT
519
+ elif isinstance(exception, ConfigurationError):
520
+ exit_code = EXIT_CONFIGURATION
521
+ elif isinstance(exception, (TypeError, ValueError)):
522
+ exit_code = EXIT_USER_INPUT
523
+ elif isinstance(exception, TimeoutError):
524
+ exit_code = EXIT_TIMEOUT
525
+ else:
526
+ exit_code = EXIT_SERVER_ERROR
527
+
528
+ safe = isinstance(
529
+ exception,
530
+ (CLIInputError, ConfigurationError, TimeoutError),
531
+ ) or declared in {EXIT_ACCESS_CONTROL, EXIT_CONFIGURATION}
532
+ message = str(exception) if safe else "Streams operation failed"
533
+ envelope = serializer.create_error_envelope(
534
+ exit_code, message, type(exception).__name__, serializer.call_id
535
+ )
536
+ if isinstance(status, int):
537
+ envelope["http_status"] = status
538
+ sys.stdout.write(json.dumps(envelope, default=str) + "\n")
539
+ sys.stdout.flush()
540
+ logger.error(
541
+ "Streams operation failed",
542
+ extra={
543
+ "call_id": serializer.call_id,
544
+ "exception_type": type(exception).__name__,
545
+ "http_status": status,
546
+ },
547
+ )
548
+ return exit_code
549
+
550
+
551
+ async def main() -> int:
552
+ """Run one stateless Foundry Streams invocation."""
553
+ parser = build_parser()
554
+ try:
555
+ args = parser.parse_args()
556
+ if not args.resource or not getattr(args, "operation", None):
557
+ raise CLIInputError("a Streams operation is required")
558
+ except CLIInputError as exc:
559
+ return _serialize_error(exc)
560
+
561
+ try:
562
+ cfg = ConfigLoader()
563
+ cfg.load()
564
+ LogSetup.configure(log_level=cfg.log_level)
565
+
566
+ resource = args.resource.replace("-", "_")
567
+ operation = args.operation.replace("-", "_")
568
+ spec = _spec_for(resource, operation)
569
+ _validate_inputs(spec, args)
570
+ default_timeout = cfg.get_int(_STREAMS_TIMEOUT_ENV, None)
571
+ if default_timeout is None:
572
+ default_timeout = _STREAMS_DEFAULT_TIMEOUT_S
573
+ timeout = _validate_timeout(
574
+ args.timeout if args.timeout is not None else default_timeout
575
+ )
576
+ AccessControlGuard(
577
+ cfg,
578
+ "STREAMS",
579
+ metadata_allowlist_path=str(_METADATA_ALLOWLIST_PATH),
580
+ ).check(resource, operation)
581
+
582
+ if spec["method"] == "publish_binary_record":
583
+ # Bounded file read after the access-control decision and before
584
+ # any client construction (DESIGN-016 binary handling contract).
585
+ file_bytes = _read_file_bounded(
586
+ getattr(args, "file", "") or "", field="file"
587
+ )
588
+ setattr(args, "_file_bytes", file_bytes)
589
+
590
+ factory = AsyncClientFactory()
591
+ with factory.invocation_scope(cfg, include_attribution=False):
592
+ root_client = factory.create(cfg, include_attribution=False)
593
+ client = _get_client(root_client, spec["client_path"])
594
+ retry_handler = RetryHandler(timeout_s=timeout)
595
+ result = await retry_handler.execute(_invoke, spec, client, args, timeout)
596
+
597
+ formatter = OutputFormatter(format_setting=args.format, pretty=args.pretty)
598
+ print(formatter.format(_model_to_dict(result)))
599
+ return EXIT_SUCCESS
600
+ except AccessControlError as exc:
601
+ ErrorSerializer().serialize(exc)
602
+ return EXIT_ACCESS_CONTROL
603
+ except asyncio.CancelledError:
604
+ return _serialize_error(TimeoutError("Operation cancelled"))
605
+ except Exception as exc:
606
+ return _serialize_error(exc)
607
+
608
+
609
+ def console_main() -> int:
610
+ """Run the async CLI through one event-loop boundary."""
611
+ return asyncio.run(main())
612
+
613
+
614
+ if __name__ == "__main__":
615
+ raise SystemExit(console_main())
@@ -0,0 +1,9 @@
1
+ """Foundry Third-Party Applications CLI package."""
2
+
3
+ from pal_found_cli.third_party_applications.scripts.pal_found_third_party_applications_cli import ( # noqa: E402
4
+ build_parser,
5
+ console_main,
6
+ main,
7
+ )
8
+
9
+ __all__ = ["build_parser", "console_main", "main"]
@@ -0,0 +1,13 @@
1
+ # Third-Party Applications metadata allow-list
2
+
3
+ | SDK Path | Status | Rationale |
4
+ | --- | --- | --- |
5
+ | `third_party_applications.third_party_application.get` | PERMITTED | Third-party application metadata |
6
+ | `third_party_applications.version.delete` | BLOCKED | Deletes a Website version |
7
+ | `third_party_applications.version.get` | PERMITTED | Website version metadata |
8
+ | `third_party_applications.version.list` | PERMITTED | Website version list metadata |
9
+ | `third_party_applications.version.upload` | BLOCKED | Uploads a Website version zip |
10
+ | `third_party_applications.version.upload_snapshot` | BLOCKED | Uploads a snapshot version zip |
11
+ | `third_party_applications.website.deploy` | BLOCKED | Deploys a Website version |
12
+ | `third_party_applications.website.get` | PERMITTED | Website metadata |
13
+ | `third_party_applications.website.undeploy` | BLOCKED | Undeploys the Website |
@@ -0,0 +1 @@
1
+ """Foundry Third-Party Applications CLI scripts package."""