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,412 @@
1
+ #!/usr/bin/env python3
2
+ """ErrorSerializer mapping SDK exceptions to exit codes (ADR-001).
3
+
4
+ Maps SDK and runtime exceptions to the structured exit code taxonomy
5
+ defined in ADR-001. Produces a JSON error envelope on stdout with
6
+ structured metadata.
7
+
8
+ Exit Code Taxonomy (ADR-001)
9
+ ----------------------------
10
+ 0 : Success
11
+ 1 : UserInputError (invalid CLI args, validation failure, missing param)
12
+ 2 : AuthenticationError (missing/invalid token, SDK auth failure)
13
+ 3 : PermissionDeniedError (API 403)
14
+ 4 : NotFoundError (API 404, resource does not exist)
15
+ 5 : TimeoutError (asyncio.wait_for timeout, SIGINT/SIGTERM)
16
+ 6 : ServerError (API 5xx excluding 503)
17
+ 7 : RateLimitExhausted (HTTP 429 + retries exhausted)
18
+ 8 : AccessControlError (CLI access control policy)
19
+ 9 : ConfigurationError (missing env var, malformed config)
20
+ """
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import sys
27
+ import traceback
28
+ import uuid
29
+ from typing import Any
30
+
31
+ from pal_found_cli.common.sdk_error_utils import (
32
+ sdk_exception_exit_code,
33
+ sdk_exception_exit_map,
34
+ sdk_http_status,
35
+ )
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ # Environment variable controlling traceback inclusion in error envelopes.
40
+ # When set to a falsy value ("0", "false", "no", "off"), tracebacks are
41
+ # suppressed to avoid leaking file paths / internal structure (OWASP A02).
42
+ ENV_INCLUDE_TRACEBACK = "FOUNDRY_INCLUDE_TRACEBACK"
43
+ DEFAULT_INCLUDE_TRACEBACK = True
44
+
45
+
46
+ # ADR-001 Exit code taxonomy
47
+ EXIT_SUCCESS = 0
48
+ EXIT_USER_INPUT = 1
49
+ EXIT_AUTH = 2
50
+ EXIT_PERMISSION_DENIED = 3
51
+ EXIT_NOT_FOUND = 4
52
+ EXIT_TIMEOUT = 5
53
+ EXIT_SERVER_ERROR = 6
54
+ EXIT_RATE_LIMIT = 7
55
+ EXIT_ACCESS_CONTROL = 8
56
+ EXIT_CONFIGURATION = 9
57
+
58
+ EXIT_CODE_NAMES = {
59
+ EXIT_SUCCESS: "Success",
60
+ EXIT_USER_INPUT: "UserInputError",
61
+ EXIT_AUTH: "AuthenticationError",
62
+ EXIT_PERMISSION_DENIED: "PermissionDeniedError",
63
+ EXIT_NOT_FOUND: "NotFoundError",
64
+ EXIT_TIMEOUT: "TimeoutError",
65
+ EXIT_SERVER_ERROR: "ServerError",
66
+ EXIT_RATE_LIMIT: "RateLimitExhausted",
67
+ EXIT_ACCESS_CONTROL: "AccessControlError",
68
+ EXIT_CONFIGURATION: "ConfigurationError",
69
+ }
70
+
71
+
72
+ class _SDKAuthError(Exception):
73
+ """Legacy internal placeholder for SDK authentication failure.
74
+
75
+ .. deprecated::
76
+ Retained only for backwards compatibility in unit tests. Real SDK
77
+ exception types are mapped dynamically via ``_register_sdk_exceptions``
78
+ when ``foundry_platform_python`` is importable.
79
+ """
80
+
81
+
82
+ class _SDKValidationError(Exception):
83
+ """Legacy internal placeholder for SDK validation failure (deprecated)."""
84
+
85
+
86
+ class _SDKNotFoundError(Exception):
87
+ """Legacy internal placeholder for SDK resource not found (deprecated)."""
88
+
89
+
90
+ class _SDKRateLimitError(Exception):
91
+ """Legacy internal placeholder for SDK rate limit exceeded (deprecated)."""
92
+
93
+
94
+ class _SDKConflictError(Exception):
95
+ """Legacy internal placeholder for SDK conflict error (deprecated)."""
96
+
97
+
98
+ class _SDKNetworkError(Exception):
99
+ """Legacy internal placeholder for SDK network error (deprecated)."""
100
+
101
+
102
+ def _register_sdk_exceptions() -> dict[type[BaseException], int]:
103
+ """Build an exit-code map that includes real SDK exceptions if available.
104
+
105
+ The Foundry SDK exposes ``PalantirRPCException`` subclasses through its
106
+ error module. When the SDK is importable, those
107
+ real exception types are mapped to ADR-001 exit codes so that
108
+ ``isinstance`` matching in :meth:`ErrorSerializer.serialize` actually fires.
109
+ When the SDK is not installed (e.g. unit tests, lightweight environments),
110
+ only the standard library fallbacks are used and HTTP status code
111
+ classification (see :meth:`ErrorSerializer._classify_http_exception`) acts
112
+ as the primary mapping path.
113
+
114
+ Returns
115
+ -------
116
+ Dict[Type[BaseException], int]
117
+ Mapping of exception classes to ADR-001 exit codes.
118
+ """
119
+ mapping: dict[type[BaseException], int] = {
120
+ # Auth errors -> code 2
121
+ _SDKAuthError: EXIT_AUTH,
122
+ # Validation -> code 1 (UserInputError in ADR-001)
123
+ _SDKValidationError: EXIT_USER_INPUT,
124
+ ValueError: EXIT_USER_INPUT,
125
+ TypeError: EXIT_USER_INPUT,
126
+ # Permission denied -> code 3
127
+ PermissionError: EXIT_PERMISSION_DENIED,
128
+ # Not found -> code 4
129
+ _SDKNotFoundError: EXIT_NOT_FOUND,
130
+ FileNotFoundError: EXIT_NOT_FOUND,
131
+ # Timeout -> code 5
132
+ TimeoutError: EXIT_TIMEOUT,
133
+ asyncio.TimeoutError: EXIT_TIMEOUT,
134
+ # Rate limit -> code 7
135
+ _SDKRateLimitError: EXIT_RATE_LIMIT,
136
+ # Config errors -> code 9
137
+ ImportError: EXIT_CONFIGURATION,
138
+ ModuleNotFoundError: EXIT_CONFIGURATION,
139
+ EnvironmentError: EXIT_CONFIGURATION,
140
+ OSError: EXIT_CONFIGURATION,
141
+ }
142
+
143
+ # Register the CLI's own AccessControlError -> exit code 8 (ADR-001).
144
+ # BUG-SUB-004: without this entry, serialize() falls through to the
145
+ # EXIT_USER_INPUT default because AccessControlError carries no HTTP
146
+ # status, so HTTP classification cannot rescue it. The import is lazy to
147
+ # avoid a circular dependency: access_control_guard imports
148
+ # EXIT_ACCESS_CONTROL from this module at its import time, which is safe
149
+ # because this function runs only after EXIT_ACCESS_CONTROL is defined.
150
+ try:
151
+ from pal_found_cli.common.access_control_guard import (
152
+ AccessControlError, # type: ignore[import-not-found]
153
+ )
154
+ except ImportError:
155
+ AccessControlError = None # type: ignore[assignment, misc]
156
+
157
+ if AccessControlError is not None:
158
+ mapping[AccessControlError] = EXIT_ACCESS_CONTROL
159
+
160
+ mapping.update(sdk_exception_exit_map())
161
+ return mapping
162
+
163
+
164
+ # Map exception classes to ADR-001 exit codes (computed at import time).
165
+ _EXCEPTION_TO_EXIT_CODE: dict[type[BaseException], int] = _register_sdk_exceptions()
166
+
167
+
168
+ class ErrorSerializer:
169
+ """Serializes exceptions to ADR-001 exit codes and JSON error envelopes.
170
+
171
+ Maps exceptions to the structured exit code taxonomy and produces
172
+ a JSON error envelope on stdout.
173
+
174
+ Error Envelope Schema
175
+ ---------------------
176
+ {
177
+ "error": true,
178
+ "exit_code": int,
179
+ "exit_code_name": str,
180
+ "message": str,
181
+ "exception_type": str,
182
+ "traceback": str,
183
+ "call_id": str,
184
+ }
185
+
186
+ Examples
187
+ --------
188
+ >>> serializer = ErrorSerializer()
189
+ >>> try:
190
+ ... await call_api()
191
+ ... except Exception as e:
192
+ ... exit_code = serializer.serialize(e)
193
+ ... sys.exit(exit_code)
194
+ """
195
+
196
+ def __init__(self, call_id: str | None = None) -> None:
197
+ """Initialize ErrorSerializer.
198
+
199
+ Parameters
200
+ ----------
201
+ call_id : str, optional
202
+ Unique identifier for this CLI invocation. Auto-generated if not set.
203
+ """
204
+ self.call_id = call_id or str(uuid.uuid4())
205
+
206
+ def _get_http_status_from_exception(self, exception: BaseException) -> int | None:
207
+ """Extract HTTP status code from an exception if available.
208
+
209
+ Parameters
210
+ ----------
211
+ exception : BaseException
212
+ The exception to inspect.
213
+
214
+ Returns
215
+ -------
216
+ int or None
217
+ HTTP status code if available, None otherwise.
218
+ """
219
+ return sdk_http_status(exception)
220
+
221
+ def _classify_http_exception(self, exception: BaseException) -> int | None:
222
+ """Classify HTTP exceptions based on status code.
223
+
224
+ Parameters
225
+ ----------
226
+ exception : BaseException
227
+ The exception to classify.
228
+
229
+ Returns
230
+ -------
231
+ int or None
232
+ Exit code if classifiable, None otherwise.
233
+ """
234
+ status = self._get_http_status_from_exception(exception)
235
+ if status is None:
236
+ return None
237
+
238
+ if status == 401:
239
+ return EXIT_AUTH
240
+ if status == 403:
241
+ return EXIT_PERMISSION_DENIED
242
+ if status == 404:
243
+ return EXIT_NOT_FOUND
244
+ if status == 409:
245
+ return EXIT_USER_INPUT # Conflict treated as user input
246
+ if status == 429:
247
+ return EXIT_RATE_LIMIT
248
+ if status == 503:
249
+ return EXIT_SERVER_ERROR
250
+ if status and status >= 500 and status != 503:
251
+ return EXIT_SERVER_ERROR
252
+ return None
253
+
254
+ def serialize(
255
+ self,
256
+ exception: BaseException,
257
+ print_to_stdout: bool = True,
258
+ ) -> int:
259
+ """Serialize an exception to an ADR-001 exit code.
260
+
261
+ Maps the exception to an exit code and optionally prints a
262
+ structured JSON error envelope to stdout.
263
+
264
+ Parameters
265
+ ----------
266
+ exception : BaseException
267
+ The exception to serialize.
268
+ print_to_stdout : bool, optional
269
+ Whether to print the JSON error envelope to stdout.
270
+ Default: True.
271
+
272
+ Returns
273
+ -------
274
+ int
275
+ The exit code (0-9) per ADR-001 taxonomy.
276
+ """
277
+ exit_code = EXIT_USER_INPUT # Default: UserInputError
278
+
279
+ # First try HTTP status classification
280
+ http_exit = self._classify_http_exception(exception)
281
+ if http_exit is not None:
282
+ exit_code = http_exit
283
+ else:
284
+ declared_exit_code = getattr(exception, "exit_code", None)
285
+ if (
286
+ isinstance(declared_exit_code, int)
287
+ and declared_exit_code in EXIT_CODE_NAMES
288
+ ):
289
+ exit_code = declared_exit_code
290
+ elif (sdk_exit_code := sdk_exception_exit_code(exception)) is not None:
291
+ exit_code = sdk_exit_code
292
+ # Walk MRO for exception type matching
293
+ else:
294
+ for klass in type(exception).__mro__:
295
+ if klass in _EXCEPTION_TO_EXIT_CODE:
296
+ exit_code = _EXCEPTION_TO_EXIT_CODE[klass]
297
+ break
298
+
299
+ exit_code_name = EXIT_CODE_NAMES.get(exit_code, "UnknownError")
300
+
301
+ # Traceback inclusion is configurable to mitigate sensitive data
302
+ # exposure (OWASP A02). Defaults to on for diagnostics; production
303
+ # deployments can set FOUNDRY_INCLUDE_TRACEBACK=false.
304
+ include_tb = os.environ.get(ENV_INCLUDE_TRACEBACK, "")
305
+ if include_tb == "":
306
+ include_tb_val = DEFAULT_INCLUDE_TRACEBACK
307
+ else:
308
+ include_tb_val = include_tb.lower() in ("true", "1", "yes", "on")
309
+
310
+ if include_tb_val:
311
+ tb_lines = traceback.format_exception(
312
+ type(exception), exception, exception.__traceback__
313
+ )
314
+ traceback_str = "".join(tb_lines).rstrip()
315
+ else:
316
+ traceback_str = ""
317
+
318
+ error_envelope: dict[str, Any] = {
319
+ "error": True,
320
+ "exit_code": exit_code,
321
+ "exit_code_name": exit_code_name,
322
+ "message": str(exception),
323
+ "exception_type": type(exception).__name__,
324
+ "traceback": traceback_str,
325
+ "call_id": self.call_id,
326
+ }
327
+
328
+ # Add HTTP status if available
329
+ http_status = self._get_http_status_from_exception(exception)
330
+ if http_status is not None:
331
+ error_envelope["http_status"] = http_status
332
+
333
+ diagnostic_metadata = getattr(exception, "diagnostic_metadata", None)
334
+ if isinstance(diagnostic_metadata, dict):
335
+ safe_keys = {"session_id", "source_size", "source_size_at_least"}
336
+ safe_metadata = {
337
+ key: value
338
+ for key, value in diagnostic_metadata.items()
339
+ if key in safe_keys
340
+ and isinstance(value, (str, int, float, bool, type(None)))
341
+ }
342
+ if safe_metadata:
343
+ error_envelope["details"] = safe_metadata
344
+
345
+ if print_to_stdout:
346
+ json_str = json.dumps(error_envelope, default=str)
347
+ sys.stdout.write(json_str + "\n")
348
+ sys.stdout.flush()
349
+
350
+ logger.error(
351
+ f"Serialized {type(exception).__name__} to exit code {exit_code} "
352
+ f"({exit_code_name}): {exception}",
353
+ extra={
354
+ "http_status": http_status,
355
+ "call_id": self.call_id,
356
+ },
357
+ )
358
+
359
+ return exit_code
360
+
361
+ @staticmethod
362
+ def get_exit_code_name(exit_code: int) -> str:
363
+ """Get the human-readable name for an exit code.
364
+
365
+ Parameters
366
+ ----------
367
+ exit_code : int
368
+ The exit code to look up.
369
+
370
+ Returns
371
+ -------
372
+ str
373
+ The name of the exit code, or 'UnknownError' if not found.
374
+ """
375
+ return EXIT_CODE_NAMES.get(exit_code, "UnknownError")
376
+
377
+ @staticmethod
378
+ def create_error_envelope(
379
+ exit_code: int,
380
+ message: str,
381
+ exception_type: str = "UnknownError",
382
+ call_id: str | None = None,
383
+ ) -> dict[str, Any]:
384
+ """Create a JSON error envelope without an exception object.
385
+
386
+ Parameters
387
+ ----------
388
+ exit_code : int
389
+ Exit code per ADR-001.
390
+ message : str
391
+ Human-readable error message.
392
+ exception_type : str, optional
393
+ Name of the exception type.
394
+ call_id : str, optional
395
+ Unique invocation identifier.
396
+
397
+ Returns
398
+ -------
399
+ Dict[str, Any]
400
+ Error envelope dictionary.
401
+ """
402
+ exit_code_name = EXIT_CODE_NAMES.get(exit_code, "UnknownError")
403
+
404
+ return {
405
+ "error": True,
406
+ "exit_code": exit_code,
407
+ "exit_code_name": exit_code_name,
408
+ "message": message,
409
+ "exception_type": exception_type,
410
+ "traceback": "",
411
+ "call_id": call_id or str(uuid.uuid4()),
412
+ }
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env python3
2
+ """Structured NDJSON logging setup for Foundry CLI (ADR-005).
3
+
4
+ Configures Python `logging` with a custom JSON formatter directed to stderr.
5
+ Implements newline-delimited JSON (NDJSON) format for machine-parseable logs.
6
+
7
+ Environment Variables
8
+ ---------------------
9
+ FOUNDRY_AGENTIC_CLI_LOG_LEVEL : str
10
+ Log level control. Supported: DEBUG, INFO, WARNING, ERROR.
11
+ Default: WARNING
12
+
13
+ Log Record Schema
14
+ -----------------
15
+ Required fields: ts, level, logger, msg
16
+ Optional context fields: op, call_id, attempt, delay_ms, access_decision, http_status
17
+
18
+ Metadata Separator
19
+ ------------------
20
+ '# ---metadata-start---' precedes metadata JSON on stderr.
21
+ """
22
+
23
+ import json
24
+ import logging
25
+ import os
26
+ import sys
27
+ import threading
28
+ from datetime import UTC, datetime
29
+ from typing import Any
30
+
31
+ # Metadata separator as defined in ADR-005
32
+ METADATA_SEPARATOR = "# ---metadata-start---"
33
+
34
+ # Default log level
35
+ DEFAULT_LOG_LEVEL = "WARNING"
36
+
37
+ # Environment variable for log level
38
+ ENV_LOG_LEVEL = "FOUNDRY_AGENTIC_CLI_LOG_LEVEL"
39
+
40
+ # Supported log levels
41
+ SUPPORTED_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
42
+
43
+
44
+ class _NdJsonFormatter(logging.Formatter):
45
+ """Newline-delimited JSON formatter for structured logging (ADR-005).
46
+
47
+ Each log record is emitted as a single JSON line to stderr.
48
+ Required fields: ts, level, logger, msg
49
+ Optional context fields are merged from extra={} passed to logging calls.
50
+ """
51
+
52
+ def format(self, record: logging.LogRecord) -> str:
53
+ """Format a log record as NDJSON.
54
+
55
+ Parameters
56
+ ----------
57
+ record : logging.LogRecord
58
+ The log record to format.
59
+
60
+ Returns
61
+ -------
62
+ str
63
+ A single JSON line representing the log record.
64
+ """
65
+ ts = datetime.fromtimestamp(record.created, tz=UTC).isoformat()
66
+
67
+ log_entry: dict[str, Any] = {
68
+ "ts": ts,
69
+ "level": record.levelname,
70
+ "logger": record.name,
71
+ "msg": record.getMessage(),
72
+ }
73
+
74
+ # Merge optional context fields from extra data
75
+ # These are attached as attributes on the LogRecord by the caller
76
+ optional_fields = [
77
+ "op",
78
+ "call_id",
79
+ "attempt",
80
+ "delay_ms",
81
+ "access_decision",
82
+ "http_status",
83
+ "session_alias",
84
+ "active_session_count",
85
+ "file_size",
86
+ "max_download_bytes",
87
+ "truncated",
88
+ "source_size",
89
+ "source_size_at_least",
90
+ "trace_id",
91
+ "span_id",
92
+ ]
93
+ for field in optional_fields:
94
+ value = getattr(record, field, None)
95
+ if value is not None:
96
+ log_entry[field] = value
97
+
98
+ # Include exception info if present
99
+ if record.exc_info and record.exc_info[0] is not None:
100
+ log_entry["exc"] = self.formatException(record.exc_info)
101
+
102
+ return json.dumps(log_entry, default=str)
103
+
104
+
105
+ class LogSetup:
106
+ """Configures Python logging with NDJSON formatter directed to stderr (ADR-005).
107
+
108
+ Usage
109
+ -----
110
+ >>> LogSetup.configure(log_level="WARNING")
111
+ >>> logger = logging.getLogger("pal_found_cli.common.retry")
112
+ >>> logger.warning("Retrying after 429", extra={"op": "datasets.list", "attempt": 2})
113
+ """
114
+
115
+ _configured: bool = False
116
+ _lock: threading.Lock = threading.Lock()
117
+
118
+ @classmethod
119
+ def configure(
120
+ cls,
121
+ log_level: str | None = None,
122
+ ) -> logging.Logger:
123
+ """Configure the root logger with NDJSON formatter to stderr.
124
+
125
+ Thread-safe: serialises concurrent first-configurations through a
126
+ class-level lock so that two threads racing to configure logging do
127
+ not register duplicate handlers.
128
+
129
+ Parameters
130
+ ----------
131
+ log_level : str, optional
132
+ Log level (DEBUG, INFO, WARNING, ERROR). Defaults to env var
133
+ `FOUNDRY_AGENTIC_CLI_LOG_LEVEL`, then to `WARNING`.
134
+
135
+ Returns
136
+ -------
137
+ logging.Logger
138
+ The configured root logger.
139
+
140
+ Raises
141
+ ------
142
+ ValueError
143
+ If log_level is not one of the supported levels.
144
+ """
145
+ # Fast path: already configured (no lock needed for the read).
146
+ if cls._configured:
147
+ return logging.getLogger()
148
+
149
+ with cls._lock:
150
+ # Re-check inside the lock to avoid duplicate configuration.
151
+ if cls._configured:
152
+ return logging.getLogger()
153
+
154
+ # Resolve log level: explicit arg > env var > default
155
+ effective_level = log_level or os.environ.get(ENV_LOG_LEVEL)
156
+ if not effective_level:
157
+ effective_level = DEFAULT_LOG_LEVEL
158
+ effective_level = effective_level.upper()
159
+
160
+ if effective_level not in SUPPORTED_LEVELS:
161
+ raise ValueError(
162
+ f"Unsupported log level '{effective_level}'. "
163
+ f"Must be one of: {', '.join(sorted(SUPPORTED_LEVELS))}"
164
+ )
165
+
166
+ root_logger = logging.getLogger()
167
+ root_logger.setLevel(getattr(logging, effective_level))
168
+
169
+ # Remove any existing handlers to prevent duplicates
170
+ root_logger.handlers.clear()
171
+
172
+ # Create stderr handler with NDJSON formatter
173
+ stderr_handler = logging.StreamHandler(sys.stderr)
174
+ stderr_handler.setFormatter(_NdJsonFormatter())
175
+ root_logger.addHandler(stderr_handler)
176
+
177
+ cls._configured = True
178
+ return root_logger
179
+
180
+ @classmethod
181
+ def reset(cls) -> None:
182
+ """Reset logging configuration (for testing purposes).
183
+
184
+ Clears all handlers and resets the configured flag.
185
+ """
186
+ with cls._lock:
187
+ root_logger = logging.getLogger()
188
+ root_logger.handlers.clear()
189
+ cls._configured = False
190
+
191
+ @staticmethod
192
+ def emit_metadata_separator() -> None:
193
+ """Emit the metadata separator line to stderr (ADR-005).
194
+
195
+ Writes `# ---metadata-start---` to stderr to indicate
196
+ the start of metadata JSON output.
197
+ """
198
+ sys.stderr.write(METADATA_SEPARATOR + "\n")
199
+ sys.stderr.flush()
200
+
201
+ @staticmethod
202
+ def emit_metadata(metadata: dict[str, Any]) -> None:
203
+ """Emit metadata JSON to stderr after the separator.
204
+
205
+ Parameters
206
+ ----------
207
+ metadata : Dict[str, Any]
208
+ Metadata dictionary to serialize as JSON on stderr.
209
+ """
210
+ LogSetup.emit_metadata_separator()
211
+ sys.stderr.write(json.dumps(metadata, default=str) + "\n")
212
+ sys.stderr.flush()
213
+
214
+
215
+ def get_logger(name: str) -> logging.Logger:
216
+ """Get a named logger for a Foundry CLI module.
217
+
218
+ The logger is automatically configured with NDJSON format to stderr.
219
+ Use `extra={}` on log calls to include optional context fields.
220
+
221
+ Parameters
222
+ ----------
223
+ name : str
224
+ Logger name, typically the module path (e.g., 'pal_found_cli.common.retry').
225
+
226
+ Returns
227
+ -------
228
+ logging.Logger
229
+ A configured logger instance.
230
+
231
+ Examples
232
+ --------
233
+ >>> logger = get_logger("pal_found_cli.common.retry")
234
+ >>> logger.warning(
235
+ ... "Retrying after 429: attempt 2/4, delay 1000ms",
236
+ ... extra={"op": "datasets.dataset.read_table", "attempt": 2, "delay_ms": 1000},
237
+ ... )
238
+ """
239
+ # Ensure logging is configured
240
+ LogSetup.configure()
241
+ return logging.getLogger(name)