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.
- pal_found_cli/__init__.py +1 -0
- pal_found_cli/_version.py +24 -0
- pal_found_cli/admin/__init__.py +2 -0
- pal_found_cli/admin/scripts/__init__.py +2 -0
- pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
- pal_found_cli/aip_agents/__init__.py +1 -0
- pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
- pal_found_cli/aip_agents/scripts/__init__.py +1 -0
- pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
- pal_found_cli/audit/__init__.py +1 -0
- pal_found_cli/audit/metadata-allow-list.md +4 -0
- pal_found_cli/audit/scripts/__init__.py +1 -0
- pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
- pal_found_cli/checkpoints/__init__.py +9 -0
- pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
- pal_found_cli/checkpoints/scripts/__init__.py +1 -0
- pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
- pal_found_cli/common/__init__.py +56 -0
- pal_found_cli/common/access_control_guard.py +591 -0
- pal_found_cli/common/async_client_factory.py +161 -0
- pal_found_cli/common/auth_provider.py +82 -0
- pal_found_cli/common/binary_download_handler.py +288 -0
- pal_found_cli/common/config_loader.py +388 -0
- pal_found_cli/common/error_serializer.py +412 -0
- pal_found_cli/common/log_setup.py +241 -0
- pal_found_cli/common/metadata-allow-list.md +169 -0
- pal_found_cli/common/output_formatter.py +267 -0
- pal_found_cli/common/pagination_helper.py +212 -0
- pal_found_cli/common/retry.py +617 -0
- pal_found_cli/common/sdk_error_utils.py +112 -0
- pal_found_cli/common/session_manager.py +461 -0
- pal_found_cli/common/tracing_provider.py +107 -0
- pal_found_cli/connectivity/__init__.py +9 -0
- pal_found_cli/connectivity/metadata-allow-list.md +24 -0
- pal_found_cli/connectivity/scripts/__init__.py +1 -0
- pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
- pal_found_cli/data_health/__init__.py +9 -0
- pal_found_cli/data_health/metadata-allow-list.md +10 -0
- pal_found_cli/data_health/scripts/__init__.py +1 -0
- pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
- pal_found_cli/datasets/__init__.py +1 -0
- pal_found_cli/datasets/scripts/__init__.py +1 -0
- pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
- pal_found_cli/filesystem/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
- pal_found_cli/functions/__init__.py +2 -0
- pal_found_cli/functions/scripts/__init__.py +2 -0
- pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
- pal_found_cli/language_models/__init__.py +1 -0
- pal_found_cli/language_models/metadata-allow-list.md +6 -0
- pal_found_cli/language_models/scripts/__init__.py +1 -0
- pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
- pal_found_cli/media_sets/__init__.py +9 -0
- pal_found_cli/media_sets/metadata-allow-list.md +23 -0
- pal_found_cli/media_sets/scripts/__init__.py +1 -0
- pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
- pal_found_cli/models/__init__.py +5 -0
- pal_found_cli/models/metadata-allow-list.md +27 -0
- pal_found_cli/models/scripts/__init__.py +1 -0
- pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
- pal_found_cli/ontologies/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
- pal_found_cli/orchestration/__init__.py +9 -0
- pal_found_cli/orchestration/metadata-allow-list.md +24 -0
- pal_found_cli/orchestration/scripts/__init__.py +1 -0
- pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
- pal_found_cli/sql_queries/__init__.py +9 -0
- pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
- pal_found_cli/sql_queries/scripts/__init__.py +1 -0
- pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
- pal_found_cli/streams/__init__.py +9 -0
- pal_found_cli/streams/metadata-allow-list.md +19 -0
- pal_found_cli/streams/scripts/__init__.py +1 -0
- pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
- pal_found_cli/third_party_applications/__init__.py +9 -0
- pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
- pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
- pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
- pal_found_cli/widgets/__init__.py +9 -0
- pal_found_cli/widgets/metadata-allow-list.md +12 -0
- pal_found_cli/widgets/scripts/__init__.py +1 -0
- pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
- pal_found_cli-0.1.2.dist-info/METADATA +74 -0
- pal_found_cli-0.1.2.dist-info/RECORD +89 -0
- pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
- pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
- 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)
|