docforge-sdk 0.1.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.
- docforge_sdk/__init__.py +161 -0
- docforge_sdk/_exceptions.py +108 -0
- docforge_sdk/_requestspec.py +34 -0
- docforge_sdk/_transport_async.py +168 -0
- docforge_sdk/_transport_base.py +141 -0
- docforge_sdk/_transport_sync.py +168 -0
- docforge_sdk/_version.py +5 -0
- docforge_sdk/client.py +131 -0
- docforge_sdk/models/__init__.py +132 -0
- docforge_sdk/models/_shared.py +121 -0
- docforge_sdk/models/auth.py +130 -0
- docforge_sdk/models/blobs.py +25 -0
- docforge_sdk/models/collections.py +142 -0
- docforge_sdk/models/documents.py +49 -0
- docforge_sdk/models/explorer.py +238 -0
- docforge_sdk/models/health.py +20 -0
- docforge_sdk/models/ir.py +139 -0
- docforge_sdk/models/jobs.py +100 -0
- docforge_sdk/models/pipelines.py +182 -0
- docforge_sdk/models/search.py +110 -0
- docforge_sdk/py.typed +0 -0
- docforge_sdk/resources/__init__.py +53 -0
- docforge_sdk/resources/_base.py +46 -0
- docforge_sdk/resources/auth.py +264 -0
- docforge_sdk/resources/blobs.py +63 -0
- docforge_sdk/resources/collections.py +204 -0
- docforge_sdk/resources/documents.py +153 -0
- docforge_sdk/resources/explorer.py +342 -0
- docforge_sdk/resources/health.py +53 -0
- docforge_sdk/resources/jobs.py +161 -0
- docforge_sdk/resources/pipelines.py +291 -0
- docforge_sdk/resources/search.py +70 -0
- docforge_sdk-0.1.0.dist-info/METADATA +108 -0
- docforge_sdk-0.1.0.dist-info/RECORD +36 -0
- docforge_sdk-0.1.0.dist-info/WHEEL +4 -0
- docforge_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
docforge_sdk/__init__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# ------------------- Exceptions ------------------- #
|
|
2
|
+
from ._exceptions import (
|
|
3
|
+
APIConnectionError,
|
|
4
|
+
APIStatusError,
|
|
5
|
+
APITimeoutError,
|
|
6
|
+
AuthError,
|
|
7
|
+
ConflictError,
|
|
8
|
+
DocForgeError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
UnprocessableError,
|
|
11
|
+
)
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
|
|
14
|
+
# ------------------- Clients ------------------- #
|
|
15
|
+
from .client import AsyncClient, Client
|
|
16
|
+
|
|
17
|
+
# ------------------- Shared vocabulary ------------------- #
|
|
18
|
+
from .models._shared import (
|
|
19
|
+
Capability,
|
|
20
|
+
DocumentStatus,
|
|
21
|
+
EnrichmentKind,
|
|
22
|
+
EnrichmentStatus,
|
|
23
|
+
FieldOrigin,
|
|
24
|
+
FieldScope,
|
|
25
|
+
FieldType,
|
|
26
|
+
KeyPermissions,
|
|
27
|
+
SourceKind,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ------------------- Auth models ------------------- #
|
|
31
|
+
from .models.auth import CreatedKey, CreateKeyRequest, KeyInfo, RotateKeyRequest
|
|
32
|
+
|
|
33
|
+
# ------------------- Blobs models ------------------- #
|
|
34
|
+
from .models.blobs import BlobContent
|
|
35
|
+
|
|
36
|
+
# ------------------- Collections models ------------------- #
|
|
37
|
+
from .models.collections import (
|
|
38
|
+
CollectionModel,
|
|
39
|
+
CreateCollectionRequest,
|
|
40
|
+
FieldSpec,
|
|
41
|
+
UpdateCollectionRequest,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# ------------------- Documents models ------------------- #
|
|
45
|
+
from .models.documents import DocumentEnabledResponse, EnabledPatch, UploadAccepted
|
|
46
|
+
|
|
47
|
+
# ------------------- Explorer models ------------------- #
|
|
48
|
+
from .models.explorer import (
|
|
49
|
+
BulkChunkEnabledPatch,
|
|
50
|
+
BulkChunkEnabledResponse,
|
|
51
|
+
ChunkEnabledPatch,
|
|
52
|
+
ChunkEnabledResult,
|
|
53
|
+
ChunkInfo,
|
|
54
|
+
DocumentDetail,
|
|
55
|
+
DocumentListItem,
|
|
56
|
+
MetadataValue,
|
|
57
|
+
PageInfo,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# ------------------- Health models ------------------- #
|
|
61
|
+
from .models.health import HealthStatus
|
|
62
|
+
|
|
63
|
+
# ------------------- IR models ------------------- #
|
|
64
|
+
from .models.ir import DocumentIRModel, IRBlock, IREnrichment, IRFigure, IRTable
|
|
65
|
+
|
|
66
|
+
# ------------------- Jobs models ------------------- #
|
|
67
|
+
from .models.jobs import JobEvent, JobStatus, JobTrace, WorkerActivity, WorkersLive
|
|
68
|
+
|
|
69
|
+
# ------------------- Pipelines models ------------------- #
|
|
70
|
+
from .models.pipelines import (
|
|
71
|
+
EditResponse,
|
|
72
|
+
InspectResponse,
|
|
73
|
+
PipelineDesignResponse,
|
|
74
|
+
PipelineIndexResponse,
|
|
75
|
+
PipelineSurface,
|
|
76
|
+
StageApplyResponse,
|
|
77
|
+
StageViewResponse,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# ------------------- Search models ------------------- #
|
|
81
|
+
from .models.search import SearchHit, SearchRequest, SearchResponse, SearchTarget
|
|
82
|
+
|
|
83
|
+
# ------------------- Public API ------------------- #
|
|
84
|
+
__all__ = [
|
|
85
|
+
"__version__",
|
|
86
|
+
# Clients
|
|
87
|
+
"AsyncClient",
|
|
88
|
+
"Client",
|
|
89
|
+
# Shared vocabulary
|
|
90
|
+
"Capability",
|
|
91
|
+
"KeyPermissions",
|
|
92
|
+
"FieldType",
|
|
93
|
+
"FieldOrigin",
|
|
94
|
+
"FieldScope",
|
|
95
|
+
"SourceKind",
|
|
96
|
+
"DocumentStatus",
|
|
97
|
+
"EnrichmentKind",
|
|
98
|
+
"EnrichmentStatus",
|
|
99
|
+
# Auth
|
|
100
|
+
"CreateKeyRequest",
|
|
101
|
+
"RotateKeyRequest",
|
|
102
|
+
"CreatedKey",
|
|
103
|
+
"KeyInfo",
|
|
104
|
+
# Health
|
|
105
|
+
"HealthStatus",
|
|
106
|
+
# Collections
|
|
107
|
+
"FieldSpec",
|
|
108
|
+
"CollectionModel",
|
|
109
|
+
"CreateCollectionRequest",
|
|
110
|
+
"UpdateCollectionRequest",
|
|
111
|
+
# Documents
|
|
112
|
+
"UploadAccepted",
|
|
113
|
+
"EnabledPatch",
|
|
114
|
+
"DocumentEnabledResponse",
|
|
115
|
+
# Explorer
|
|
116
|
+
"MetadataValue",
|
|
117
|
+
"DocumentListItem",
|
|
118
|
+
"DocumentDetail",
|
|
119
|
+
"PageInfo",
|
|
120
|
+
"ChunkInfo",
|
|
121
|
+
"ChunkEnabledPatch",
|
|
122
|
+
"BulkChunkEnabledPatch",
|
|
123
|
+
"ChunkEnabledResult",
|
|
124
|
+
"BulkChunkEnabledResponse",
|
|
125
|
+
# IR
|
|
126
|
+
"IRBlock",
|
|
127
|
+
"IRTable",
|
|
128
|
+
"IRFigure",
|
|
129
|
+
"IREnrichment",
|
|
130
|
+
"DocumentIRModel",
|
|
131
|
+
# Search
|
|
132
|
+
"SearchTarget",
|
|
133
|
+
"SearchRequest",
|
|
134
|
+
"SearchHit",
|
|
135
|
+
"SearchResponse",
|
|
136
|
+
# Jobs
|
|
137
|
+
"JobStatus",
|
|
138
|
+
"JobEvent",
|
|
139
|
+
"JobTrace",
|
|
140
|
+
"WorkerActivity",
|
|
141
|
+
"WorkersLive",
|
|
142
|
+
# Blobs
|
|
143
|
+
"BlobContent",
|
|
144
|
+
# Pipelines
|
|
145
|
+
"PipelineSurface",
|
|
146
|
+
"PipelineIndexResponse",
|
|
147
|
+
"PipelineDesignResponse",
|
|
148
|
+
"InspectResponse",
|
|
149
|
+
"EditResponse",
|
|
150
|
+
"StageViewResponse",
|
|
151
|
+
"StageApplyResponse",
|
|
152
|
+
# Exceptions
|
|
153
|
+
"DocForgeError",
|
|
154
|
+
"APIConnectionError",
|
|
155
|
+
"APITimeoutError",
|
|
156
|
+
"APIStatusError",
|
|
157
|
+
"AuthError",
|
|
158
|
+
"NotFoundError",
|
|
159
|
+
"ConflictError",
|
|
160
|
+
"UnprocessableError",
|
|
161
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# ====== Code Summary ======
|
|
2
|
+
# The public exception hierarchy for the SDK. Every failure a caller can catch descends from
|
|
3
|
+
# ``DocForgeError``. Transport-level failures (no connection, timeout) and HTTP error statuses map to
|
|
4
|
+
# dedicated subclasses so callers can branch on failure kind without inspecting raw httpx objects.
|
|
5
|
+
|
|
6
|
+
# ====== Standard Library Imports ======
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# ====== Third-Party Library Imports ======
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DocForgeError(Exception):
|
|
14
|
+
"""Base class for every error raised by the DocForge SDK."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class APIConnectionError(DocForgeError):
|
|
18
|
+
"""Raised when the API could not be reached (DNS, refused connection, network drop)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class APITimeoutError(APIConnectionError):
|
|
22
|
+
"""Raised when a request exceeded the configured timeout before a response arrived."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class APIStatusError(DocForgeError):
|
|
26
|
+
"""
|
|
27
|
+
Raised when the API returned a 4xx/5xx HTTP status.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
status_code (int): The HTTP status code returned by the API.
|
|
31
|
+
body (Any): The parsed response body (JSON when decodable, else raw text) — an opaque,
|
|
32
|
+
server-shaped error payload.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, *, status_code: int, body: Any) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Initialize the status error with the failing code and response body.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
message (str): Human-readable summary of the failure.
|
|
41
|
+
status_code (int): The HTTP status code returned by the API.
|
|
42
|
+
body (Any): The parsed response body (opaque server error payload).
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status_code: int = status_code
|
|
46
|
+
self.body: Any = body
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AuthError(APIStatusError):
|
|
50
|
+
"""Raised on 401 (unauthenticated) or 403 (unauthorized) responses."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NotFoundError(APIStatusError):
|
|
54
|
+
"""Raised on 404 responses (the target resource does not exist)."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConflictError(APIStatusError):
|
|
58
|
+
"""Raised on 409 responses (the request conflicts with the resource's current state)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class UnprocessableError(APIStatusError):
|
|
62
|
+
"""Raised on 422 responses (the request body failed server-side validation)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Maps an HTTP status code to the most specific exception class for it.
|
|
66
|
+
_STATUS_TO_EXCEPTION: dict[int, type[APIStatusError]] = {
|
|
67
|
+
401: AuthError,
|
|
68
|
+
403: AuthError,
|
|
69
|
+
404: NotFoundError,
|
|
70
|
+
409: ConflictError,
|
|
71
|
+
422: UnprocessableError,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def exception_from_response(response: httpx.Response) -> APIStatusError:
|
|
76
|
+
"""
|
|
77
|
+
Build the most specific ``APIStatusError`` for an errored HTTP response.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
response (httpx.Response): The response whose status is 4xx/5xx.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
APIStatusError: A ``NotFoundError`` / ``AuthError`` / ``ConflictError`` /
|
|
84
|
+
``UnprocessableError`` when the status is recognised, else a plain ``APIStatusError``.
|
|
85
|
+
"""
|
|
86
|
+
# 1. Decode the body as JSON when possible; fall back to raw text for non-JSON errors.
|
|
87
|
+
try:
|
|
88
|
+
body: Any = response.json()
|
|
89
|
+
except ValueError:
|
|
90
|
+
body = response.text
|
|
91
|
+
|
|
92
|
+
# 2. Pick the dedicated subclass for this status, defaulting to the generic status error.
|
|
93
|
+
exception_class = _STATUS_TO_EXCEPTION.get(response.status_code, APIStatusError)
|
|
94
|
+
message = f"API request failed with status {response.status_code}"
|
|
95
|
+
return exception_class(message, status_code=response.status_code, body=body)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = [
|
|
99
|
+
"DocForgeError",
|
|
100
|
+
"APIConnectionError",
|
|
101
|
+
"APITimeoutError",
|
|
102
|
+
"APIStatusError",
|
|
103
|
+
"AuthError",
|
|
104
|
+
"NotFoundError",
|
|
105
|
+
"ConflictError",
|
|
106
|
+
"UnprocessableError",
|
|
107
|
+
"exception_from_response",
|
|
108
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# ====== Code Summary ======
|
|
2
|
+
# The immutable description of a single HTTP call. A RequestSpec is pure data — it carries the method,
|
|
3
|
+
# API-relative path and payload, and performs no I/O. Resource classes build specs; transports execute
|
|
4
|
+
# them. Centralising URL/body shape here keeps it out of the async/sync transport bodies.
|
|
5
|
+
|
|
6
|
+
# ====== Standard Library Imports ======
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RequestSpec:
|
|
13
|
+
"""
|
|
14
|
+
An immutable, I/O-free description of one API request.
|
|
15
|
+
|
|
16
|
+
The ``json`` and ``files`` payloads are typed ``Any`` because they are opaque, endpoint-shaped
|
|
17
|
+
bodies already serialised by the calling resource (a model dump or an httpx multipart mapping).
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
method (str): The HTTP method (e.g. ``"GET"``, ``"POST"``, ``"DELETE"``).
|
|
21
|
+
path (str): The path relative to the API root (e.g. ``"/auth/keys"``).
|
|
22
|
+
params (dict[str, Any] | None): Query parameters; ``None`` values are dropped at execution.
|
|
23
|
+
json (Any): The JSON request body, or ``None`` for bodyless requests.
|
|
24
|
+
files (Any): The multipart file mapping for uploads, or ``None``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
method: str
|
|
28
|
+
path: str
|
|
29
|
+
params: dict[str, Any] | None = None
|
|
30
|
+
json: Any = None
|
|
31
|
+
files: Any = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["RequestSpec"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# ====== Code Summary ======
|
|
2
|
+
# The asynchronous HTTP transport (over httpx.AsyncClient). Inherits every pure helper from
|
|
3
|
+
# _TransportBase and supplies only the async I/O primitive (_send); spec execution, upload and raw
|
|
4
|
+
# byte fetch route through that single chokepoint, so no endpoint hand-rolls its own parsing.
|
|
5
|
+
|
|
6
|
+
# ====== Standard Library Imports ======
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# ====== Third-Party Library Imports ======
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
# ====== Local Project Imports ======
|
|
13
|
+
from ._exceptions import APIConnectionError, APITimeoutError
|
|
14
|
+
from ._requestspec import RequestSpec
|
|
15
|
+
from ._transport_base import T, _TransportBase
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AsyncTransport(_TransportBase):
|
|
19
|
+
"""Asynchronous transport backed by an ``httpx.AsyncClient``."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, base_url: str, timeout: float, api_token: str = "") -> None:
|
|
22
|
+
"""
|
|
23
|
+
Create the underlying async httpx client with the resolved headers and timeout.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url (str): The API origin.
|
|
27
|
+
timeout (float): Per-request timeout in seconds.
|
|
28
|
+
api_token (str): Bearer token; empty means unauthenticated.
|
|
29
|
+
"""
|
|
30
|
+
super().__init__(base_url, timeout, api_token)
|
|
31
|
+
self._client = httpx.AsyncClient(timeout=timeout, headers=self._headers)
|
|
32
|
+
|
|
33
|
+
async def _send(
|
|
34
|
+
self,
|
|
35
|
+
method: str,
|
|
36
|
+
url: str,
|
|
37
|
+
*,
|
|
38
|
+
params: dict[str, Any] | None = None,
|
|
39
|
+
json: Any = None,
|
|
40
|
+
data: Any = None,
|
|
41
|
+
files: Any = None,
|
|
42
|
+
) -> httpx.Response:
|
|
43
|
+
"""
|
|
44
|
+
Execute a single httpx call, mapping transport failures to SDK exceptions.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
method (str): The HTTP method.
|
|
48
|
+
url (str): The absolute request URL.
|
|
49
|
+
params (dict[str, Any] | None): Cleaned query parameters.
|
|
50
|
+
json (Any): The JSON body, if any.
|
|
51
|
+
data (Any): The form body for multipart uploads, if any.
|
|
52
|
+
files (Any): The multipart file mapping, if any.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
httpx.Response: The raw response (status not yet checked).
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
APITimeoutError: When the request timed out.
|
|
59
|
+
APIConnectionError: When the API could not be reached.
|
|
60
|
+
"""
|
|
61
|
+
# 1. A timeout is a specific connection failure — catch it before the broader transport error.
|
|
62
|
+
try:
|
|
63
|
+
return await self._client.request(
|
|
64
|
+
method, url, params=params, json=json, data=data, files=files
|
|
65
|
+
)
|
|
66
|
+
except httpx.TimeoutException as error:
|
|
67
|
+
raise APITimeoutError(str(error)) from error
|
|
68
|
+
except httpx.TransportError as error:
|
|
69
|
+
raise APIConnectionError(str(error)) from error
|
|
70
|
+
|
|
71
|
+
async def request(self, spec: RequestSpec, model: type[T]) -> T:
|
|
72
|
+
"""
|
|
73
|
+
Execute a request spec and validate its response into the target model.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
spec (RequestSpec): The request description.
|
|
77
|
+
model (type[T]): The target type to validate the response into.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
T: The validated response model.
|
|
81
|
+
"""
|
|
82
|
+
# 1. Execute, 2. surface any error status, 3. parse — the single per-transport pipeline.
|
|
83
|
+
response = await self._send(
|
|
84
|
+
spec.method,
|
|
85
|
+
self._url(spec.path),
|
|
86
|
+
params=self._clean(spec.params),
|
|
87
|
+
json=spec.json,
|
|
88
|
+
files=spec.files,
|
|
89
|
+
)
|
|
90
|
+
self._raise_for_status(response)
|
|
91
|
+
return self._parse(response, model)
|
|
92
|
+
|
|
93
|
+
async def request_bare(self, spec: RequestSpec, model: type[T]) -> T:
|
|
94
|
+
"""
|
|
95
|
+
Execute a request spec against the BARE origin (outside ``/api/v1``) and parse it.
|
|
96
|
+
|
|
97
|
+
Mirrors ``request`` but targets an un-versioned route (e.g. the public ``/health`` probe).
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
spec (RequestSpec): The request description (its path is origin-relative).
|
|
101
|
+
model (type[T]): The target type to validate the response into.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
T: The validated response model.
|
|
105
|
+
"""
|
|
106
|
+
response = await self._send(
|
|
107
|
+
spec.method,
|
|
108
|
+
self._bare_url(spec.path),
|
|
109
|
+
params=self._clean(spec.params),
|
|
110
|
+
json=spec.json,
|
|
111
|
+
files=spec.files,
|
|
112
|
+
)
|
|
113
|
+
self._raise_for_status(response)
|
|
114
|
+
return self._parse(response, model)
|
|
115
|
+
|
|
116
|
+
async def upload(self, path: str, files: Any, data: Any, model: type[T]) -> T:
|
|
117
|
+
"""
|
|
118
|
+
Send a multipart upload and validate the response into the target model.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path (str): The API-relative path.
|
|
122
|
+
files (Any): The multipart file mapping.
|
|
123
|
+
data (Any): The accompanying form fields, if any.
|
|
124
|
+
model (type[T]): The target response type.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
T: The validated response model.
|
|
128
|
+
"""
|
|
129
|
+
response = await self._send("POST", self._url(path), files=files, data=data)
|
|
130
|
+
self._raise_for_status(response)
|
|
131
|
+
return self._parse(response, model)
|
|
132
|
+
|
|
133
|
+
async def get_bytes(self, path: str) -> bytes:
|
|
134
|
+
"""
|
|
135
|
+
Fetch a raw binary body (e.g. a stored blob) without JSON parsing.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
path (str): The API-relative path.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
bytes: The raw response content.
|
|
142
|
+
"""
|
|
143
|
+
response = await self._send("GET", self._url(path))
|
|
144
|
+
self._raise_for_status(response)
|
|
145
|
+
return response.content
|
|
146
|
+
|
|
147
|
+
async def get_bytes_typed(self, path: str) -> tuple[bytes, str]:
|
|
148
|
+
"""
|
|
149
|
+
Fetch a raw binary body together with its server-declared media type.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
path (str): The API-relative path.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
tuple[bytes, str]: The raw content and its ``Content-Type`` (a generic octet-stream
|
|
156
|
+
fallback when the header is absent).
|
|
157
|
+
"""
|
|
158
|
+
response = await self._send("GET", self._url(path))
|
|
159
|
+
self._raise_for_status(response)
|
|
160
|
+
mime_type = response.headers.get("content-type", "application/octet-stream")
|
|
161
|
+
return response.content, mime_type
|
|
162
|
+
|
|
163
|
+
async def aclose(self) -> None:
|
|
164
|
+
"""Close the underlying httpx client and release its connections."""
|
|
165
|
+
await self._client.aclose()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["AsyncTransport"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# ====== Code Summary ======
|
|
2
|
+
# The shared, I/O-free transport core. _TransportBase holds the resolved URL roots, the default
|
|
3
|
+
# headers and every pure helper (param cleaning, status→exception mapping, response parsing). The
|
|
4
|
+
# AsyncTransport and SyncTransport subclasses (in _transport_async / _transport_sync) inherit all of
|
|
5
|
+
# this and only supply the actual httpx call, so no endpoint ever hand-rolls its own parsing.
|
|
6
|
+
|
|
7
|
+
# ====== Standard Library Imports ======
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
# ====== Third-Party Library Imports ======
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import TypeAdapter
|
|
13
|
+
|
|
14
|
+
# ====== Local Project Imports ======
|
|
15
|
+
from ._exceptions import exception_from_response
|
|
16
|
+
|
|
17
|
+
# The type of the model a request is validated into (a BaseModel subclass, ``list[...]`` or ``None``).
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
# The API version prefix every resource path is mounted under.
|
|
21
|
+
_API_PREFIX = "/api/v1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _TransportBase:
|
|
25
|
+
"""
|
|
26
|
+
Shared, I/O-free behaviour for both transports.
|
|
27
|
+
|
|
28
|
+
Holds the resolved URL roots, the default headers and the pure helpers (param cleaning, status
|
|
29
|
+
mapping, response parsing) so the async and sync subclasses only differ by the actual httpx call.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, base_url: str, timeout: float, api_token: str = "") -> None:
|
|
33
|
+
"""
|
|
34
|
+
Resolve the URL roots and build the default headers.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
base_url (str): The API origin, e.g. ``"http://localhost:10040"``.
|
|
38
|
+
timeout (float): Per-request timeout in seconds.
|
|
39
|
+
api_token (str): Bearer token; when empty, no Authorization header is sent.
|
|
40
|
+
"""
|
|
41
|
+
# 1. Keep both a bare origin (for un-versioned routes like /health) and the versioned root.
|
|
42
|
+
self._root: str = base_url.rstrip("/")
|
|
43
|
+
self._api_root: str = f"{self._root}{_API_PREFIX}"
|
|
44
|
+
self._timeout: float = timeout
|
|
45
|
+
self._headers: dict[str, str] = self._build_headers(api_token)
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _build_headers(api_token: str) -> dict[str, str]:
|
|
49
|
+
"""
|
|
50
|
+
Build the default request headers, adding Authorization only when a token is present.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
api_token (str): The bearer token, possibly empty.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
dict[str, str]: The default headers for every request.
|
|
57
|
+
"""
|
|
58
|
+
# 1. Always accept JSON; only attach the bearer credential when one was configured.
|
|
59
|
+
headers = {"Accept": "application/json"}
|
|
60
|
+
if api_token:
|
|
61
|
+
headers["Authorization"] = f"Bearer {api_token}"
|
|
62
|
+
return headers
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _clean(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
66
|
+
"""
|
|
67
|
+
Drop ``None``-valued query parameters so they are never serialised into the URL.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
params (dict[str, Any] | None): The raw query parameters.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
dict[str, Any] | None: The params without any ``None`` values, or ``None``.
|
|
74
|
+
"""
|
|
75
|
+
# 1. Nothing to clean when no params were supplied.
|
|
76
|
+
if params is None:
|
|
77
|
+
return None
|
|
78
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
79
|
+
|
|
80
|
+
def _url(self, path: str) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Build the absolute URL for an API-relative path.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
path (str): The path relative to the API root (leading slash included).
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
str: The fully-qualified request URL.
|
|
89
|
+
"""
|
|
90
|
+
return f"{self._api_root}{path}"
|
|
91
|
+
|
|
92
|
+
def _bare_url(self, path: str) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Build the absolute URL for a path mounted at the bare origin (outside ``/api/v1``).
|
|
95
|
+
|
|
96
|
+
Used only by the public, un-versioned routes such as ``/health``.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
path (str): The path relative to the origin (leading slash included).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
str: The fully-qualified request URL, without the API version prefix.
|
|
103
|
+
"""
|
|
104
|
+
return f"{self._root}{path}"
|
|
105
|
+
|
|
106
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Map any 4xx/5xx response to the matching SDK exception.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
response (httpx.Response): The response to inspect.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
APIStatusError: (or a subclass) when the status code is >= 400.
|
|
115
|
+
"""
|
|
116
|
+
# 1. Success statuses pass through untouched.
|
|
117
|
+
if response.status_code >= 400:
|
|
118
|
+
raise exception_from_response(response)
|
|
119
|
+
|
|
120
|
+
def _parse(self, response: httpx.Response, model: type[T]) -> T:
|
|
121
|
+
"""
|
|
122
|
+
Validate a response body into the target model.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
response (httpx.Response): The successful response to parse.
|
|
126
|
+
model (type[T]): The target type — a ``BaseModel`` subclass, a ``list[...]`` alias, or
|
|
127
|
+
``type(None)`` for bodyless (204) responses.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
T: The validated model instance (or ``None`` for a bodyless response).
|
|
131
|
+
"""
|
|
132
|
+
# 1. A no-content endpoint (e.g. 204 on delete) has no body to validate. In this branch the
|
|
133
|
+
# target type is narrowed to ``None``, so returning None satisfies the declared T.
|
|
134
|
+
if model is type(None):
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
# 2. TypeAdapter handles single models AND ``list[...]`` aliases with one code path.
|
|
138
|
+
return TypeAdapter(model).validate_python(response.json())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
__all__ = ["T", "_TransportBase"]
|