knowledge2 0.4.0__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.
- knowledge2-0.4.0.dist-info/METADATA +556 -0
- knowledge2-0.4.0.dist-info/RECORD +139 -0
- knowledge2-0.4.0.dist-info/WHEEL +5 -0
- knowledge2-0.4.0.dist-info/top_level.txt +1 -0
- sdk/__init__.py +70 -0
- sdk/_async_base.py +525 -0
- sdk/_async_paging.py +57 -0
- sdk/_base.py +541 -0
- sdk/_logging.py +41 -0
- sdk/_paging.py +73 -0
- sdk/_preview.py +70 -0
- sdk/_raw_response.py +25 -0
- sdk/_request_options.py +51 -0
- sdk/_transport.py +144 -0
- sdk/_validation.py +25 -0
- sdk/_validation_response.py +36 -0
- sdk/_version.py +3 -0
- sdk/async_client.py +320 -0
- sdk/async_resources/__init__.py +45 -0
- sdk/async_resources/_mixin_base.py +42 -0
- sdk/async_resources/a2a.py +230 -0
- sdk/async_resources/agents.py +489 -0
- sdk/async_resources/audit.py +145 -0
- sdk/async_resources/auth.py +133 -0
- sdk/async_resources/console.py +409 -0
- sdk/async_resources/corpora.py +276 -0
- sdk/async_resources/deployments.py +106 -0
- sdk/async_resources/documents.py +592 -0
- sdk/async_resources/feeds.py +248 -0
- sdk/async_resources/indexes.py +208 -0
- sdk/async_resources/jobs.py +165 -0
- sdk/async_resources/metadata.py +48 -0
- sdk/async_resources/models.py +102 -0
- sdk/async_resources/onboarding.py +538 -0
- sdk/async_resources/orgs.py +37 -0
- sdk/async_resources/pipelines.py +523 -0
- sdk/async_resources/projects.py +90 -0
- sdk/async_resources/search.py +262 -0
- sdk/async_resources/training.py +357 -0
- sdk/async_resources/usage.py +91 -0
- sdk/client.py +417 -0
- sdk/config.py +182 -0
- sdk/errors.py +178 -0
- sdk/examples/auth_factory.py +34 -0
- sdk/examples/batch_operations.py +57 -0
- sdk/examples/document_upload.py +56 -0
- sdk/examples/e2e_lifecycle.py +213 -0
- sdk/examples/error_handling.py +61 -0
- sdk/examples/pagination.py +64 -0
- sdk/examples/quickstart.py +36 -0
- sdk/examples/request_options.py +44 -0
- sdk/examples/search.py +64 -0
- sdk/integrations/__init__.py +57 -0
- sdk/integrations/_client.py +101 -0
- sdk/integrations/langchain/__init__.py +6 -0
- sdk/integrations/langchain/retriever.py +166 -0
- sdk/integrations/langchain/tools.py +108 -0
- sdk/integrations/llamaindex/__init__.py +11 -0
- sdk/integrations/llamaindex/filters.py +78 -0
- sdk/integrations/llamaindex/retriever.py +162 -0
- sdk/integrations/llamaindex/tools.py +109 -0
- sdk/integrations/llamaindex/vector_store.py +320 -0
- sdk/models/__init__.py +18 -0
- sdk/models/_base.py +24 -0
- sdk/models/_registry.py +457 -0
- sdk/models/a2a.py +92 -0
- sdk/models/agents.py +109 -0
- sdk/models/audit.py +28 -0
- sdk/models/auth.py +49 -0
- sdk/models/chunks.py +20 -0
- sdk/models/common.py +14 -0
- sdk/models/console.py +103 -0
- sdk/models/corpora.py +48 -0
- sdk/models/deployments.py +13 -0
- sdk/models/documents.py +126 -0
- sdk/models/embeddings.py +24 -0
- sdk/models/evaluation.py +17 -0
- sdk/models/feedback.py +9 -0
- sdk/models/feeds.py +57 -0
- sdk/models/indexes.py +36 -0
- sdk/models/jobs.py +52 -0
- sdk/models/models.py +26 -0
- sdk/models/onboarding.py +323 -0
- sdk/models/orgs.py +11 -0
- sdk/models/pipelines.py +147 -0
- sdk/models/projects.py +19 -0
- sdk/models/search.py +149 -0
- sdk/models/training.py +57 -0
- sdk/models/usage.py +39 -0
- sdk/namespaces.py +386 -0
- sdk/py.typed +0 -0
- sdk/resources/__init__.py +45 -0
- sdk/resources/_mixin_base.py +40 -0
- sdk/resources/a2a.py +230 -0
- sdk/resources/agents.py +487 -0
- sdk/resources/audit.py +144 -0
- sdk/resources/auth.py +138 -0
- sdk/resources/console.py +411 -0
- sdk/resources/corpora.py +269 -0
- sdk/resources/deployments.py +105 -0
- sdk/resources/documents.py +597 -0
- sdk/resources/feeds.py +246 -0
- sdk/resources/indexes.py +210 -0
- sdk/resources/jobs.py +164 -0
- sdk/resources/metadata.py +53 -0
- sdk/resources/models.py +99 -0
- sdk/resources/onboarding.py +542 -0
- sdk/resources/orgs.py +35 -0
- sdk/resources/pipeline_builder.py +257 -0
- sdk/resources/pipelines.py +520 -0
- sdk/resources/projects.py +87 -0
- sdk/resources/search.py +277 -0
- sdk/resources/training.py +358 -0
- sdk/resources/usage.py +92 -0
- sdk/types/__init__.py +366 -0
- sdk/types/a2a.py +88 -0
- sdk/types/agents.py +133 -0
- sdk/types/audit.py +26 -0
- sdk/types/auth.py +45 -0
- sdk/types/chunks.py +18 -0
- sdk/types/common.py +10 -0
- sdk/types/console.py +99 -0
- sdk/types/corpora.py +42 -0
- sdk/types/deployments.py +11 -0
- sdk/types/documents.py +104 -0
- sdk/types/embeddings.py +22 -0
- sdk/types/evaluation.py +15 -0
- sdk/types/feedback.py +7 -0
- sdk/types/feeds.py +61 -0
- sdk/types/indexes.py +30 -0
- sdk/types/jobs.py +50 -0
- sdk/types/models.py +22 -0
- sdk/types/onboarding.py +395 -0
- sdk/types/orgs.py +9 -0
- sdk/types/pipelines.py +177 -0
- sdk/types/projects.py +14 -0
- sdk/types/search.py +116 -0
- sdk/types/training.py +55 -0
- sdk/types/usage.py +37 -0
sdk/_base.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Base HTTP client for the Knowledge2 SDK.
|
|
2
|
+
|
|
3
|
+
Provides :class:`BaseClient` which handles HTTP transport, automatic
|
|
4
|
+
retries with exponential backoff, error classification, pagination,
|
|
5
|
+
and debug logging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
15
|
+
|
|
16
|
+
from sdk._paging import Page, SyncPager
|
|
17
|
+
from sdk._raw_response import RawResponse
|
|
18
|
+
from sdk._validation_response import maybe_validate
|
|
19
|
+
|
|
20
|
+
try: # Python 3.11+
|
|
21
|
+
from typing import Self
|
|
22
|
+
except ImportError: # pragma: no cover - Python < 3.11
|
|
23
|
+
from typing_extensions import Self
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from sdk._logging import _redact_headers, logger
|
|
28
|
+
from sdk._transport import build_auth_headers, calculate_backoff, error_from_response
|
|
29
|
+
from sdk._version import __version__
|
|
30
|
+
from sdk.errors import (
|
|
31
|
+
APIConnectionError,
|
|
32
|
+
APIError,
|
|
33
|
+
APITimeoutError,
|
|
34
|
+
Knowledge2Error,
|
|
35
|
+
RateLimitError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from sdk._request_options import RequestOptions
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ClientTimeouts:
|
|
44
|
+
"""Per-phase HTTP timeout configuration.
|
|
45
|
+
|
|
46
|
+
All values are in seconds. ``None`` means no limit for that phase.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
connect: float | None = 5.0
|
|
50
|
+
read: float | None = 60.0
|
|
51
|
+
write: float | None = 30.0
|
|
52
|
+
pool: float | None = 10.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ClientLimits:
|
|
57
|
+
"""HTTP connection pool limits for the SDK client."""
|
|
58
|
+
|
|
59
|
+
max_connections: int = 20
|
|
60
|
+
max_keepalive_connections: int = 10
|
|
61
|
+
keepalive_expiry: float = 30.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BaseClient:
|
|
65
|
+
@staticmethod
|
|
66
|
+
def _normalize_base_url(base_url: str) -> str:
|
|
67
|
+
"""Normalize and validate base URL input before constructing httpx.Client."""
|
|
68
|
+
normalized = base_url.strip().rstrip("/")
|
|
69
|
+
if not normalized:
|
|
70
|
+
raise ValueError("api_host must not be empty")
|
|
71
|
+
|
|
72
|
+
for idx, char in enumerate(normalized):
|
|
73
|
+
if ord(char) < 32 or ord(char) == 127:
|
|
74
|
+
escaped = repr(char).strip("'")
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"api_host contains invalid control character {escaped} at position {idx}"
|
|
77
|
+
)
|
|
78
|
+
return normalized
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
base_url: str,
|
|
83
|
+
api_key: str | None,
|
|
84
|
+
*,
|
|
85
|
+
bearer_token: str | None = None,
|
|
86
|
+
bearer_token_factory: Callable[[], str] | None = None,
|
|
87
|
+
token_cache_ttl: float = 300.0,
|
|
88
|
+
admin_token: str | None = None,
|
|
89
|
+
headers: dict[str, str] | None = None,
|
|
90
|
+
user_agent: str | None = None,
|
|
91
|
+
timeout: float | ClientTimeouts | httpx.Timeout | None = None,
|
|
92
|
+
limits: ClientLimits | None = None,
|
|
93
|
+
max_retries: int = 2,
|
|
94
|
+
validate_responses: bool = False,
|
|
95
|
+
http_client: httpx.Client | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
if bearer_token and bearer_token_factory:
|
|
98
|
+
raise ValueError("Cannot specify both 'bearer_token' and 'bearer_token_factory'")
|
|
99
|
+
|
|
100
|
+
self.base_url = self._normalize_base_url(base_url)
|
|
101
|
+
self.api_key = api_key
|
|
102
|
+
self.bearer_token = bearer_token
|
|
103
|
+
self.admin_token = admin_token
|
|
104
|
+
self._bearer_token_factory = bearer_token_factory
|
|
105
|
+
self._token_cache_ttl = token_cache_ttl
|
|
106
|
+
self._cached_token: str | None = None
|
|
107
|
+
self._token_expires_at: float = 0.0
|
|
108
|
+
self._token_lock = threading.Lock()
|
|
109
|
+
self._default_headers = dict(headers or {})
|
|
110
|
+
self._user_agent = user_agent or f"k2-python-sdk/{__version__}"
|
|
111
|
+
self._max_retries = max_retries
|
|
112
|
+
self._backoff_factor = 0.5
|
|
113
|
+
self._backoff_max = 8.0
|
|
114
|
+
self._validate_responses = validate_responses
|
|
115
|
+
self._raw_response_flag: threading.local = threading.local()
|
|
116
|
+
|
|
117
|
+
if http_client is not None:
|
|
118
|
+
# Caller-supplied client — SDK does NOT own it.
|
|
119
|
+
self._client = http_client
|
|
120
|
+
self._owns_http_client = False
|
|
121
|
+
if timeout is not None or limits is not None:
|
|
122
|
+
logger.warning(
|
|
123
|
+
"When a caller-supplied http_client is provided, the SDK-level "
|
|
124
|
+
"'timeout' and 'limits' parameters are ignored. Configure these "
|
|
125
|
+
"settings on your httpx.Client instance directly."
|
|
126
|
+
)
|
|
127
|
+
else:
|
|
128
|
+
# SDK constructs and owns the client.
|
|
129
|
+
self._owns_http_client = True
|
|
130
|
+
|
|
131
|
+
# Resolve ClientTimeouts → httpx.Timeout
|
|
132
|
+
if isinstance(timeout, ClientTimeouts):
|
|
133
|
+
resolved_timeout: float | httpx.Timeout | None = httpx.Timeout(
|
|
134
|
+
connect=timeout.connect,
|
|
135
|
+
read=timeout.read,
|
|
136
|
+
write=timeout.write,
|
|
137
|
+
pool=timeout.pool,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
resolved_timeout = timeout
|
|
141
|
+
|
|
142
|
+
# Build httpx.Client with optional limits
|
|
143
|
+
client_kwargs: dict[str, Any] = {
|
|
144
|
+
"base_url": self.base_url,
|
|
145
|
+
"timeout": resolved_timeout,
|
|
146
|
+
}
|
|
147
|
+
if limits is not None:
|
|
148
|
+
client_kwargs["limits"] = httpx.Limits(
|
|
149
|
+
max_connections=limits.max_connections,
|
|
150
|
+
max_keepalive_connections=limits.max_keepalive_connections,
|
|
151
|
+
keepalive_expiry=limits.keepalive_expiry,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self._client = httpx.Client(**client_kwargs)
|
|
155
|
+
|
|
156
|
+
def __enter__(self) -> Self:
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
160
|
+
self.close()
|
|
161
|
+
|
|
162
|
+
def close(self) -> None:
|
|
163
|
+
"""Close the underlying HTTP client.
|
|
164
|
+
|
|
165
|
+
If the ``httpx.Client`` was supplied by the caller, this method
|
|
166
|
+
is a no-op — the caller retains ownership and responsibility
|
|
167
|
+
for closing it.
|
|
168
|
+
"""
|
|
169
|
+
if self._owns_http_client:
|
|
170
|
+
self._client.close()
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
# Response validation
|
|
174
|
+
# ------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _maybe_validate(self, data: Any, model_name: str) -> Any:
|
|
177
|
+
"""Validate response data through its Pydantic model if validation is enabled."""
|
|
178
|
+
return maybe_validate(
|
|
179
|
+
data, model_name, validate=self._validate_responses, raw_response_cls=RawResponse
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Token factory helpers
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _resolve_bearer_token(self) -> str | None:
|
|
187
|
+
"""Return the current bearer token, calling the factory if needed."""
|
|
188
|
+
if self.bearer_token:
|
|
189
|
+
return self.bearer_token
|
|
190
|
+
if self._bearer_token_factory is None:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
now = time.monotonic()
|
|
194
|
+
if self._cached_token is not None and now < self._token_expires_at:
|
|
195
|
+
return self._cached_token
|
|
196
|
+
|
|
197
|
+
with self._token_lock:
|
|
198
|
+
# Double-check after acquiring lock
|
|
199
|
+
now = time.monotonic()
|
|
200
|
+
if self._cached_token is not None and now < self._token_expires_at:
|
|
201
|
+
return self._cached_token
|
|
202
|
+
token = self._bearer_token_factory()
|
|
203
|
+
self._cached_token = token
|
|
204
|
+
if self._token_cache_ttl > 0:
|
|
205
|
+
self._token_expires_at = now + self._token_cache_ttl
|
|
206
|
+
else:
|
|
207
|
+
# TTL=0 means no caching — expire immediately
|
|
208
|
+
self._token_expires_at = 0.0
|
|
209
|
+
return token
|
|
210
|
+
|
|
211
|
+
def _clear_token_cache(self) -> None:
|
|
212
|
+
"""Clear the cached bearer token (e.g. after a 401)."""
|
|
213
|
+
with self._token_lock:
|
|
214
|
+
self._cached_token = None
|
|
215
|
+
self._token_expires_at = 0.0
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Header helpers
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
222
|
+
resolved_token = self._resolve_bearer_token()
|
|
223
|
+
return build_auth_headers(
|
|
224
|
+
api_key=self.api_key,
|
|
225
|
+
bearer_token=resolved_token,
|
|
226
|
+
admin_token=self.admin_token,
|
|
227
|
+
user_agent=self._user_agent,
|
|
228
|
+
default_headers=self._default_headers,
|
|
229
|
+
extra=extra,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def _idempotency_headers(idempotency_key: str | None) -> dict[str, str]:
|
|
234
|
+
if not idempotency_key:
|
|
235
|
+
return {}
|
|
236
|
+
return {"Idempotency-Key": idempotency_key}
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# Retry helpers
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def _backoff_delay(self, attempt: int, error: Knowledge2Error | None = None) -> float:
|
|
243
|
+
"""Calculate backoff delay with jitter for retry attempt *attempt*."""
|
|
244
|
+
return calculate_backoff(
|
|
245
|
+
attempt,
|
|
246
|
+
error,
|
|
247
|
+
backoff_factor=self._backoff_factor,
|
|
248
|
+
backoff_max=self._backoff_max,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Core request with retry
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def _request(
|
|
256
|
+
self,
|
|
257
|
+
method: str,
|
|
258
|
+
path: str,
|
|
259
|
+
*,
|
|
260
|
+
headers: dict[str, str] | None = None,
|
|
261
|
+
request_options: RequestOptions | None = None,
|
|
262
|
+
**kwargs: Any,
|
|
263
|
+
) -> Any:
|
|
264
|
+
"""Send an HTTP request with automatic retry on transient failures.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
method: HTTP method.
|
|
268
|
+
path: API path.
|
|
269
|
+
headers: Extra headers for this request.
|
|
270
|
+
request_options: Per-call overrides for timeout, retries,
|
|
271
|
+
and passthrough headers.
|
|
272
|
+
**kwargs: Forwarded to ``httpx.Client.request()``.
|
|
273
|
+
"""
|
|
274
|
+
# Resolve per-call overrides from RequestOptions
|
|
275
|
+
effective_retries = self._max_retries
|
|
276
|
+
if request_options is not None:
|
|
277
|
+
if request_options.max_retries is not None:
|
|
278
|
+
effective_retries = request_options.max_retries
|
|
279
|
+
if request_options.timeout is not None:
|
|
280
|
+
ct = request_options.timeout
|
|
281
|
+
kwargs["timeout"] = httpx.Timeout(
|
|
282
|
+
connect=ct.connect,
|
|
283
|
+
read=ct.read,
|
|
284
|
+
write=ct.write,
|
|
285
|
+
pool=ct.pool,
|
|
286
|
+
)
|
|
287
|
+
if request_options.passthrough_headers:
|
|
288
|
+
headers = {**(headers or {}), **request_options.passthrough_headers}
|
|
289
|
+
|
|
290
|
+
last_error: Knowledge2Error | None = None
|
|
291
|
+
merged_headers = self._headers(headers)
|
|
292
|
+
return_raw = getattr(self._raw_response_flag, "enabled", False)
|
|
293
|
+
|
|
294
|
+
for attempt in range(1 + effective_retries):
|
|
295
|
+
try:
|
|
296
|
+
logger.debug(
|
|
297
|
+
"%s %s (attempt %d/%d) headers=%s",
|
|
298
|
+
method,
|
|
299
|
+
path,
|
|
300
|
+
attempt + 1,
|
|
301
|
+
1 + effective_retries,
|
|
302
|
+
_redact_headers(merged_headers),
|
|
303
|
+
)
|
|
304
|
+
response = self._client.request(method, path, headers=merged_headers, **kwargs)
|
|
305
|
+
except httpx.ConnectError as exc:
|
|
306
|
+
last_error = APIConnectionError(f"Connection error: {exc}")
|
|
307
|
+
last_error.__cause__ = exc
|
|
308
|
+
if attempt < effective_retries:
|
|
309
|
+
delay = self._backoff_delay(attempt)
|
|
310
|
+
logger.debug(
|
|
311
|
+
"Retry %d/%d after %.2fs (connection error)",
|
|
312
|
+
attempt + 1,
|
|
313
|
+
effective_retries,
|
|
314
|
+
delay,
|
|
315
|
+
)
|
|
316
|
+
time.sleep(delay)
|
|
317
|
+
continue
|
|
318
|
+
raise last_error from exc
|
|
319
|
+
except httpx.TimeoutException as exc:
|
|
320
|
+
last_error = APITimeoutError(f"Request timed out: {exc}")
|
|
321
|
+
last_error.__cause__ = exc
|
|
322
|
+
if attempt < effective_retries:
|
|
323
|
+
delay = self._backoff_delay(attempt)
|
|
324
|
+
logger.debug(
|
|
325
|
+
"Retry %d/%d after %.2fs (timeout)",
|
|
326
|
+
attempt + 1,
|
|
327
|
+
effective_retries,
|
|
328
|
+
delay,
|
|
329
|
+
)
|
|
330
|
+
time.sleep(delay)
|
|
331
|
+
continue
|
|
332
|
+
raise last_error from exc
|
|
333
|
+
except httpx.HTTPError as exc:
|
|
334
|
+
last_error = APIConnectionError(f"Transport error: {exc}")
|
|
335
|
+
last_error.__cause__ = exc
|
|
336
|
+
if attempt < effective_retries:
|
|
337
|
+
delay = self._backoff_delay(attempt)
|
|
338
|
+
logger.debug(
|
|
339
|
+
"Retry %d/%d after %.2fs (transport error)",
|
|
340
|
+
attempt + 1,
|
|
341
|
+
effective_retries,
|
|
342
|
+
delay,
|
|
343
|
+
)
|
|
344
|
+
time.sleep(delay)
|
|
345
|
+
continue
|
|
346
|
+
raise last_error from exc
|
|
347
|
+
|
|
348
|
+
logger.debug(
|
|
349
|
+
"%s %s → %d",
|
|
350
|
+
method,
|
|
351
|
+
path,
|
|
352
|
+
response.status_code,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if response.is_error:
|
|
356
|
+
error = self._error_from_response(response)
|
|
357
|
+
# Clear cached factory token on 401 so the next attempt
|
|
358
|
+
# (or next call) fetches a fresh token.
|
|
359
|
+
if response.status_code == 401 and self._bearer_token_factory:
|
|
360
|
+
self._clear_token_cache()
|
|
361
|
+
if error.retryable and attempt < effective_retries:
|
|
362
|
+
delay = self._backoff_delay(attempt, error)
|
|
363
|
+
logger.debug(
|
|
364
|
+
"Retry %d/%d after %.2fs (status %d)",
|
|
365
|
+
attempt + 1,
|
|
366
|
+
effective_retries,
|
|
367
|
+
delay,
|
|
368
|
+
response.status_code,
|
|
369
|
+
)
|
|
370
|
+
time.sleep(delay)
|
|
371
|
+
last_error = error
|
|
372
|
+
continue
|
|
373
|
+
if return_raw:
|
|
374
|
+
# Wrap HTTP errors into RawResponse instead of raising
|
|
375
|
+
# so callers can inspect status/headers/body.
|
|
376
|
+
if response.content:
|
|
377
|
+
try:
|
|
378
|
+
error_parsed = response.json()
|
|
379
|
+
except ValueError:
|
|
380
|
+
error_parsed = response.text if response.text else None
|
|
381
|
+
else:
|
|
382
|
+
error_parsed = None
|
|
383
|
+
return RawResponse(
|
|
384
|
+
status_code=response.status_code,
|
|
385
|
+
headers=dict(response.headers),
|
|
386
|
+
parsed=error_parsed,
|
|
387
|
+
)
|
|
388
|
+
raise error
|
|
389
|
+
|
|
390
|
+
# Success
|
|
391
|
+
if response.content:
|
|
392
|
+
try:
|
|
393
|
+
parsed = response.json()
|
|
394
|
+
except ValueError as exc:
|
|
395
|
+
raise APIConnectionError(
|
|
396
|
+
f"Expected JSON response but got {response.headers.get('content-type', 'unknown')}: {exc}"
|
|
397
|
+
) from exc
|
|
398
|
+
else:
|
|
399
|
+
parsed = None
|
|
400
|
+
if return_raw:
|
|
401
|
+
return RawResponse(
|
|
402
|
+
status_code=response.status_code,
|
|
403
|
+
headers=dict(response.headers),
|
|
404
|
+
parsed=parsed,
|
|
405
|
+
)
|
|
406
|
+
return parsed
|
|
407
|
+
|
|
408
|
+
# All retries exhausted — should not normally reach here because
|
|
409
|
+
# the last iteration raises, but satisfies the type checker.
|
|
410
|
+
if last_error is not None: # pragma: no cover
|
|
411
|
+
raise last_error
|
|
412
|
+
return None # pragma: no cover
|
|
413
|
+
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
# Job polling
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def _wait_for_job(
|
|
419
|
+
self, job_id: str, *, poll_s: int = 5, timeout_s: float | None = None
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
# Temporarily disable raw response mode for internal polling calls
|
|
422
|
+
saved = getattr(self._raw_response_flag, "enabled", False)
|
|
423
|
+
self._raw_response_flag.enabled = False
|
|
424
|
+
try:
|
|
425
|
+
return self._wait_for_job_inner(job_id, poll_s=poll_s, timeout_s=timeout_s)
|
|
426
|
+
finally:
|
|
427
|
+
self._raw_response_flag.enabled = saved
|
|
428
|
+
|
|
429
|
+
def _wait_for_job_inner(
|
|
430
|
+
self, job_id: str, *, poll_s: int = 5, timeout_s: float | None = None
|
|
431
|
+
) -> dict[str, Any]:
|
|
432
|
+
start = time.monotonic()
|
|
433
|
+
while True:
|
|
434
|
+
job = self._request("GET", f"/v1/jobs/{job_id}")
|
|
435
|
+
if not isinstance(job, dict):
|
|
436
|
+
raise RuntimeError(
|
|
437
|
+
f"Unexpected response polling job {job_id}: {type(job).__name__}"
|
|
438
|
+
)
|
|
439
|
+
status = job.get("status")
|
|
440
|
+
if status in {"succeeded", "failed", "canceled"}:
|
|
441
|
+
if status != "succeeded":
|
|
442
|
+
message = job.get("error_message") or f"Job {job_id} ended with status={status}"
|
|
443
|
+
raise RuntimeError(message)
|
|
444
|
+
return job
|
|
445
|
+
if timeout_s is not None and (time.monotonic() - start) > timeout_s:
|
|
446
|
+
raise TimeoutError(f"Timed out waiting for job {job_id}")
|
|
447
|
+
time.sleep(poll_s)
|
|
448
|
+
|
|
449
|
+
# ------------------------------------------------------------------
|
|
450
|
+
# Pagination
|
|
451
|
+
# ------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
def _list_page(
|
|
454
|
+
self,
|
|
455
|
+
method: str,
|
|
456
|
+
path: str,
|
|
457
|
+
*,
|
|
458
|
+
items_key: str,
|
|
459
|
+
params: dict[str, Any] | None = None,
|
|
460
|
+
limit: int = 100,
|
|
461
|
+
offset: int = 0,
|
|
462
|
+
) -> Page[dict[str, Any]]:
|
|
463
|
+
"""Fetch a single page and return a Page object with metadata."""
|
|
464
|
+
page_params = {**(params or {}), "limit": limit, "offset": offset}
|
|
465
|
+
data = self._request(method, path, params=page_params)
|
|
466
|
+
response_meta: RawResponse[dict[str, Any] | list[Any] | None] | None = None
|
|
467
|
+
if isinstance(data, RawResponse):
|
|
468
|
+
response_meta = data
|
|
469
|
+
data = data.parsed
|
|
470
|
+
|
|
471
|
+
if isinstance(data, dict):
|
|
472
|
+
items = data.get(items_key, [])
|
|
473
|
+
total = data.get("total", len(items))
|
|
474
|
+
elif isinstance(data, list):
|
|
475
|
+
items = data
|
|
476
|
+
total = len(items)
|
|
477
|
+
else:
|
|
478
|
+
items = []
|
|
479
|
+
total = 0
|
|
480
|
+
|
|
481
|
+
page = Page(items=items, total=total, offset=offset, limit=limit)
|
|
482
|
+
if response_meta is not None:
|
|
483
|
+
return cast(
|
|
484
|
+
"Page[dict[str, Any]]",
|
|
485
|
+
RawResponse(
|
|
486
|
+
status_code=response_meta.status_code,
|
|
487
|
+
headers=response_meta.headers,
|
|
488
|
+
parsed=page,
|
|
489
|
+
),
|
|
490
|
+
)
|
|
491
|
+
return page
|
|
492
|
+
|
|
493
|
+
def _paginate(
|
|
494
|
+
self,
|
|
495
|
+
method: str,
|
|
496
|
+
path: str,
|
|
497
|
+
*,
|
|
498
|
+
items_key: str,
|
|
499
|
+
params: dict[str, Any] | None = None,
|
|
500
|
+
limit: int = 100,
|
|
501
|
+
) -> SyncPager[dict[str, Any]]:
|
|
502
|
+
"""Return a SyncPager for lazy multi-page iteration.
|
|
503
|
+
|
|
504
|
+
Pages are fetched on demand — the next page is requested only
|
|
505
|
+
when the current page's items are exhausted.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
method: HTTP method (usually ``"GET"``).
|
|
509
|
+
path: API path (e.g. ``"/v1/corpora"``).
|
|
510
|
+
items_key: JSON key that contains the list of items in the
|
|
511
|
+
response (e.g. ``"items"``).
|
|
512
|
+
params: Extra query parameters forwarded to each page
|
|
513
|
+
request.
|
|
514
|
+
limit: Page size (default 100).
|
|
515
|
+
"""
|
|
516
|
+
base_params = dict(params or {})
|
|
517
|
+
|
|
518
|
+
def fetch_page(offset: int, page_limit: int) -> tuple[list[dict[str, Any]], int]:
|
|
519
|
+
page_params = {**base_params, "limit": page_limit, "offset": offset}
|
|
520
|
+
data = self._request(method, path, params=page_params)
|
|
521
|
+
if isinstance(data, dict):
|
|
522
|
+
items = data.get(items_key, [])
|
|
523
|
+
total = data.get("total", len(items))
|
|
524
|
+
elif isinstance(data, list):
|
|
525
|
+
items = data
|
|
526
|
+
total = len(items)
|
|
527
|
+
else:
|
|
528
|
+
items = []
|
|
529
|
+
total = 0
|
|
530
|
+
return items, total
|
|
531
|
+
|
|
532
|
+
return SyncPager(fetch_page, limit=limit)
|
|
533
|
+
|
|
534
|
+
# ------------------------------------------------------------------
|
|
535
|
+
# Error classification
|
|
536
|
+
# ------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
@staticmethod
|
|
539
|
+
def _error_from_response(response: httpx.Response) -> APIError:
|
|
540
|
+
"""Parse an error response into the appropriate :class:`APIError` subclass."""
|
|
541
|
+
return error_from_response(response)
|
sdk/_logging.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Logging utilities for the Knowledge2 SDK.
|
|
2
|
+
|
|
3
|
+
The SDK uses a logger named ``knowledge2``. By default no handlers are
|
|
4
|
+
attached (standard library convention) — consumers configure logging as
|
|
5
|
+
they see fit. :func:`set_debug` is a convenience shortcut that adds a
|
|
6
|
+
``StreamHandler`` with ``DEBUG`` level.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("knowledge2")
|
|
14
|
+
|
|
15
|
+
_REDACT_HEADERS: frozenset[str] = frozenset({"x-api-key", "authorization", "x-admin-token"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _redact_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
19
|
+
"""Return a copy of *headers* with auth values replaced by ``***``."""
|
|
20
|
+
return {k: ("***" if k.lower() in _REDACT_HEADERS else v) for k, v in headers.items()}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_debug(enabled: bool = True) -> None:
|
|
24
|
+
"""Enable or disable SDK debug logging to stderr.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
enabled: When *True*, adds a ``StreamHandler`` at ``DEBUG`` level
|
|
28
|
+
to the ``knowledge2`` logger. When *False*, removes all
|
|
29
|
+
handlers and resets the level.
|
|
30
|
+
"""
|
|
31
|
+
if enabled:
|
|
32
|
+
if not logger.handlers:
|
|
33
|
+
handler = logging.StreamHandler()
|
|
34
|
+
handler.setFormatter(
|
|
35
|
+
logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
36
|
+
)
|
|
37
|
+
logger.addHandler(handler)
|
|
38
|
+
logger.setLevel(logging.DEBUG)
|
|
39
|
+
else:
|
|
40
|
+
logger.handlers.clear()
|
|
41
|
+
logger.setLevel(logging.WARNING)
|
sdk/_paging.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Pagination primitives for the Knowledge2 SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Callable, Generic, Iterator, TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Page(Generic[T]):
|
|
13
|
+
"""A single page of results with pagination metadata."""
|
|
14
|
+
|
|
15
|
+
items: list[T]
|
|
16
|
+
total: int
|
|
17
|
+
offset: int
|
|
18
|
+
limit: int
|
|
19
|
+
|
|
20
|
+
def __len__(self) -> int:
|
|
21
|
+
return len(self.items)
|
|
22
|
+
|
|
23
|
+
def __iter__(self) -> Iterator[T]:
|
|
24
|
+
return iter(self.items)
|
|
25
|
+
|
|
26
|
+
def __bool__(self) -> bool:
|
|
27
|
+
return len(self.items) > 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SyncPager(Generic[T]):
|
|
31
|
+
"""Stateful paginator that lazily fetches pages.
|
|
32
|
+
|
|
33
|
+
Hides whether underlying pagination is offset- or cursor-based.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
fetch_page: Callable[[int, int], tuple[list[T], int]],
|
|
39
|
+
*,
|
|
40
|
+
limit: int = 100,
|
|
41
|
+
offset: int = 0,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._fetch_page = fetch_page
|
|
44
|
+
self._limit = limit
|
|
45
|
+
self._offset = offset
|
|
46
|
+
self._exhausted = False
|
|
47
|
+
|
|
48
|
+
def next_page(self) -> Page[T] | None:
|
|
49
|
+
"""Fetch the next page. Returns None when exhausted."""
|
|
50
|
+
if self._exhausted:
|
|
51
|
+
return None
|
|
52
|
+
items, total = self._fetch_page(self._offset, self._limit)
|
|
53
|
+
page = Page(items=items, total=total, offset=self._offset, limit=self._limit)
|
|
54
|
+
if len(items) < self._limit or (total > len(items) and self._offset + self._limit >= total):
|
|
55
|
+
self._exhausted = True
|
|
56
|
+
else:
|
|
57
|
+
self._offset += self._limit
|
|
58
|
+
return page
|
|
59
|
+
|
|
60
|
+
def iter_pages(self) -> Iterator[Page[T]]:
|
|
61
|
+
"""Iterate over all pages."""
|
|
62
|
+
while True:
|
|
63
|
+
page = self.next_page()
|
|
64
|
+
if page is None:
|
|
65
|
+
break
|
|
66
|
+
yield page
|
|
67
|
+
if not page.items:
|
|
68
|
+
break
|
|
69
|
+
|
|
70
|
+
def __iter__(self) -> Iterator[T]:
|
|
71
|
+
"""Item-level iteration across all pages."""
|
|
72
|
+
for page in self.iter_pages():
|
|
73
|
+
yield from page.items
|