clinia-context-engine 0.8.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.
- clinia_context_engine/__init__.py +28 -0
- clinia_context_engine/_auth.py +232 -0
- clinia_context_engine/_errors.py +45 -0
- clinia_context_engine/_http.py +280 -0
- clinia_context_engine/client.py +115 -0
- clinia_context_engine/generated/__init__.py +3 -0
- clinia_context_engine/generated/models.py +3086 -0
- clinia_context_engine/py.typed +0 -0
- clinia_context_engine/resources/__init__.py +24 -0
- clinia_context_engine/resources/graph.py +631 -0
- clinia_context_engine/resources/info.py +45 -0
- clinia_context_engine/resources/ingest.py +222 -0
- clinia_context_engine/resources/patients.py +217 -0
- clinia_context_engine/resources/sessions.py +255 -0
- clinia_context_engine/resources/vfs.py +100 -0
- clinia_context_engine-0.8.0.dist-info/METADATA +132 -0
- clinia_context_engine-0.8.0.dist-info/RECORD +19 -0
- clinia_context_engine-0.8.0.dist-info/WHEEL +4 -0
- clinia_context_engine-0.8.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from . import resources as resources
|
|
2
|
+
from ._auth import (
|
|
3
|
+
AsyncClientCredentialsTokenProvider,
|
|
4
|
+
ClientCredentials,
|
|
5
|
+
ClientCredentialsTokenProvider,
|
|
6
|
+
)
|
|
7
|
+
from ._errors import APIConnectionError, APIStatusError, APITimeoutError, ContextEngineError
|
|
8
|
+
from ._http import AsyncHttpClient, AsyncTokenProvider, HttpClient, TokenProvider
|
|
9
|
+
from .client import AsyncContextEngineClient, ContextEngineClient
|
|
10
|
+
from .generated import models as models
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"APIConnectionError",
|
|
14
|
+
"APIStatusError",
|
|
15
|
+
"APITimeoutError",
|
|
16
|
+
"AsyncClientCredentialsTokenProvider",
|
|
17
|
+
"AsyncContextEngineClient",
|
|
18
|
+
"AsyncHttpClient",
|
|
19
|
+
"AsyncTokenProvider",
|
|
20
|
+
"ClientCredentials",
|
|
21
|
+
"ClientCredentialsTokenProvider",
|
|
22
|
+
"ContextEngineClient",
|
|
23
|
+
"ContextEngineError",
|
|
24
|
+
"HttpClient",
|
|
25
|
+
"TokenProvider",
|
|
26
|
+
"models",
|
|
27
|
+
"resources",
|
|
28
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._errors import ContextEngineError
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AsyncClientCredentialsTokenProvider",
|
|
16
|
+
"ClientCredentials",
|
|
17
|
+
"ClientCredentialsTokenProvider",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
# Clinia's authorization server, applied when a config names neither a
|
|
21
|
+
# `token_url` nor an `issuer` — so `ClientCredentials(client_id, client_secret)`
|
|
22
|
+
# is a complete config for a Clinia workspace.
|
|
23
|
+
#
|
|
24
|
+
# The resource indicator is only defaulted alongside the token URL: it is an
|
|
25
|
+
# RFC 8707 indicator naming Clinia's protected resource, and sending it to
|
|
26
|
+
# somebody else's authorization server would be wrong.
|
|
27
|
+
CLINIA_OAUTH_TOKEN_URL = "https://console.clinia.cloud/api/auth/oauth2/token"
|
|
28
|
+
CLINIA_OAUTH_RESOURCE = "https://console.clinia.cloud"
|
|
29
|
+
|
|
30
|
+
DEFAULT_EXPIRY_MARGIN = 60.0
|
|
31
|
+
# Fallback lifetime when the token endpoint omits `expires_in`.
|
|
32
|
+
DEFAULT_TTL = 300.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, kw_only=True)
|
|
36
|
+
class ClientCredentials:
|
|
37
|
+
"""OAuth2 client-credentials configuration.
|
|
38
|
+
|
|
39
|
+
For a Clinia workspace, `client_id` + `client_secret` is enough — the token
|
|
40
|
+
endpoint and `resource` indicator default to Clinia's authorization server.
|
|
41
|
+
Naming an `issuer` instead locates a different authorization server by
|
|
42
|
+
OIDC/OAuth2 discovery; naming a `token_url` points at one explicitly.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
client_id: str
|
|
46
|
+
client_secret: str
|
|
47
|
+
token_url: str | None = None
|
|
48
|
+
issuer: str | None = None
|
|
49
|
+
# Discovery transformation used when `issuer` is given: OIDC (default) or
|
|
50
|
+
# RFC 8414.
|
|
51
|
+
discovery_algorithm: Literal["oidc", "oauth2"] = "oidc"
|
|
52
|
+
# RFC 8707 resource indicator naming the protected resource the token is
|
|
53
|
+
# for.
|
|
54
|
+
resource: str | None = None
|
|
55
|
+
# `audience` parameter used by some IdPs (e.g. Auth0).
|
|
56
|
+
audience: str | None = None
|
|
57
|
+
# Space-delimited scopes.
|
|
58
|
+
scope: str | None = None
|
|
59
|
+
# How the credentials are presented to the token endpoint: `post` (the
|
|
60
|
+
# default, what Clinia's authorization server expects) sends them in the
|
|
61
|
+
# request body; `basic` sends them in the Authorization header
|
|
62
|
+
# (RFC 6749 §2.3.1).
|
|
63
|
+
auth_method: Literal["basic", "post"] = "post"
|
|
64
|
+
# Refresh the token this many seconds before it actually expires, so a
|
|
65
|
+
# token is never handed out mid-flight of expiring.
|
|
66
|
+
expiry_margin: float = DEFAULT_EXPIRY_MARGIN
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _discovery_url(config: ClientCredentials) -> str:
|
|
70
|
+
assert config.issuer is not None
|
|
71
|
+
if config.discovery_algorithm == "oauth2":
|
|
72
|
+
# RFC 8414: the well-known segment is inserted between host and path.
|
|
73
|
+
parts = urlsplit(config.issuer)
|
|
74
|
+
path = parts.path.rstrip("/")
|
|
75
|
+
return f"{parts.scheme}://{parts.netloc}/.well-known/oauth-authorization-server{path}"
|
|
76
|
+
return config.issuer.rstrip("/") + "/.well-known/openid-configuration"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _token_request_args(config: ClientCredentials, token_endpoint: str) -> dict[str, Any]:
|
|
80
|
+
uses_clinia_auth_server = config.token_url is None and config.issuer is None
|
|
81
|
+
resource = config.resource or (CLINIA_OAUTH_RESOURCE if uses_clinia_auth_server else None)
|
|
82
|
+
|
|
83
|
+
data: dict[str, str] = {"grant_type": "client_credentials"}
|
|
84
|
+
if resource:
|
|
85
|
+
data["resource"] = resource
|
|
86
|
+
if config.audience:
|
|
87
|
+
data["audience"] = config.audience
|
|
88
|
+
if config.scope:
|
|
89
|
+
data["scope"] = config.scope
|
|
90
|
+
|
|
91
|
+
args: dict[str, Any] = {"url": token_endpoint, "data": data}
|
|
92
|
+
if config.auth_method == "basic":
|
|
93
|
+
args["auth"] = httpx.BasicAuth(config.client_id, config.client_secret)
|
|
94
|
+
else:
|
|
95
|
+
data["client_id"] = config.client_id
|
|
96
|
+
data["client_secret"] = config.client_secret
|
|
97
|
+
return args
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_token_response(response: httpx.Response) -> tuple[str, float]:
|
|
101
|
+
"""Returns the access token and the absolute time it should be refreshed."""
|
|
102
|
+
if response.is_error:
|
|
103
|
+
detail: str
|
|
104
|
+
try:
|
|
105
|
+
payload = response.json()
|
|
106
|
+
detail = str(payload.get("error", response.reason_phrase))
|
|
107
|
+
if payload.get("error_description"):
|
|
108
|
+
detail += f" — {payload['error_description']}"
|
|
109
|
+
except ValueError:
|
|
110
|
+
detail = response.reason_phrase
|
|
111
|
+
raise ContextEngineError(
|
|
112
|
+
f"client_credentials token request failed: {response.status_code} {detail}"
|
|
113
|
+
)
|
|
114
|
+
payload = response.json()
|
|
115
|
+
token = payload.get("access_token")
|
|
116
|
+
if not isinstance(token, str) or not token:
|
|
117
|
+
raise ContextEngineError(
|
|
118
|
+
"client_credentials token request failed: response carries no access_token"
|
|
119
|
+
)
|
|
120
|
+
ttl = float(payload["expires_in"]) if payload.get("expires_in") else DEFAULT_TTL
|
|
121
|
+
return token, ttl
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_discovery_response(response: httpx.Response) -> str:
|
|
125
|
+
if response.is_error:
|
|
126
|
+
raise ContextEngineError(f"authorization server discovery failed: {response.status_code}")
|
|
127
|
+
endpoint = response.json().get("token_endpoint")
|
|
128
|
+
if not isinstance(endpoint, str) or not endpoint:
|
|
129
|
+
raise ContextEngineError(
|
|
130
|
+
"authorization server discovery failed: metadata carries no token_endpoint"
|
|
131
|
+
)
|
|
132
|
+
return endpoint
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class _TokenState:
|
|
136
|
+
"""Cached token plus the resolved token endpoint, shared by both providers."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, config: ClientCredentials) -> None:
|
|
139
|
+
self.config = config
|
|
140
|
+
# Discovery only when an issuer is named and no explicit endpoint
|
|
141
|
+
# overrides it; with neither, Clinia's endpoint is the default.
|
|
142
|
+
if config.token_url or config.issuer is None:
|
|
143
|
+
self.token_endpoint: str | None = config.token_url or CLINIA_OAUTH_TOKEN_URL
|
|
144
|
+
else:
|
|
145
|
+
self.token_endpoint = None
|
|
146
|
+
self.token: str | None = None
|
|
147
|
+
# Absolute monotonic time at which the cached token is considered stale.
|
|
148
|
+
self.refresh_at = 0.0
|
|
149
|
+
|
|
150
|
+
def fresh_token(self) -> str | None:
|
|
151
|
+
if self.token is not None and time.monotonic() < self.refresh_at:
|
|
152
|
+
return self.token
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def store(self, token: str, ttl: float) -> str:
|
|
156
|
+
self.token = token
|
|
157
|
+
self.refresh_at = time.monotonic() + max(0.0, ttl - self.config.expiry_margin)
|
|
158
|
+
return token
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class ClientCredentialsTokenProvider:
|
|
162
|
+
"""Callable token provider backed by the OAuth2 client-credentials grant.
|
|
163
|
+
|
|
164
|
+
Caches the access token until it is within `expiry_margin` of expiry;
|
|
165
|
+
concurrent callers share one token request. When configured with an
|
|
166
|
+
`issuer`, the authorization server metadata is discovered once and
|
|
167
|
+
memoized.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self, config: ClientCredentials, *, http_client: httpx.Client | None = None
|
|
172
|
+
) -> None:
|
|
173
|
+
self._state = _TokenState(config)
|
|
174
|
+
self._http_client = http_client
|
|
175
|
+
self._lock = threading.Lock()
|
|
176
|
+
|
|
177
|
+
def __call__(self) -> str:
|
|
178
|
+
token = self._state.fresh_token()
|
|
179
|
+
if token is not None:
|
|
180
|
+
return token
|
|
181
|
+
with self._lock:
|
|
182
|
+
token = self._state.fresh_token()
|
|
183
|
+
if token is not None:
|
|
184
|
+
return token
|
|
185
|
+
return self._fetch()
|
|
186
|
+
|
|
187
|
+
def _fetch(self) -> str:
|
|
188
|
+
client = self._http_client or httpx.Client()
|
|
189
|
+
try:
|
|
190
|
+
if self._state.token_endpoint is None:
|
|
191
|
+
response = client.get(_discovery_url(self._state.config))
|
|
192
|
+
self._state.token_endpoint = _parse_discovery_response(response)
|
|
193
|
+
args = _token_request_args(self._state.config, self._state.token_endpoint)
|
|
194
|
+
token, ttl = _parse_token_response(client.post(**args))
|
|
195
|
+
return self._state.store(token, ttl)
|
|
196
|
+
finally:
|
|
197
|
+
if self._http_client is None:
|
|
198
|
+
client.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class AsyncClientCredentialsTokenProvider:
|
|
202
|
+
"""Async twin of `ClientCredentialsTokenProvider`."""
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self, config: ClientCredentials, *, http_client: httpx.AsyncClient | None = None
|
|
206
|
+
) -> None:
|
|
207
|
+
self._state = _TokenState(config)
|
|
208
|
+
self._http_client = http_client
|
|
209
|
+
self._lock = asyncio.Lock()
|
|
210
|
+
|
|
211
|
+
async def __call__(self) -> str:
|
|
212
|
+
token = self._state.fresh_token()
|
|
213
|
+
if token is not None:
|
|
214
|
+
return token
|
|
215
|
+
async with self._lock:
|
|
216
|
+
token = self._state.fresh_token()
|
|
217
|
+
if token is not None:
|
|
218
|
+
return token
|
|
219
|
+
return await self._fetch()
|
|
220
|
+
|
|
221
|
+
async def _fetch(self) -> str:
|
|
222
|
+
client = self._http_client or httpx.AsyncClient()
|
|
223
|
+
try:
|
|
224
|
+
if self._state.token_endpoint is None:
|
|
225
|
+
response = await client.get(_discovery_url(self._state.config))
|
|
226
|
+
self._state.token_endpoint = _parse_discovery_response(response)
|
|
227
|
+
args = _token_request_args(self._state.config, self._state.token_endpoint)
|
|
228
|
+
token, ttl = _parse_token_response(await client.post(**args))
|
|
229
|
+
return self._state.store(token, ttl)
|
|
230
|
+
finally:
|
|
231
|
+
if self._http_client is None:
|
|
232
|
+
await client.aclose()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .generated.models import ApiError
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"APIConnectionError",
|
|
9
|
+
"APIStatusError",
|
|
10
|
+
"APITimeoutError",
|
|
11
|
+
"ContextEngineError",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContextEngineError(Exception):
|
|
16
|
+
"""Base class for every error raised by this client."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APIConnectionError(ContextEngineError):
|
|
20
|
+
"""The request never produced an HTTP response."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str, *, request: httpx.Request) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.request = request
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APITimeoutError(APIConnectionError):
|
|
28
|
+
def __init__(self, *, request: httpx.Request) -> None:
|
|
29
|
+
super().__init__("Request timed out.", request=request)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class APIStatusError(ContextEngineError):
|
|
33
|
+
"""A non-2xx HTTP response.
|
|
34
|
+
|
|
35
|
+
`error` carries the server's parsed error envelope when the body is a valid
|
|
36
|
+
`ApiError`; `body` always carries the raw response text.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, *, response: httpx.Response, error: ApiError | None) -> None:
|
|
40
|
+
detail = f" — {error.type}: {error.message}" if error else ""
|
|
41
|
+
super().__init__(f"{response.status_code} {response.reason_phrase}{detail}")
|
|
42
|
+
self.response = response
|
|
43
|
+
self.status_code = response.status_code
|
|
44
|
+
self.error = error
|
|
45
|
+
self.body = response.text
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
4
|
+
from typing import Any, TypeVar, overload
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
9
|
+
|
|
10
|
+
from ._errors import APIConnectionError, APIStatusError, APITimeoutError
|
|
11
|
+
from .generated.models import ApiError
|
|
12
|
+
|
|
13
|
+
__all__ = ["AsyncHttpClient", "AsyncTokenProvider", "HttpClient", "TokenProvider"]
|
|
14
|
+
|
|
15
|
+
TokenProvider = Callable[[], str]
|
|
16
|
+
AsyncTokenProvider = Callable[[], Awaitable[str]]
|
|
17
|
+
|
|
18
|
+
DEFAULT_TIMEOUT = httpx.Timeout(60.0)
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _substitute_path(path: str, path_params: Mapping[str, Any] | None) -> str:
|
|
24
|
+
if not path_params:
|
|
25
|
+
return path
|
|
26
|
+
for name, value in path_params.items():
|
|
27
|
+
path = path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
28
|
+
return path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _prepare_query(query: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
32
|
+
"""Omitted (None) parameters are dropped rather than sent as empty values."""
|
|
33
|
+
if query is None:
|
|
34
|
+
return None
|
|
35
|
+
prepared = {name: value for name, value in query.items() if value is not None}
|
|
36
|
+
return prepared or None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _prepare_json(body: Any) -> Any:
|
|
40
|
+
if isinstance(body, BaseModel):
|
|
41
|
+
return body.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
42
|
+
return body
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_response(response: httpx.Response, cast_to: TypeAdapter[T] | None) -> T | None:
|
|
46
|
+
if cast_to is None:
|
|
47
|
+
return None
|
|
48
|
+
# Operations that also declare a text response (e.g. a Markdown rendering
|
|
49
|
+
# behind a `format` query parameter) carry `str` in `cast_to`'s union, so a
|
|
50
|
+
# non-JSON response validates as its text.
|
|
51
|
+
if "json" in response.headers.get("Content-Type", ""):
|
|
52
|
+
return cast_to.validate_json(response.content)
|
|
53
|
+
return cast_to.validate_python(response.text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _status_error(response: httpx.Response) -> APIStatusError:
|
|
57
|
+
try:
|
|
58
|
+
error = ApiError.model_validate_json(response.content)
|
|
59
|
+
except ValidationError:
|
|
60
|
+
error = None
|
|
61
|
+
return APIStatusError(response=response, error=error)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _RequestBuilder:
|
|
65
|
+
"""Request construction shared by the sync and async clients."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, base_url: str) -> None:
|
|
68
|
+
self._base_url = base_url.rstrip("/")
|
|
69
|
+
|
|
70
|
+
def build_args(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
path: str,
|
|
74
|
+
*,
|
|
75
|
+
path_params: Mapping[str, Any] | None,
|
|
76
|
+
query: Mapping[str, Any] | None,
|
|
77
|
+
json: Any,
|
|
78
|
+
content: str | bytes | None,
|
|
79
|
+
content_type: str | None,
|
|
80
|
+
token: str | None,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
headers: dict[str, str] = {}
|
|
83
|
+
if token:
|
|
84
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
85
|
+
if content_type is not None:
|
|
86
|
+
headers["Content-Type"] = content_type
|
|
87
|
+
|
|
88
|
+
args: dict[str, Any] = {
|
|
89
|
+
"method": method,
|
|
90
|
+
"url": self._base_url + _substitute_path(path, path_params),
|
|
91
|
+
"headers": headers,
|
|
92
|
+
}
|
|
93
|
+
params = _prepare_query(query)
|
|
94
|
+
if params is not None:
|
|
95
|
+
args["params"] = params
|
|
96
|
+
if json is not None:
|
|
97
|
+
args["json"] = _prepare_json(json)
|
|
98
|
+
if content is not None:
|
|
99
|
+
args["content"] = content
|
|
100
|
+
return args
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class HttpClient:
|
|
104
|
+
"""Thin typed transport: one `request()` the generated resources call.
|
|
105
|
+
|
|
106
|
+
Owns an `httpx.Client` unless one is injected; injected clients are not
|
|
107
|
+
closed by `close()`.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
base_url: str,
|
|
114
|
+
token_provider: TokenProvider | None = None,
|
|
115
|
+
timeout: float | httpx.Timeout | None = None,
|
|
116
|
+
http_client: httpx.Client | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._builder = _RequestBuilder(base_url)
|
|
119
|
+
self._token_provider = token_provider
|
|
120
|
+
self._owns_client = http_client is None
|
|
121
|
+
self._client = http_client or httpx.Client(timeout=timeout or DEFAULT_TIMEOUT)
|
|
122
|
+
|
|
123
|
+
@overload
|
|
124
|
+
def request(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
cast_to: TypeAdapter[T],
|
|
130
|
+
path_params: Mapping[str, Any] | None = None,
|
|
131
|
+
query: Mapping[str, Any] | None = None,
|
|
132
|
+
json: Any = None,
|
|
133
|
+
content: str | bytes | None = None,
|
|
134
|
+
content_type: str | None = None,
|
|
135
|
+
) -> T: ...
|
|
136
|
+
|
|
137
|
+
@overload
|
|
138
|
+
def request(
|
|
139
|
+
self,
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
*,
|
|
143
|
+
cast_to: None,
|
|
144
|
+
path_params: Mapping[str, Any] | None = None,
|
|
145
|
+
query: Mapping[str, Any] | None = None,
|
|
146
|
+
json: Any = None,
|
|
147
|
+
content: str | bytes | None = None,
|
|
148
|
+
content_type: str | None = None,
|
|
149
|
+
) -> None: ...
|
|
150
|
+
|
|
151
|
+
def request(
|
|
152
|
+
self,
|
|
153
|
+
method: str,
|
|
154
|
+
path: str,
|
|
155
|
+
*,
|
|
156
|
+
cast_to: TypeAdapter[T] | None,
|
|
157
|
+
path_params: Mapping[str, Any] | None = None,
|
|
158
|
+
query: Mapping[str, Any] | None = None,
|
|
159
|
+
json: Any = None,
|
|
160
|
+
content: str | bytes | None = None,
|
|
161
|
+
content_type: str | None = None,
|
|
162
|
+
) -> T | None:
|
|
163
|
+
token = self._token_provider() if self._token_provider else None
|
|
164
|
+
args = self._builder.build_args(
|
|
165
|
+
method,
|
|
166
|
+
path,
|
|
167
|
+
path_params=path_params,
|
|
168
|
+
query=query,
|
|
169
|
+
json=json,
|
|
170
|
+
content=content,
|
|
171
|
+
content_type=content_type,
|
|
172
|
+
token=token,
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
response = self._client.request(**args)
|
|
176
|
+
except httpx.TimeoutException as err:
|
|
177
|
+
raise APITimeoutError(request=err.request) from err
|
|
178
|
+
except httpx.TransportError as err:
|
|
179
|
+
raise APIConnectionError(str(err), request=err.request) from err
|
|
180
|
+
if response.is_error:
|
|
181
|
+
raise _status_error(response)
|
|
182
|
+
return _parse_response(response, cast_to)
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
if self._owns_client:
|
|
186
|
+
self._client.close()
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> HttpClient:
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
192
|
+
self.close()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class AsyncHttpClient:
|
|
196
|
+
"""Async twin of `HttpClient`, backed by `httpx.AsyncClient`."""
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
base_url: str,
|
|
202
|
+
token_provider: AsyncTokenProvider | None = None,
|
|
203
|
+
timeout: float | httpx.Timeout | None = None,
|
|
204
|
+
http_client: httpx.AsyncClient | None = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
self._builder = _RequestBuilder(base_url)
|
|
207
|
+
self._token_provider = token_provider
|
|
208
|
+
self._owns_client = http_client is None
|
|
209
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout or DEFAULT_TIMEOUT)
|
|
210
|
+
|
|
211
|
+
@overload
|
|
212
|
+
async def request(
|
|
213
|
+
self,
|
|
214
|
+
method: str,
|
|
215
|
+
path: str,
|
|
216
|
+
*,
|
|
217
|
+
cast_to: TypeAdapter[T],
|
|
218
|
+
path_params: Mapping[str, Any] | None = None,
|
|
219
|
+
query: Mapping[str, Any] | None = None,
|
|
220
|
+
json: Any = None,
|
|
221
|
+
content: str | bytes | None = None,
|
|
222
|
+
content_type: str | None = None,
|
|
223
|
+
) -> T: ...
|
|
224
|
+
|
|
225
|
+
@overload
|
|
226
|
+
async def request(
|
|
227
|
+
self,
|
|
228
|
+
method: str,
|
|
229
|
+
path: str,
|
|
230
|
+
*,
|
|
231
|
+
cast_to: None,
|
|
232
|
+
path_params: Mapping[str, Any] | None = None,
|
|
233
|
+
query: Mapping[str, Any] | None = None,
|
|
234
|
+
json: Any = None,
|
|
235
|
+
content: str | bytes | None = None,
|
|
236
|
+
content_type: str | None = None,
|
|
237
|
+
) -> None: ...
|
|
238
|
+
|
|
239
|
+
async def request(
|
|
240
|
+
self,
|
|
241
|
+
method: str,
|
|
242
|
+
path: str,
|
|
243
|
+
*,
|
|
244
|
+
cast_to: TypeAdapter[T] | None,
|
|
245
|
+
path_params: Mapping[str, Any] | None = None,
|
|
246
|
+
query: Mapping[str, Any] | None = None,
|
|
247
|
+
json: Any = None,
|
|
248
|
+
content: str | bytes | None = None,
|
|
249
|
+
content_type: str | None = None,
|
|
250
|
+
) -> T | None:
|
|
251
|
+
token = await self._token_provider() if self._token_provider else None
|
|
252
|
+
args = self._builder.build_args(
|
|
253
|
+
method,
|
|
254
|
+
path,
|
|
255
|
+
path_params=path_params,
|
|
256
|
+
query=query,
|
|
257
|
+
json=json,
|
|
258
|
+
content=content,
|
|
259
|
+
content_type=content_type,
|
|
260
|
+
token=token,
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
response = await self._client.request(**args)
|
|
264
|
+
except httpx.TimeoutException as err:
|
|
265
|
+
raise APITimeoutError(request=err.request) from err
|
|
266
|
+
except httpx.TransportError as err:
|
|
267
|
+
raise APIConnectionError(str(err), request=err.request) from err
|
|
268
|
+
if response.is_error:
|
|
269
|
+
raise _status_error(response)
|
|
270
|
+
return _parse_response(response, cast_to)
|
|
271
|
+
|
|
272
|
+
async def aclose(self) -> None:
|
|
273
|
+
if self._owns_client:
|
|
274
|
+
await self._client.aclose()
|
|
275
|
+
|
|
276
|
+
async def __aenter__(self) -> AsyncHttpClient:
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
280
|
+
await self.aclose()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from ._auth import (
|
|
6
|
+
AsyncClientCredentialsTokenProvider,
|
|
7
|
+
ClientCredentials,
|
|
8
|
+
ClientCredentialsTokenProvider,
|
|
9
|
+
)
|
|
10
|
+
from ._http import AsyncHttpClient, AsyncTokenProvider, HttpClient, TokenProvider
|
|
11
|
+
from .resources import (
|
|
12
|
+
AsyncGraphResource,
|
|
13
|
+
AsyncInfoResource,
|
|
14
|
+
AsyncIngestResource,
|
|
15
|
+
AsyncPatientsResource,
|
|
16
|
+
AsyncSessionsResource,
|
|
17
|
+
AsyncVFSResource,
|
|
18
|
+
GraphResource,
|
|
19
|
+
InfoResource,
|
|
20
|
+
IngestResource,
|
|
21
|
+
PatientsResource,
|
|
22
|
+
SessionsResource,
|
|
23
|
+
VFSResource,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = ["AsyncContextEngineClient", "ContextEngineClient"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ContextEngineClient:
|
|
30
|
+
"""Synchronous client for the Context Engine API.
|
|
31
|
+
|
|
32
|
+
`auth` accepts either a `ClientCredentials` config — the common case, the
|
|
33
|
+
client then handles token acquisition, caching, and refresh — or a token
|
|
34
|
+
callable for full control (a token from elsewhere, a different grant, or a
|
|
35
|
+
custom refresh strategy), called per request and attached as
|
|
36
|
+
`Authorization: Bearer <token>`. Omit for an unsecured server.
|
|
37
|
+
|
|
38
|
+
Owns network resources; use it as a context manager or call `close()`.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str,
|
|
45
|
+
auth: ClientCredentials | TokenProvider | None = None,
|
|
46
|
+
timeout: float | httpx.Timeout | None = None,
|
|
47
|
+
http_client: httpx.Client | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
token_provider: TokenProvider | None
|
|
50
|
+
if isinstance(auth, ClientCredentials):
|
|
51
|
+
token_provider = ClientCredentialsTokenProvider(auth)
|
|
52
|
+
else:
|
|
53
|
+
token_provider = auth
|
|
54
|
+
|
|
55
|
+
self.http = HttpClient(
|
|
56
|
+
base_url=base_url,
|
|
57
|
+
token_provider=token_provider,
|
|
58
|
+
timeout=timeout,
|
|
59
|
+
http_client=http_client,
|
|
60
|
+
)
|
|
61
|
+
self.graph = GraphResource(self.http)
|
|
62
|
+
self.info = InfoResource(self.http)
|
|
63
|
+
self.ingest = IngestResource(self.http)
|
|
64
|
+
self.patients = PatientsResource(self.http)
|
|
65
|
+
self.sessions = SessionsResource(self.http)
|
|
66
|
+
self.vfs = VFSResource(self.http)
|
|
67
|
+
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
self.http.close()
|
|
70
|
+
|
|
71
|
+
def __enter__(self) -> ContextEngineClient:
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AsyncContextEngineClient:
|
|
79
|
+
"""Async twin of `ContextEngineClient`."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
base_url: str,
|
|
85
|
+
auth: ClientCredentials | AsyncTokenProvider | None = None,
|
|
86
|
+
timeout: float | httpx.Timeout | None = None,
|
|
87
|
+
http_client: httpx.AsyncClient | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
token_provider: AsyncTokenProvider | None
|
|
90
|
+
if isinstance(auth, ClientCredentials):
|
|
91
|
+
token_provider = AsyncClientCredentialsTokenProvider(auth)
|
|
92
|
+
else:
|
|
93
|
+
token_provider = auth
|
|
94
|
+
|
|
95
|
+
self.http = AsyncHttpClient(
|
|
96
|
+
base_url=base_url,
|
|
97
|
+
token_provider=token_provider,
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
http_client=http_client,
|
|
100
|
+
)
|
|
101
|
+
self.graph = AsyncGraphResource(self.http)
|
|
102
|
+
self.info = AsyncInfoResource(self.http)
|
|
103
|
+
self.ingest = AsyncIngestResource(self.http)
|
|
104
|
+
self.patients = AsyncPatientsResource(self.http)
|
|
105
|
+
self.sessions = AsyncSessionsResource(self.http)
|
|
106
|
+
self.vfs = AsyncVFSResource(self.http)
|
|
107
|
+
|
|
108
|
+
async def aclose(self) -> None:
|
|
109
|
+
await self.http.aclose()
|
|
110
|
+
|
|
111
|
+
async def __aenter__(self) -> AsyncContextEngineClient:
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
115
|
+
await self.aclose()
|