hubble-sdk-python 0.3.0__tar.gz → 0.3.1__tar.gz

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 (81) hide show
  1. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/CHANGELOG.md +15 -0
  2. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/PKG-INFO +1 -1
  3. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/export_patient_fhir.py +18 -0
  4. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/export_patient_pdf.py +61 -4
  5. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/get_patient_pdf_export.py +16 -8
  6. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/read_fhir.py +26 -4
  7. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/__init__.py +4 -0
  8. hubble_sdk_python-0.3.1/hubble_sdk/_generated/models/export_patient_pdf_combined.py +27 -0
  9. hubble_sdk_python-0.3.1/hubble_sdk/_generated/models/export_patient_pdf_hubble_version.py +27 -0
  10. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/operation_outcome.py +1 -3
  11. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_hubble.py +127 -34
  12. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_webhooks.py +2 -2
  13. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/pyproject.toml +1 -1
  14. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/tests/test_hubble.py +98 -0
  15. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/.gitignore +0 -0
  16. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/LICENSE +0 -0
  17. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/README.md +0 -0
  18. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/config.yaml +0 -0
  19. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/__init__.py +0 -0
  20. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/__init__.py +0 -0
  21. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/__init__.py +0 -0
  22. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/__init__.py +0 -0
  23. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/attach_client_authorization.py +0 -0
  24. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/create_consent.py +0 -0
  25. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/create_consent_link.py +0 -0
  26. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/exchange_token.py +0 -0
  27. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/get_application_token.py +0 -0
  28. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/get_consent.py +0 -0
  29. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/list_consents.py +0 -0
  30. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/api/default/revoke_consent.py +0 -0
  31. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/client.py +0 -0
  32. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/errors.py +0 -0
  33. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/api_error.py +0 -0
  34. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/application_token_error.py +0 -0
  35. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/application_token_error_error.py +0 -0
  36. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/application_token_request.py +0 -0
  37. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/application_token_response.py +0 -0
  38. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/attach_client_authorization_body.py +0 -0
  39. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/client_supplied_discloser.py +0 -0
  40. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/client_supplied_discloser_facility_type.py +0 -0
  41. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent.py +0 -0
  42. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent_error.py +0 -0
  43. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent_link_request.py +0 -0
  44. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent_list.py +0 -0
  45. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent_source.py +0 -0
  46. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent_source_status.py +0 -0
  47. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent_source_type.py +0 -0
  48. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/consent_status.py +0 -0
  49. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/created_consent.py +0 -0
  50. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/device_authorization_request.py +0 -0
  51. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/device_authorization_response.py +0 -0
  52. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/export_patient_fhir_prefer.py +0 -0
  53. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/export_patient_fhir_response_202.py +0 -0
  54. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/export_patient_fhir_response_202_status.py +0 -0
  55. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/export_patient_pdf_prefer.py +0 -0
  56. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/export_patient_pdf_response_202.py +0 -0
  57. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/export_patient_pdf_response_202_status.py +0 -0
  58. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/facility_address.py +0 -0
  59. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/facility_address_state.py +0 -0
  60. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/fhir_resource.py +0 -0
  61. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/get_patient_pdf_export_download.py +0 -0
  62. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/get_patient_pdf_export_response_200.py +0 -0
  63. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/get_patient_pdf_export_response_200_status.py +0 -0
  64. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/o_auth_error.py +0 -0
  65. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/o_auth_error_error.py +0 -0
  66. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/operation_outcome_issue_item.py +0 -0
  67. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/patient_address.py +0 -0
  68. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/patient_address_state.py +0 -0
  69. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/patient_demographics.py +0 -0
  70. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/provider_seed.py +0 -0
  71. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/token_request.py +0 -0
  72. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/token_request_grant_type.py +0 -0
  73. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/models/token_response.py +0 -0
  74. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_generated/types.py +0 -0
  75. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/_params.py +0 -0
  76. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/hubble_sdk/py.typed +0 -0
  77. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/pydoc-markdown.yml +0 -0
  78. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/templates/property_templates/enum_property.py.jinja +0 -0
  79. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/templates/str_enum.py.jinja +0 -0
  80. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/tests/test_params.py +0 -0
  81. {hubble_sdk_python-0.3.0 → hubble_sdk_python-0.3.1}/tests/test_webhooks.py +0 -0
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1](https://github.com/hubble-dot-ai/hubble-sdks/compare/hubble-sdk-python-v0.3.0...hubble-sdk-python-v0.3.1) (2026-11-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * Support `Hubble-Version` through the `version` option on `export_pdf`, including waiting for background generation and downloading the completed records archive. ([#82](https://github.com/hubble-dot-ai/hubble-sdks/issues/82)) ([8f295db](https://github.com/hubble-dot-ai/hubble-sdks/commit/8f295db3f67c8606a29db0f7dfd99752e963c99e))
9
+ * Request a single PDF containing a source's summary and documents with `combined=true` in the generated client. ([#84](https://github.com/hubble-dot-ai/hubble-sdks/issues/84)) ([3a70d41](https://github.com/hubble-dot-ai/hubble-sdks/commit/3a70d417c9cc27700679b3e957a748dd251fcd71))
10
+ * Limit FHIR reads and exports to one connected source with the generated client's `connection` parameter. ([#81](https://github.com/hubble-dot-ai/hubble-sdks/issues/81)) ([0f812d0](https://github.com/hubble-dot-ai/hubble-sdks/commit/0f812d06ac59e23285fd72238529eb21afcbc80a))
11
+
12
+
13
+ ### Documentation
14
+
15
+ * Document records archives and 30-day export availability after completion, with download links valid for at most 60 seconds. ([#81](https://github.com/hubble-dot-ai/hubble-sdks/issues/81)) ([0f812d0](https://github.com/hubble-dot-ai/hubble-sdks/commit/0f812d06ac59e23285fd72238529eb21afcbc80a))
16
+ * Clarify that `consent.completed` means the patient finished adding sources; their records may still be processing. ([#78](https://github.com/hubble-dot-ai/hubble-sdks/issues/78)) ([b16e458](https://github.com/hubble-dot-ai/hubble-sdks/commit/b16e45869dd7a5f531318e58c182022b12e3139b))
17
+
3
18
  ## [0.3.0](https://github.com/hubble-dot-ai/hubble-sdks/compare/hubble-sdk-python-v0.2.1...hubble-sdk-python-v0.3.0) (2026-10-27)
4
19
 
5
20
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hubble-sdk-python
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Python client for the Hubble Patient Access API.
5
5
  Project-URL: Homepage, https://github.com/hubble-dot-ai/hubble-sdks
6
6
  Project-URL: Repository, https://github.com/hubble-dot-ai/hubble-sdks
@@ -16,6 +16,7 @@ from ...types import UNSET, Response, Unset
16
16
  def _get_kwargs(
17
17
  *,
18
18
  consent: UUID | Unset = UNSET,
19
+ connection: UUID | Unset = UNSET,
19
20
  field_count: int | Unset = UNSET,
20
21
  field_offset: int | Unset = UNSET,
21
22
  prefer: ExportPatientFhirPrefer | Unset = UNSET,
@@ -31,6 +32,11 @@ def _get_kwargs(
31
32
  json_consent = str(consent)
32
33
  params["consent"] = json_consent
33
34
 
35
+ json_connection: str | Unset = UNSET
36
+ if not isinstance(connection, Unset):
37
+ json_connection = str(connection)
38
+ params["connection"] = json_connection
39
+
34
40
  params["_count"] = field_count
35
41
 
36
42
  params["_offset"] = field_offset
@@ -111,6 +117,7 @@ def sync_detailed(
111
117
  *,
112
118
  client: AuthenticatedClient,
113
119
  consent: UUID | Unset = UNSET,
120
+ connection: UUID | Unset = UNSET,
114
121
  field_count: int | Unset = UNSET,
115
122
  field_offset: int | Unset = UNSET,
116
123
  prefer: ExportPatientFhirPrefer | Unset = UNSET,
@@ -127,6 +134,7 @@ def sync_detailed(
127
134
 
128
135
  Args:
129
136
  consent (UUID | Unset):
137
+ connection (UUID | Unset):
130
138
  field_count (int | Unset):
131
139
  field_offset (int | Unset):
132
140
  prefer (ExportPatientFhirPrefer | Unset):
@@ -141,6 +149,7 @@ def sync_detailed(
141
149
 
142
150
  kwargs = _get_kwargs(
143
151
  consent=consent,
152
+ connection=connection,
144
153
  field_count=field_count,
145
154
  field_offset=field_offset,
146
155
  prefer=prefer,
@@ -157,6 +166,7 @@ def sync(
157
166
  *,
158
167
  client: AuthenticatedClient,
159
168
  consent: UUID | Unset = UNSET,
169
+ connection: UUID | Unset = UNSET,
160
170
  field_count: int | Unset = UNSET,
161
171
  field_offset: int | Unset = UNSET,
162
172
  prefer: ExportPatientFhirPrefer | Unset = UNSET,
@@ -173,6 +183,7 @@ def sync(
173
183
 
174
184
  Args:
175
185
  consent (UUID | Unset):
186
+ connection (UUID | Unset):
176
187
  field_count (int | Unset):
177
188
  field_offset (int | Unset):
178
189
  prefer (ExportPatientFhirPrefer | Unset):
@@ -188,6 +199,7 @@ def sync(
188
199
  return sync_detailed(
189
200
  client=client,
190
201
  consent=consent,
202
+ connection=connection,
191
203
  field_count=field_count,
192
204
  field_offset=field_offset,
193
205
  prefer=prefer,
@@ -198,6 +210,7 @@ async def asyncio_detailed(
198
210
  *,
199
211
  client: AuthenticatedClient,
200
212
  consent: UUID | Unset = UNSET,
213
+ connection: UUID | Unset = UNSET,
201
214
  field_count: int | Unset = UNSET,
202
215
  field_offset: int | Unset = UNSET,
203
216
  prefer: ExportPatientFhirPrefer | Unset = UNSET,
@@ -214,6 +227,7 @@ async def asyncio_detailed(
214
227
 
215
228
  Args:
216
229
  consent (UUID | Unset):
230
+ connection (UUID | Unset):
217
231
  field_count (int | Unset):
218
232
  field_offset (int | Unset):
219
233
  prefer (ExportPatientFhirPrefer | Unset):
@@ -228,6 +242,7 @@ async def asyncio_detailed(
228
242
 
229
243
  kwargs = _get_kwargs(
230
244
  consent=consent,
245
+ connection=connection,
231
246
  field_count=field_count,
232
247
  field_offset=field_offset,
233
248
  prefer=prefer,
@@ -242,6 +257,7 @@ async def asyncio(
242
257
  *,
243
258
  client: AuthenticatedClient,
244
259
  consent: UUID | Unset = UNSET,
260
+ connection: UUID | Unset = UNSET,
245
261
  field_count: int | Unset = UNSET,
246
262
  field_offset: int | Unset = UNSET,
247
263
  prefer: ExportPatientFhirPrefer | Unset = UNSET,
@@ -258,6 +274,7 @@ async def asyncio(
258
274
 
259
275
  Args:
260
276
  consent (UUID | Unset):
277
+ connection (UUID | Unset):
261
278
  field_count (int | Unset):
262
279
  field_offset (int | Unset):
263
280
  prefer (ExportPatientFhirPrefer | Unset):
@@ -274,6 +291,7 @@ async def asyncio(
274
291
  await asyncio_detailed(
275
292
  client=client,
276
293
  consent=consent,
294
+ connection=connection,
277
295
  field_count=field_count,
278
296
  field_offset=field_offset,
279
297
  prefer=prefer,
@@ -6,6 +6,8 @@ import httpx
6
6
 
7
7
  from ... import errors
8
8
  from ...client import AuthenticatedClient, Client
9
+ from ...models.export_patient_pdf_combined import ExportPatientPdfCombined
10
+ from ...models.export_patient_pdf_hubble_version import ExportPatientPdfHubbleVersion
9
11
  from ...models.export_patient_pdf_prefer import ExportPatientPdfPrefer
10
12
  from ...models.export_patient_pdf_response_202 import ExportPatientPdfResponse202
11
13
  from ...models.operation_outcome import OperationOutcome
@@ -16,9 +18,14 @@ def _get_kwargs(
16
18
  *,
17
19
  consent: UUID | Unset = UNSET,
18
20
  connection: str | Unset = UNSET,
21
+ combined: ExportPatientPdfCombined | Unset = UNSET,
22
+ hubble_version: ExportPatientPdfHubbleVersion | Unset = UNSET,
19
23
  prefer: ExportPatientPdfPrefer | Unset = UNSET,
20
24
  ) -> dict[str, Any]:
21
25
  headers: dict[str, Any] = {}
26
+ if not isinstance(hubble_version, Unset):
27
+ headers["Hubble-Version"] = str(hubble_version)
28
+
22
29
  if not isinstance(prefer, Unset):
23
30
  headers["Prefer"] = str(prefer)
24
31
 
@@ -31,6 +38,12 @@ def _get_kwargs(
31
38
 
32
39
  params["connection"] = connection
33
40
 
41
+ json_combined: str | Unset = UNSET
42
+ if not isinstance(combined, Unset):
43
+ json_combined = combined.value
44
+
45
+ params["combined"] = json_combined
46
+
34
47
  params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
35
48
 
36
49
  _kwargs: dict[str, Any] = {
@@ -103,6 +116,8 @@ def sync_detailed(
103
116
  client: AuthenticatedClient,
104
117
  consent: UUID | Unset = UNSET,
105
118
  connection: str | Unset = UNSET,
119
+ combined: ExportPatientPdfCombined | Unset = UNSET,
120
+ hubble_version: ExportPatientPdfHubbleVersion | Unset = UNSET,
106
121
  prefer: ExportPatientPdfPrefer | Unset = UNSET,
107
122
  ) -> Response[ExportPatientPdfResponse202 | OperationOutcome]:
108
123
  """Export records as PDF (one per source)
@@ -115,11 +130,18 @@ def sync_detailed(
115
130
  Prefer: respond-async to prepare the export in a background worker. Poll Content-Location with the
116
131
  same token no sooner than Retry-After until it returns a 303 redirect to a short-lived download URL.
117
132
  An organization can hold 20 exports in flight at once; a repeat request for one already running
118
- reuses it. Without Prefer, the synchronous response is unchanged.
133
+ reuses it. Without Prefer, the synchronous response is unchanged. Send `Hubble-Version: 2026-11-01`
134
+ with Prefer: respond-async for the records archive (preview) instead: always a zip, with or without
135
+ `connection`, holding a folder per source and a `manifest.json` that lists every file. It is only
136
+ built asynchronously. Find files through the manifest rather than by path; new files may appear. Add
137
+ `combined=true` with `connection` for one PDF of that source instead of the zip: its summary
138
+ followed by its documents, in no guaranteed order.
119
139
 
120
140
  Args:
121
141
  consent (UUID | Unset):
122
142
  connection (str | Unset):
143
+ combined (ExportPatientPdfCombined | Unset):
144
+ hubble_version (ExportPatientPdfHubbleVersion | Unset):
123
145
  prefer (ExportPatientPdfPrefer | Unset):
124
146
 
125
147
  Raises:
@@ -133,6 +155,8 @@ def sync_detailed(
133
155
  kwargs = _get_kwargs(
134
156
  consent=consent,
135
157
  connection=connection,
158
+ combined=combined,
159
+ hubble_version=hubble_version,
136
160
  prefer=prefer,
137
161
  )
138
162
 
@@ -148,6 +172,8 @@ def sync(
148
172
  client: AuthenticatedClient,
149
173
  consent: UUID | Unset = UNSET,
150
174
  connection: str | Unset = UNSET,
175
+ combined: ExportPatientPdfCombined | Unset = UNSET,
176
+ hubble_version: ExportPatientPdfHubbleVersion | Unset = UNSET,
151
177
  prefer: ExportPatientPdfPrefer | Unset = UNSET,
152
178
  ) -> ExportPatientPdfResponse202 | OperationOutcome | None:
153
179
  """Export records as PDF (one per source)
@@ -160,11 +186,18 @@ def sync(
160
186
  Prefer: respond-async to prepare the export in a background worker. Poll Content-Location with the
161
187
  same token no sooner than Retry-After until it returns a 303 redirect to a short-lived download URL.
162
188
  An organization can hold 20 exports in flight at once; a repeat request for one already running
163
- reuses it. Without Prefer, the synchronous response is unchanged.
189
+ reuses it. Without Prefer, the synchronous response is unchanged. Send `Hubble-Version: 2026-11-01`
190
+ with Prefer: respond-async for the records archive (preview) instead: always a zip, with or without
191
+ `connection`, holding a folder per source and a `manifest.json` that lists every file. It is only
192
+ built asynchronously. Find files through the manifest rather than by path; new files may appear. Add
193
+ `combined=true` with `connection` for one PDF of that source instead of the zip: its summary
194
+ followed by its documents, in no guaranteed order.
164
195
 
165
196
  Args:
166
197
  consent (UUID | Unset):
167
198
  connection (str | Unset):
199
+ combined (ExportPatientPdfCombined | Unset):
200
+ hubble_version (ExportPatientPdfHubbleVersion | Unset):
168
201
  prefer (ExportPatientPdfPrefer | Unset):
169
202
 
170
203
  Raises:
@@ -179,6 +212,8 @@ def sync(
179
212
  client=client,
180
213
  consent=consent,
181
214
  connection=connection,
215
+ combined=combined,
216
+ hubble_version=hubble_version,
182
217
  prefer=prefer,
183
218
  ).parsed
184
219
 
@@ -188,6 +223,8 @@ async def asyncio_detailed(
188
223
  client: AuthenticatedClient,
189
224
  consent: UUID | Unset = UNSET,
190
225
  connection: str | Unset = UNSET,
226
+ combined: ExportPatientPdfCombined | Unset = UNSET,
227
+ hubble_version: ExportPatientPdfHubbleVersion | Unset = UNSET,
191
228
  prefer: ExportPatientPdfPrefer | Unset = UNSET,
192
229
  ) -> Response[ExportPatientPdfResponse202 | OperationOutcome]:
193
230
  """Export records as PDF (one per source)
@@ -200,11 +237,18 @@ async def asyncio_detailed(
200
237
  Prefer: respond-async to prepare the export in a background worker. Poll Content-Location with the
201
238
  same token no sooner than Retry-After until it returns a 303 redirect to a short-lived download URL.
202
239
  An organization can hold 20 exports in flight at once; a repeat request for one already running
203
- reuses it. Without Prefer, the synchronous response is unchanged.
240
+ reuses it. Without Prefer, the synchronous response is unchanged. Send `Hubble-Version: 2026-11-01`
241
+ with Prefer: respond-async for the records archive (preview) instead: always a zip, with or without
242
+ `connection`, holding a folder per source and a `manifest.json` that lists every file. It is only
243
+ built asynchronously. Find files through the manifest rather than by path; new files may appear. Add
244
+ `combined=true` with `connection` for one PDF of that source instead of the zip: its summary
245
+ followed by its documents, in no guaranteed order.
204
246
 
205
247
  Args:
206
248
  consent (UUID | Unset):
207
249
  connection (str | Unset):
250
+ combined (ExportPatientPdfCombined | Unset):
251
+ hubble_version (ExportPatientPdfHubbleVersion | Unset):
208
252
  prefer (ExportPatientPdfPrefer | Unset):
209
253
 
210
254
  Raises:
@@ -218,6 +262,8 @@ async def asyncio_detailed(
218
262
  kwargs = _get_kwargs(
219
263
  consent=consent,
220
264
  connection=connection,
265
+ combined=combined,
266
+ hubble_version=hubble_version,
221
267
  prefer=prefer,
222
268
  )
223
269
 
@@ -231,6 +277,8 @@ async def asyncio(
231
277
  client: AuthenticatedClient,
232
278
  consent: UUID | Unset = UNSET,
233
279
  connection: str | Unset = UNSET,
280
+ combined: ExportPatientPdfCombined | Unset = UNSET,
281
+ hubble_version: ExportPatientPdfHubbleVersion | Unset = UNSET,
234
282
  prefer: ExportPatientPdfPrefer | Unset = UNSET,
235
283
  ) -> ExportPatientPdfResponse202 | OperationOutcome | None:
236
284
  """Export records as PDF (one per source)
@@ -243,11 +291,18 @@ async def asyncio(
243
291
  Prefer: respond-async to prepare the export in a background worker. Poll Content-Location with the
244
292
  same token no sooner than Retry-After until it returns a 303 redirect to a short-lived download URL.
245
293
  An organization can hold 20 exports in flight at once; a repeat request for one already running
246
- reuses it. Without Prefer, the synchronous response is unchanged.
294
+ reuses it. Without Prefer, the synchronous response is unchanged. Send `Hubble-Version: 2026-11-01`
295
+ with Prefer: respond-async for the records archive (preview) instead: always a zip, with or without
296
+ `connection`, holding a folder per source and a `manifest.json` that lists every file. It is only
297
+ built asynchronously. Find files through the manifest rather than by path; new files may appear. Add
298
+ `combined=true` with `connection` for one PDF of that source instead of the zip: its summary
299
+ followed by its documents, in no guaranteed order.
247
300
 
248
301
  Args:
249
302
  consent (UUID | Unset):
250
303
  connection (str | Unset):
304
+ combined (ExportPatientPdfCombined | Unset):
305
+ hubble_version (ExportPatientPdfHubbleVersion | Unset):
251
306
  prefer (ExportPatientPdfPrefer | Unset):
252
307
 
253
308
  Raises:
@@ -263,6 +318,8 @@ async def asyncio(
263
318
  client=client,
264
319
  consent=consent,
265
320
  connection=connection,
321
+ combined=combined,
322
+ hubble_version=hubble_version,
266
323
  prefer=prefer,
267
324
  )
268
325
  ).parsed
@@ -129,8 +129,10 @@ def sync_detailed(
129
129
  when the export started already includes it. Pending work returns 202. Ready work returns 303 to a
130
130
  download URL valid for at most 60 seconds. Do not forward the Hubble Bearer token to the storage
131
131
  host. Accept: application/json returns a ready status and same-origin downloadUrl instead; navigate
132
- to that URL for the redirect. Exports expire two hours after creation. Revoked or expired source
133
- authorization invalidates the artifact.
132
+ to that URL for the redirect. Preparation must finish within two hours of creation. On completion,
133
+ expiresAt becomes the download deadline, 30 days later. Revoked or expired source authorization
134
+ invalidates the artifact. An export started with `Hubble-Version` carries that version in a `Hubble-
135
+ Version` response header.
134
136
 
135
137
  Args:
136
138
  id (UUID):
@@ -175,8 +177,10 @@ def sync(
175
177
  when the export started already includes it. Pending work returns 202. Ready work returns 303 to a
176
178
  download URL valid for at most 60 seconds. Do not forward the Hubble Bearer token to the storage
177
179
  host. Accept: application/json returns a ready status and same-origin downloadUrl instead; navigate
178
- to that URL for the redirect. Exports expire two hours after creation. Revoked or expired source
179
- authorization invalidates the artifact.
180
+ to that URL for the redirect. Preparation must finish within two hours of creation. On completion,
181
+ expiresAt becomes the download deadline, 30 days later. Revoked or expired source authorization
182
+ invalidates the artifact. An export started with `Hubble-Version` carries that version in a `Hubble-
183
+ Version` response header.
180
184
 
181
185
  Args:
182
186
  id (UUID):
@@ -216,8 +220,10 @@ async def asyncio_detailed(
216
220
  when the export started already includes it. Pending work returns 202. Ready work returns 303 to a
217
221
  download URL valid for at most 60 seconds. Do not forward the Hubble Bearer token to the storage
218
222
  host. Accept: application/json returns a ready status and same-origin downloadUrl instead; navigate
219
- to that URL for the redirect. Exports expire two hours after creation. Revoked or expired source
220
- authorization invalidates the artifact.
223
+ to that URL for the redirect. Preparation must finish within two hours of creation. On completion,
224
+ expiresAt becomes the download deadline, 30 days later. Revoked or expired source authorization
225
+ invalidates the artifact. An export started with `Hubble-Version` carries that version in a `Hubble-
226
+ Version` response header.
221
227
 
222
228
  Args:
223
229
  id (UUID):
@@ -260,8 +266,10 @@ async def asyncio(
260
266
  when the export started already includes it. Pending work returns 202. Ready work returns 303 to a
261
267
  download URL valid for at most 60 seconds. Do not forward the Hubble Bearer token to the storage
262
268
  host. Accept: application/json returns a ready status and same-origin downloadUrl instead; navigate
263
- to that URL for the redirect. Exports expire two hours after creation. Revoked or expired source
264
- authorization invalidates the artifact.
269
+ to that URL for the redirect. Preparation must finish within two hours of creation. On completion,
270
+ expiresAt becomes the download deadline, 30 days later. Revoked or expired source authorization
271
+ invalidates the artifact. An export started with `Hubble-Version` carries that version in a `Hubble-
272
+ Version` response header.
265
273
 
266
274
  Args:
267
275
  id (UUID):
@@ -16,6 +16,7 @@ def _get_kwargs(
16
16
  path: str,
17
17
  *,
18
18
  consent: UUID | Unset = UNSET,
19
+ connection: UUID | Unset = UNSET,
19
20
  ) -> dict[str, Any]:
20
21
 
21
22
  params: dict[str, Any] = {}
@@ -25,6 +26,11 @@ def _get_kwargs(
25
26
  json_consent = str(consent)
26
27
  params["consent"] = json_consent
27
28
 
29
+ json_connection: str | Unset = UNSET
30
+ if not isinstance(connection, Unset):
31
+ json_connection = str(connection)
32
+ params["connection"] = json_connection
33
+
28
34
  params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
29
35
 
30
36
  _kwargs: dict[str, Any] = {
@@ -88,16 +94,19 @@ def sync_detailed(
88
94
  *,
89
95
  client: AuthenticatedClient,
90
96
  consent: UUID | Unset = UNSET,
97
+ connection: UUID | Unset = UNSET,
91
98
  ) -> Response[FhirResource | OperationOutcome]:
92
99
  """Read FHIR data
93
100
 
94
101
  The FHIR R4 data plane. Authenticated by a consent token, which determines the consent, or an
95
102
  application token with `consent=<id>` naming it. Returns arbitrary FHIR resources; type responses
96
- against @types/fhir (TS) or fhir.resources (Python) rather than this spec.
103
+ against @types/fhir (TS) or fhir.resources (Python) rather than this spec. Reads merge every source
104
+ on the consent unless `connection` names one.
97
105
 
98
106
  Args:
99
107
  path (str):
100
108
  consent (UUID | Unset):
109
+ connection (UUID | Unset):
101
110
 
102
111
  Raises:
103
112
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -110,6 +119,7 @@ def sync_detailed(
110
119
  kwargs = _get_kwargs(
111
120
  path=path,
112
121
  consent=consent,
122
+ connection=connection,
113
123
  )
114
124
 
115
125
  response = client.get_httpx_client().request(
@@ -124,16 +134,19 @@ def sync(
124
134
  *,
125
135
  client: AuthenticatedClient,
126
136
  consent: UUID | Unset = UNSET,
137
+ connection: UUID | Unset = UNSET,
127
138
  ) -> FhirResource | OperationOutcome | None:
128
139
  """Read FHIR data
129
140
 
130
141
  The FHIR R4 data plane. Authenticated by a consent token, which determines the consent, or an
131
142
  application token with `consent=<id>` naming it. Returns arbitrary FHIR resources; type responses
132
- against @types/fhir (TS) or fhir.resources (Python) rather than this spec.
143
+ against @types/fhir (TS) or fhir.resources (Python) rather than this spec. Reads merge every source
144
+ on the consent unless `connection` names one.
133
145
 
134
146
  Args:
135
147
  path (str):
136
148
  consent (UUID | Unset):
149
+ connection (UUID | Unset):
137
150
 
138
151
  Raises:
139
152
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -147,6 +160,7 @@ def sync(
147
160
  path=path,
148
161
  client=client,
149
162
  consent=consent,
163
+ connection=connection,
150
164
  ).parsed
151
165
 
152
166
 
@@ -155,16 +169,19 @@ async def asyncio_detailed(
155
169
  *,
156
170
  client: AuthenticatedClient,
157
171
  consent: UUID | Unset = UNSET,
172
+ connection: UUID | Unset = UNSET,
158
173
  ) -> Response[FhirResource | OperationOutcome]:
159
174
  """Read FHIR data
160
175
 
161
176
  The FHIR R4 data plane. Authenticated by a consent token, which determines the consent, or an
162
177
  application token with `consent=<id>` naming it. Returns arbitrary FHIR resources; type responses
163
- against @types/fhir (TS) or fhir.resources (Python) rather than this spec.
178
+ against @types/fhir (TS) or fhir.resources (Python) rather than this spec. Reads merge every source
179
+ on the consent unless `connection` names one.
164
180
 
165
181
  Args:
166
182
  path (str):
167
183
  consent (UUID | Unset):
184
+ connection (UUID | Unset):
168
185
 
169
186
  Raises:
170
187
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -177,6 +194,7 @@ async def asyncio_detailed(
177
194
  kwargs = _get_kwargs(
178
195
  path=path,
179
196
  consent=consent,
197
+ connection=connection,
180
198
  )
181
199
 
182
200
  response = await client.get_async_httpx_client().request(**kwargs)
@@ -189,16 +207,19 @@ async def asyncio(
189
207
  *,
190
208
  client: AuthenticatedClient,
191
209
  consent: UUID | Unset = UNSET,
210
+ connection: UUID | Unset = UNSET,
192
211
  ) -> FhirResource | OperationOutcome | None:
193
212
  """Read FHIR data
194
213
 
195
214
  The FHIR R4 data plane. Authenticated by a consent token, which determines the consent, or an
196
215
  application token with `consent=<id>` naming it. Returns arbitrary FHIR resources; type responses
197
- against @types/fhir (TS) or fhir.resources (Python) rather than this spec.
216
+ against @types/fhir (TS) or fhir.resources (Python) rather than this spec. Reads merge every source
217
+ on the consent unless `connection` names one.
198
218
 
199
219
  Args:
200
220
  path (str):
201
221
  consent (UUID | Unset):
222
+ connection (UUID | Unset):
202
223
 
203
224
  Raises:
204
225
  errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -213,5 +234,6 @@ async def asyncio(
213
234
  path=path,
214
235
  client=client,
215
236
  consent=consent,
237
+ connection=connection,
216
238
  )
217
239
  ).parsed
@@ -22,6 +22,8 @@ from .device_authorization_response import DeviceAuthorizationResponse
22
22
  from .export_patient_fhir_prefer import ExportPatientFhirPrefer
23
23
  from .export_patient_fhir_response_202 import ExportPatientFhirResponse202
24
24
  from .export_patient_fhir_response_202_status import ExportPatientFhirResponse202Status
25
+ from .export_patient_pdf_combined import ExportPatientPdfCombined
26
+ from .export_patient_pdf_hubble_version import ExportPatientPdfHubbleVersion
25
27
  from .export_patient_pdf_prefer import ExportPatientPdfPrefer
26
28
  from .export_patient_pdf_response_202 import ExportPatientPdfResponse202
27
29
  from .export_patient_pdf_response_202_status import ExportPatientPdfResponse202Status
@@ -68,6 +70,8 @@ __all__ = (
68
70
  "ExportPatientFhirPrefer",
69
71
  "ExportPatientFhirResponse202",
70
72
  "ExportPatientFhirResponse202Status",
73
+ "ExportPatientPdfCombined",
74
+ "ExportPatientPdfHubbleVersion",
71
75
  "ExportPatientPdfPrefer",
72
76
  "ExportPatientPdfResponse202",
73
77
  "ExportPatientPdfResponse202Status",
@@ -0,0 +1,27 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class ExportPatientPdfCombined(StrEnum):
5
+ TRUE = "true"
6
+
7
+ def __str__(self) -> str:
8
+ return str(self.value)
9
+
10
+ # How API responses are parsed. The API may add values to this enum without a
11
+ # new SDK release, so a value this version does not know becomes a member
12
+ # carrying the raw string instead of raising. It compares equal to that string
13
+ # and is absent from list(ExportPatientPdfCombined), which is how to tell it apart.
14
+ # A value that is not a string is a malformed response and still raises.
15
+ # Constructing the enum directly stays strict, so a typo in a value you pass
16
+ # in still raises and membership checks are unchanged.
17
+ @classmethod
18
+ def _from_value(cls, value: object) -> "ExportPatientPdfCombined":
19
+ try:
20
+ return cls(value)
21
+ except ValueError:
22
+ if not isinstance(value, str):
23
+ raise
24
+ member = str.__new__(cls, value)
25
+ member._name_ = value
26
+ member._value_ = value
27
+ return member
@@ -0,0 +1,27 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class ExportPatientPdfHubbleVersion(StrEnum):
5
+ VALUE_0 = "2026-11-01"
6
+
7
+ def __str__(self) -> str:
8
+ return str(self.value)
9
+
10
+ # How API responses are parsed. The API may add values to this enum without a
11
+ # new SDK release, so a value this version does not know becomes a member
12
+ # carrying the raw string instead of raising. It compares equal to that string
13
+ # and is absent from list(ExportPatientPdfHubbleVersion), which is how to tell it apart.
14
+ # A value that is not a string is a malformed response and still raises.
15
+ # Constructing the enum directly stays strict, so a typo in a value you pass
16
+ # in still raises and membership checks are unchanged.
17
+ @classmethod
18
+ def _from_value(cls, value: object) -> "ExportPatientPdfHubbleVersion":
19
+ try:
20
+ return cls(value)
21
+ except ValueError:
22
+ if not isinstance(value, str):
23
+ raise
24
+ member = str.__new__(cls, value)
25
+ member._name_ = value
26
+ member._value_ = value
27
+ return member
@@ -51,9 +51,7 @@ class OperationOutcome:
51
51
 
52
52
  @classmethod
53
53
  def from_dict(cls, src_dict: Mapping[str, Any]) -> Self:
54
- from ..models.operation_outcome_issue_item import (
55
- OperationOutcomeIssueItem,
56
- )
54
+ from ..models.operation_outcome_issue_item import OperationOutcomeIssueItem
57
55
 
58
56
  d = dict(src_dict)
59
57
  resource_type = cast(Literal["OperationOutcome"], d.pop("resourceType"))
@@ -65,6 +65,8 @@ _APP_TOKEN_RENEW_MARGIN = 60.0
65
65
  # goes into a URL, so neither can add path segments or a query string.
66
66
  _RESOURCE_TYPE_RE = re.compile(r"[A-Z][A-Za-z]+")
67
67
  _RESOURCE_ID_RE = re.compile(r"[A-Za-z0-9\-.]{1,64}")
68
+ # How long export_pdf waits by default for a versioned export to be built.
69
+ _EXPORT_TIMEOUT = 600.0
68
70
 
69
71
 
70
72
 
@@ -130,22 +132,68 @@ def _require_fhir_namespace(base_url: str, url: httpx.URL, shown: str) -> None:
130
132
  raise HubbleError(f"Invalid FHIR path: {shown!r}")
131
133
 
132
134
 
133
- def _pdf_export(resp: httpx.Response) -> PdfExport:
135
+ def _pdf_export(
136
+ resp: httpx.Response, file: httpx.Response | None = None, version: str | None = None
137
+ ) -> PdfExport:
138
+ # The counts come from Hubble's response; the body from `file` when the export
139
+ # was downloaded separately.
134
140
  def _count(header: str) -> int | None:
135
141
  try:
136
142
  return int(resp.headers[header])
137
143
  except (KeyError, ValueError):
138
144
  return None
139
145
 
146
+ body = file if file is not None else resp
140
147
  return PdfExport(
141
- content=resp.content,
142
- content_type=resp.headers.get("content-type", ""),
148
+ content=body.content,
149
+ content_type=body.headers.get("content-type", ""),
143
150
  sources_total=_count("X-Hubble-Sources-Total"),
144
151
  sources_failed=_count("X-Hubble-Sources-Failed"),
145
152
  resource_count=_count("X-Hubble-Resource-Count"),
153
+ version=version,
146
154
  )
147
155
 
148
156
 
157
+ def _versioned_pdf_export(
158
+ send: Callable[[httpx.URL, dict[str, str]], httpx.Response],
159
+ base_url: str,
160
+ start: httpx.URL,
161
+ version: str,
162
+ timeout: float,
163
+ ) -> PdfExport:
164
+ # A versioned export is only built in the background: start it, poll
165
+ # Content-Location until the 303 to the file, then download the file without
166
+ # the access token, which must never reach the storage host.
167
+ deadline = time.monotonic() + timeout
168
+ resp = send(start, {"Hubble-Version": version, "Prefer": "respond-async"})
169
+ while True:
170
+ if resp.status_code not in (202, 303):
171
+ raise HubbleError(f"PDF export failed: {resp.status_code}")
172
+ # Any other version would be a file in a layout the caller did not ask for.
173
+ served = resp.headers.get("Hubble-Version")
174
+ if served != version:
175
+ raise HubbleError(f"Asked for Hubble-Version {version}, got {served!r}")
176
+ if resp.status_code == 303:
177
+ break
178
+ location = resp.headers.get("Content-Location")
179
+ wait = resp.headers.get("Retry-After")
180
+ if location is None or wait is None or not wait.isdigit():
181
+ raise HubbleError("PDF export response has no Content-Location or Retry-After")
182
+ poll = start.join(location)
183
+ _require_fhir_namespace(base_url, poll, location)
184
+ if time.monotonic() + int(wait) > deadline:
185
+ raise HubbleError("PDF export was not ready before the timeout.")
186
+ time.sleep(int(wait))
187
+ resp = send(poll, {})
188
+ download = resp.headers.get("Location")
189
+ if download is None:
190
+ raise HubbleError("PDF export redirect has no Location")
191
+ file = httpx.get(download)
192
+ if not file.is_success:
193
+ raise HubbleError(f"PDF export download failed: {file.status_code}")
194
+ return _pdf_export(resp, file, version)
195
+
196
+
149
197
  class Hubble:
150
198
  """Client for the Hubble Patient Access API.
151
199
 
@@ -276,34 +324,55 @@ class Hubble:
276
324
  ``{"_count": ..., "_offset": ...}``."""
277
325
  return self.fhir(consent_id, "Patient/$everything", params=params)
278
326
 
279
- def export_pdf(self, consent_id: str, *, connection: str | None = None) -> PdfExport:
327
+ def export_pdf(
328
+ self,
329
+ consent_id: str,
330
+ *,
331
+ connection: str | None = None,
332
+ version: str | None = None,
333
+ timeout: float = _EXPORT_TIMEOUT,
334
+ ) -> PdfExport:
280
335
  """The patient's records as PDF via ``Patient/$pdf``: a ZIP of per-source
281
- PDFs, or one source's PDF when ``connection`` selects it. See `PdfExport`."""
282
- params = {"connection": connection} if connection is not None else None
283
- return _pdf_export(
284
- self._consent_get(consent_id, _fhir_url(self.base_url, "Patient/$pdf"), params)
336
+ PDFs, or one source's PDF when ``connection`` selects it. See `PdfExport`.
337
+
338
+ Pass ``version`` (a ``Hubble-Version``, such as ``"2026-11-01"``) for that
339
+ version's export instead. It is built in the background, so this waits up
340
+ to ``timeout`` seconds for it, then downloads it."""
341
+ url = _fhir_url(self.base_url, "Patient/$pdf")
342
+ if connection is not None:
343
+ url = url.copy_set_param("connection", connection)
344
+ if version is None:
345
+ return _pdf_export(self._consent_get(consent_id, url, None))
346
+ return _versioned_pdf_export(
347
+ lambda u, headers: self._consent_request(consent_id, u, headers),
348
+ self.base_url,
349
+ url,
350
+ version,
351
+ timeout,
285
352
  )
286
353
 
287
354
  def _consent_get(
288
- self,
289
- consent_id: str,
290
- url: httpx.URL,
291
- params: Mapping[str, Any] | None,
292
- retry: bool = True,
355
+ self, consent_id: str, url: httpx.URL, params: Mapping[str, Any] | None
356
+ ) -> httpx.Response:
357
+ resp = self._consent_request(consent_id, url.copy_merge_params(dict(params or {})), {})
358
+ if not resp.is_success:
359
+ raise HubbleError(f"FHIR request failed: {resp.status_code}")
360
+ return resp
361
+
362
+ def _consent_request(
363
+ self, consent_id: str, url: httpx.URL, headers: dict[str, str], retry: bool = True
293
364
  ) -> httpx.Response:
294
365
  # The control client's connection pool, with the Bearer token replacing its
295
366
  # Basic credentials for this one request. `consent` is set last, so a
296
367
  # caller's params or a followed link cannot point it at another consent.
297
368
  token = self._application_token()
298
369
  resp = self._control.get_httpx_client().get(
299
- url.copy_merge_params(dict(params or {})).copy_set_param("consent", consent_id),
300
- headers={"Authorization": f"Bearer {token}"},
370
+ url.copy_set_param("consent", consent_id),
371
+ headers={**headers, "Authorization": f"Bearer {token}"},
301
372
  )
302
373
  if resp.status_code == 401 and retry:
303
374
  self._application_token(stale=token)
304
- return self._consent_get(consent_id, url, params, retry=False)
305
- if not resp.is_success:
306
- raise HubbleError(f"FHIR request failed: {resp.status_code}")
375
+ return self._consent_request(consent_id, url, headers, retry=False)
307
376
  return resp
308
377
 
309
378
  def _usable_app_token(self, stale: str | None) -> str | None:
@@ -602,7 +671,13 @@ class PatientSession:
602
671
  """Read the patient summary via ``Patient/$summary`` (a FHIR Bundle)."""
603
672
  return self.fhir("Patient/$summary")
604
673
 
605
- def export_pdf(self, *, connection: str | None = None) -> PdfExport:
674
+ def export_pdf(
675
+ self,
676
+ *,
677
+ connection: str | None = None,
678
+ version: str | None = None,
679
+ timeout: float = _EXPORT_TIMEOUT,
680
+ ) -> PdfExport:
606
681
  """Export the patient's records as PDF via ``Patient/$pdf``.
607
682
 
608
683
  Returns a `PdfExport`: the raw body (a ZIP of per-source PDFs plus a
@@ -610,27 +685,42 @@ class PatientSession:
610
685
  single PDF when ``connection`` selects one source) together with the
611
686
  per-source counts from the response headers, so you can tell how many
612
687
  sources failed without opening the archive. Refreshes and retries once
613
- on 401."""
614
- params = {"connection": connection} if connection is not None else None
615
- return _pdf_export(
616
- self._fhir_request("Patient/$pdf", params, retry=self.refresh_token is not None)
688
+ on 401.
689
+
690
+ Pass ``version`` (a ``Hubble-Version``, such as ``"2026-11-01"``) for that
691
+ version's export instead. It is built in the background, so this waits up
692
+ to ``timeout`` seconds for it, then downloads it."""
693
+ retry = self.refresh_token is not None
694
+ url = _fhir_url(self._hubble.base_url, "Patient/$pdf")
695
+ if connection is not None:
696
+ url = url.copy_set_param("connection", connection)
697
+ if version is None:
698
+ return _pdf_export(self._fhir_request(url, retry))
699
+ return _versioned_pdf_export(
700
+ lambda u, headers: self._send(u, headers, retry),
701
+ self._hubble.base_url,
702
+ url,
703
+ version,
704
+ timeout,
617
705
  )
618
706
 
619
707
  def _fhir_get(self, path: str, params: dict[str, Any] | None, retry: bool) -> dict[str, Any]:
620
- return self._fhir_request(path, params, retry).json()
708
+ url = _fhir_url(self._hubble.base_url, path).copy_merge_params(params or {})
709
+ return self._fhir_request(url, retry).json()
621
710
 
622
- def _fhir_request(
623
- self, path: str, params: dict[str, Any] | None, retry: bool
624
- ) -> httpx.Response:
625
- url = _fhir_url(self._hubble.base_url, path)
626
- resp = self._data.get_httpx_client().get(url, params=params)
627
- if resp.status_code == 401 and retry:
628
- self.refresh()
629
- return self._fhir_request(path, params, retry=False)
711
+ def _fhir_request(self, url: httpx.URL, retry: bool) -> httpx.Response:
712
+ resp = self._send(url, {}, retry)
630
713
  if not resp.is_success:
631
714
  raise HubbleError(f"FHIR request failed: {resp.status_code}")
632
715
  return resp
633
716
 
717
+ def _send(self, url: httpx.URL, headers: dict[str, str], retry: bool) -> httpx.Response:
718
+ resp = self._data.get_httpx_client().get(url, headers=headers)
719
+ if resp.status_code == 401 and retry:
720
+ self.refresh()
721
+ return self._send(url, headers, retry=False)
722
+ return resp
723
+
634
724
  def refresh(self) -> None:
635
725
  """Exchange the refresh token for a fresh access token (rotating)."""
636
726
  if not self.refresh_token:
@@ -688,15 +778,18 @@ class PdfExport:
688
778
 
689
779
  ``content`` is the raw body — a ZIP of per-source PDFs (with a
690
780
  ``manifest.json`` naming each file and marking it complete/partial/failed),
691
- or a single PDF when ``connection`` was set. The counts come from the
781
+ or a single PDF when ``connection`` was set. With a ``version``, it is that
782
+ version's export, described in the API reference. The counts come from the
692
783
  response headers; a non-zero ``sources_failed`` means some records are
693
- missing."""
784
+ missing. ``version`` is the ``Hubble-Version`` served, or ``None`` when none
785
+ was asked for."""
694
786
 
695
787
  content: bytes
696
788
  content_type: str
697
789
  sources_total: int | None = None
698
790
  sources_failed: int | None = None
699
791
  resource_count: int | None = None
792
+ version: str | None = None
700
793
 
701
794
 
702
795
  # Invoked whenever a session rotates its tokens (on refresh), so callers can
@@ -60,8 +60,8 @@ class WebhookEvent:
60
60
  ``type`` is one of:
61
61
 
62
62
  - ``consent.created``: a consent was created, from the API or the portal.
63
- - ``consent.completed``: the patient finished the link flow, so the device
64
- code can be redeemed.
63
+ - ``consent.completed``: the patient finished the link flow and is done
64
+ adding sources. Sources still pending keep settling afterwards.
65
65
  - ``consent.source.created``: a source was added; ``source.status`` is the
66
66
  status it starts in (``available`` if the patient connected it directly,
67
67
  ``pending`` if it is retrieved on the patient's behalf).
@@ -3,7 +3,7 @@
3
3
  # none); this file and the build config are not.
4
4
  [project]
5
5
  name = "hubble-sdk-python"
6
- version = "0.3.0"
6
+ version = "0.3.1"
7
7
  description = "Python client for the Hubble Patient Access API."
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.11"
@@ -464,6 +464,104 @@ def test_export_pdf_forwards_connection_and_is_not_partial_without_failures():
464
464
  assert "/api/v1/fhir/Patient/$pdf" in str(req.url)
465
465
 
466
466
 
467
+ VERSION = "2026-11-01"
468
+ STORAGE = "https://exports.example-storage.com/abc.zip?sig=1"
469
+
470
+
471
+ def _versioned_export_routes(poll_url: str = "/api/v1/fhir/exports/exp_1"):
472
+ queued = {"Hubble-Version": VERSION, "Content-Location": poll_url, "Retry-After": "0"}
473
+ start = respx.get(f"{BASE}/api/v1/fhir/Patient/$pdf").mock(
474
+ return_value=httpx.Response(202, headers=queued)
475
+ )
476
+ poll = respx.get(f"{BASE}/api/v1/fhir/exports/exp_1").mock(
477
+ side_effect=[
478
+ httpx.Response(202, headers=queued),
479
+ httpx.Response(
480
+ 303,
481
+ headers={
482
+ "Hubble-Version": VERSION,
483
+ "Location": STORAGE,
484
+ "X-Hubble-Sources-Total": "2",
485
+ "X-Hubble-Sources-Failed": "0",
486
+ },
487
+ ),
488
+ ]
489
+ )
490
+ download = respx.get(STORAGE).mock(
491
+ return_value=httpx.Response(200, content=b"PK zip", headers={"Content-Type": "application/zip"})
492
+ )
493
+ return start, poll, download
494
+
495
+
496
+ @respx.mock
497
+ def test_export_pdf_with_a_version_polls_then_downloads_without_the_token():
498
+ start, poll, download = _versioned_export_routes()
499
+ session = hub().session("cat_abc")
500
+
501
+ result = session.export_pdf(version=VERSION, connection="epic")
502
+
503
+ started = start.calls.last.request
504
+ assert started.headers["Hubble-Version"] == VERSION
505
+ assert started.headers["Prefer"] == "respond-async"
506
+ assert started.url.params["connection"] == "epic"
507
+ assert poll.call_count == 2
508
+ assert poll.calls.last.request.headers["Authorization"] == "Bearer cat_abc"
509
+ assert "Authorization" not in download.calls.last.request.headers
510
+ assert result.content == b"PK zip"
511
+ assert result.content_type == "application/zip"
512
+ assert (result.sources_total, result.sources_failed, result.version) == (2, 0, VERSION)
513
+
514
+
515
+ @respx.mock
516
+ def test_export_pdf_with_a_version_names_the_consent_on_every_request():
517
+ _token_route(return_value=httpx.Response(200, json=APP_TOKEN))
518
+ start, poll, _ = _versioned_export_routes("/api/v1/fhir/exports/exp_1?consent=cns_1")
519
+
520
+ hub().export_pdf("cns_1", version=VERSION)
521
+
522
+ assert start.calls.last.request.url.params["consent"] == "cns_1"
523
+ assert all(c.request.url.params["consent"] == "cns_1" for c in poll.calls)
524
+
525
+
526
+ @respx.mock
527
+ def test_export_pdf_refuses_a_different_version_than_it_asked_for():
528
+ respx.get(f"{BASE}/api/v1/fhir/Patient/$pdf").mock(return_value=httpx.Response(200, content=b"%PDF"))
529
+ with pytest.raises(HubbleError, match="PDF export failed: 200"):
530
+ hub().session("cat_abc").export_pdf(version=VERSION)
531
+
532
+ respx.get(f"{BASE}/api/v1/fhir/Patient/$pdf").mock(
533
+ return_value=httpx.Response(
534
+ 202, headers={"Content-Location": "/api/v1/fhir/exports/exp_1", "Retry-After": "0"}
535
+ )
536
+ )
537
+ with pytest.raises(HubbleError, match="got None"):
538
+ hub().session("cat_abc").export_pdf(version=VERSION)
539
+
540
+
541
+ @respx.mock
542
+ def test_export_pdf_polls_only_this_apis_fhir_surface():
543
+ respx.get(f"{BASE}/api/v1/fhir/Patient/$pdf").mock(
544
+ return_value=httpx.Response(
545
+ 202,
546
+ headers={"Hubble-Version": VERSION, "Content-Location": "https://evil.example/x", "Retry-After": "0"},
547
+ )
548
+ )
549
+ with pytest.raises(HubbleError, match="Invalid FHIR path"):
550
+ hub().session("cat_abc").export_pdf(version=VERSION)
551
+
552
+
553
+ @respx.mock
554
+ def test_export_pdf_gives_up_after_the_timeout():
555
+ respx.get(f"{BASE}/api/v1/fhir/Patient/$pdf").mock(
556
+ return_value=httpx.Response(
557
+ 202,
558
+ headers={"Hubble-Version": VERSION, "Content-Location": "/api/v1/fhir/exports/exp_1", "Retry-After": "5"},
559
+ )
560
+ )
561
+ with pytest.raises(HubbleError, match="not ready before the timeout"):
562
+ hub().session("cat_abc").export_pdf(version=VERSION, timeout=1)
563
+
564
+
467
565
  @respx.mock
468
566
  def test_fhir_preserves_slash_and_dollar_in_operation_path():
469
567
  route = respx.get(url__regex=rf"{BASE}/api/v1/fhir/Patient/\$everything").mock(