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.
Files changed (139) hide show
  1. knowledge2-0.4.0.dist-info/METADATA +556 -0
  2. knowledge2-0.4.0.dist-info/RECORD +139 -0
  3. knowledge2-0.4.0.dist-info/WHEEL +5 -0
  4. knowledge2-0.4.0.dist-info/top_level.txt +1 -0
  5. sdk/__init__.py +70 -0
  6. sdk/_async_base.py +525 -0
  7. sdk/_async_paging.py +57 -0
  8. sdk/_base.py +541 -0
  9. sdk/_logging.py +41 -0
  10. sdk/_paging.py +73 -0
  11. sdk/_preview.py +70 -0
  12. sdk/_raw_response.py +25 -0
  13. sdk/_request_options.py +51 -0
  14. sdk/_transport.py +144 -0
  15. sdk/_validation.py +25 -0
  16. sdk/_validation_response.py +36 -0
  17. sdk/_version.py +3 -0
  18. sdk/async_client.py +320 -0
  19. sdk/async_resources/__init__.py +45 -0
  20. sdk/async_resources/_mixin_base.py +42 -0
  21. sdk/async_resources/a2a.py +230 -0
  22. sdk/async_resources/agents.py +489 -0
  23. sdk/async_resources/audit.py +145 -0
  24. sdk/async_resources/auth.py +133 -0
  25. sdk/async_resources/console.py +409 -0
  26. sdk/async_resources/corpora.py +276 -0
  27. sdk/async_resources/deployments.py +106 -0
  28. sdk/async_resources/documents.py +592 -0
  29. sdk/async_resources/feeds.py +248 -0
  30. sdk/async_resources/indexes.py +208 -0
  31. sdk/async_resources/jobs.py +165 -0
  32. sdk/async_resources/metadata.py +48 -0
  33. sdk/async_resources/models.py +102 -0
  34. sdk/async_resources/onboarding.py +538 -0
  35. sdk/async_resources/orgs.py +37 -0
  36. sdk/async_resources/pipelines.py +523 -0
  37. sdk/async_resources/projects.py +90 -0
  38. sdk/async_resources/search.py +262 -0
  39. sdk/async_resources/training.py +357 -0
  40. sdk/async_resources/usage.py +91 -0
  41. sdk/client.py +417 -0
  42. sdk/config.py +182 -0
  43. sdk/errors.py +178 -0
  44. sdk/examples/auth_factory.py +34 -0
  45. sdk/examples/batch_operations.py +57 -0
  46. sdk/examples/document_upload.py +56 -0
  47. sdk/examples/e2e_lifecycle.py +213 -0
  48. sdk/examples/error_handling.py +61 -0
  49. sdk/examples/pagination.py +64 -0
  50. sdk/examples/quickstart.py +36 -0
  51. sdk/examples/request_options.py +44 -0
  52. sdk/examples/search.py +64 -0
  53. sdk/integrations/__init__.py +57 -0
  54. sdk/integrations/_client.py +101 -0
  55. sdk/integrations/langchain/__init__.py +6 -0
  56. sdk/integrations/langchain/retriever.py +166 -0
  57. sdk/integrations/langchain/tools.py +108 -0
  58. sdk/integrations/llamaindex/__init__.py +11 -0
  59. sdk/integrations/llamaindex/filters.py +78 -0
  60. sdk/integrations/llamaindex/retriever.py +162 -0
  61. sdk/integrations/llamaindex/tools.py +109 -0
  62. sdk/integrations/llamaindex/vector_store.py +320 -0
  63. sdk/models/__init__.py +18 -0
  64. sdk/models/_base.py +24 -0
  65. sdk/models/_registry.py +457 -0
  66. sdk/models/a2a.py +92 -0
  67. sdk/models/agents.py +109 -0
  68. sdk/models/audit.py +28 -0
  69. sdk/models/auth.py +49 -0
  70. sdk/models/chunks.py +20 -0
  71. sdk/models/common.py +14 -0
  72. sdk/models/console.py +103 -0
  73. sdk/models/corpora.py +48 -0
  74. sdk/models/deployments.py +13 -0
  75. sdk/models/documents.py +126 -0
  76. sdk/models/embeddings.py +24 -0
  77. sdk/models/evaluation.py +17 -0
  78. sdk/models/feedback.py +9 -0
  79. sdk/models/feeds.py +57 -0
  80. sdk/models/indexes.py +36 -0
  81. sdk/models/jobs.py +52 -0
  82. sdk/models/models.py +26 -0
  83. sdk/models/onboarding.py +323 -0
  84. sdk/models/orgs.py +11 -0
  85. sdk/models/pipelines.py +147 -0
  86. sdk/models/projects.py +19 -0
  87. sdk/models/search.py +149 -0
  88. sdk/models/training.py +57 -0
  89. sdk/models/usage.py +39 -0
  90. sdk/namespaces.py +386 -0
  91. sdk/py.typed +0 -0
  92. sdk/resources/__init__.py +45 -0
  93. sdk/resources/_mixin_base.py +40 -0
  94. sdk/resources/a2a.py +230 -0
  95. sdk/resources/agents.py +487 -0
  96. sdk/resources/audit.py +144 -0
  97. sdk/resources/auth.py +138 -0
  98. sdk/resources/console.py +411 -0
  99. sdk/resources/corpora.py +269 -0
  100. sdk/resources/deployments.py +105 -0
  101. sdk/resources/documents.py +597 -0
  102. sdk/resources/feeds.py +246 -0
  103. sdk/resources/indexes.py +210 -0
  104. sdk/resources/jobs.py +164 -0
  105. sdk/resources/metadata.py +53 -0
  106. sdk/resources/models.py +99 -0
  107. sdk/resources/onboarding.py +542 -0
  108. sdk/resources/orgs.py +35 -0
  109. sdk/resources/pipeline_builder.py +257 -0
  110. sdk/resources/pipelines.py +520 -0
  111. sdk/resources/projects.py +87 -0
  112. sdk/resources/search.py +277 -0
  113. sdk/resources/training.py +358 -0
  114. sdk/resources/usage.py +92 -0
  115. sdk/types/__init__.py +366 -0
  116. sdk/types/a2a.py +88 -0
  117. sdk/types/agents.py +133 -0
  118. sdk/types/audit.py +26 -0
  119. sdk/types/auth.py +45 -0
  120. sdk/types/chunks.py +18 -0
  121. sdk/types/common.py +10 -0
  122. sdk/types/console.py +99 -0
  123. sdk/types/corpora.py +42 -0
  124. sdk/types/deployments.py +11 -0
  125. sdk/types/documents.py +104 -0
  126. sdk/types/embeddings.py +22 -0
  127. sdk/types/evaluation.py +15 -0
  128. sdk/types/feedback.py +7 -0
  129. sdk/types/feeds.py +61 -0
  130. sdk/types/indexes.py +30 -0
  131. sdk/types/jobs.py +50 -0
  132. sdk/types/models.py +22 -0
  133. sdk/types/onboarding.py +395 -0
  134. sdk/types/orgs.py +9 -0
  135. sdk/types/pipelines.py +177 -0
  136. sdk/types/projects.py +14 -0
  137. sdk/types/search.py +116 -0
  138. sdk/types/training.py +55 -0
  139. 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