hubble-sdk-python 0.0.2__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.
- hubble_sdk/__init__.py +43 -0
- hubble_sdk/_generated/__init__.py +8 -0
- hubble_sdk/_generated/api/__init__.py +1 -0
- hubble_sdk/_generated/api/default/__init__.py +1 -0
- hubble_sdk/_generated/api/default/attach_client_authorization.py +220 -0
- hubble_sdk/_generated/api/default/create_consent_link.py +197 -0
- hubble_sdk/_generated/api/default/exchange_token.py +186 -0
- hubble_sdk/_generated/api/default/export_patient_pdf.py +194 -0
- hubble_sdk/_generated/api/default/get_consent.py +166 -0
- hubble_sdk/_generated/api/default/list_consents.py +191 -0
- hubble_sdk/_generated/api/default/read_fhir.py +182 -0
- hubble_sdk/_generated/api/default/revoke_consent.py +167 -0
- hubble_sdk/_generated/client.py +282 -0
- hubble_sdk/_generated/errors.py +16 -0
- hubble_sdk/_generated/models/__init__.py +59 -0
- hubble_sdk/_generated/models/api_error.py +42 -0
- hubble_sdk/_generated/models/attach_client_authorization_body.py +112 -0
- hubble_sdk/_generated/models/attach_client_authorization_response_201.py +100 -0
- hubble_sdk/_generated/models/attach_client_authorization_response_201_status.py +8 -0
- hubble_sdk/_generated/models/consent.py +160 -0
- hubble_sdk/_generated/models/consent_error.py +50 -0
- hubble_sdk/_generated/models/consent_link_request.py +88 -0
- hubble_sdk/_generated/models/consent_list.py +56 -0
- hubble_sdk/_generated/models/consent_source.py +63 -0
- hubble_sdk/_generated/models/consent_source_status.py +10 -0
- hubble_sdk/_generated/models/consent_source_type.py +9 -0
- hubble_sdk/_generated/models/consent_status.py +12 -0
- hubble_sdk/_generated/models/device_authorization_request.py +142 -0
- hubble_sdk/_generated/models/device_authorization_response.py +90 -0
- hubble_sdk/_generated/models/fhir_resource.py +62 -0
- hubble_sdk/_generated/models/o_auth_error.py +54 -0
- hubble_sdk/_generated/models/o_auth_error_error.py +16 -0
- hubble_sdk/_generated/models/operation_outcome.py +75 -0
- hubble_sdk/_generated/models/operation_outcome_issue_item.py +61 -0
- hubble_sdk/_generated/models/patient_address.py +69 -0
- hubble_sdk/_generated/models/patient_demographics.py +109 -0
- hubble_sdk/_generated/models/provider_seed.py +53 -0
- hubble_sdk/_generated/models/token_request.py +81 -0
- hubble_sdk/_generated/models/token_request_grant_type.py +12 -0
- hubble_sdk/_generated/models/token_response.py +84 -0
- hubble_sdk/_generated/types.py +54 -0
- hubble_sdk/_hubble.py +379 -0
- hubble_sdk_python-0.0.2.dist-info/METADATA +89 -0
- hubble_sdk_python-0.0.2.dist-info/RECORD +46 -0
- hubble_sdk_python-0.0.2.dist-info/WHEEL +4 -0
- hubble_sdk_python-0.0.2.dist-info/licenses/LICENSE +21 -0
hubble_sdk/_hubble.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Hand-written ergonomics layer over the generated client.
|
|
2
|
+
|
|
3
|
+
The generated code under ``_generated`` is a faithful, low-level mapping of the
|
|
4
|
+
OpenAPI contract and is regenerated on every spec change; nothing here is
|
|
5
|
+
generated. This module is the surface users actually import: it defaults the
|
|
6
|
+
base URL, picks the right auth scheme per call (HTTP Basic for the control
|
|
7
|
+
plane, Bearer for the FHIR data plane), builds FHIR URLs correctly, polls the
|
|
8
|
+
device-authorization flow, and refreshes consent tokens transparently.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Mapping
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any, TypeVar
|
|
18
|
+
from urllib.parse import unquote
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._generated.api.default import (
|
|
23
|
+
create_consent_link,
|
|
24
|
+
exchange_token,
|
|
25
|
+
get_consent as _get_consent,
|
|
26
|
+
list_consents as _list_consents,
|
|
27
|
+
revoke_consent as _revoke_consent,
|
|
28
|
+
)
|
|
29
|
+
from ._generated.client import AuthenticatedClient
|
|
30
|
+
from ._generated.models import (
|
|
31
|
+
Consent,
|
|
32
|
+
ConsentLinkRequest,
|
|
33
|
+
ConsentList,
|
|
34
|
+
ConsentStatus,
|
|
35
|
+
DeviceAuthorizationResponse,
|
|
36
|
+
TokenRequest,
|
|
37
|
+
TokenRequestGrantType,
|
|
38
|
+
TokenResponse,
|
|
39
|
+
)
|
|
40
|
+
from ._generated.types import UNSET
|
|
41
|
+
|
|
42
|
+
DEFAULT_BASE_URL = "https://api.hubble.ai"
|
|
43
|
+
_DEVICE_CODE_GRANT = TokenRequestGrantType("urn:ietf:params:oauth:grant-type:device_code")
|
|
44
|
+
_REFRESH_GRANT = TokenRequestGrantType("refresh_token")
|
|
45
|
+
_PENDING = {"authorization_pending", "slow_down"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _err_code(parsed: Any) -> str | None:
|
|
49
|
+
code = getattr(parsed, "error", None)
|
|
50
|
+
return code.value if hasattr(code, "value") else code
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_T = TypeVar("_T")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unwrap(result: Any, expected: type[_T], ctx: str) -> _T:
|
|
57
|
+
# The generated .sync() returns the success model, an error model, or None.
|
|
58
|
+
# Route calls through this so a non-success result can't be silently used.
|
|
59
|
+
if not isinstance(result, expected):
|
|
60
|
+
raise HubbleError(f"{ctx}: {result}")
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ensure_ok(resp: Any, ctx: str) -> None:
|
|
65
|
+
# For operations with no response body (e.g. a 204): assert a 2xx status.
|
|
66
|
+
if not 200 <= resp.status_code < 300:
|
|
67
|
+
raise HubbleError(f"{ctx}: HTTP {resp.status_code}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Hubble:
|
|
71
|
+
"""Client for the Hubble Patient Access API.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
client_id / client_secret: your application credentials.
|
|
75
|
+
base_url: defaults to the production host; override only with a trusted
|
|
76
|
+
value (your access tokens are sent to it).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, client_id: str, client_secret: str, base_url: str = DEFAULT_BASE_URL):
|
|
80
|
+
self.base_url = base_url.rstrip("/")
|
|
81
|
+
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
|
82
|
+
# Control plane authenticates with HTTP Basic.
|
|
83
|
+
self._control = AuthenticatedClient(base_url=self.base_url, token=basic, prefix="Basic")
|
|
84
|
+
|
|
85
|
+
# ── link flow (OAuth device authorization grant) ──────────────────────────
|
|
86
|
+
|
|
87
|
+
def create_link(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
patient_ref: str | None = None,
|
|
91
|
+
patient: Mapping[str, Any] | None = None,
|
|
92
|
+
providers: list[Mapping[str, Any]] | None = None,
|
|
93
|
+
) -> DeviceAuthorizationResponse:
|
|
94
|
+
"""Start the patient link flow. Send the patient to the returned
|
|
95
|
+
``verification_uri_complete``, then call `wait_for_authorization()`.
|
|
96
|
+
|
|
97
|
+
Optionally prefill the link. ``patient`` is a mapping of demographics
|
|
98
|
+
(``first_name``, ``last_name``, ``birth_date``, ``sex``, ``phone``,
|
|
99
|
+
``email``, and a nested ``address``); ``providers`` is a list of provider
|
|
100
|
+
seeds (each ``{"name": ..., "phone"?: ...}``) to prefill the patient's
|
|
101
|
+
list. The patient can add, change, or remove any of it on the link page."""
|
|
102
|
+
body: dict[str, Any] = {}
|
|
103
|
+
if patient_ref is not None:
|
|
104
|
+
body["patient_ref"] = patient_ref
|
|
105
|
+
if patient is not None:
|
|
106
|
+
body["patient"] = dict(patient)
|
|
107
|
+
if providers is not None:
|
|
108
|
+
body["providers"] = [dict(p) for p in providers]
|
|
109
|
+
return _unwrap(
|
|
110
|
+
create_consent_link.sync(client=self._control, body=ConsentLinkRequest.from_dict(body)),
|
|
111
|
+
DeviceAuthorizationResponse,
|
|
112
|
+
"Could not create consent link",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def wait_for_authorization(
|
|
116
|
+
self,
|
|
117
|
+
link: DeviceAuthorizationResponse,
|
|
118
|
+
*,
|
|
119
|
+
timeout: float = 600.0,
|
|
120
|
+
on_refresh: RefreshCallback | None = None,
|
|
121
|
+
) -> PatientSession:
|
|
122
|
+
"""Poll the token endpoint until the patient authorizes, honoring the
|
|
123
|
+
server's ``interval`` and ``slow_down``. Returns a `PatientSession`.
|
|
124
|
+
|
|
125
|
+
Pass ``on_refresh`` to be notified when the returned session later rotates
|
|
126
|
+
its tokens, so you can persist the new values."""
|
|
127
|
+
deadline = time.monotonic() + timeout
|
|
128
|
+
interval = float(link.interval)
|
|
129
|
+
while True:
|
|
130
|
+
resp = exchange_token.sync_detailed(
|
|
131
|
+
client=self._control,
|
|
132
|
+
body=TokenRequest(grant_type=_DEVICE_CODE_GRANT, device_code=link.device_code),
|
|
133
|
+
)
|
|
134
|
+
if resp.status_code == 200 and isinstance(resp.parsed, TokenResponse):
|
|
135
|
+
return PatientSession._from_token(self, resp.parsed, on_refresh=on_refresh)
|
|
136
|
+
code = _err_code(resp.parsed)
|
|
137
|
+
if code not in _PENDING:
|
|
138
|
+
raise HubbleError(f"Authorization failed: {code or resp.status_code}")
|
|
139
|
+
if time.monotonic() >= deadline:
|
|
140
|
+
raise ConsentTimeout("Patient did not authorize before the timeout.")
|
|
141
|
+
if code == "slow_down":
|
|
142
|
+
interval += 5
|
|
143
|
+
time.sleep(interval)
|
|
144
|
+
|
|
145
|
+
# ── consent management (control plane) ────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def list_consents(
|
|
148
|
+
self, *, status: str | None = None, patient_ref: str | None = None
|
|
149
|
+
) -> list[Consent]:
|
|
150
|
+
"""List consents, optionally filtered by ``status`` or ``patient_ref``."""
|
|
151
|
+
result = _list_consents.sync(
|
|
152
|
+
client=self._control,
|
|
153
|
+
status=ConsentStatus(status) if status is not None else UNSET,
|
|
154
|
+
patient_ref=patient_ref if patient_ref is not None else UNSET,
|
|
155
|
+
)
|
|
156
|
+
# _unwrap raises on failure, so this never returns [] to mean "errored".
|
|
157
|
+
return list(_unwrap(result, ConsentList, "Could not list consents").consents)
|
|
158
|
+
|
|
159
|
+
def get_consent(self, consent_id: str) -> Consent:
|
|
160
|
+
"""Fetch a single consent by id."""
|
|
161
|
+
return _unwrap(
|
|
162
|
+
_get_consent.sync(id=consent_id, client=self._control),
|
|
163
|
+
Consent,
|
|
164
|
+
f"Could not fetch consent {consent_id}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def revoke_consent(self, consent_id: str) -> None:
|
|
168
|
+
"""Revoke a consent. Idempotent; all its tokens stop working immediately."""
|
|
169
|
+
# 204 with no body — assert success rather than expecting data.
|
|
170
|
+
_ensure_ok(
|
|
171
|
+
_revoke_consent.sync_detailed(id=consent_id, client=self._control),
|
|
172
|
+
f"Could not revoke consent {consent_id}",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# ── reconstruct a session from stored tokens ──────────────────────────────
|
|
176
|
+
|
|
177
|
+
def session(
|
|
178
|
+
self,
|
|
179
|
+
access_token: str,
|
|
180
|
+
*,
|
|
181
|
+
refresh_token: str | None = None,
|
|
182
|
+
consent_id: str | None = None,
|
|
183
|
+
on_refresh: RefreshCallback | None = None,
|
|
184
|
+
) -> PatientSession:
|
|
185
|
+
"""Reconstruct a `PatientSession` from previously stored tokens. Pass
|
|
186
|
+
``on_refresh`` to be notified when the session rotates its tokens, so you can
|
|
187
|
+
persist the new values."""
|
|
188
|
+
return PatientSession(
|
|
189
|
+
self,
|
|
190
|
+
access_token=access_token,
|
|
191
|
+
refresh_token=refresh_token,
|
|
192
|
+
consent_id=consent_id,
|
|
193
|
+
on_refresh=on_refresh,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class PatientSession:
|
|
198
|
+
"""A consent-scoped session for reading one patient's FHIR data.
|
|
199
|
+
|
|
200
|
+
Holds the consent tokens and reads the FHIR data plane with a Bearer token,
|
|
201
|
+
refreshing transparently on expiry. Reconstruct a later session from stored
|
|
202
|
+
tokens with `Hubble.session()`.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
hubble: Hubble,
|
|
208
|
+
*,
|
|
209
|
+
access_token: str,
|
|
210
|
+
refresh_token: str | None = None,
|
|
211
|
+
consent_id: str | None = None,
|
|
212
|
+
on_refresh: RefreshCallback | None = None,
|
|
213
|
+
):
|
|
214
|
+
self._hubble = hubble
|
|
215
|
+
self.access_token = access_token
|
|
216
|
+
self.refresh_token = refresh_token
|
|
217
|
+
self.consent_id = consent_id
|
|
218
|
+
#: Called with the rotated tokens whenever this session refreshes.
|
|
219
|
+
self.on_refresh = on_refresh
|
|
220
|
+
self._data = AuthenticatedClient(base_url=hubble.base_url, token=access_token) # Bearer
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def _from_token(
|
|
224
|
+
cls, hubble: Hubble, tr: TokenResponse, on_refresh: RefreshCallback | None = None
|
|
225
|
+
) -> PatientSession:
|
|
226
|
+
return cls(
|
|
227
|
+
hubble,
|
|
228
|
+
access_token=tr.access_token,
|
|
229
|
+
refresh_token=tr.refresh_token,
|
|
230
|
+
consent_id=tr.consent_id,
|
|
231
|
+
on_refresh=on_refresh,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# ── FHIR data plane (Bearer) ──────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def fhir(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
237
|
+
"""GET a FHIR path, e.g. ``"Patient/$everything"`` or ``"Observation"``.
|
|
238
|
+
|
|
239
|
+
Returns the raw FHIR R4 resource as JSON (a dict). To work with typed
|
|
240
|
+
models, parse it with the optional ``fhir.resources`` package, e.g.
|
|
241
|
+
``from fhir.resources.bundle import Bundle; Bundle.model_validate(result)``.
|
|
242
|
+
|
|
243
|
+
The path is passed through httpx unencoded, so ``/`` and ``$`` survive
|
|
244
|
+
(unlike the raw generated client). Refreshes and retries once on 401."""
|
|
245
|
+
return self._fhir_get(path, params, retry=self.refresh_token is not None)
|
|
246
|
+
|
|
247
|
+
def everything(self, **params: Any) -> dict[str, Any]:
|
|
248
|
+
"""Read the patient's full record via ``Patient/$everything`` (a FHIR Bundle)."""
|
|
249
|
+
return self.fhir("Patient/$everything", params=params or None)
|
|
250
|
+
|
|
251
|
+
def summary(self) -> dict[str, Any]:
|
|
252
|
+
"""Read the patient summary via ``Patient/$summary`` (a FHIR Bundle)."""
|
|
253
|
+
return self.fhir("Patient/$summary")
|
|
254
|
+
|
|
255
|
+
def export_pdf(self, *, connection: str | None = None) -> PdfExport:
|
|
256
|
+
"""Export the patient's records as PDF via ``Patient/$pdf``.
|
|
257
|
+
|
|
258
|
+
Returns a `PdfExport`: the raw body (a ZIP of per-source PDFs plus a
|
|
259
|
+
``manifest.json`` marking each source complete/partial/failed, or a
|
|
260
|
+
single PDF when ``connection`` selects one source) together with the
|
|
261
|
+
per-source counts from the response headers, so you can tell how many
|
|
262
|
+
sources failed without opening the archive. Refreshes and retries once
|
|
263
|
+
on 401."""
|
|
264
|
+
params = {"connection": connection} if connection is not None else None
|
|
265
|
+
resp = self._fhir_request("Patient/$pdf", params, retry=self.refresh_token is not None)
|
|
266
|
+
|
|
267
|
+
def _count(header: str) -> int | None:
|
|
268
|
+
try:
|
|
269
|
+
return int(resp.headers[header])
|
|
270
|
+
except (KeyError, ValueError):
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
return PdfExport(
|
|
274
|
+
content=resp.content,
|
|
275
|
+
content_type=resp.headers.get("content-type", ""),
|
|
276
|
+
sources_total=_count("X-Hubble-Sources-Total"),
|
|
277
|
+
sources_failed=_count("X-Hubble-Sources-Failed"),
|
|
278
|
+
resource_count=_count("X-Hubble-Resource-Count"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def _fhir_get(self, path: str, params: dict[str, Any] | None, retry: bool) -> dict[str, Any]:
|
|
282
|
+
return self._fhir_request(path, params, retry).json()
|
|
283
|
+
|
|
284
|
+
def _fhir_request(
|
|
285
|
+
self, path: str, params: dict[str, Any] | None, retry: bool
|
|
286
|
+
) -> httpx.Response:
|
|
287
|
+
# Guard against escaping the FHIR namespace (which would send the consent
|
|
288
|
+
# token there): URL resolution normalizes raw `..`, and decoding then
|
|
289
|
+
# catches percent-encoded (`%2e%2e`) segments a server might resolve.
|
|
290
|
+
url = httpx.URL(self._hubble.base_url).join(f"/api/v1/fhir/{path.lstrip('/')}")
|
|
291
|
+
if not url.path.startswith("/api/v1/fhir/") or ".." in unquote(url.path).split("/"):
|
|
292
|
+
raise HubbleError(f"Invalid FHIR path: {path!r}")
|
|
293
|
+
resp = self._data.get_httpx_client().get(url, params=params)
|
|
294
|
+
if resp.status_code == 401 and retry:
|
|
295
|
+
self.refresh()
|
|
296
|
+
return self._fhir_request(path, params, retry=False)
|
|
297
|
+
if not resp.is_success:
|
|
298
|
+
raise HubbleError(f"FHIR request failed: {resp.status_code}")
|
|
299
|
+
return resp
|
|
300
|
+
|
|
301
|
+
def refresh(self) -> None:
|
|
302
|
+
"""Exchange the refresh token for a fresh access token (rotating)."""
|
|
303
|
+
if not self.refresh_token:
|
|
304
|
+
raise HubbleError("No refresh token available to refresh this session.")
|
|
305
|
+
result = _unwrap(
|
|
306
|
+
exchange_token.sync(
|
|
307
|
+
client=self._hubble._control,
|
|
308
|
+
body=TokenRequest(grant_type=_REFRESH_GRANT, refresh_token=self.refresh_token),
|
|
309
|
+
),
|
|
310
|
+
TokenResponse,
|
|
311
|
+
"Token refresh failed",
|
|
312
|
+
)
|
|
313
|
+
self.access_token = result.access_token
|
|
314
|
+
self.refresh_token = result.refresh_token
|
|
315
|
+
# Close the old client's connection pool before replacing it with one
|
|
316
|
+
# carrying the new token, so rotating tokens does not leak connections.
|
|
317
|
+
self._data.get_httpx_client().close()
|
|
318
|
+
self._data = AuthenticatedClient(base_url=self._hubble.base_url, token=self.access_token)
|
|
319
|
+
# Notify the caller so it can persist the rotated tokens.
|
|
320
|
+
if self.on_refresh is not None:
|
|
321
|
+
self.on_refresh(
|
|
322
|
+
TokenSet(
|
|
323
|
+
access_token=self.access_token,
|
|
324
|
+
refresh_token=self.refresh_token,
|
|
325
|
+
consent_id=self.consent_id,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def close(self) -> None:
|
|
330
|
+
"""Close the underlying HTTP connection pool."""
|
|
331
|
+
self._data.get_httpx_client().close()
|
|
332
|
+
|
|
333
|
+
def __enter__(self) -> PatientSession:
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def __exit__(self, *exc: Any) -> None:
|
|
337
|
+
self.close()
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── callback types and errors ─────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class TokenSet:
|
|
345
|
+
"""The rotating token set a session holds; passed to an ``on_refresh`` callback."""
|
|
346
|
+
|
|
347
|
+
access_token: str
|
|
348
|
+
refresh_token: str | None = None
|
|
349
|
+
consent_id: str | None = None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True)
|
|
353
|
+
class PdfExport:
|
|
354
|
+
"""The result of `PatientSession.export_pdf`.
|
|
355
|
+
|
|
356
|
+
``content`` is the raw body — a ZIP of per-source PDFs (with a
|
|
357
|
+
``manifest.json`` naming each file and marking it complete/partial/failed),
|
|
358
|
+
or a single PDF when ``connection`` was set. The counts come from the
|
|
359
|
+
response headers; a non-zero ``sources_failed`` means some records are
|
|
360
|
+
missing."""
|
|
361
|
+
|
|
362
|
+
content: bytes
|
|
363
|
+
content_type: str
|
|
364
|
+
sources_total: int | None = None
|
|
365
|
+
sources_failed: int | None = None
|
|
366
|
+
resource_count: int | None = None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# Invoked whenever a session rotates its tokens (on refresh), so callers can
|
|
370
|
+
# persist the new values.
|
|
371
|
+
RefreshCallback = Callable[["TokenSet"], None]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class HubbleError(Exception):
|
|
375
|
+
"""Base error for the Hubble client."""
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class ConsentTimeout(HubbleError):
|
|
379
|
+
"""The patient did not authorize before the timeout elapsed."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hubble-sdk-python
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Python client for the Hubble Patient Access API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/hubble-dot-ai/hubble-sdks
|
|
6
|
+
Project-URL: Repository, https://github.com/hubble-dot-ai/hubble-sdks
|
|
7
|
+
Project-URL: Issues, https://github.com/hubble-dot-ai/hubble-sdks/issues
|
|
8
|
+
Author: Hubble
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: health records,hubble,medical records,patient
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: attrs>=22.2.0
|
|
24
|
+
Requires-Dist: httpx<0.29.0,>=0.23.1
|
|
25
|
+
Requires-Dist: python-dateutil>=2.8.0
|
|
26
|
+
Provides-Extra: docs
|
|
27
|
+
Requires-Dist: pydoc-markdown<5,>=4.8; extra == 'docs'
|
|
28
|
+
Provides-Extra: test
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
30
|
+
Requires-Dist: respx>=0.21; extra == 'test'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# hubble-sdk-python
|
|
34
|
+
|
|
35
|
+
Python client for the Hubble Patient Access API: create patient authorization
|
|
36
|
+
links, exchange them for consent-scoped access tokens, and read FHIR R4 data.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install hubble-sdk-python
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from hubble_sdk import Hubble
|
|
44
|
+
|
|
45
|
+
hub = Hubble(client_id="hbl_client_prod_...", client_secret="hbl_prod_...")
|
|
46
|
+
|
|
47
|
+
# 1. Create a patient link and send the patient to it. Optionally prefill with
|
|
48
|
+
# patient={"first_name": ...} demographics and providers=[{"name": ...}].
|
|
49
|
+
link = hub.create_link(patient_ref="abc123")
|
|
50
|
+
print(link.verification_uri_complete)
|
|
51
|
+
|
|
52
|
+
# 2. Wait for the patient to authorize (polls the token endpoint for you).
|
|
53
|
+
session = hub.wait_for_authorization(link)
|
|
54
|
+
|
|
55
|
+
# 3. Read the patient's FHIR data (Bearer token + auto-refresh handled for you).
|
|
56
|
+
bundle = session.everything()
|
|
57
|
+
observations = session.fhir("Observation", params={"category": "vital-signs"})
|
|
58
|
+
export = session.export_pdf() # export.content bytes; export.sources_failed counts failed sources
|
|
59
|
+
|
|
60
|
+
# Consent management
|
|
61
|
+
for consent in hub.list_consents(status="active"):
|
|
62
|
+
print(consent.id, consent.patient_ref)
|
|
63
|
+
hub.revoke_consent(session.consent_id)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## What the client handles for you
|
|
67
|
+
|
|
68
|
+
- **Auth per plane** — HTTP Basic (your app credentials) for consent management
|
|
69
|
+
and the token endpoint; Bearer (the consent token) for FHIR reads. You never
|
|
70
|
+
wire the schemes yourself.
|
|
71
|
+
- **Default base URL** — `https://api.hubble.ai`. Override only with a trusted
|
|
72
|
+
value; your access token is sent to it.
|
|
73
|
+
- **Device-flow polling** — `wait_for_authorization` honors the server's
|
|
74
|
+
`interval` and `slow_down`.
|
|
75
|
+
- **Correct FHIR paths** — operation and sub-resource paths like
|
|
76
|
+
`Patient/$everything` are sent unencoded.
|
|
77
|
+
- **Transparent refresh** — FHIR reads refresh and retry once on a 401.
|
|
78
|
+
- **FHIR responses** — reads return the raw FHIR R4 resource as a dict. For typed
|
|
79
|
+
models, parse with the optional [`fhir.resources`](https://pypi.org/project/fhir.resources/)
|
|
80
|
+
package: `from fhir.resources.bundle import Bundle; Bundle.model_validate(session.everything())`.
|
|
81
|
+
|
|
82
|
+
## Layout
|
|
83
|
+
|
|
84
|
+
`Hubble` / `PatientSession` are hand-written. The low-level client generated
|
|
85
|
+
from the OpenAPI contract with
|
|
86
|
+
[openapi-python-client](https://github.com/openapi-generators/openapi-python-client)
|
|
87
|
+
lives under `hubble_sdk._generated` (regenerated on every spec change;
|
|
88
|
+
never edited by hand). Reach into it only if you need raw operations. See the
|
|
89
|
+
repo root README for how to regenerate.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
hubble_sdk/__init__.py,sha256=lyGGBoULJqdApAePja5KAOjHMMhM60F-XhvLXJR2dm4,1042
|
|
2
|
+
hubble_sdk/_hubble.py,sha256=JI6gF3AfgDn37NkkBXWkzUgJsTSVCF647da1N95TO4E,15489
|
|
3
|
+
hubble_sdk/_generated/__init__.py,sha256=vHzWGo0njcFBcxGMOUuCWyhYdJi_xldILX7pdrPzdYA,168
|
|
4
|
+
hubble_sdk/_generated/client.py,sha256=aXn2Tt52-cnyAkBJcNlIzxMx35J2g6FneRGuk_wlA40,12530
|
|
5
|
+
hubble_sdk/_generated/errors.py,sha256=gO8GBmKqmSNgAg-E5oT-oOyxztvp7V_6XG7OUTT15q0,546
|
|
6
|
+
hubble_sdk/_generated/types.py,sha256=0We4NPvhIYASRpQ3le41nmJeEAVm42-2VKdzlJ4Ogok,1343
|
|
7
|
+
hubble_sdk/_generated/api/__init__.py,sha256=zTSiG_ujSjAqWPyc435YXaX9XTlpMjiJWBbV-f-YtdA,45
|
|
8
|
+
hubble_sdk/_generated/api/default/__init__.py,sha256=5vd9uJWAjRqa9xzxzYkLD1yoZ12Ld_bAaNB5WX4fbE8,56
|
|
9
|
+
hubble_sdk/_generated/api/default/attach_client_authorization.py,sha256=ZeMTt7o3tVJlI2hJHZ3v7iz1H4DiLo33kYh64vY-SLE,6622
|
|
10
|
+
hubble_sdk/_generated/api/default/create_consent_link.py,sha256=88KZtzq0cJ9hLZpNQqzpj2GjR0rmxzHTSWvqmMCGFW8,6377
|
|
11
|
+
hubble_sdk/_generated/api/default/exchange_token.py,sha256=86ysMEnY2jOUItEHEv06zeszFe4k2vxjn4h0nzvR8zk,5268
|
|
12
|
+
hubble_sdk/_generated/api/default/export_patient_pdf.py,sha256=1EwGf-H1HMo0L0SwwzuTsml3lfWESexYwDQWLYClFus,6269
|
|
13
|
+
hubble_sdk/_generated/api/default/get_consent.py,sha256=UkH6LMTsx2Mmqg9xgkIymw08QuJvcu1DMzuevi6z4pM,3808
|
|
14
|
+
hubble_sdk/_generated/api/default/list_consents.py,sha256=LOcrDwTiRh4MV0-PigLYZWY1dkUfdyljNBA7d8zAa64,4906
|
|
15
|
+
hubble_sdk/_generated/api/default/read_fhir.py,sha256=1sny0rT0ToKQ1sCWciEASQCUH-fOv4RNvbS80U2x2BU,5026
|
|
16
|
+
hubble_sdk/_generated/api/default/revoke_consent.py,sha256=ocFu3svKMa-E2EKIPTE-JYZ7D0-ye9mR_XUNj58mwWE,3966
|
|
17
|
+
hubble_sdk/_generated/models/__init__.py,sha256=iQFE98ouXrDKUs23VmpQcQFr7Qkdheexnp-rVy8Pg84,2053
|
|
18
|
+
hubble_sdk/_generated/models/api_error.py,sha256=RrgLT6j31RWPbyMsgkFcu5DWaWi1aJooVL7C24gbGhw,780
|
|
19
|
+
hubble_sdk/_generated/models/attach_client_authorization_body.py,sha256=ur71Z-fbahmKaJ0r68fkpAvaAM2ShtR1xc2ypqeNmAY,3475
|
|
20
|
+
hubble_sdk/_generated/models/attach_client_authorization_response_201.py,sha256=5L2MNvzlYkpFFbt-oJI9fPf1r2oCZ9cIReqkcMn_0Wc,3220
|
|
21
|
+
hubble_sdk/_generated/models/attach_client_authorization_response_201_status.py,sha256=B8XSR9rNquad1znJZygM-U47W-HNFe6DEi0QPLUt504,171
|
|
22
|
+
hubble_sdk/_generated/models/consent.py,sha256=azEAKRMCeFAav3qcFhylMct1GnXQRmGcR8HJp9FwLcU,4949
|
|
23
|
+
hubble_sdk/_generated/models/consent_error.py,sha256=6FO1yRkKkpS7ENGBZeCExFqjugzhScYYDV-W9A4t47Y,1030
|
|
24
|
+
hubble_sdk/_generated/models/consent_link_request.py,sha256=I7GxVFnqwcrn-BNvzxZVjD1cUu-NEYhbe-tdn8MCyVI,2853
|
|
25
|
+
hubble_sdk/_generated/models/consent_list.py,sha256=y9oZYL7QU6UQvm4qf9J_RbvLQnjWZvydhbpX6GO-Ld4,1250
|
|
26
|
+
hubble_sdk/_generated/models/consent_source.py,sha256=3uj5-xud14iKW_jOO711CpnGsWFeGWs0LDALmg8kEJ0,1688
|
|
27
|
+
hubble_sdk/_generated/models/consent_source_status.py,sha256=AMApXkgwZN0j8DOmwYPBh2zinjXriHYM39yRGjN4u-4,198
|
|
28
|
+
hubble_sdk/_generated/models/consent_source_type.py,sha256=qfwhIElp1PQDuLgUdQryn-_aTDvZ8xkLUfIq237_MK4,166
|
|
29
|
+
hubble_sdk/_generated/models/consent_status.py,sha256=jXZ3_yQ-M0vvoVuvs3vBbLnGcWPXYF-5ERz144_48aA,232
|
|
30
|
+
hubble_sdk/_generated/models/device_authorization_request.py,sha256=fSilSwVylrWSzwLv_4LIGwh1kaIunr2pcdOx8sPZQpg,5138
|
|
31
|
+
hubble_sdk/_generated/models/device_authorization_response.py,sha256=LWsLmhthCw_eRO_DaYrVdFPR8XTU6ZfS95FZYR2-DrY,2627
|
|
32
|
+
hubble_sdk/_generated/models/fhir_resource.py,sha256=RWGpFkFgJVFdRleOKM8WTiFl4RFhM0wa_FjFVsaLvBI,1635
|
|
33
|
+
hubble_sdk/_generated/models/o_auth_error.py,sha256=mrAIGsI6Cxq1f_xHPXD53xwMS_OI6lEJZ_SXObpnmxc,1252
|
|
34
|
+
hubble_sdk/_generated/models/o_auth_error_error.py,sha256=lpg84xgoJ_hW8qaJr6CUJfBH1tsSKgeQfb7D1hStuIQ,474
|
|
35
|
+
hubble_sdk/_generated/models/operation_outcome.py,sha256=oGYvHr9__XZ3wOP_LADPH9P8CCSjtvppAz6AS40az9U,1877
|
|
36
|
+
hubble_sdk/_generated/models/operation_outcome_issue_item.py,sha256=wmV4n_E0880pwYchBbWH8f9PXRbR8atuzjK5Ep-54y0,1318
|
|
37
|
+
hubble_sdk/_generated/models/patient_address.py,sha256=cajHVQIuvGl83SkQNdgwvdZOzfWTYNo8hRW2azNmTec,1619
|
|
38
|
+
hubble_sdk/_generated/models/patient_demographics.py,sha256=igIRoU9SBKmnyEXjTJ_VIW-It19Z5y40Wn72wLZ6V5g,3000
|
|
39
|
+
hubble_sdk/_generated/models/provider_seed.py,sha256=EmvivHxaDLQzl5H64FeZ86mUyTTxxUZB5KfFhhqm218,1133
|
|
40
|
+
hubble_sdk/_generated/models/token_request.py,sha256=8ZOQVJIIPPTdtAUeuG8ADeQdz0QHrc0yyrR9pXXMjVM,2391
|
|
41
|
+
hubble_sdk/_generated/models/token_request_grant_type.py,sha256=1PyWEyd65JbKn_gDDXoSa808pjia0Xj3hmPk-67XNZg,317
|
|
42
|
+
hubble_sdk/_generated/models/token_response.py,sha256=HORaPEpLNuPdCtGMlR-CXt8sQEYQH6gEp0gNTpQoFeE,2121
|
|
43
|
+
hubble_sdk_python-0.0.2.dist-info/METADATA,sha256=--IFrEDXx-xbr52oV5KcRxBFP3xTxWU-esOlqJroq4E,3773
|
|
44
|
+
hubble_sdk_python-0.0.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
45
|
+
hubble_sdk_python-0.0.2.dist-info/licenses/LICENSE,sha256=S8zTUFi7pK2HOg-wSn6bFAWbNAO4Lt1UiyDu_vcqO6w,1063
|
|
46
|
+
hubble_sdk_python-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hubble
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|