glaip-sdk 0.6.12__py3-none-any.whl → 0.6.14__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.
- glaip_sdk/__init__.py +42 -5
- {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.14.dist-info}/METADATA +31 -37
- glaip_sdk-0.6.14.dist-info/RECORD +12 -0
- {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.14.dist-info}/WHEEL +2 -1
- glaip_sdk-0.6.14.dist-info/entry_points.txt +2 -0
- glaip_sdk-0.6.14.dist-info/top_level.txt +1 -0
- glaip_sdk/agents/__init__.py +0 -27
- glaip_sdk/agents/base.py +0 -1191
- glaip_sdk/cli/__init__.py +0 -9
- glaip_sdk/cli/account_store.py +0 -540
- glaip_sdk/cli/agent_config.py +0 -78
- glaip_sdk/cli/auth.py +0 -699
- glaip_sdk/cli/commands/__init__.py +0 -5
- glaip_sdk/cli/commands/accounts.py +0 -746
- glaip_sdk/cli/commands/agents.py +0 -1509
- glaip_sdk/cli/commands/common_config.py +0 -101
- glaip_sdk/cli/commands/configure.py +0 -896
- glaip_sdk/cli/commands/mcps.py +0 -1356
- glaip_sdk/cli/commands/models.py +0 -69
- glaip_sdk/cli/commands/tools.py +0 -576
- glaip_sdk/cli/commands/transcripts.py +0 -755
- glaip_sdk/cli/commands/update.py +0 -61
- glaip_sdk/cli/config.py +0 -95
- glaip_sdk/cli/constants.py +0 -38
- glaip_sdk/cli/context.py +0 -150
- glaip_sdk/cli/core/__init__.py +0 -79
- glaip_sdk/cli/core/context.py +0 -124
- glaip_sdk/cli/core/output.py +0 -846
- glaip_sdk/cli/core/prompting.py +0 -649
- glaip_sdk/cli/core/rendering.py +0 -187
- glaip_sdk/cli/display.py +0 -355
- glaip_sdk/cli/hints.py +0 -57
- glaip_sdk/cli/io.py +0 -112
- glaip_sdk/cli/main.py +0 -604
- glaip_sdk/cli/masking.py +0 -136
- glaip_sdk/cli/mcp_validators.py +0 -287
- glaip_sdk/cli/pager.py +0 -266
- glaip_sdk/cli/parsers/__init__.py +0 -7
- glaip_sdk/cli/parsers/json_input.py +0 -177
- glaip_sdk/cli/resolution.py +0 -67
- glaip_sdk/cli/rich_helpers.py +0 -27
- glaip_sdk/cli/slash/__init__.py +0 -15
- glaip_sdk/cli/slash/accounts_controller.py +0 -578
- glaip_sdk/cli/slash/accounts_shared.py +0 -75
- glaip_sdk/cli/slash/agent_session.py +0 -285
- glaip_sdk/cli/slash/prompt.py +0 -256
- glaip_sdk/cli/slash/remote_runs_controller.py +0 -566
- glaip_sdk/cli/slash/session.py +0 -1708
- glaip_sdk/cli/slash/tui/__init__.py +0 -9
- glaip_sdk/cli/slash/tui/accounts_app.py +0 -876
- glaip_sdk/cli/slash/tui/background_tasks.py +0 -72
- glaip_sdk/cli/slash/tui/loading.py +0 -58
- glaip_sdk/cli/slash/tui/remote_runs_app.py +0 -628
- glaip_sdk/cli/transcript/__init__.py +0 -31
- glaip_sdk/cli/transcript/cache.py +0 -536
- glaip_sdk/cli/transcript/capture.py +0 -329
- glaip_sdk/cli/transcript/export.py +0 -38
- glaip_sdk/cli/transcript/history.py +0 -815
- glaip_sdk/cli/transcript/launcher.py +0 -77
- glaip_sdk/cli/transcript/viewer.py +0 -374
- glaip_sdk/cli/update_notifier.py +0 -290
- glaip_sdk/cli/utils.py +0 -263
- glaip_sdk/cli/validators.py +0 -238
- glaip_sdk/client/__init__.py +0 -11
- glaip_sdk/client/_agent_payloads.py +0 -520
- glaip_sdk/client/agent_runs.py +0 -147
- glaip_sdk/client/agents.py +0 -1335
- glaip_sdk/client/base.py +0 -502
- glaip_sdk/client/main.py +0 -249
- glaip_sdk/client/mcps.py +0 -370
- glaip_sdk/client/run_rendering.py +0 -700
- glaip_sdk/client/shared.py +0 -21
- glaip_sdk/client/tools.py +0 -661
- glaip_sdk/client/validators.py +0 -198
- glaip_sdk/config/constants.py +0 -52
- glaip_sdk/mcps/__init__.py +0 -21
- glaip_sdk/mcps/base.py +0 -345
- glaip_sdk/models/__init__.py +0 -90
- glaip_sdk/models/agent.py +0 -47
- glaip_sdk/models/agent_runs.py +0 -116
- glaip_sdk/models/common.py +0 -42
- glaip_sdk/models/mcp.py +0 -33
- glaip_sdk/models/tool.py +0 -33
- glaip_sdk/payload_schemas/__init__.py +0 -7
- glaip_sdk/payload_schemas/agent.py +0 -85
- glaip_sdk/registry/__init__.py +0 -55
- glaip_sdk/registry/agent.py +0 -164
- glaip_sdk/registry/base.py +0 -139
- glaip_sdk/registry/mcp.py +0 -253
- glaip_sdk/registry/tool.py +0 -232
- glaip_sdk/runner/__init__.py +0 -59
- glaip_sdk/runner/base.py +0 -84
- glaip_sdk/runner/deps.py +0 -115
- glaip_sdk/runner/langgraph.py +0 -782
- glaip_sdk/runner/mcp_adapter/__init__.py +0 -13
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +0 -43
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +0 -257
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +0 -95
- glaip_sdk/runner/tool_adapter/__init__.py +0 -18
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +0 -44
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +0 -219
- glaip_sdk/tools/__init__.py +0 -22
- glaip_sdk/tools/base.py +0 -435
- glaip_sdk/utils/__init__.py +0 -86
- glaip_sdk/utils/a2a/__init__.py +0 -34
- glaip_sdk/utils/a2a/event_processor.py +0 -188
- glaip_sdk/utils/agent_config.py +0 -194
- glaip_sdk/utils/bundler.py +0 -267
- glaip_sdk/utils/client.py +0 -111
- glaip_sdk/utils/client_utils.py +0 -486
- glaip_sdk/utils/datetime_helpers.py +0 -58
- glaip_sdk/utils/discovery.py +0 -78
- glaip_sdk/utils/display.py +0 -135
- glaip_sdk/utils/export.py +0 -143
- glaip_sdk/utils/general.py +0 -61
- glaip_sdk/utils/import_export.py +0 -168
- glaip_sdk/utils/import_resolver.py +0 -492
- glaip_sdk/utils/instructions.py +0 -101
- glaip_sdk/utils/rendering/__init__.py +0 -115
- glaip_sdk/utils/rendering/formatting.py +0 -264
- glaip_sdk/utils/rendering/layout/__init__.py +0 -64
- glaip_sdk/utils/rendering/layout/panels.py +0 -156
- glaip_sdk/utils/rendering/layout/progress.py +0 -202
- glaip_sdk/utils/rendering/layout/summary.py +0 -74
- glaip_sdk/utils/rendering/layout/transcript.py +0 -606
- glaip_sdk/utils/rendering/models.py +0 -85
- glaip_sdk/utils/rendering/renderer/__init__.py +0 -55
- glaip_sdk/utils/rendering/renderer/base.py +0 -1024
- glaip_sdk/utils/rendering/renderer/config.py +0 -27
- glaip_sdk/utils/rendering/renderer/console.py +0 -55
- glaip_sdk/utils/rendering/renderer/debug.py +0 -178
- glaip_sdk/utils/rendering/renderer/factory.py +0 -138
- glaip_sdk/utils/rendering/renderer/stream.py +0 -202
- glaip_sdk/utils/rendering/renderer/summary_window.py +0 -79
- glaip_sdk/utils/rendering/renderer/thinking.py +0 -273
- glaip_sdk/utils/rendering/renderer/toggle.py +0 -182
- glaip_sdk/utils/rendering/renderer/tool_panels.py +0 -442
- glaip_sdk/utils/rendering/renderer/transcript_mode.py +0 -162
- glaip_sdk/utils/rendering/state.py +0 -204
- glaip_sdk/utils/rendering/step_tree_state.py +0 -100
- glaip_sdk/utils/rendering/steps/__init__.py +0 -34
- glaip_sdk/utils/rendering/steps/event_processor.py +0 -778
- glaip_sdk/utils/rendering/steps/format.py +0 -176
- glaip_sdk/utils/rendering/steps/manager.py +0 -387
- glaip_sdk/utils/rendering/timing.py +0 -36
- glaip_sdk/utils/rendering/viewer/__init__.py +0 -21
- glaip_sdk/utils/rendering/viewer/presenter.py +0 -184
- glaip_sdk/utils/resource_refs.py +0 -195
- glaip_sdk/utils/run_renderer.py +0 -41
- glaip_sdk/utils/runtime_config.py +0 -425
- glaip_sdk/utils/serialization.py +0 -424
- glaip_sdk/utils/sync.py +0 -142
- glaip_sdk/utils/tool_detection.py +0 -33
- glaip_sdk/utils/validation.py +0 -264
- glaip_sdk-0.6.12.dist-info/RECORD +0 -159
- glaip_sdk-0.6.12.dist-info/entry_points.txt +0 -3
glaip_sdk/client/base.py
DELETED
|
@@ -1,502 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Base client for AIP SDK.
|
|
3
|
-
|
|
4
|
-
Authors:
|
|
5
|
-
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
6
|
-
Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
import logging
|
|
10
|
-
import os
|
|
11
|
-
from collections.abc import Iterable, Mapping
|
|
12
|
-
from typing import Any, NoReturn, Union
|
|
13
|
-
|
|
14
|
-
import httpx
|
|
15
|
-
from dotenv import load_dotenv
|
|
16
|
-
|
|
17
|
-
import glaip_sdk
|
|
18
|
-
from glaip_sdk._version import __version__ as SDK_VERSION
|
|
19
|
-
from glaip_sdk.config.constants import DEFAULT_ERROR_MESSAGE, SDK_NAME
|
|
20
|
-
from glaip_sdk.exceptions import (
|
|
21
|
-
AuthenticationError,
|
|
22
|
-
ConflictError,
|
|
23
|
-
ForbiddenError,
|
|
24
|
-
NotFoundError,
|
|
25
|
-
RateLimitError,
|
|
26
|
-
ServerError,
|
|
27
|
-
TimeoutError,
|
|
28
|
-
ValidationError,
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
# Set up logging without basicConfig (library best practice)
|
|
32
|
-
logger = logging.getLogger("glaip_sdk")
|
|
33
|
-
logger.addHandler(logging.NullHandler())
|
|
34
|
-
|
|
35
|
-
client_log = logging.getLogger("glaip_sdk.client")
|
|
36
|
-
client_log.addHandler(logging.NullHandler())
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
class BaseClient:
|
|
40
|
-
"""Base client with HTTP operations and authentication."""
|
|
41
|
-
|
|
42
|
-
def __init__(
|
|
43
|
-
self,
|
|
44
|
-
api_url: str | None = None,
|
|
45
|
-
api_key: str | None = None,
|
|
46
|
-
timeout: float = 30.0,
|
|
47
|
-
*,
|
|
48
|
-
parent_client: Union["BaseClient", None] = None,
|
|
49
|
-
load_env: bool = True,
|
|
50
|
-
):
|
|
51
|
-
"""Initialize the base client.
|
|
52
|
-
|
|
53
|
-
Args:
|
|
54
|
-
api_url: API base URL
|
|
55
|
-
api_key: API authentication key
|
|
56
|
-
timeout: Request timeout in seconds
|
|
57
|
-
parent_client: Parent client to adopt session/config from
|
|
58
|
-
load_env: Whether to load environment variables
|
|
59
|
-
"""
|
|
60
|
-
self._parent_client = parent_client
|
|
61
|
-
self._session_scoped = False # Mark as not session-scoped by default
|
|
62
|
-
|
|
63
|
-
if parent_client is not None:
|
|
64
|
-
# Adopt parent's session/config; DO NOT call super().__init__
|
|
65
|
-
client_log.debug("Adopting parent client configuration")
|
|
66
|
-
self.api_url = parent_client.api_url
|
|
67
|
-
self.api_key = parent_client.api_key
|
|
68
|
-
self._timeout = parent_client._timeout
|
|
69
|
-
self.http_client = parent_client.http_client
|
|
70
|
-
else:
|
|
71
|
-
# Initialize as standalone client
|
|
72
|
-
if load_env and not (api_url and api_key):
|
|
73
|
-
# Only load .env file if explicit credentials not provided
|
|
74
|
-
package_dir = os.path.dirname(glaip_sdk.__file__)
|
|
75
|
-
env_file = os.path.join(package_dir, ".env")
|
|
76
|
-
load_dotenv(env_file)
|
|
77
|
-
|
|
78
|
-
self.api_url = api_url or os.getenv("AIP_API_URL")
|
|
79
|
-
self.api_key = api_key or os.getenv("AIP_API_KEY")
|
|
80
|
-
self._timeout = timeout
|
|
81
|
-
|
|
82
|
-
if not self.api_url:
|
|
83
|
-
client_log.error("AIP_API_URL not found in environment or parameters")
|
|
84
|
-
raise ValueError("AIP_API_URL not found")
|
|
85
|
-
if not self.api_key:
|
|
86
|
-
client_log.error("AIP_API_KEY not found in environment or parameters")
|
|
87
|
-
raise ValueError("AIP_API_KEY not found")
|
|
88
|
-
|
|
89
|
-
client_log.info(f"Initializing client with API URL: {self.api_url}")
|
|
90
|
-
self.http_client = self._build_client(timeout)
|
|
91
|
-
|
|
92
|
-
def _build_client(self, timeout: float) -> httpx.Client:
|
|
93
|
-
"""Build HTTP client with configuration."""
|
|
94
|
-
# For streaming operations, we need more generous read timeouts
|
|
95
|
-
# while keeping reasonable connect timeouts
|
|
96
|
-
timeout_config = httpx.Timeout(
|
|
97
|
-
timeout=timeout, # Total timeout
|
|
98
|
-
connect=min(30.0, timeout), # Connect timeout (max 30s)
|
|
99
|
-
read=timeout, # Read timeout (same as total for streaming)
|
|
100
|
-
write=min(30.0, timeout), # Write timeout (max 30s)
|
|
101
|
-
pool=timeout, # Pool timeout (same as total)
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
return httpx.Client(
|
|
105
|
-
base_url=self.api_url,
|
|
106
|
-
headers={
|
|
107
|
-
"X-API-Key": self.api_key,
|
|
108
|
-
"User-Agent": f"{SDK_NAME}/{SDK_VERSION}",
|
|
109
|
-
},
|
|
110
|
-
timeout=timeout_config,
|
|
111
|
-
follow_redirects=True,
|
|
112
|
-
http2=False,
|
|
113
|
-
limits=httpx.Limits(max_keepalive_connections=10, max_connections=100),
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
def _build_async_client(self, timeout: float) -> dict[str, Any]:
|
|
117
|
-
"""Build async client configuration (returns dict of kwargs for httpx.AsyncClient).
|
|
118
|
-
|
|
119
|
-
Args:
|
|
120
|
-
timeout: Request timeout in seconds
|
|
121
|
-
|
|
122
|
-
Returns:
|
|
123
|
-
Dictionary of kwargs for httpx.AsyncClient
|
|
124
|
-
"""
|
|
125
|
-
# For streaming operations, we need more generous read timeouts
|
|
126
|
-
# while keeping reasonable connect timeouts
|
|
127
|
-
timeout_config = httpx.Timeout(
|
|
128
|
-
timeout=timeout, # Total timeout
|
|
129
|
-
connect=min(30.0, timeout), # Connect timeout (max 30s)
|
|
130
|
-
read=timeout, # Read timeout (same as total for streaming)
|
|
131
|
-
write=min(30.0, timeout), # Write timeout (max 30s)
|
|
132
|
-
pool=timeout, # Pool timeout (same as total)
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
"base_url": self.api_url,
|
|
137
|
-
"headers": {
|
|
138
|
-
"X-API-Key": self.api_key,
|
|
139
|
-
"User-Agent": f"{SDK_NAME}/{SDK_VERSION}",
|
|
140
|
-
},
|
|
141
|
-
"timeout": timeout_config,
|
|
142
|
-
"follow_redirects": True,
|
|
143
|
-
"http2": False,
|
|
144
|
-
"limits": httpx.Limits(max_keepalive_connections=10, max_connections=100),
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
@property
|
|
148
|
-
def timeout(self) -> float:
|
|
149
|
-
"""Get current timeout value."""
|
|
150
|
-
return self._timeout
|
|
151
|
-
|
|
152
|
-
@timeout.setter
|
|
153
|
-
def timeout(self, value: float) -> None:
|
|
154
|
-
"""Set timeout and rebuild client."""
|
|
155
|
-
self._timeout = value
|
|
156
|
-
if hasattr(self, "http_client") and self.http_client and not self._session_scoped and not self._parent_client:
|
|
157
|
-
self.http_client.close()
|
|
158
|
-
self.http_client = self._build_client(value)
|
|
159
|
-
|
|
160
|
-
def _post_then_fetch(
|
|
161
|
-
self,
|
|
162
|
-
id_key: str,
|
|
163
|
-
post_endpoint: str,
|
|
164
|
-
get_endpoint_fmt: str,
|
|
165
|
-
*,
|
|
166
|
-
json: Any | None = None,
|
|
167
|
-
data: Any | None = None,
|
|
168
|
-
files: Any | None = None,
|
|
169
|
-
**kwargs: Any,
|
|
170
|
-
) -> Any:
|
|
171
|
-
"""Helper for POST-then-GET pattern used in create methods.
|
|
172
|
-
|
|
173
|
-
Args:
|
|
174
|
-
id_key: Key in POST response containing the ID
|
|
175
|
-
post_endpoint: Endpoint for POST request
|
|
176
|
-
get_endpoint_fmt: Format string for GET endpoint (e.g., "/items/{id}")
|
|
177
|
-
json: JSON data for POST
|
|
178
|
-
data: Form data for POST
|
|
179
|
-
files: Files for POST
|
|
180
|
-
**kwargs: Additional kwargs for POST
|
|
181
|
-
|
|
182
|
-
Returns:
|
|
183
|
-
Full resource data from GET request
|
|
184
|
-
"""
|
|
185
|
-
# Create the resource
|
|
186
|
-
post_kwargs = {}
|
|
187
|
-
if json is not None:
|
|
188
|
-
post_kwargs["json"] = json
|
|
189
|
-
if data is not None:
|
|
190
|
-
post_kwargs["data"] = data
|
|
191
|
-
if files is not None:
|
|
192
|
-
post_kwargs["files"] = files
|
|
193
|
-
post_kwargs.update(kwargs)
|
|
194
|
-
|
|
195
|
-
response_data = self._request("POST", post_endpoint, **post_kwargs)
|
|
196
|
-
|
|
197
|
-
# Extract the ID
|
|
198
|
-
if isinstance(response_data, dict):
|
|
199
|
-
resource_id = response_data.get(id_key)
|
|
200
|
-
else:
|
|
201
|
-
# Fallback: assume response_data is the ID directly
|
|
202
|
-
resource_id = str(response_data)
|
|
203
|
-
|
|
204
|
-
if not resource_id:
|
|
205
|
-
raise ValueError(f"Backend did not return {id_key}")
|
|
206
|
-
|
|
207
|
-
# Fetch the full resource details
|
|
208
|
-
get_endpoint = get_endpoint_fmt.format(id=resource_id)
|
|
209
|
-
return self._request("GET", get_endpoint)
|
|
210
|
-
|
|
211
|
-
def _ensure_client_alive(self) -> None:
|
|
212
|
-
"""Ensure HTTP client is alive, recreate if needed."""
|
|
213
|
-
if not hasattr(self, "http_client") or self.http_client is None:
|
|
214
|
-
if not self._parent_client:
|
|
215
|
-
self.http_client = self._build_client(self._timeout)
|
|
216
|
-
return
|
|
217
|
-
|
|
218
|
-
# Check if client is closed by attempting a simple operation
|
|
219
|
-
try:
|
|
220
|
-
# Try to access a property that would fail if closed
|
|
221
|
-
_ = self.http_client.headers
|
|
222
|
-
except (RuntimeError, AttributeError) as e:
|
|
223
|
-
if "closed" in str(e).lower() or "NoneType" in str(e):
|
|
224
|
-
client_log.debug("HTTP client was closed, recreating")
|
|
225
|
-
if not self._parent_client:
|
|
226
|
-
self.http_client = self._build_client(self._timeout)
|
|
227
|
-
else:
|
|
228
|
-
raise
|
|
229
|
-
|
|
230
|
-
def _perform_request(
|
|
231
|
-
self,
|
|
232
|
-
method: str,
|
|
233
|
-
endpoint: str,
|
|
234
|
-
**kwargs: Any,
|
|
235
|
-
) -> httpx.Response:
|
|
236
|
-
"""Execute a raw HTTP request with retry handling."""
|
|
237
|
-
# Ensure client is alive before making request
|
|
238
|
-
self._ensure_client_alive()
|
|
239
|
-
|
|
240
|
-
client_log.debug(f"Making {method} request to {endpoint}")
|
|
241
|
-
try:
|
|
242
|
-
response = self.http_client.request(method, endpoint, **kwargs)
|
|
243
|
-
client_log.debug(f"Response status: {response.status_code}")
|
|
244
|
-
return response
|
|
245
|
-
except httpx.ConnectError as e:
|
|
246
|
-
client_log.warning(f"Connection error on {method} {endpoint}, retrying once: {e}")
|
|
247
|
-
try:
|
|
248
|
-
response = self.http_client.request(method, endpoint, **kwargs)
|
|
249
|
-
client_log.debug(f"Retry successful, response status: {response.status_code}")
|
|
250
|
-
return response
|
|
251
|
-
except httpx.ConnectError:
|
|
252
|
-
client_log.error(f"Retry failed for {method} {endpoint}: {e}")
|
|
253
|
-
raise
|
|
254
|
-
|
|
255
|
-
def _request(self, method: str, endpoint: str, **kwargs) -> Any:
|
|
256
|
-
"""Make HTTP request with error handling and unwrap success envelopes."""
|
|
257
|
-
response = self._perform_request(method, endpoint, **kwargs)
|
|
258
|
-
return self._handle_response(response, unwrap=True)
|
|
259
|
-
|
|
260
|
-
def _request_with_envelope(
|
|
261
|
-
self,
|
|
262
|
-
method: str,
|
|
263
|
-
endpoint: str,
|
|
264
|
-
**kwargs: Any,
|
|
265
|
-
) -> Any:
|
|
266
|
-
"""Make HTTP request but return the full success envelope."""
|
|
267
|
-
response = self._perform_request(method, endpoint, **kwargs)
|
|
268
|
-
return self._handle_response(response, unwrap=False)
|
|
269
|
-
|
|
270
|
-
def _parse_response_content(self, response: httpx.Response) -> Any | None:
|
|
271
|
-
"""Parse response content based on content type."""
|
|
272
|
-
if response.status_code == 204:
|
|
273
|
-
return None
|
|
274
|
-
|
|
275
|
-
content_type = response.headers.get("content-type", "").lower()
|
|
276
|
-
if "json" in content_type:
|
|
277
|
-
try:
|
|
278
|
-
return response.json()
|
|
279
|
-
except ValueError:
|
|
280
|
-
pass
|
|
281
|
-
|
|
282
|
-
if 200 <= response.status_code < 300:
|
|
283
|
-
return response.text
|
|
284
|
-
else:
|
|
285
|
-
return None # Let _handle_response deal with error status codes
|
|
286
|
-
|
|
287
|
-
def _handle_success_response(self, parsed: Any, *, unwrap: bool) -> Any:
|
|
288
|
-
"""Handle successful response with success flag."""
|
|
289
|
-
if isinstance(parsed, dict) and "success" in parsed:
|
|
290
|
-
if parsed.get("success"):
|
|
291
|
-
return parsed.get("data", parsed) if unwrap else parsed
|
|
292
|
-
else:
|
|
293
|
-
error_type = parsed.get("error", "UnknownError")
|
|
294
|
-
message = self._format_error_dict({key: value for key, value in parsed.items() if key != "success"})
|
|
295
|
-
self._raise_api_error(
|
|
296
|
-
400,
|
|
297
|
-
message,
|
|
298
|
-
error_type,
|
|
299
|
-
payload=parsed, # Using 400 as status since original response had error
|
|
300
|
-
)
|
|
301
|
-
|
|
302
|
-
return parsed
|
|
303
|
-
|
|
304
|
-
def _get_error_message(self, response: httpx.Response) -> str:
|
|
305
|
-
"""Extract error message from response, preferring parsed content."""
|
|
306
|
-
parsed = self._parse_error_json(response)
|
|
307
|
-
if parsed is None:
|
|
308
|
-
return response.text
|
|
309
|
-
|
|
310
|
-
formatted = self._format_parsed_error(parsed)
|
|
311
|
-
return formatted if formatted is not None else response.text
|
|
312
|
-
|
|
313
|
-
def _parse_error_json(self, response: httpx.Response) -> Any | None:
|
|
314
|
-
"""Safely parse JSON from an error response."""
|
|
315
|
-
try:
|
|
316
|
-
return response.json()
|
|
317
|
-
except (ValueError, TypeError):
|
|
318
|
-
return None
|
|
319
|
-
|
|
320
|
-
def _format_parsed_error(self, parsed: Any) -> str | None:
|
|
321
|
-
"""Build a readable error message from parsed JSON payloads."""
|
|
322
|
-
if isinstance(parsed, dict):
|
|
323
|
-
return self._format_error_dict(parsed)
|
|
324
|
-
if isinstance(parsed, str):
|
|
325
|
-
return parsed
|
|
326
|
-
return str(parsed) if parsed else None
|
|
327
|
-
|
|
328
|
-
def _format_error_dict(self, parsed: dict[str, Any]) -> str:
|
|
329
|
-
"""Format structured API error payloads."""
|
|
330
|
-
detail = parsed.get("detail")
|
|
331
|
-
if isinstance(detail, list):
|
|
332
|
-
validation_message = self._format_validation_errors(detail)
|
|
333
|
-
if validation_message:
|
|
334
|
-
return validation_message
|
|
335
|
-
return f"Validation error: {parsed}"
|
|
336
|
-
|
|
337
|
-
formatted_details = None
|
|
338
|
-
if "details" in parsed:
|
|
339
|
-
formatted_details = self._format_error_details(parsed["details"])
|
|
340
|
-
|
|
341
|
-
message = parsed.get("message")
|
|
342
|
-
if message:
|
|
343
|
-
if formatted_details:
|
|
344
|
-
return f"{message}\n{formatted_details}"
|
|
345
|
-
return message
|
|
346
|
-
|
|
347
|
-
if formatted_details:
|
|
348
|
-
return formatted_details
|
|
349
|
-
|
|
350
|
-
return str(parsed) if parsed else DEFAULT_ERROR_MESSAGE
|
|
351
|
-
|
|
352
|
-
def _format_error_details(self, details: Any) -> str | None:
|
|
353
|
-
"""Render generic error details into a human-readable string."""
|
|
354
|
-
if details is None:
|
|
355
|
-
return None
|
|
356
|
-
|
|
357
|
-
if isinstance(details, dict):
|
|
358
|
-
return self._format_detail_mapping(details)
|
|
359
|
-
|
|
360
|
-
if isinstance(details, (list, tuple, set)):
|
|
361
|
-
return self._format_detail_iterable(details)
|
|
362
|
-
|
|
363
|
-
return f"Details: {details}"
|
|
364
|
-
|
|
365
|
-
@staticmethod
|
|
366
|
-
def _format_detail_mapping(details: Mapping[str, Any]) -> str | None:
|
|
367
|
-
"""Format details provided as a mapping."""
|
|
368
|
-
entries = [f" {key}: {value}" for key, value in details.items()]
|
|
369
|
-
if not entries:
|
|
370
|
-
return None
|
|
371
|
-
return "Details:\n" + "\n".join(entries)
|
|
372
|
-
|
|
373
|
-
@staticmethod
|
|
374
|
-
def _format_detail_iterable(details: Iterable[Any]) -> str | None:
|
|
375
|
-
"""Format details provided as an iterable collection."""
|
|
376
|
-
entries: list[str] = []
|
|
377
|
-
for item in details:
|
|
378
|
-
if isinstance(item, Mapping):
|
|
379
|
-
inner = ", ".join(f"{k}={v}" for k, v in item.items())
|
|
380
|
-
entries.append(f" - {inner if inner else '{}'}")
|
|
381
|
-
else:
|
|
382
|
-
entries.append(f" - {item}")
|
|
383
|
-
|
|
384
|
-
if not entries:
|
|
385
|
-
return None
|
|
386
|
-
|
|
387
|
-
return "Details:\n" + "\n".join(entries)
|
|
388
|
-
|
|
389
|
-
def _format_validation_errors(self, errors: list[Any]) -> str | None:
|
|
390
|
-
"""Render validation errors into a human-readable string."""
|
|
391
|
-
entries: list[str] = []
|
|
392
|
-
for error in errors:
|
|
393
|
-
if isinstance(error, dict):
|
|
394
|
-
loc = " -> ".join(str(x) for x in error.get("loc", []))
|
|
395
|
-
msg = error.get("msg", DEFAULT_ERROR_MESSAGE)
|
|
396
|
-
error_type = error.get("type", "unknown")
|
|
397
|
-
prefix = loc if loc else "Field"
|
|
398
|
-
entries.append(f" {prefix}: {msg} ({error_type})")
|
|
399
|
-
else:
|
|
400
|
-
entries.append(f" {error}")
|
|
401
|
-
|
|
402
|
-
if not entries:
|
|
403
|
-
return None
|
|
404
|
-
|
|
405
|
-
return "Validation errors:\n" + "\n".join(entries)
|
|
406
|
-
|
|
407
|
-
@staticmethod
|
|
408
|
-
def _is_no_content_response(response: httpx.Response) -> bool:
|
|
409
|
-
"""Return True when the response contains no content."""
|
|
410
|
-
return response.status_code == 204
|
|
411
|
-
|
|
412
|
-
@staticmethod
|
|
413
|
-
def _is_success_status(response: httpx.Response) -> bool:
|
|
414
|
-
"""Return True for successful HTTP status codes."""
|
|
415
|
-
return 200 <= response.status_code < 300
|
|
416
|
-
|
|
417
|
-
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
418
|
-
"""Raise an API error for non-success responses."""
|
|
419
|
-
error_message = self._get_error_message(response)
|
|
420
|
-
parsed_content = self._parse_response_content(response)
|
|
421
|
-
self._raise_api_error(response.status_code, error_message, payload=parsed_content)
|
|
422
|
-
|
|
423
|
-
def _handle_response(
|
|
424
|
-
self,
|
|
425
|
-
response: httpx.Response,
|
|
426
|
-
*,
|
|
427
|
-
unwrap: bool = True,
|
|
428
|
-
) -> Any:
|
|
429
|
-
"""Handle HTTP response with proper error handling."""
|
|
430
|
-
# Handle no-content success before general error handling
|
|
431
|
-
if self._is_no_content_response(response):
|
|
432
|
-
return None
|
|
433
|
-
|
|
434
|
-
if self._is_success_status(response):
|
|
435
|
-
parsed = self._parse_response_content(response)
|
|
436
|
-
if parsed is None:
|
|
437
|
-
return None
|
|
438
|
-
return self._handle_success_response(parsed, unwrap=unwrap)
|
|
439
|
-
|
|
440
|
-
self._handle_error_response(response)
|
|
441
|
-
return None
|
|
442
|
-
|
|
443
|
-
def _raise_api_error(
|
|
444
|
-
self,
|
|
445
|
-
status: int,
|
|
446
|
-
message: str,
|
|
447
|
-
error_type: str | None = None,
|
|
448
|
-
*,
|
|
449
|
-
payload: Any | None = None,
|
|
450
|
-
) -> NoReturn:
|
|
451
|
-
"""Raise appropriate exception with rich context."""
|
|
452
|
-
request_id = None
|
|
453
|
-
try:
|
|
454
|
-
request_id = self.http_client.headers.get("X-Request-Id")
|
|
455
|
-
except Exception:
|
|
456
|
-
pass
|
|
457
|
-
|
|
458
|
-
mapping = {
|
|
459
|
-
400: ValidationError,
|
|
460
|
-
401: AuthenticationError,
|
|
461
|
-
403: ForbiddenError,
|
|
462
|
-
404: NotFoundError,
|
|
463
|
-
408: TimeoutError,
|
|
464
|
-
409: ConflictError,
|
|
465
|
-
429: RateLimitError,
|
|
466
|
-
500: ServerError,
|
|
467
|
-
503: ServerError,
|
|
468
|
-
504: TimeoutError,
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
exception_class = mapping.get(status, ValidationError)
|
|
472
|
-
error_msg = f"HTTP {status}: {message}"
|
|
473
|
-
if request_id:
|
|
474
|
-
error_msg += f" (Request ID: {request_id})"
|
|
475
|
-
|
|
476
|
-
raise exception_class(
|
|
477
|
-
error_msg,
|
|
478
|
-
status_code=status,
|
|
479
|
-
error_type=error_type,
|
|
480
|
-
payload=payload,
|
|
481
|
-
request_id=request_id,
|
|
482
|
-
)
|
|
483
|
-
|
|
484
|
-
def close(self) -> None:
|
|
485
|
-
"""Close the HTTP client."""
|
|
486
|
-
if hasattr(self, "http_client") and self.http_client and not self._session_scoped and not self._parent_client:
|
|
487
|
-
self.http_client.close()
|
|
488
|
-
|
|
489
|
-
def __enter__(self) -> "BaseClient":
|
|
490
|
-
"""Context manager entry."""
|
|
491
|
-
return self
|
|
492
|
-
|
|
493
|
-
def __exit__(
|
|
494
|
-
self,
|
|
495
|
-
_exc_type: type[BaseException] | None,
|
|
496
|
-
_exc_val: BaseException | None,
|
|
497
|
-
_exc_tb: Any,
|
|
498
|
-
) -> None:
|
|
499
|
-
"""Context manager exit."""
|
|
500
|
-
# Only close if this is not session-scoped
|
|
501
|
-
if not self._session_scoped:
|
|
502
|
-
self.close()
|