ap-client 0.1.4.dev0__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.
- ap_client/__init__.py +13 -0
- ap_client/api.py +516 -0
- ap_client/cli.py +1016 -0
- ap_client/config.py +65 -0
- ap_client/exporter.py +368 -0
- ap_client/waiter.py +70 -0
- ap_client-0.1.4.dev0.dist-info/METADATA +9 -0
- ap_client-0.1.4.dev0.dist-info/RECORD +10 -0
- ap_client-0.1.4.dev0.dist-info/WHEEL +4 -0
- ap_client-0.1.4.dev0.dist-info/entry_points.txt +2 -0
ap_client/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Agent Platform API Client"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .api import APIClient, get_client
|
|
6
|
+
from .config import Config, get_config
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
__version__ = version("ap-client")
|
|
10
|
+
except PackageNotFoundError:
|
|
11
|
+
__version__ = "0.1.4"
|
|
12
|
+
|
|
13
|
+
__all__ = ["APIClient", "Config", "__version__", "get_client", "get_config"]
|
ap_client/api.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""API client."""
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
import platform
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from .config import Config, get_config
|
|
14
|
+
|
|
15
|
+
_GROUP_ARTIFACTS_PAGE_SIZE = 500
|
|
16
|
+
_VERBOSE_SAMPLE_KEYS = 5
|
|
17
|
+
_VERBOSE_SAMPLE_ITEMS = 3
|
|
18
|
+
_VERBOSE_VALUE_PREVIEW = 120
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APIClient:
|
|
22
|
+
"""Agent Platform API client."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: Config):
|
|
25
|
+
self.config = config
|
|
26
|
+
self.session = requests.Session()
|
|
27
|
+
headers = dict(config.headers)
|
|
28
|
+
user_agent = _build_user_agent(headers.get("User-Agent"))
|
|
29
|
+
headers["User-Agent"] = user_agent
|
|
30
|
+
self.session.headers.update(
|
|
31
|
+
{
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
**headers,
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def _url(self, path: str) -> str:
|
|
38
|
+
"""Build the full URL."""
|
|
39
|
+
if (
|
|
40
|
+
path.startswith("/")
|
|
41
|
+
and "/api" not in self.config.base_url
|
|
42
|
+
and "nlb-" not in self.config.base_url
|
|
43
|
+
and "localhost" not in self.config.base_url
|
|
44
|
+
):
|
|
45
|
+
return f"{self.config.base_url}/api{path}"
|
|
46
|
+
return f"{self.config.base_url}{path}"
|
|
47
|
+
|
|
48
|
+
def _request(
|
|
49
|
+
self,
|
|
50
|
+
method: str,
|
|
51
|
+
path: str,
|
|
52
|
+
*,
|
|
53
|
+
params: Optional[dict] = None,
|
|
54
|
+
json_body: Any = None,
|
|
55
|
+
timeout: float = 30,
|
|
56
|
+
) -> Any:
|
|
57
|
+
"""Send an HTTP request with X-Request-ID and optional verbose logging."""
|
|
58
|
+
url = self._url(path)
|
|
59
|
+
request_id = str(uuid.uuid4())
|
|
60
|
+
headers = {"X-Request-ID": request_id}
|
|
61
|
+
verbose = self.config.verbose
|
|
62
|
+
if verbose:
|
|
63
|
+
self._log_request(method, url, request_id, params, json_body)
|
|
64
|
+
start = time.perf_counter()
|
|
65
|
+
try:
|
|
66
|
+
resp = self.session.request(
|
|
67
|
+
method,
|
|
68
|
+
url,
|
|
69
|
+
params=params,
|
|
70
|
+
json=json_body,
|
|
71
|
+
headers=headers,
|
|
72
|
+
timeout=timeout,
|
|
73
|
+
)
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
if verbose:
|
|
76
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
77
|
+
self._log_exception(method, url, request_id, duration_ms, exc)
|
|
78
|
+
raise
|
|
79
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
80
|
+
if verbose:
|
|
81
|
+
self._log_response(method, url, request_id, resp, duration_ms)
|
|
82
|
+
if not resp.ok:
|
|
83
|
+
try:
|
|
84
|
+
err_data = resp.json()
|
|
85
|
+
detail = err_data.get("detail", resp.text)
|
|
86
|
+
except Exception:
|
|
87
|
+
detail = resp.text
|
|
88
|
+
raise Exception(
|
|
89
|
+
f"API Error {resp.status_code}: {detail} (request_id={request_id})"
|
|
90
|
+
)
|
|
91
|
+
if not resp.content:
|
|
92
|
+
return None
|
|
93
|
+
return resp.json()
|
|
94
|
+
|
|
95
|
+
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
96
|
+
"""Send a GET request."""
|
|
97
|
+
return self._request("GET", path, params=params)
|
|
98
|
+
|
|
99
|
+
def _post(self, path: str, data: Any, timeout: float = 30) -> Any:
|
|
100
|
+
"""Send a POST request."""
|
|
101
|
+
return self._request("POST", path, json_body=data, timeout=timeout)
|
|
102
|
+
|
|
103
|
+
def _delete(self, path: str) -> Any:
|
|
104
|
+
"""Send a DELETE request."""
|
|
105
|
+
return self._request("DELETE", path)
|
|
106
|
+
|
|
107
|
+
# ==================== Verbose logging ====================
|
|
108
|
+
|
|
109
|
+
def _log_request(
|
|
110
|
+
self,
|
|
111
|
+
method: str,
|
|
112
|
+
url: str,
|
|
113
|
+
request_id: str,
|
|
114
|
+
params: Optional[dict],
|
|
115
|
+
body: Any,
|
|
116
|
+
) -> None:
|
|
117
|
+
lines = [f">> {method} {url}", f" request_id={request_id}"]
|
|
118
|
+
if params:
|
|
119
|
+
lines.append(f" params={_safe_json(params)}")
|
|
120
|
+
if body is not None:
|
|
121
|
+
encoded = _safe_json(body).encode("utf-8", errors="replace")
|
|
122
|
+
lines.append(f" body_size={len(encoded)}")
|
|
123
|
+
preview = self._summarize_body(body, encoded)
|
|
124
|
+
if preview:
|
|
125
|
+
lines.append(f" body={preview}")
|
|
126
|
+
_write_stderr(lines)
|
|
127
|
+
|
|
128
|
+
def _log_response(
|
|
129
|
+
self,
|
|
130
|
+
method: str,
|
|
131
|
+
url: str,
|
|
132
|
+
request_id: str,
|
|
133
|
+
resp: requests.Response,
|
|
134
|
+
duration_ms: float,
|
|
135
|
+
) -> None:
|
|
136
|
+
raw = resp.content or b""
|
|
137
|
+
lines = [
|
|
138
|
+
f"<< {method} {url}",
|
|
139
|
+
f" request_id={request_id}",
|
|
140
|
+
f" status={resp.status_code}",
|
|
141
|
+
f" duration_ms={duration_ms:.1f}",
|
|
142
|
+
f" body_size={len(raw)}",
|
|
143
|
+
]
|
|
144
|
+
preview = self._summarize_response(resp, raw)
|
|
145
|
+
if preview:
|
|
146
|
+
lines.append(f" body={preview}")
|
|
147
|
+
_write_stderr(lines)
|
|
148
|
+
|
|
149
|
+
def _log_exception(
|
|
150
|
+
self,
|
|
151
|
+
method: str,
|
|
152
|
+
url: str,
|
|
153
|
+
request_id: str,
|
|
154
|
+
duration_ms: float,
|
|
155
|
+
exc: Exception,
|
|
156
|
+
) -> None:
|
|
157
|
+
_write_stderr(
|
|
158
|
+
[
|
|
159
|
+
f"!! {method} {url}",
|
|
160
|
+
f" request_id={request_id}",
|
|
161
|
+
f" duration_ms={duration_ms:.1f}",
|
|
162
|
+
f" error={type(exc).__name__}: {exc}",
|
|
163
|
+
]
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _summarize_body(self, body: Any, encoded: bytes) -> str:
|
|
167
|
+
if self.config.verbose_full_body:
|
|
168
|
+
return encoded.decode("utf-8", errors="replace")
|
|
169
|
+
limit = self.config.verbose_body_limit
|
|
170
|
+
if len(encoded) <= limit:
|
|
171
|
+
return encoded.decode("utf-8", errors="replace")
|
|
172
|
+
return _summarize_json_value(body, limit)
|
|
173
|
+
|
|
174
|
+
def _summarize_response(self, resp: requests.Response, raw: bytes) -> str:
|
|
175
|
+
if not raw:
|
|
176
|
+
return ""
|
|
177
|
+
if self.config.verbose_full_body:
|
|
178
|
+
return _safe_decode(raw)
|
|
179
|
+
limit = self.config.verbose_body_limit
|
|
180
|
+
content_type = (resp.headers.get("Content-Type") or "").lower()
|
|
181
|
+
if "json" in content_type:
|
|
182
|
+
try:
|
|
183
|
+
data = resp.json()
|
|
184
|
+
except Exception:
|
|
185
|
+
return _truncate_text(_safe_decode(raw), limit)
|
|
186
|
+
encoded = _safe_json(data)
|
|
187
|
+
if len(encoded.encode("utf-8", errors="replace")) <= limit:
|
|
188
|
+
return encoded
|
|
189
|
+
return _summarize_json_value(data, limit)
|
|
190
|
+
return _truncate_text(_safe_decode(raw), limit)
|
|
191
|
+
|
|
192
|
+
# ==================== Template operations ====================
|
|
193
|
+
|
|
194
|
+
def list_templates(self) -> list:
|
|
195
|
+
"""List all templates."""
|
|
196
|
+
params = {}
|
|
197
|
+
if self.config.agenthub_ref:
|
|
198
|
+
params["agenthub_revision"] = self.config.agenthub_ref
|
|
199
|
+
return self._get("/templates", params=params)
|
|
200
|
+
|
|
201
|
+
def get_template(self, name: str) -> dict:
|
|
202
|
+
"""Get template details."""
|
|
203
|
+
params = {}
|
|
204
|
+
if self.config.agenthub_ref:
|
|
205
|
+
params["agenthub_revision"] = self.config.agenthub_ref
|
|
206
|
+
return self._get(f"/templates/{quote(name)}", params=params)
|
|
207
|
+
|
|
208
|
+
# ==================== Dataset operations ====================
|
|
209
|
+
|
|
210
|
+
def list_datasets(self, search: Optional[str] = None) -> dict:
|
|
211
|
+
"""List all datasets."""
|
|
212
|
+
params = {"limit": 500}
|
|
213
|
+
if search:
|
|
214
|
+
params["search"] = search
|
|
215
|
+
return self._get("/datasets", params=params)
|
|
216
|
+
|
|
217
|
+
def list_dataset_versions(self, dataset: str) -> list:
|
|
218
|
+
"""List dataset versions."""
|
|
219
|
+
return self._get(f"/datasets/{quote(dataset)}/versions")
|
|
220
|
+
|
|
221
|
+
def list_dataset_instances(self, dataset_version: str, limit: int = 10000) -> dict:
|
|
222
|
+
"""List dataset instances."""
|
|
223
|
+
return self._get(f"/datasets/{quote(dataset_version)}/instances", params={"limit": limit})
|
|
224
|
+
|
|
225
|
+
# ==================== Job operations ====================
|
|
226
|
+
|
|
227
|
+
def create_group(
|
|
228
|
+
self,
|
|
229
|
+
name: Optional[str] = None,
|
|
230
|
+
max_concurrency: Optional[int] = None,
|
|
231
|
+
eval_config: Optional[dict] = None,
|
|
232
|
+
) -> dict:
|
|
233
|
+
"""Create a group."""
|
|
234
|
+
body: dict = {}
|
|
235
|
+
if name:
|
|
236
|
+
body["name"] = name
|
|
237
|
+
if max_concurrency is not None:
|
|
238
|
+
body["max_concurrency"] = max_concurrency
|
|
239
|
+
if eval_config is not None:
|
|
240
|
+
body["eval_config"] = eval_config
|
|
241
|
+
return self._post("/groups", body)
|
|
242
|
+
|
|
243
|
+
def create_job(
|
|
244
|
+
self,
|
|
245
|
+
template: str,
|
|
246
|
+
params: Optional[dict] = None,
|
|
247
|
+
params_list: Optional[list] = None,
|
|
248
|
+
suite_name: Optional[str] = None,
|
|
249
|
+
tags: Optional[list] = None,
|
|
250
|
+
overrides: Optional[dict] = None,
|
|
251
|
+
max_concurrency: Optional[int] = None,
|
|
252
|
+
group_id: Optional[str] = None,
|
|
253
|
+
enable_otel_tracing: Optional[bool] = None,
|
|
254
|
+
timeout: float = 30,
|
|
255
|
+
) -> dict:
|
|
256
|
+
"""Create a job."""
|
|
257
|
+
body: dict = {"template": template}
|
|
258
|
+
|
|
259
|
+
if params is not None:
|
|
260
|
+
body["params"] = params
|
|
261
|
+
if params_list is not None:
|
|
262
|
+
body["params_list"] = params_list
|
|
263
|
+
if suite_name:
|
|
264
|
+
body["suite_name"] = suite_name
|
|
265
|
+
if self.config.agenthub_ref:
|
|
266
|
+
body["agenthub_revision"] = self.config.agenthub_ref
|
|
267
|
+
if tags:
|
|
268
|
+
body["tags"] = tags
|
|
269
|
+
if overrides:
|
|
270
|
+
body["overrides"] = overrides
|
|
271
|
+
if max_concurrency is not None:
|
|
272
|
+
body["max_concurrency"] = max_concurrency
|
|
273
|
+
if group_id is not None:
|
|
274
|
+
body["group_id"] = group_id
|
|
275
|
+
if enable_otel_tracing is not None:
|
|
276
|
+
body["enable_otel_tracing"] = enable_otel_tracing
|
|
277
|
+
|
|
278
|
+
return self._post("/jobs", body, timeout=timeout)
|
|
279
|
+
|
|
280
|
+
def get_job(self, job_id: str) -> dict:
|
|
281
|
+
"""Get job status."""
|
|
282
|
+
return self._get(f"/jobs/{quote(job_id)}")
|
|
283
|
+
|
|
284
|
+
def list_jobs(
|
|
285
|
+
self,
|
|
286
|
+
template: Optional[str] = None,
|
|
287
|
+
group_id: Optional[str] = None,
|
|
288
|
+
status: Optional[str] = None,
|
|
289
|
+
job_id: Optional[str] = None,
|
|
290
|
+
instance_id: Optional[str] = None,
|
|
291
|
+
finished_after: Optional[str] = None,
|
|
292
|
+
finished_before: Optional[str] = None,
|
|
293
|
+
created_after: Optional[str] = None,
|
|
294
|
+
created_before: Optional[str] = None,
|
|
295
|
+
skip: int = 0,
|
|
296
|
+
limit: int = 100,
|
|
297
|
+
) -> dict:
|
|
298
|
+
"""List jobs."""
|
|
299
|
+
params = {"skip": skip, "limit": limit}
|
|
300
|
+
if template:
|
|
301
|
+
params["template"] = template
|
|
302
|
+
if group_id:
|
|
303
|
+
params["group_id"] = group_id
|
|
304
|
+
if status:
|
|
305
|
+
params["status"] = status
|
|
306
|
+
if job_id:
|
|
307
|
+
params["job_id"] = job_id
|
|
308
|
+
if instance_id:
|
|
309
|
+
params["instance_id"] = instance_id
|
|
310
|
+
if finished_after:
|
|
311
|
+
params["finished_after"] = finished_after
|
|
312
|
+
if finished_before:
|
|
313
|
+
params["finished_before"] = finished_before
|
|
314
|
+
if created_after:
|
|
315
|
+
params["created_after"] = created_after
|
|
316
|
+
if created_before:
|
|
317
|
+
params["created_before"] = created_before
|
|
318
|
+
return self._get("/jobs", params=params)
|
|
319
|
+
|
|
320
|
+
# ==================== Group operations ====================
|
|
321
|
+
|
|
322
|
+
def get_group(self, group_id: str) -> dict:
|
|
323
|
+
"""Get group details."""
|
|
324
|
+
return self._get(f"/groups/{quote(group_id)}")
|
|
325
|
+
|
|
326
|
+
def list_groups(self, skip: int = 0, limit: int = 100) -> dict:
|
|
327
|
+
"""List groups."""
|
|
328
|
+
return self._get("/groups", params={"skip": skip, "limit": limit})
|
|
329
|
+
|
|
330
|
+
def list_group_jobs(
|
|
331
|
+
self,
|
|
332
|
+
group_id: str,
|
|
333
|
+
tag: Optional[list[str]] = None,
|
|
334
|
+
skip: int = 0,
|
|
335
|
+
limit: int = 100,
|
|
336
|
+
) -> dict:
|
|
337
|
+
"""List jobs in a group."""
|
|
338
|
+
params: dict = {"skip": skip, "limit": limit}
|
|
339
|
+
if tag:
|
|
340
|
+
params["tag"] = tag
|
|
341
|
+
return self._get(f"/groups/{quote(group_id)}/jobs", params=params)
|
|
342
|
+
|
|
343
|
+
def get_group_stats(self, group_id: str) -> dict:
|
|
344
|
+
"""Get group progress."""
|
|
345
|
+
return self._get(f"/groups/{quote(group_id)}/stats")
|
|
346
|
+
|
|
347
|
+
def get_group_eval(self, group_id: str) -> dict:
|
|
348
|
+
"""Get aggregated evaluation results for a group."""
|
|
349
|
+
return self._get(f"/groups/{quote(group_id)}/eval")
|
|
350
|
+
|
|
351
|
+
# ==================== Logs and artifacts ====================
|
|
352
|
+
def cancel_group(self, group_id: str) -> dict:
|
|
353
|
+
"""Cancel all unfinished jobs in a group."""
|
|
354
|
+
return self._post(f"/groups/{quote(group_id)}/cancel", {})
|
|
355
|
+
|
|
356
|
+
def get_job_logs(
|
|
357
|
+
self, job_id: str, container: Optional[str] = None, tail: Optional[int] = None
|
|
358
|
+
) -> dict:
|
|
359
|
+
"""Get job logs."""
|
|
360
|
+
params = {}
|
|
361
|
+
if container:
|
|
362
|
+
params["container"] = container
|
|
363
|
+
if tail:
|
|
364
|
+
params["tail"] = tail
|
|
365
|
+
return self._get(f"/jobs/{quote(job_id)}/logs", params=params or None)
|
|
366
|
+
|
|
367
|
+
def get_job_events(self, job_id: str, container: Optional[str] = None) -> dict:
|
|
368
|
+
"""Get job events."""
|
|
369
|
+
params = {}
|
|
370
|
+
if container:
|
|
371
|
+
params["container"] = container
|
|
372
|
+
return self._get(f"/jobs/{quote(job_id)}/events", params=params or None)
|
|
373
|
+
|
|
374
|
+
def get_job_metrics(self, job_id: str) -> dict:
|
|
375
|
+
"""Get job metrics."""
|
|
376
|
+
return self._get(f"/metrics/{quote(job_id)}")
|
|
377
|
+
|
|
378
|
+
def cancel_job(self, job_id: str) -> dict:
|
|
379
|
+
"""Cancel a job."""
|
|
380
|
+
return self._delete(f"/jobs/{quote(job_id)}")
|
|
381
|
+
|
|
382
|
+
def get_group_artifacts_page(
|
|
383
|
+
self,
|
|
384
|
+
group_id: str,
|
|
385
|
+
*,
|
|
386
|
+
skip: int = 0,
|
|
387
|
+
limit: int = _GROUP_ARTIFACTS_PAGE_SIZE,
|
|
388
|
+
) -> dict:
|
|
389
|
+
"""Get one page of artifact download links for a group."""
|
|
390
|
+
return self._get(
|
|
391
|
+
f"/groups/{quote(group_id)}/artifacts",
|
|
392
|
+
params={"skip": skip, "limit": limit},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def get_group_artifacts(self, group_id: str) -> dict:
|
|
396
|
+
"""Get artifact download links for a group."""
|
|
397
|
+
skip = 0
|
|
398
|
+
artifacts: list[dict] = []
|
|
399
|
+
last_page: dict | None = None
|
|
400
|
+
|
|
401
|
+
while True:
|
|
402
|
+
page = self.get_group_artifacts_page(
|
|
403
|
+
group_id,
|
|
404
|
+
skip=skip,
|
|
405
|
+
limit=_GROUP_ARTIFACTS_PAGE_SIZE,
|
|
406
|
+
)
|
|
407
|
+
last_page = page
|
|
408
|
+
page_artifacts = page.get("artifacts") or []
|
|
409
|
+
artifacts.extend(page_artifacts)
|
|
410
|
+
|
|
411
|
+
total = page.get("total")
|
|
412
|
+
if total is not None and len(artifacts) >= total:
|
|
413
|
+
break
|
|
414
|
+
if len(page_artifacts) < _GROUP_ARTIFACTS_PAGE_SIZE:
|
|
415
|
+
break
|
|
416
|
+
|
|
417
|
+
skip += len(page_artifacts)
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
**last_page,
|
|
421
|
+
"skip": 0,
|
|
422
|
+
"limit": len(artifacts),
|
|
423
|
+
"artifacts": artifacts,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
def get_job_artifacts(self, job_ids: list) -> list:
|
|
427
|
+
"""Get artifact download links for one or more jobs."""
|
|
428
|
+
return self._post("/jobs/artifacts", {"job_ids": job_ids})
|
|
429
|
+
|
|
430
|
+
def render_template(self, name: str, params: dict) -> dict:
|
|
431
|
+
"""Preview the rendered template output."""
|
|
432
|
+
return self._post(f"/templates/{quote(name)}/render", {"params": params})
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
_verbose_override: Optional[bool] = None
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def set_verbose_override(value: Optional[bool]) -> None:
|
|
439
|
+
"""Set a process-wide verbose override consulted by get_client()."""
|
|
440
|
+
global _verbose_override
|
|
441
|
+
_verbose_override = value
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def get_client() -> APIClient:
|
|
445
|
+
"""Get an API client."""
|
|
446
|
+
return APIClient(get_config(verbose=_verbose_override))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _build_user_agent(existing: Optional[str] = None) -> str:
|
|
450
|
+
"""Build client User-Agent string."""
|
|
451
|
+
client_ua = (
|
|
452
|
+
f"ap-client/{_client_version()} "
|
|
453
|
+
f"python/{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} "
|
|
454
|
+
f"{platform.system().lower()}/{platform.machine().lower()}"
|
|
455
|
+
)
|
|
456
|
+
if existing:
|
|
457
|
+
return f"{existing} {client_ua}"
|
|
458
|
+
return client_ua
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _client_version() -> str:
|
|
462
|
+
from . import __version__
|
|
463
|
+
|
|
464
|
+
return __version__
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _write_stderr(lines: list[str]) -> None:
|
|
468
|
+
sys.stderr.write("\n".join(lines) + "\n")
|
|
469
|
+
sys.stderr.flush()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _safe_json(value: Any) -> str:
|
|
473
|
+
try:
|
|
474
|
+
return _json.dumps(value, ensure_ascii=False, default=str)
|
|
475
|
+
except Exception:
|
|
476
|
+
return str(value)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _safe_decode(raw: bytes) -> str:
|
|
480
|
+
try:
|
|
481
|
+
return raw.decode("utf-8", errors="replace")
|
|
482
|
+
except Exception:
|
|
483
|
+
return f"<{len(raw)} bytes binary>"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _truncate_text(text: str, limit: int) -> str:
|
|
487
|
+
if len(text) <= limit:
|
|
488
|
+
return text
|
|
489
|
+
return text[:limit] + f"...<truncated {len(text) - limit} chars>"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _shorten_value(value: Any) -> Any:
|
|
493
|
+
if isinstance(value, str):
|
|
494
|
+
if len(value) > _VERBOSE_VALUE_PREVIEW:
|
|
495
|
+
return value[:_VERBOSE_VALUE_PREVIEW] + "..."
|
|
496
|
+
return value
|
|
497
|
+
if isinstance(value, dict):
|
|
498
|
+
return f"<dict {len(value)} keys>"
|
|
499
|
+
if isinstance(value, list):
|
|
500
|
+
return f"<list {len(value)} items>"
|
|
501
|
+
return value
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _summarize_json_value(data: Any, limit: int) -> str:
|
|
505
|
+
if isinstance(data, dict):
|
|
506
|
+
keys = list(data.keys())
|
|
507
|
+
sample = {k: _shorten_value(data[k]) for k in keys[:_VERBOSE_SAMPLE_KEYS]}
|
|
508
|
+
summary = {"_keys": keys, "_sample": sample}
|
|
509
|
+
if len(keys) > _VERBOSE_SAMPLE_KEYS:
|
|
510
|
+
summary["_truncated_keys"] = len(keys) - _VERBOSE_SAMPLE_KEYS
|
|
511
|
+
return _truncate_text(_safe_json(summary), limit)
|
|
512
|
+
if isinstance(data, list):
|
|
513
|
+
sample = [_shorten_value(item) for item in data[:_VERBOSE_SAMPLE_ITEMS]]
|
|
514
|
+
summary = {"_count": len(data), "_sample": sample}
|
|
515
|
+
return _truncate_text(_safe_json(summary), limit)
|
|
516
|
+
return _truncate_text(_safe_json(data), limit)
|