phenostore-sdk-python 0.1.1__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.
- phenostore/__init__.py +26 -0
- phenostore/_auth.py +94 -0
- phenostore/client.py +276 -0
- phenostore/errors.py +93 -0
- phenostore/gen/__init__.py +8 -0
- phenostore/gen/api/__init__.py +1 -0
- phenostore/gen/api/bulk/__init__.py +1 -0
- phenostore/gen/api/bulk/get_bulk_status.py +194 -0
- phenostore/gen/api/bulk/submit_bulk.py +223 -0
- phenostore/gen/api/fhir/__init__.py +1 -0
- phenostore/gen/api/fhir/conditional_delete_resource.py +277 -0
- phenostore/gen/api/fhir/create_resource.py +257 -0
- phenostore/gen/api/fhir/delete_resource.py +212 -0
- phenostore/gen/api/fhir/get_docref.py +281 -0
- phenostore/gen/api/fhir/get_metadata.py +185 -0
- phenostore/gen/api/fhir/get_resource_history.py +240 -0
- phenostore/gen/api/fhir/get_smart_configuration.py +169 -0
- phenostore/gen/api/fhir/patch_resource.py +282 -0
- phenostore/gen/api/fhir/post_docref.py +200 -0
- phenostore/gen/api/fhir/process_bundle.py +201 -0
- phenostore/gen/api/fhir/read_resource.py +214 -0
- phenostore/gen/api/fhir/read_resource_version.py +228 -0
- phenostore/gen/api/fhir/search_resources.py +390 -0
- phenostore/gen/api/fhir/search_resources_post.py +228 -0
- phenostore/gen/api/fhir/update_resource.py +281 -0
- phenostore/gen/api/fhir/validate_resource.py +217 -0
- phenostore/gen/api/o_auth/__init__.py +1 -0
- phenostore/gen/api/o_auth/issue_token.py +186 -0
- phenostore/gen/api/system/__init__.py +1 -0
- phenostore/gen/api/system/get_health.py +128 -0
- phenostore/gen/client.py +280 -0
- phenostore/gen/errors.py +16 -0
- phenostore/gen/models/__init__.py +71 -0
- phenostore/gen/models/bulk_operation_accepted.py +79 -0
- phenostore/gen/models/bulk_operation_accepted_status.py +8 -0
- phenostore/gen/models/bulk_operation_status.py +122 -0
- phenostore/gen/models/bulk_operation_status_code.py +11 -0
- phenostore/gen/models/bundle.py +132 -0
- phenostore/gen/models/bundle_entry.py +128 -0
- phenostore/gen/models/bundle_entry_request.py +70 -0
- phenostore/gen/models/bundle_entry_response.py +97 -0
- phenostore/gen/models/bundle_entry_search.py +69 -0
- phenostore/gen/models/bundle_entry_search_mode.py +9 -0
- phenostore/gen/models/bundle_link.py +69 -0
- phenostore/gen/models/bundle_resource_type.py +8 -0
- phenostore/gen/models/bundle_type.py +11 -0
- phenostore/gen/models/fhir_resource.py +47 -0
- phenostore/gen/models/health_response.py +63 -0
- phenostore/gen/models/health_response_status.py +8 -0
- phenostore/gen/models/issue_severity.py +11 -0
- phenostore/gen/models/issue_token_body.py +91 -0
- phenostore/gen/models/issue_token_body_grant_type.py +8 -0
- phenostore/gen/models/json_patch_item.py +90 -0
- phenostore/gen/models/json_patch_op.py +13 -0
- phenostore/gen/models/o_auth_error.py +69 -0
- phenostore/gen/models/operation_outcome.py +85 -0
- phenostore/gen/models/operation_outcome_issue.py +92 -0
- phenostore/gen/models/operation_outcome_resource_type.py +8 -0
- phenostore/gen/models/post_docref_body.py +124 -0
- phenostore/gen/models/resource_type.py +47 -0
- phenostore/gen/models/search_resources_post_body.py +155 -0
- phenostore/gen/models/search_summary_mode.py +12 -0
- phenostore/gen/models/search_total_mode.py +10 -0
- phenostore/gen/models/smart_configuration.py +111 -0
- phenostore/gen/models/token_response.py +87 -0
- phenostore/gen/models/token_response_token_type.py +8 -0
- phenostore/gen/types.py +54 -0
- phenostore/py.typed +0 -0
- phenostore_sdk_python-0.1.1.dist-info/METADATA +196 -0
- phenostore_sdk_python-0.1.1.dist-info/RECORD +72 -0
- phenostore_sdk_python-0.1.1.dist-info/WHEEL +4 -0
- phenostore_sdk_python-0.1.1.dist-info/licenses/LICENSE +21 -0
phenostore/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Python SDK for the Phenostore FHIR data API."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncClient, Client
|
|
4
|
+
from .errors import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
InvalidResponseError,
|
|
7
|
+
OperationOutcomeError,
|
|
8
|
+
is_conflict,
|
|
9
|
+
is_forbidden,
|
|
10
|
+
is_gone,
|
|
11
|
+
is_not_found,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.1" # x-release-please-version
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AsyncClient",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"Client",
|
|
20
|
+
"InvalidResponseError",
|
|
21
|
+
"OperationOutcomeError",
|
|
22
|
+
"is_conflict",
|
|
23
|
+
"is_forbidden",
|
|
24
|
+
"is_gone",
|
|
25
|
+
"is_not_found",
|
|
26
|
+
]
|
phenostore/_auth.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""HTTPX OAuth2 client credentials authentication for both execution modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import AsyncGenerator, Generator, Sequence
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import AuthenticationError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClientCredentialsAuth(httpx.Auth):
|
|
17
|
+
requires_response_body = True
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, token_url: str, client_id: str, client_secret: str, scopes: Sequence[str]
|
|
21
|
+
) -> None:
|
|
22
|
+
self._token_url = token_url
|
|
23
|
+
self._client_id = client_id
|
|
24
|
+
self._client_secret = client_secret
|
|
25
|
+
self._scopes = tuple(scopes)
|
|
26
|
+
self._token = ""
|
|
27
|
+
self._expires_at = 0.0
|
|
28
|
+
self._sync_lock = threading.Lock()
|
|
29
|
+
self._async_lock = asyncio.Lock()
|
|
30
|
+
|
|
31
|
+
def _expired(self) -> bool:
|
|
32
|
+
return not self._token or time.monotonic() >= self._expires_at
|
|
33
|
+
|
|
34
|
+
def _token_request(self, request: httpx.Request) -> httpx.Request:
|
|
35
|
+
data = {
|
|
36
|
+
"grant_type": "client_credentials",
|
|
37
|
+
"client_id": self._client_id,
|
|
38
|
+
"client_secret": self._client_secret,
|
|
39
|
+
}
|
|
40
|
+
if self._scopes:
|
|
41
|
+
data["scope"] = " ".join(self._scopes)
|
|
42
|
+
return httpx.Request(
|
|
43
|
+
"POST",
|
|
44
|
+
self._token_url,
|
|
45
|
+
data=data,
|
|
46
|
+
headers={"Accept": "application/json"},
|
|
47
|
+
extensions={"timeout": request.extensions.get("timeout", {})},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _receive_token(self, response: httpx.Response) -> None:
|
|
51
|
+
if response.status_code != 200:
|
|
52
|
+
raise AuthenticationError(response)
|
|
53
|
+
try:
|
|
54
|
+
data = response.json()
|
|
55
|
+
token = data["access_token"]
|
|
56
|
+
token_type = data["token_type"]
|
|
57
|
+
lifetime = float(data["expires_in"])
|
|
58
|
+
if (
|
|
59
|
+
not isinstance(token, str)
|
|
60
|
+
or not token
|
|
61
|
+
or not token.isascii()
|
|
62
|
+
or any(char.isspace() or ord(char) < 32 or ord(char) == 127 for char in token)
|
|
63
|
+
or not isinstance(token_type, str)
|
|
64
|
+
or token_type.lower() != "bearer"
|
|
65
|
+
or not math.isfinite(lifetime)
|
|
66
|
+
or lifetime <= 0
|
|
67
|
+
):
|
|
68
|
+
raise ValueError("invalid token")
|
|
69
|
+
except (ValueError, TypeError, KeyError, OverflowError):
|
|
70
|
+
raise AuthenticationError(response, "invalid token response") from None
|
|
71
|
+
self._token = token
|
|
72
|
+
self._expires_at = time.monotonic() + lifetime - min(10, lifetime / 10)
|
|
73
|
+
|
|
74
|
+
def sync_auth_flow(
|
|
75
|
+
self, request: httpx.Request
|
|
76
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
77
|
+
with self._sync_lock:
|
|
78
|
+
if self._expired():
|
|
79
|
+
response = yield self._token_request(request)
|
|
80
|
+
response.read()
|
|
81
|
+
self._receive_token(response)
|
|
82
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
83
|
+
yield request
|
|
84
|
+
|
|
85
|
+
async def async_auth_flow(
|
|
86
|
+
self, request: httpx.Request
|
|
87
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
88
|
+
async with self._async_lock:
|
|
89
|
+
if self._expired():
|
|
90
|
+
response = yield self._token_request(request)
|
|
91
|
+
await response.aread()
|
|
92
|
+
self._receive_token(response)
|
|
93
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
94
|
+
yield request
|
phenostore/client.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Small convenience layer over generated request builders and HTTPX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ssl
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any, Self, cast
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._auth import ClientCredentialsAuth
|
|
12
|
+
from .errors import OperationOutcomeError, _json_object
|
|
13
|
+
from .gen.api.fhir import (
|
|
14
|
+
create_resource,
|
|
15
|
+
delete_resource,
|
|
16
|
+
process_bundle,
|
|
17
|
+
read_resource,
|
|
18
|
+
search_resources,
|
|
19
|
+
update_resource,
|
|
20
|
+
)
|
|
21
|
+
from .gen.client import Client as GeneratedClient
|
|
22
|
+
from .gen.models import FhirResource, ResourceType
|
|
23
|
+
from .gen.types import UNSET
|
|
24
|
+
|
|
25
|
+
JSON = dict[str, Any]
|
|
26
|
+
SearchParams = Mapping[str, str | int | Sequence[str]] | Sequence[tuple[str, str | int]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _BaseClient:
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: str,
|
|
33
|
+
client_id: str,
|
|
34
|
+
client_secret: str,
|
|
35
|
+
tenant: str,
|
|
36
|
+
store: str,
|
|
37
|
+
*,
|
|
38
|
+
scopes: Sequence[str] = (),
|
|
39
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
40
|
+
verify: bool | ssl.SSLContext = True,
|
|
41
|
+
transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
url = httpx.URL(base_url)
|
|
44
|
+
if (
|
|
45
|
+
url.scheme not in ("http", "https")
|
|
46
|
+
or not url.host
|
|
47
|
+
or url.query
|
|
48
|
+
or url.fragment
|
|
49
|
+
or url.userinfo
|
|
50
|
+
):
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"base_url must be an absolute HTTP(S) URL without credentials or query"
|
|
53
|
+
)
|
|
54
|
+
if not all((client_id, client_secret, tenant, store)):
|
|
55
|
+
raise ValueError("client_id, client_secret, tenant, and store must be nonempty")
|
|
56
|
+
if isinstance(scopes, str):
|
|
57
|
+
raise TypeError("scopes must be a sequence of scope strings")
|
|
58
|
+
self.tenant = tenant
|
|
59
|
+
self.store = store
|
|
60
|
+
base_url = str(url).rstrip("/")
|
|
61
|
+
auth = ClientCredentialsAuth(base_url + "/oauth/token", client_id, client_secret, scopes)
|
|
62
|
+
self.inner = GeneratedClient(
|
|
63
|
+
base_url=base_url,
|
|
64
|
+
timeout=timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout),
|
|
65
|
+
verify_ssl=verify,
|
|
66
|
+
follow_redirects=False,
|
|
67
|
+
headers={"Accept": "application/fhir+json", "User-Agent": "phenostore-sdk-python"},
|
|
68
|
+
httpx_args={"auth": auth, "transport": transport},
|
|
69
|
+
raise_on_unexpected_status=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _read(self, resource_type: str, id: str) -> JSON:
|
|
73
|
+
return read_resource._get_kwargs(
|
|
74
|
+
self.tenant, self.store, cast(ResourceType, resource_type), id
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _create(
|
|
78
|
+
self, resource_type: str, body: Mapping[str, Any], if_none_exist: str | None
|
|
79
|
+
) -> JSON:
|
|
80
|
+
return create_resource._get_kwargs(
|
|
81
|
+
self.tenant,
|
|
82
|
+
self.store,
|
|
83
|
+
cast(ResourceType, resource_type),
|
|
84
|
+
body=FhirResource.from_dict(body),
|
|
85
|
+
if_none_exist=if_none_exist if if_none_exist is not None else UNSET,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def _update(
|
|
89
|
+
self, resource_type: str, id: str, body: Mapping[str, Any], if_match: str | None
|
|
90
|
+
) -> JSON:
|
|
91
|
+
return update_resource._get_kwargs(
|
|
92
|
+
self.tenant,
|
|
93
|
+
self.store,
|
|
94
|
+
cast(ResourceType, resource_type),
|
|
95
|
+
id,
|
|
96
|
+
body=FhirResource.from_dict(body),
|
|
97
|
+
if_match=if_match if if_match is not None else UNSET,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _delete(self, resource_type: str, id: str) -> JSON:
|
|
101
|
+
return delete_resource._get_kwargs(
|
|
102
|
+
self.tenant, self.store, cast(ResourceType, resource_type), id
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _search(self, resource_type: str, params: SearchParams | None) -> JSON:
|
|
106
|
+
kwargs = search_resources._get_kwargs(
|
|
107
|
+
self.tenant, self.store, cast(ResourceType, resource_type)
|
|
108
|
+
)
|
|
109
|
+
# The generated signature only includes parameters explicitly named in the
|
|
110
|
+
# spec. FHIR also allows arbitrary names, modifiers, chains and repetition.
|
|
111
|
+
kwargs["params"] = params if params is not None else {}
|
|
112
|
+
return kwargs
|
|
113
|
+
|
|
114
|
+
def _bundle(self, bundle: Mapping[str, Any]) -> JSON:
|
|
115
|
+
return process_bundle._get_kwargs(
|
|
116
|
+
self.tenant, self.store, body=FhirResource.from_dict(bundle)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Client(_BaseClient):
|
|
121
|
+
"""Synchronous Phenostore client with lazy OAuth2 authentication.
|
|
122
|
+
|
|
123
|
+
Use as a context manager or call close() to release pooled connections.
|
|
124
|
+
The transport, timeout and TLS configuration also apply to token requests.
|
|
125
|
+
Access inner to use generated API functions and typed models directly.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
base_url: str,
|
|
131
|
+
client_id: str,
|
|
132
|
+
client_secret: str,
|
|
133
|
+
tenant: str,
|
|
134
|
+
store: str,
|
|
135
|
+
*,
|
|
136
|
+
scopes: Sequence[str] = (),
|
|
137
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
138
|
+
verify: bool | ssl.SSLContext = True,
|
|
139
|
+
transport: httpx.BaseTransport | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
super().__init__(
|
|
142
|
+
base_url,
|
|
143
|
+
client_id,
|
|
144
|
+
client_secret,
|
|
145
|
+
tenant,
|
|
146
|
+
store,
|
|
147
|
+
scopes=scopes,
|
|
148
|
+
timeout=timeout,
|
|
149
|
+
verify=verify,
|
|
150
|
+
transport=transport,
|
|
151
|
+
)
|
|
152
|
+
# Construct once before the client can be shared between threads. Token
|
|
153
|
+
# acquisition stays lazy; the generated client's lazy initializer is not
|
|
154
|
+
# synchronized and can otherwise allocate multiple connection pools.
|
|
155
|
+
self.inner.get_httpx_client()
|
|
156
|
+
|
|
157
|
+
def _request(self, kwargs: JSON) -> httpx.Response:
|
|
158
|
+
response = self.inner.get_httpx_client().request(**kwargs)
|
|
159
|
+
if not 200 <= response.status_code < 300:
|
|
160
|
+
raise OperationOutcomeError(response)
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
def read_resource(self, resource_type: str, id: str) -> JSON:
|
|
164
|
+
"""Read a resource, preserving every JSON field."""
|
|
165
|
+
return _json_object(self._request(self._read(resource_type, id)))
|
|
166
|
+
|
|
167
|
+
def create_resource(
|
|
168
|
+
self, resource_type: str, body: Mapping[str, Any], *, if_none_exist: str | None = None
|
|
169
|
+
) -> JSON:
|
|
170
|
+
"""Create a resource, optionally with conditional search criteria."""
|
|
171
|
+
return _json_object(self._request(self._create(resource_type, body, if_none_exist)))
|
|
172
|
+
|
|
173
|
+
def update_resource(
|
|
174
|
+
self, resource_type: str, id: str, body: Mapping[str, Any], *, if_match: str | None = None
|
|
175
|
+
) -> JSON:
|
|
176
|
+
"""Update or upsert, optionally requiring an ETag such as W/\"1\"."""
|
|
177
|
+
return _json_object(self._request(self._update(resource_type, id, body, if_match)))
|
|
178
|
+
|
|
179
|
+
def delete_resource(self, resource_type: str, id: str) -> None:
|
|
180
|
+
"""Delete a resource. Deleted and missing resources may raise API errors."""
|
|
181
|
+
self._request(self._delete(resource_type, id))
|
|
182
|
+
|
|
183
|
+
def search_resources(self, resource_type: str, params: SearchParams | None = None) -> JSON:
|
|
184
|
+
"""Search with arbitrary FHIR parameters; sequence values repeat a parameter."""
|
|
185
|
+
return _json_object(self._request(self._search(resource_type, params)))
|
|
186
|
+
|
|
187
|
+
def process_bundle(self, bundle: Mapping[str, Any]) -> JSON:
|
|
188
|
+
"""Execute a transaction or batch bundle."""
|
|
189
|
+
return _json_object(self._request(self._bundle(bundle)))
|
|
190
|
+
|
|
191
|
+
def close(self) -> None:
|
|
192
|
+
"""Close the underlying synchronous HTTP client."""
|
|
193
|
+
self.inner.get_httpx_client().close()
|
|
194
|
+
|
|
195
|
+
def __enter__(self) -> Self:
|
|
196
|
+
self.inner.get_httpx_client().__enter__()
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def __exit__(self, *args: Any) -> None:
|
|
200
|
+
self.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class AsyncClient(_BaseClient):
|
|
204
|
+
"""Asynchronous equivalent of Client. Use async with or await aclose()."""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
base_url: str,
|
|
209
|
+
client_id: str,
|
|
210
|
+
client_secret: str,
|
|
211
|
+
tenant: str,
|
|
212
|
+
store: str,
|
|
213
|
+
*,
|
|
214
|
+
scopes: Sequence[str] = (),
|
|
215
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
216
|
+
verify: bool | ssl.SSLContext = True,
|
|
217
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
218
|
+
) -> None:
|
|
219
|
+
super().__init__(
|
|
220
|
+
base_url,
|
|
221
|
+
client_id,
|
|
222
|
+
client_secret,
|
|
223
|
+
tenant,
|
|
224
|
+
store,
|
|
225
|
+
scopes=scopes,
|
|
226
|
+
timeout=timeout,
|
|
227
|
+
verify=verify,
|
|
228
|
+
transport=transport,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def _request(self, kwargs: JSON) -> httpx.Response:
|
|
232
|
+
response = await self.inner.get_async_httpx_client().request(**kwargs)
|
|
233
|
+
if not 200 <= response.status_code < 300:
|
|
234
|
+
raise OperationOutcomeError(response)
|
|
235
|
+
return response
|
|
236
|
+
|
|
237
|
+
async def read_resource(self, resource_type: str, id: str) -> JSON:
|
|
238
|
+
"""Read a resource, preserving every JSON field."""
|
|
239
|
+
return _json_object(await self._request(self._read(resource_type, id)))
|
|
240
|
+
|
|
241
|
+
async def create_resource(
|
|
242
|
+
self, resource_type: str, body: Mapping[str, Any], *, if_none_exist: str | None = None
|
|
243
|
+
) -> JSON:
|
|
244
|
+
"""Create a resource, optionally with conditional search criteria."""
|
|
245
|
+
return _json_object(await self._request(self._create(resource_type, body, if_none_exist)))
|
|
246
|
+
|
|
247
|
+
async def update_resource(
|
|
248
|
+
self, resource_type: str, id: str, body: Mapping[str, Any], *, if_match: str | None = None
|
|
249
|
+
) -> JSON:
|
|
250
|
+
"""Update or upsert, optionally requiring an ETag."""
|
|
251
|
+
return _json_object(await self._request(self._update(resource_type, id, body, if_match)))
|
|
252
|
+
|
|
253
|
+
async def delete_resource(self, resource_type: str, id: str) -> None:
|
|
254
|
+
"""Delete a resource."""
|
|
255
|
+
await self._request(self._delete(resource_type, id))
|
|
256
|
+
|
|
257
|
+
async def search_resources(
|
|
258
|
+
self, resource_type: str, params: SearchParams | None = None
|
|
259
|
+
) -> JSON:
|
|
260
|
+
"""Search with arbitrary FHIR parameters, including repeated values."""
|
|
261
|
+
return _json_object(await self._request(self._search(resource_type, params)))
|
|
262
|
+
|
|
263
|
+
async def process_bundle(self, bundle: Mapping[str, Any]) -> JSON:
|
|
264
|
+
"""Execute a transaction or batch bundle."""
|
|
265
|
+
return _json_object(await self._request(self._bundle(bundle)))
|
|
266
|
+
|
|
267
|
+
async def aclose(self) -> None:
|
|
268
|
+
"""Close the underlying asynchronous HTTP client."""
|
|
269
|
+
await self.inner.get_async_httpx_client().aclose()
|
|
270
|
+
|
|
271
|
+
async def __aenter__(self) -> Self:
|
|
272
|
+
await self.inner.get_async_httpx_client().__aenter__()
|
|
273
|
+
return self
|
|
274
|
+
|
|
275
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
276
|
+
await self.aclose()
|
phenostore/errors.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Errors raised by the convenience clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OperationOutcomeError(Exception):
|
|
12
|
+
"""An API error, retaining the HTTP response and unmodified response bytes."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
15
|
+
self.response = response
|
|
16
|
+
self.status_code = response.status_code
|
|
17
|
+
self.body = response.content
|
|
18
|
+
self.outcome: dict[str, Any] | None = None
|
|
19
|
+
message = f"phenostore: HTTP {self.status_code}"
|
|
20
|
+
try:
|
|
21
|
+
parsed = response.json()
|
|
22
|
+
except (ValueError, UnicodeError):
|
|
23
|
+
parsed = None
|
|
24
|
+
if isinstance(parsed, dict):
|
|
25
|
+
self.outcome = parsed
|
|
26
|
+
issues = parsed.get("issue")
|
|
27
|
+
if isinstance(issues, list) and issues and isinstance(issues[0], dict):
|
|
28
|
+
issue = issues[0]
|
|
29
|
+
message = "[{}/{}] {}".format(
|
|
30
|
+
issue.get("severity", ""), issue.get("code", ""), issue.get("diagnostics", "")
|
|
31
|
+
)
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationError(Exception):
|
|
36
|
+
"""Token exchange failed or returned an invalid token response."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, response: httpx.Response, message: str = "token exchange failed") -> None:
|
|
39
|
+
self.response = response
|
|
40
|
+
self.status_code = response.status_code
|
|
41
|
+
self.body = response.content
|
|
42
|
+
# Avoid putting credentials or token response bodies in exception messages.
|
|
43
|
+
super().__init__(f"phenostore: {message} (HTTP {self.status_code})")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InvalidResponseError(ValueError):
|
|
47
|
+
"""A successful API response did not contain a JSON object."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
50
|
+
self.response = response
|
|
51
|
+
super().__init__(f"phenostore: expected a JSON object (HTTP {response.status_code})")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _has_status(error: BaseException | None, status: int) -> bool:
|
|
55
|
+
seen: set[int] = set()
|
|
56
|
+
while error is not None and id(error) not in seen:
|
|
57
|
+
seen.add(id(error))
|
|
58
|
+
if isinstance(error, OperationOutcomeError):
|
|
59
|
+
return error.status_code == status
|
|
60
|
+
error = error.__cause__ or error.__context__
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_not_found(error: BaseException | None) -> bool:
|
|
65
|
+
"""Return whether an error (including chained exceptions) represents HTTP 404."""
|
|
66
|
+
return _has_status(error, 404)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_gone(error: BaseException | None) -> bool:
|
|
70
|
+
"""Return whether an error represents HTTP 410."""
|
|
71
|
+
return _has_status(error, 410)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def is_conflict(error: BaseException | None) -> bool:
|
|
75
|
+
"""Return whether an error represents HTTP 412 (a failed precondition)."""
|
|
76
|
+
return _has_status(error, 412)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_forbidden(error: BaseException | None) -> bool:
|
|
80
|
+
"""Return whether an error represents HTTP 403."""
|
|
81
|
+
return _has_status(error, 403)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _json_object(response: httpx.Response) -> dict[str, Any]:
|
|
85
|
+
if not 200 <= response.status_code < 300:
|
|
86
|
+
raise OperationOutcomeError(response)
|
|
87
|
+
try:
|
|
88
|
+
result = response.json()
|
|
89
|
+
except (json.JSONDecodeError, UnicodeError) as exc:
|
|
90
|
+
raise InvalidResponseError(response) from exc
|
|
91
|
+
if not isinstance(result, dict):
|
|
92
|
+
raise InvalidResponseError(response)
|
|
93
|
+
return result
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Contains methods for accessing the API"""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Contains endpoint functions for accessing the API"""
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from http import HTTPStatus
|
|
2
|
+
from typing import Any
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ... import errors
|
|
8
|
+
from ...client import AuthenticatedClient, Client
|
|
9
|
+
from ...models.bulk_operation_status import BulkOperationStatus
|
|
10
|
+
from ...models.operation_outcome import OperationOutcome
|
|
11
|
+
from ...types import Response
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_kwargs(
|
|
15
|
+
tenant_id: str,
|
|
16
|
+
store_id: str,
|
|
17
|
+
op_id: str,
|
|
18
|
+
) -> dict[str, Any]:
|
|
19
|
+
|
|
20
|
+
_kwargs: dict[str, Any] = {
|
|
21
|
+
"method": "get",
|
|
22
|
+
"url": "/v1/tenants/{tenant_id}/stores/{store_id}/$bulk/{op_id}".format(
|
|
23
|
+
tenant_id=quote(str(tenant_id), safe=""),
|
|
24
|
+
store_id=quote(str(store_id), safe=""),
|
|
25
|
+
op_id=quote(str(op_id), safe=""),
|
|
26
|
+
),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return _kwargs
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_response(
|
|
33
|
+
*, client: AuthenticatedClient | Client, response: httpx.Response
|
|
34
|
+
) -> BulkOperationStatus | OperationOutcome | None:
|
|
35
|
+
if response.status_code == 200:
|
|
36
|
+
response_200 = BulkOperationStatus.from_dict(response.json())
|
|
37
|
+
|
|
38
|
+
return response_200
|
|
39
|
+
|
|
40
|
+
if response.status_code == 202:
|
|
41
|
+
response_202 = BulkOperationStatus.from_dict(response.json())
|
|
42
|
+
|
|
43
|
+
return response_202
|
|
44
|
+
|
|
45
|
+
if response.status_code == 404:
|
|
46
|
+
response_404 = OperationOutcome.from_dict(response.json())
|
|
47
|
+
|
|
48
|
+
return response_404
|
|
49
|
+
|
|
50
|
+
if client.raise_on_unexpected_status:
|
|
51
|
+
raise errors.UnexpectedStatus(response.status_code, response.content)
|
|
52
|
+
else:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_response(
|
|
57
|
+
*, client: AuthenticatedClient | Client, response: httpx.Response
|
|
58
|
+
) -> Response[BulkOperationStatus | OperationOutcome]:
|
|
59
|
+
return Response(
|
|
60
|
+
status_code=HTTPStatus(response.status_code),
|
|
61
|
+
content=response.content,
|
|
62
|
+
headers=response.headers,
|
|
63
|
+
parsed=_parse_response(client=client, response=response),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sync_detailed(
|
|
68
|
+
tenant_id: str,
|
|
69
|
+
store_id: str,
|
|
70
|
+
op_id: str,
|
|
71
|
+
*,
|
|
72
|
+
client: AuthenticatedClient | Client,
|
|
73
|
+
) -> Response[BulkOperationStatus | OperationOutcome]:
|
|
74
|
+
"""Get bulk operation status
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
tenant_id (str):
|
|
78
|
+
store_id (str):
|
|
79
|
+
op_id (str):
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
83
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Response[BulkOperationStatus | OperationOutcome]
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
kwargs = _get_kwargs(
|
|
90
|
+
tenant_id=tenant_id,
|
|
91
|
+
store_id=store_id,
|
|
92
|
+
op_id=op_id,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
response = client.get_httpx_client().request(
|
|
96
|
+
**kwargs,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return _build_response(client=client, response=response)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def sync(
|
|
103
|
+
tenant_id: str,
|
|
104
|
+
store_id: str,
|
|
105
|
+
op_id: str,
|
|
106
|
+
*,
|
|
107
|
+
client: AuthenticatedClient | Client,
|
|
108
|
+
) -> BulkOperationStatus | OperationOutcome | None:
|
|
109
|
+
"""Get bulk operation status
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
tenant_id (str):
|
|
113
|
+
store_id (str):
|
|
114
|
+
op_id (str):
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
118
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
BulkOperationStatus | OperationOutcome
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
return sync_detailed(
|
|
125
|
+
tenant_id=tenant_id,
|
|
126
|
+
store_id=store_id,
|
|
127
|
+
op_id=op_id,
|
|
128
|
+
client=client,
|
|
129
|
+
).parsed
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def asyncio_detailed(
|
|
133
|
+
tenant_id: str,
|
|
134
|
+
store_id: str,
|
|
135
|
+
op_id: str,
|
|
136
|
+
*,
|
|
137
|
+
client: AuthenticatedClient | Client,
|
|
138
|
+
) -> Response[BulkOperationStatus | OperationOutcome]:
|
|
139
|
+
"""Get bulk operation status
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
tenant_id (str):
|
|
143
|
+
store_id (str):
|
|
144
|
+
op_id (str):
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
148
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Response[BulkOperationStatus | OperationOutcome]
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
kwargs = _get_kwargs(
|
|
155
|
+
tenant_id=tenant_id,
|
|
156
|
+
store_id=store_id,
|
|
157
|
+
op_id=op_id,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
response = await client.get_async_httpx_client().request(**kwargs)
|
|
161
|
+
|
|
162
|
+
return _build_response(client=client, response=response)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def asyncio(
|
|
166
|
+
tenant_id: str,
|
|
167
|
+
store_id: str,
|
|
168
|
+
op_id: str,
|
|
169
|
+
*,
|
|
170
|
+
client: AuthenticatedClient | Client,
|
|
171
|
+
) -> BulkOperationStatus | OperationOutcome | None:
|
|
172
|
+
"""Get bulk operation status
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
tenant_id (str):
|
|
176
|
+
store_id (str):
|
|
177
|
+
op_id (str):
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
|
|
181
|
+
httpx.TimeoutException: If the request takes longer than Client.timeout.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
BulkOperationStatus | OperationOutcome
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
await asyncio_detailed(
|
|
189
|
+
tenant_id=tenant_id,
|
|
190
|
+
store_id=store_id,
|
|
191
|
+
op_id=op_id,
|
|
192
|
+
client=client,
|
|
193
|
+
)
|
|
194
|
+
).parsed
|