phactor 0.2.0__tar.gz → 0.3.2__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.
- {phactor-0.2.0 → phactor-0.3.2}/CHANGELOG.md +61 -0
- {phactor-0.2.0 → phactor-0.3.2}/PKG-INFO +48 -1
- {phactor-0.2.0 → phactor-0.3.2}/README.md +47 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/__init__.py +6 -0
- phactor-0.3.2/src/phactor/_graphql.py +37 -0
- phactor-0.3.2/src/phactor/_version.py +1 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/auth.py +2 -6
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/client.py +6 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/client.py +39 -82
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/models.py +56 -14
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/exceptions.py +19 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/pandas.py +2 -2
- phactor-0.3.2/src/phactor/sites/__init__.py +13 -0
- phactor-0.3.2/src/phactor/sites/client.py +353 -0
- phactor-0.3.2/src/phactor/sites/models.py +66 -0
- phactor-0.3.2/src/phactor/sites/query.py +32 -0
- phactor-0.2.0/src/phactor/_version.py +0 -1
- {phactor-0.2.0 → phactor-0.3.2}/.gitignore +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/LICENSE +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/pyproject.toml +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/_http.py +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/__init__.py +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/builder.py +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/query.py +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/config.py +0 -0
- {phactor-0.2.0 → phactor-0.3.2}/src/phactor/py.typed +0 -0
|
@@ -6,6 +6,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
While the SDK is pre-1.0 (`0.x`), breaking changes may accompany a minor version bump.
|
|
8
8
|
|
|
9
|
+
## [0.3.2] - 2026-09-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `client.cohorts.analyze` no longer fails when a provider reports a criterion
|
|
14
|
+
error reason other than `NO_TRANSLATION` / `NO_PATH`. 0.3.1 raised a pydantic
|
|
15
|
+
`ValidationError` for `UNSUPPORTED_SEMANTICS` (returned today, for example,
|
|
16
|
+
for exact product codes a provider cannot match), so one unevaluated criterion
|
|
17
|
+
made the whole result unreadable.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `CriterionErrorReason.UNSUPPORTED_SEMANTICS`, `INSUFFICIENT_DATA`,
|
|
22
|
+
`MATERIALIZATION_NOT_READY` and `CriterionErrorDomain.AGE`, `MEASUREMENT`,
|
|
23
|
+
`ENCOUNTER`, matching the API.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Response values this SDK version does not know yet (`CriterionError.reason`,
|
|
28
|
+
`CriterionError.domain`, `Site.access`) are kept as plain strings instead of
|
|
29
|
+
failing validation, so a value the API adds later cannot break parsing.
|
|
30
|
+
|
|
31
|
+
## [0.3.1] - 2026-09-24
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- `client.cohorts.analyze` accepts SDK models anywhere inside plain-dict
|
|
36
|
+
arguments. A `site.as_location()` value in a hand-written proposition or
|
|
37
|
+
`global_filters` dict used to fail with "Object of type LocationValueInput is
|
|
38
|
+
not JSON serializable"; it is now sent as a `SITE` value. Plain dict values are
|
|
39
|
+
sent unchanged.
|
|
40
|
+
|
|
41
|
+
## [0.3.0] - 2026-09-23
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **Registry site search: `client.sites`** on both `PhactorClient` and
|
|
46
|
+
`AsyncPhactorClient`, over the gateway's `searchSites` / `sitesByIds` queries.
|
|
47
|
+
Results cover the sites visible to the caller's tenant (PUBLIC, plus RESTRICTED
|
|
48
|
+
sites it owns or was granted) that are active and have coordinates.
|
|
49
|
+
- `search(query, *, limit=20, cursor=None) -> SitePage` returns one page
|
|
50
|
+
(`items`, `next_cursor`).
|
|
51
|
+
- `iter(query, *, max_results=100, page_size=50)` lazily yields `Site`s,
|
|
52
|
+
fetching the next page only when the consumer asks for it and never past
|
|
53
|
+
`max_results` (at most 500).
|
|
54
|
+
- `get(ids) -> SitesByIds` resolves up to 100 distinct IDs and reports the
|
|
55
|
+
unresolvable ones in `missing_ids`.
|
|
56
|
+
- Guardrails are checked before any request (`ValueError`): search text 2 to
|
|
57
|
+
100 characters after trimming, `limit` / `page_size` 1 to 50, at most 100 IDs.
|
|
58
|
+
- Search pages and by-ID lookups are cached per client for 60 seconds (bounded,
|
|
59
|
+
thread-safe and asyncio-safe); sites seen in a search also answer `get`.
|
|
60
|
+
- New models `Site`, `SiteAccess`, `SitePage`, `SitesByIds`.
|
|
61
|
+
- **`LocationTypeEnum.SITE`**: a location value whose `value` is a registry site
|
|
62
|
+
ID, matching patients within `radius` miles of the site (`radius=0` uses the
|
|
63
|
+
connector's 10-mile default). `Site.as_location(radius=10)` builds one.
|
|
64
|
+
`LocationValueInput` rejects `coords` and a negative `radius` on a SITE value,
|
|
65
|
+
and `LocationInput` accepts at most 25 SITE values.
|
|
66
|
+
- `GraphQLError.missing_site_ids`: the SITE IDs `analyzeCohorts` could not
|
|
67
|
+
resolve (from `extensions.missingSiteIds`); the whole request fails when any
|
|
68
|
+
are missing.
|
|
69
|
+
|
|
9
70
|
## [0.2.0] - 2026-07-04
|
|
10
71
|
|
|
11
72
|
### Breaking
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: phactor
|
|
3
|
-
Version: 0.2
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Python SDK for the Phactor clinical trial feasibility platform
|
|
5
5
|
Project-URL: Homepage, https://phactor.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.phactor.ai
|
|
@@ -157,6 +157,53 @@ client = PhactorClient(
|
|
|
157
157
|
- If an API request returns `401`, the SDK invalidates the cached token, fetches a fresh token, and retries once before raising `AuthenticationError`.
|
|
158
158
|
- `AsyncPhactorClient` should be used through `async with` or closed explicitly with `await client.close()`. Once closed, further use raises `PhactorError`.
|
|
159
159
|
|
|
160
|
+
## Site Search
|
|
161
|
+
|
|
162
|
+
`client.sites` searches the global site registry: PUBLIC sites plus the
|
|
163
|
+
RESTRICTED sites your tenant owns or was granted. Only active sites with
|
|
164
|
+
coordinates are returned, and a site ID can be used directly as a cohort
|
|
165
|
+
location criterion.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from phactor import PhactorClient
|
|
169
|
+
from phactor.cohorts import CohortBuilder, Proposition
|
|
170
|
+
|
|
171
|
+
with PhactorClient() as client:
|
|
172
|
+
page = client.sites.search("general hospital", limit=10) # one page
|
|
173
|
+
more = client.sites.search("general hospital", limit=10, cursor=page.next_cursor)
|
|
174
|
+
|
|
175
|
+
# Lazy: fetches a page only as you consume it, never past max_results.
|
|
176
|
+
for site in client.sites.iter("boston", max_results=100):
|
|
177
|
+
print(site.id, site.name, site.city, site.access)
|
|
178
|
+
|
|
179
|
+
lookup = client.sites.get(["<site-id>", "<other-site-id>"])
|
|
180
|
+
print(lookup.missing_ids) # unknown, not visible to you, inactive, or no coordinates
|
|
181
|
+
|
|
182
|
+
site = page.items[0]
|
|
183
|
+
cohort = (
|
|
184
|
+
CohortBuilder()
|
|
185
|
+
.include("Near the site")
|
|
186
|
+
# Patients within 15 miles of the site; radius=0 means the 10-mile default.
|
|
187
|
+
.add(Proposition(location={"values": [site.as_location(radius=15)]}))
|
|
188
|
+
.done()
|
|
189
|
+
.build()
|
|
190
|
+
)
|
|
191
|
+
result = client.cohorts.analyze(providers=["provider-uuid"], cohort_groups=[cohort])
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The async client exposes the same methods (`await client.sites.search(...)`,
|
|
195
|
+
`async for site in client.sites.iter(...)`). Criteria exported from the sponsor
|
|
196
|
+
portal carry SITE values as plain dicts
|
|
197
|
+
(`{"type": "SITE", "value": "<site-id>", "radius": 10}`) and can be passed to
|
|
198
|
+
`analyze` unchanged.
|
|
199
|
+
|
|
200
|
+
Limits, checked before any request is sent (`ValueError`): search text 2 to 100
|
|
201
|
+
characters after trimming, `limit` and `page_size` 1 to 50, `max_results` up to
|
|
202
|
+
500, `get` up to 100 distinct IDs, and at most 25 SITE values per location. A
|
|
203
|
+
SITE value never carries `coords`. Search pages and by-ID lookups are cached per
|
|
204
|
+
client for 60 seconds. If `analyze` names a site the server cannot resolve, the
|
|
205
|
+
whole request fails with a `GraphQLError` whose `missing_site_ids` lists them.
|
|
206
|
+
|
|
160
207
|
## Fluent Cohort Builder
|
|
161
208
|
|
|
162
209
|
```python
|
|
@@ -118,6 +118,53 @@ client = PhactorClient(
|
|
|
118
118
|
- If an API request returns `401`, the SDK invalidates the cached token, fetches a fresh token, and retries once before raising `AuthenticationError`.
|
|
119
119
|
- `AsyncPhactorClient` should be used through `async with` or closed explicitly with `await client.close()`. Once closed, further use raises `PhactorError`.
|
|
120
120
|
|
|
121
|
+
## Site Search
|
|
122
|
+
|
|
123
|
+
`client.sites` searches the global site registry: PUBLIC sites plus the
|
|
124
|
+
RESTRICTED sites your tenant owns or was granted. Only active sites with
|
|
125
|
+
coordinates are returned, and a site ID can be used directly as a cohort
|
|
126
|
+
location criterion.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from phactor import PhactorClient
|
|
130
|
+
from phactor.cohorts import CohortBuilder, Proposition
|
|
131
|
+
|
|
132
|
+
with PhactorClient() as client:
|
|
133
|
+
page = client.sites.search("general hospital", limit=10) # one page
|
|
134
|
+
more = client.sites.search("general hospital", limit=10, cursor=page.next_cursor)
|
|
135
|
+
|
|
136
|
+
# Lazy: fetches a page only as you consume it, never past max_results.
|
|
137
|
+
for site in client.sites.iter("boston", max_results=100):
|
|
138
|
+
print(site.id, site.name, site.city, site.access)
|
|
139
|
+
|
|
140
|
+
lookup = client.sites.get(["<site-id>", "<other-site-id>"])
|
|
141
|
+
print(lookup.missing_ids) # unknown, not visible to you, inactive, or no coordinates
|
|
142
|
+
|
|
143
|
+
site = page.items[0]
|
|
144
|
+
cohort = (
|
|
145
|
+
CohortBuilder()
|
|
146
|
+
.include("Near the site")
|
|
147
|
+
# Patients within 15 miles of the site; radius=0 means the 10-mile default.
|
|
148
|
+
.add(Proposition(location={"values": [site.as_location(radius=15)]}))
|
|
149
|
+
.done()
|
|
150
|
+
.build()
|
|
151
|
+
)
|
|
152
|
+
result = client.cohorts.analyze(providers=["provider-uuid"], cohort_groups=[cohort])
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The async client exposes the same methods (`await client.sites.search(...)`,
|
|
156
|
+
`async for site in client.sites.iter(...)`). Criteria exported from the sponsor
|
|
157
|
+
portal carry SITE values as plain dicts
|
|
158
|
+
(`{"type": "SITE", "value": "<site-id>", "radius": 10}`) and can be passed to
|
|
159
|
+
`analyze` unchanged.
|
|
160
|
+
|
|
161
|
+
Limits, checked before any request is sent (`ValueError`): search text 2 to 100
|
|
162
|
+
characters after trimming, `limit` and `page_size` 1 to 50, `max_results` up to
|
|
163
|
+
500, `get` up to 100 distinct IDs, and at most 25 SITE values per location. A
|
|
164
|
+
SITE value never carries `coords`. Search pages and by-ID lookups are cached per
|
|
165
|
+
client for 60 seconds. If `analyze` names a site the server cannot resolve, the
|
|
166
|
+
whole request fails with a `GraphQLError` whose `missing_site_ids` lists them.
|
|
167
|
+
|
|
121
168
|
## Fluent Cohort Builder
|
|
122
169
|
|
|
123
170
|
```python
|
|
@@ -76,6 +76,7 @@ from phactor.exceptions import (
|
|
|
76
76
|
TimeoutError,
|
|
77
77
|
ValidationError,
|
|
78
78
|
)
|
|
79
|
+
from phactor.sites.models import Site, SiteAccess, SitePage, SitesByIds
|
|
79
80
|
|
|
80
81
|
__all__ = [
|
|
81
82
|
"__version__",
|
|
@@ -149,6 +150,11 @@ __all__ = [
|
|
|
149
150
|
"GeographicGroup",
|
|
150
151
|
"SiteDistanceBandResult",
|
|
151
152
|
"SiteRadiusResult",
|
|
153
|
+
# Site search
|
|
154
|
+
"Site",
|
|
155
|
+
"SiteAccess",
|
|
156
|
+
"SitePage",
|
|
157
|
+
"SitesByIds",
|
|
152
158
|
# Exceptions
|
|
153
159
|
"AuthenticationError",
|
|
154
160
|
"ConnectionError",
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Shared GraphQL response handling for every gateway operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from phactor.exceptions import GraphQLError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_field(response: dict[str, Any], field: str) -> Any:
|
|
11
|
+
"""Return ``response["data"][field]``, raising ``GraphQLError`` on any error shape."""
|
|
12
|
+
if response.get("errors"):
|
|
13
|
+
errors = response["errors"]
|
|
14
|
+
messages = [e.get("message", str(e)) for e in errors]
|
|
15
|
+
raise GraphQLError(
|
|
16
|
+
f"GraphQL errors: {'; '.join(messages)}",
|
|
17
|
+
errors=errors,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# A non-nullable field violation nullifies the whole `data` tree and reports the
|
|
21
|
+
# cause under `extensions.valueCompletion` (Apollo Router / graphql-js value
|
|
22
|
+
# completion), NOT under top-level `errors`. Surface those messages instead of
|
|
23
|
+
# blindly dereferencing a null `data` (which raised an opaque AttributeError).
|
|
24
|
+
value_completion = (response.get("extensions") or {}).get("valueCompletion")
|
|
25
|
+
if value_completion:
|
|
26
|
+
messages = [e.get("message", str(e)) for e in value_completion]
|
|
27
|
+
raise GraphQLError(
|
|
28
|
+
f"GraphQL value-completion errors: {'; '.join(messages)}",
|
|
29
|
+
errors=value_completion,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# `data` may be present-but-null (see above) - `or {}` handles that, unlike a
|
|
33
|
+
# default that only applies when the key is absent.
|
|
34
|
+
payload = (response.get("data") or {}).get(field)
|
|
35
|
+
if payload is None:
|
|
36
|
+
raise GraphQLError(f"Unexpected response: missing {field} data")
|
|
37
|
+
return payload
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.2"
|
|
@@ -35,9 +35,7 @@ class TokenManager:
|
|
|
35
35
|
self._config = config
|
|
36
36
|
self._token_url = f"{config.fusionauth_url}/oauth2/token"
|
|
37
37
|
self._scope = (
|
|
38
|
-
f"target-entity:{config.entity_id}:internal:read"
|
|
39
|
-
if config.entity_id
|
|
40
|
-
else None
|
|
38
|
+
f"target-entity:{config.entity_id}:internal:read" if config.entity_id else None
|
|
41
39
|
)
|
|
42
40
|
self._cached: _CachedToken | None = None
|
|
43
41
|
self._lock = threading.Lock()
|
|
@@ -105,9 +103,7 @@ class AsyncTokenManager:
|
|
|
105
103
|
self._config = config
|
|
106
104
|
self._token_url = f"{config.fusionauth_url}/oauth2/token"
|
|
107
105
|
self._scope = (
|
|
108
|
-
f"target-entity:{config.entity_id}:internal:read"
|
|
109
|
-
if config.entity_id
|
|
110
|
-
else None
|
|
106
|
+
f"target-entity:{config.entity_id}:internal:read" if config.entity_id else None
|
|
111
107
|
)
|
|
112
108
|
self._cached: _CachedToken | None = None
|
|
113
109
|
self._lock = asyncio.Lock()
|
|
@@ -17,6 +17,7 @@ from phactor.cohorts.models import (
|
|
|
17
17
|
)
|
|
18
18
|
from phactor.config import PhactorConfig
|
|
19
19
|
from phactor.exceptions import PhactorError
|
|
20
|
+
from phactor.sites.client import AsyncSitesClient, SitesClient
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class PhactorClient:
|
|
@@ -25,6 +26,7 @@ class PhactorClient:
|
|
|
25
26
|
Usage:
|
|
26
27
|
client = PhactorClient(client_id="...", client_secret="...", ...)
|
|
27
28
|
result = client.cohorts.analyze(providers=[...], cohort_groups=[...])
|
|
29
|
+
sites = client.sites.search("mass general")
|
|
28
30
|
client.close()
|
|
29
31
|
|
|
30
32
|
Or as a context manager:
|
|
@@ -57,6 +59,7 @@ class PhactorClient:
|
|
|
57
59
|
token_manager, timeout=self._config.timeout, verify=self._config.verify
|
|
58
60
|
)
|
|
59
61
|
self.cohorts = CohortsClient(self._http, self._config.gateway_url)
|
|
62
|
+
self.sites = SitesClient(self._http, self._config.gateway_url)
|
|
60
63
|
|
|
61
64
|
def close(self) -> None:
|
|
62
65
|
"""Close the underlying HTTP client."""
|
|
@@ -109,6 +112,9 @@ class AsyncPhactorClient:
|
|
|
109
112
|
)
|
|
110
113
|
self._cohorts = AsyncCohortsClient(self._http, self._config.gateway_url)
|
|
111
114
|
self.cohorts = _AsyncCohortsProxy(self)
|
|
115
|
+
self.sites = AsyncSitesClient(
|
|
116
|
+
self._http, self._config.gateway_url, ensure_open=self._ensure_open
|
|
117
|
+
)
|
|
112
118
|
|
|
113
119
|
async def close(self) -> None:
|
|
114
120
|
"""Close the underlying HTTP client."""
|
|
@@ -8,6 +8,9 @@ import time
|
|
|
8
8
|
from enum import Enum
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from phactor._graphql import extract_field
|
|
11
14
|
from phactor._http import AsyncHTTPClient, SyncHTTPClient
|
|
12
15
|
from phactor.cohorts.models import (
|
|
13
16
|
AnalyzeCohortsPayload,
|
|
@@ -17,7 +20,6 @@ from phactor.cohorts.models import (
|
|
|
17
20
|
StudyContextInput,
|
|
18
21
|
)
|
|
19
22
|
from phactor.cohorts.query import ANALYZE_COHORTS_QUERY
|
|
20
|
-
from phactor.exceptions import GraphQLError
|
|
21
23
|
|
|
22
24
|
# Opt-in instrumentation: when PHACTOR_TIMING is set to a truthy value, every
|
|
23
25
|
# analyzeCohorts call emits one line to stderr summarizing the wire round-trip
|
|
@@ -63,6 +65,24 @@ def _emit_timing(
|
|
|
63
65
|
)
|
|
64
66
|
|
|
65
67
|
|
|
68
|
+
def _to_json(value: Any) -> Any:
|
|
69
|
+
"""Make a request value JSON-ready, whether it is a model or a plain dict.
|
|
70
|
+
|
|
71
|
+
SDK models may sit anywhere inside a plain dict or list, e.g. a
|
|
72
|
+
``site.as_location()`` value in a hand-written proposition, so they are
|
|
73
|
+
converted wherever they appear. Plain dict values pass through unchanged.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(value, BaseModel):
|
|
76
|
+
return value.model_dump(mode="json", by_alias=True, exclude_none=True)
|
|
77
|
+
if isinstance(value, Enum):
|
|
78
|
+
return value.value
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
return {key: _to_json(item) for key, item in value.items()}
|
|
81
|
+
if isinstance(value, (list, tuple)):
|
|
82
|
+
return [_to_json(item) for item in value]
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
66
86
|
def _build_variables(
|
|
67
87
|
providers: list[str],
|
|
68
88
|
cohort_groups: list[CohortGroupInput | dict[str, Any]],
|
|
@@ -76,92 +96,29 @@ def _build_variables(
|
|
|
76
96
|
global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
|
|
77
97
|
) -> dict[str, Any]:
|
|
78
98
|
"""Build the GraphQL variables dict, accepting both models and dicts."""
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
serialized_groups.append(group.model_dump(by_alias=True, exclude_none=True))
|
|
83
|
-
else:
|
|
84
|
-
serialized_groups.append(group)
|
|
85
|
-
|
|
86
|
-
variables: dict[str, Any] = {
|
|
87
|
-
"input": {
|
|
88
|
-
"providers": providers,
|
|
89
|
-
"cohortGroups": serialized_groups,
|
|
90
|
-
}
|
|
99
|
+
input_: dict[str, Any] = {
|
|
100
|
+
"providers": providers,
|
|
101
|
+
"cohortGroups": _to_json(cohort_groups),
|
|
91
102
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
variables["input"]["studyContext"] = study_context
|
|
107
|
-
|
|
108
|
-
if include_impact_analysis is not None:
|
|
109
|
-
variables["input"]["includeImpactAnalysis"] = include_impact_analysis
|
|
110
|
-
|
|
111
|
-
if include_demographic_breakdown is not None:
|
|
112
|
-
variables["input"]["includeDemographicBreakdown"] = include_demographic_breakdown
|
|
113
|
-
|
|
114
|
-
if age_buckets is not None:
|
|
115
|
-
variables["input"]["ageBuckets"] = age_buckets
|
|
116
|
-
|
|
117
|
-
if include_combined_demographic_breakdown is not None:
|
|
118
|
-
variables["input"]["includeCombinedDemographicBreakdown"] = (
|
|
119
|
-
include_combined_demographic_breakdown
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
if grouping is not None:
|
|
123
|
-
variables["input"]["grouping"] = grouping
|
|
124
|
-
|
|
125
|
-
if global_filters is not None:
|
|
126
|
-
if isinstance(global_filters, GlobalFiltersInput):
|
|
127
|
-
variables["input"]["globalFilters"] = global_filters.model_dump(
|
|
128
|
-
by_alias=True, exclude_none=True
|
|
129
|
-
)
|
|
130
|
-
else:
|
|
131
|
-
variables["input"]["globalFilters"] = global_filters
|
|
132
|
-
|
|
133
|
-
return variables
|
|
103
|
+
optional = {
|
|
104
|
+
"cohortGroupOperator": cohort_group_operator,
|
|
105
|
+
"studyContext": study_context,
|
|
106
|
+
"includeImpactAnalysis": include_impact_analysis,
|
|
107
|
+
"includeDemographicBreakdown": include_demographic_breakdown,
|
|
108
|
+
"ageBuckets": age_buckets,
|
|
109
|
+
"includeCombinedDemographicBreakdown": include_combined_demographic_breakdown,
|
|
110
|
+
"grouping": grouping,
|
|
111
|
+
"globalFilters": global_filters,
|
|
112
|
+
}
|
|
113
|
+
for key, value in optional.items():
|
|
114
|
+
if value is not None:
|
|
115
|
+
input_[key] = _to_json(value)
|
|
116
|
+
return {"input": input_}
|
|
134
117
|
|
|
135
118
|
|
|
136
119
|
def _parse_response(data: dict[str, Any]) -> AnalyzeCohortsPayload:
|
|
137
120
|
"""Parse GraphQL response, raising on errors."""
|
|
138
|
-
|
|
139
|
-
errors = data["errors"]
|
|
140
|
-
messages = [e.get("message", str(e)) for e in errors]
|
|
141
|
-
raise GraphQLError(
|
|
142
|
-
f"GraphQL errors: {'; '.join(messages)}",
|
|
143
|
-
errors=errors,
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
# A non-nullable field violation nullifies the whole `data` tree and reports the
|
|
147
|
-
# cause under `extensions.valueCompletion` (Apollo Router / graphql-js value
|
|
148
|
-
# completion), NOT under top-level `errors`. Surface those messages instead of
|
|
149
|
-
# blindly dereferencing a null `data` (which raised an opaque AttributeError).
|
|
150
|
-
value_completion = (data.get("extensions") or {}).get("valueCompletion")
|
|
151
|
-
if value_completion:
|
|
152
|
-
messages = [e.get("message", str(e)) for e in value_completion]
|
|
153
|
-
raise GraphQLError(
|
|
154
|
-
f"GraphQL value-completion errors: {'; '.join(messages)}",
|
|
155
|
-
errors=value_completion,
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
# `data` may be present-but-null (see above) — `or {}` handles that, unlike a
|
|
159
|
-
# default that only applies when the key is absent.
|
|
160
|
-
payload = (data.get("data") or {}).get("analyzeCohorts")
|
|
161
|
-
if payload is None:
|
|
162
|
-
raise GraphQLError("Unexpected response: missing analyzeCohorts data")
|
|
163
|
-
|
|
164
|
-
return AnalyzeCohortsPayload.model_validate(payload)
|
|
121
|
+
return AnalyzeCohortsPayload.model_validate(extract_field(data, "analyzeCohorts"))
|
|
165
122
|
|
|
166
123
|
|
|
167
124
|
class CohortsClient:
|
|
@@ -10,6 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
|
10
10
|
# Helper config for all models: camelCase aliasing
|
|
11
11
|
# ---------------------------------------------------------------------------
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
def _alias_generator(field_name: str) -> str:
|
|
14
15
|
"""Convert snake_case to camelCase."""
|
|
15
16
|
parts = field_name.split("_")
|
|
@@ -82,6 +83,9 @@ class LocationTypeEnum(str, Enum):
|
|
|
82
83
|
CITY = "CITY"
|
|
83
84
|
STATE = "STATE"
|
|
84
85
|
COORDS = "COORDS"
|
|
86
|
+
# A registry site ID (see ``client.sites``). The server resolves it to the
|
|
87
|
+
# site's coordinates, so a SITE value never carries ``coords`` itself.
|
|
88
|
+
SITE = "SITE"
|
|
85
89
|
|
|
86
90
|
|
|
87
91
|
# Code systems mirror the Query API GraphQL schema (ConditionTypeEnum) one-to-one.
|
|
@@ -177,14 +181,14 @@ class ImmunizationTypeEnum(str, Enum):
|
|
|
177
181
|
# Observation.interpretation binds to) — mirrors the Query API GraphQL schema
|
|
178
182
|
# (InterpretationCode) one-to-one.
|
|
179
183
|
class InterpretationCode(str, Enum):
|
|
180
|
-
N = "N"
|
|
181
|
-
H = "H"
|
|
182
|
-
L = "L"
|
|
184
|
+
N = "N" # Normal
|
|
185
|
+
H = "H" # High
|
|
186
|
+
L = "L" # Low
|
|
183
187
|
HH = "HH" # Critically high (panic high)
|
|
184
188
|
LL = "LL" # Critically low (panic low)
|
|
185
189
|
HU = "HU" # Significantly above upper limit
|
|
186
190
|
LU = "LU" # Significantly below lower limit
|
|
187
|
-
A = "A"
|
|
191
|
+
A = "A" # Abnormal (use when no high/low direction applies)
|
|
188
192
|
AA = "AA" # Critically abnormal
|
|
189
193
|
|
|
190
194
|
|
|
@@ -229,11 +233,23 @@ class CohortGroupOperator(str, Enum):
|
|
|
229
233
|
|
|
230
234
|
|
|
231
235
|
class CriterionErrorReason(str, Enum):
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
"""Why a provider could not evaluate a criterion. Values the server adds
|
|
237
|
+
later reach ``CriterionError.reason`` as plain strings instead of failing."""
|
|
238
|
+
|
|
239
|
+
UNSUPPORTED_SEMANTICS = "UNSUPPORTED_SEMANTICS"
|
|
240
|
+
INSUFFICIENT_DATA = "INSUFFICIENT_DATA"
|
|
241
|
+
MATERIALIZATION_NOT_READY = "MATERIALIZATION_NOT_READY"
|
|
242
|
+
NO_TRANSLATION = "NO_TRANSLATION" # a crosswalk route exists but this code has no entry
|
|
243
|
+
NO_PATH = "NO_PATH" # no translation route from the source system to the provider's
|
|
234
244
|
|
|
235
245
|
|
|
236
246
|
class CriterionErrorDomain(str, Enum):
|
|
247
|
+
"""The criterion type a ``CriterionError`` is about. Values the server adds
|
|
248
|
+
later reach ``CriterionError.domain`` as plain strings instead of failing."""
|
|
249
|
+
|
|
250
|
+
AGE = "AGE"
|
|
251
|
+
MEASUREMENT = "MEASUREMENT"
|
|
252
|
+
ENCOUNTER = "ENCOUNTER"
|
|
237
253
|
CONDITION = "CONDITION"
|
|
238
254
|
PROCEDURE = "PROCEDURE"
|
|
239
255
|
ALLERGY = "ALLERGY"
|
|
@@ -284,20 +300,48 @@ class CoordsInput(BaseModel):
|
|
|
284
300
|
lng: float
|
|
285
301
|
|
|
286
302
|
|
|
303
|
+
# Mirrors the Query API guardrail on SITE values per LocationInput.
|
|
304
|
+
_MAX_SITE_VALUES = 25
|
|
305
|
+
|
|
306
|
+
|
|
287
307
|
class LocationValueInput(BaseModel):
|
|
288
308
|
model_config = _MODEL_CONFIG
|
|
289
309
|
|
|
290
310
|
type: LocationTypeEnum
|
|
291
311
|
value: str
|
|
312
|
+
# Miles around the location. For SITE, 0 falls back to the connector's
|
|
313
|
+
# 10-mile default.
|
|
292
314
|
radius: int
|
|
293
315
|
coords: CoordsInput | None = None
|
|
294
316
|
|
|
317
|
+
@model_validator(mode="after")
|
|
318
|
+
def _validate_site_value(self) -> LocationValueInput:
|
|
319
|
+
if self.type != LocationTypeEnum.SITE:
|
|
320
|
+
return self
|
|
321
|
+
if self.coords is not None:
|
|
322
|
+
raise ValueError(
|
|
323
|
+
"A SITE location value must not carry coords: the server resolves "
|
|
324
|
+
"the site ID to its registry coordinates."
|
|
325
|
+
)
|
|
326
|
+
if self.radius < 0:
|
|
327
|
+
raise ValueError(f"A SITE location radius must be >= 0, got {self.radius}.")
|
|
328
|
+
return self
|
|
329
|
+
|
|
295
330
|
|
|
296
331
|
class LocationInput(BaseModel):
|
|
297
332
|
model_config = _MODEL_CONFIG
|
|
298
333
|
|
|
299
334
|
values: list[LocationValueInput] | None = None
|
|
300
335
|
|
|
336
|
+
@model_validator(mode="after")
|
|
337
|
+
def _limit_site_values(self) -> LocationInput:
|
|
338
|
+
site_count = sum(1 for v in self.values or [] if v.type == LocationTypeEnum.SITE)
|
|
339
|
+
if site_count > _MAX_SITE_VALUES:
|
|
340
|
+
raise ValueError(
|
|
341
|
+
f"A location accepts at most {_MAX_SITE_VALUES} SITE values, got {site_count}."
|
|
342
|
+
)
|
|
343
|
+
return self
|
|
344
|
+
|
|
301
345
|
|
|
302
346
|
class ConditionStageInput(BaseModel):
|
|
303
347
|
# FHIR Condition.stage.type — staging vocabulary (e.g. TNM, FIGO,
|
|
@@ -459,18 +503,14 @@ class LabValueInput(BaseModel):
|
|
|
459
503
|
# complete numeric comparison nor an interpretation, inflating counts;
|
|
460
504
|
# fail fast here instead. A lab value's numeric triple is operator +
|
|
461
505
|
# value + unit.
|
|
462
|
-
has_numeric =
|
|
463
|
-
self.operator is not None and self.value is not None and self.unit is not None
|
|
464
|
-
)
|
|
506
|
+
has_numeric = self.operator is not None and self.value is not None and self.unit is not None
|
|
465
507
|
if not has_numeric and not self.interpretation:
|
|
466
508
|
raise ValueError(
|
|
467
509
|
"LabValueInput requires either a complete numeric comparison "
|
|
468
510
|
"(operator AND value AND unit) or a non-empty interpretation."
|
|
469
511
|
)
|
|
470
512
|
if self.test_code is None and self.test_name is None:
|
|
471
|
-
raise ValueError(
|
|
472
|
-
"LabValueInput requires at least one of test_code or test_name."
|
|
473
|
-
)
|
|
513
|
+
raise ValueError("LabValueInput requires at least one of test_code or test_name.")
|
|
474
514
|
return self
|
|
475
515
|
|
|
476
516
|
|
|
@@ -649,10 +689,12 @@ class DemographicBreakdown(BaseModel):
|
|
|
649
689
|
class CriterionError(BaseModel):
|
|
650
690
|
model_config = _MODEL_CONFIG
|
|
651
691
|
|
|
652
|
-
|
|
692
|
+
# Response enums fall back to the raw string for values this SDK version
|
|
693
|
+
# does not know yet, so a new server value never breaks parsing a result.
|
|
694
|
+
domain: CriterionErrorDomain | str = Field(union_mode="left_to_right")
|
|
653
695
|
source_type: str
|
|
654
696
|
source_value: str
|
|
655
|
-
reason: CriterionErrorReason
|
|
697
|
+
reason: CriterionErrorReason | str = Field(union_mode="left_to_right")
|
|
656
698
|
message: str | None = None
|
|
657
699
|
|
|
658
700
|
|
|
@@ -22,6 +22,25 @@ class GraphQLError(PhactorError):
|
|
|
22
22
|
super().__init__(message)
|
|
23
23
|
self.errors = errors or []
|
|
24
24
|
|
|
25
|
+
@property
|
|
26
|
+
def missing_site_ids(self) -> list[str]:
|
|
27
|
+
"""Registry site IDs the server could not resolve for the caller.
|
|
28
|
+
|
|
29
|
+
``analyzeCohorts`` rejects the whole request when a ``SITE`` location value
|
|
30
|
+
names a site that does not exist, is not visible to the caller's tenant,
|
|
31
|
+
is inactive, or has no coordinates; those IDs arrive under
|
|
32
|
+
``extensions.missingSiteIds``. Empty for every other error.
|
|
33
|
+
"""
|
|
34
|
+
missing: list[str] = []
|
|
35
|
+
for error in self.errors:
|
|
36
|
+
extensions = error.get("extensions")
|
|
37
|
+
if not isinstance(extensions, dict):
|
|
38
|
+
continue
|
|
39
|
+
ids = extensions.get("missingSiteIds")
|
|
40
|
+
if isinstance(ids, list):
|
|
41
|
+
missing.extend(str(site_id) for site_id in ids)
|
|
42
|
+
return missing
|
|
43
|
+
|
|
25
44
|
|
|
26
45
|
class ValidationError(PhactorError):
|
|
27
46
|
"""Raised when input validation fails (e.g., builder misuse)."""
|
|
@@ -4,6 +4,7 @@ Install with::
|
|
|
4
4
|
|
|
5
5
|
pip install 'phactor[pandas]'
|
|
6
6
|
"""
|
|
7
|
+
|
|
7
8
|
from __future__ import annotations
|
|
8
9
|
|
|
9
10
|
from typing import TYPE_CHECKING
|
|
@@ -15,8 +16,7 @@ try:
|
|
|
15
16
|
import pandas as pd
|
|
16
17
|
except ImportError:
|
|
17
18
|
raise ImportError(
|
|
18
|
-
"Install phactor[pandas] for DataFrame support: "
|
|
19
|
-
"pip install 'phactor[pandas]'"
|
|
19
|
+
"Install phactor[pandas] for DataFrame support: pip install 'phactor[pandas]'"
|
|
20
20
|
) from None
|
|
21
21
|
|
|
22
22
|
from phactor.cohorts.models import (
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Registry site search sub-package."""
|
|
2
|
+
|
|
3
|
+
from phactor.sites.client import AsyncSitesClient, SitesClient
|
|
4
|
+
from phactor.sites.models import Site, SiteAccess, SitePage, SitesByIds
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"SitesClient",
|
|
8
|
+
"AsyncSitesClient",
|
|
9
|
+
"Site",
|
|
10
|
+
"SiteAccess",
|
|
11
|
+
"SitePage",
|
|
12
|
+
"SitesByIds",
|
|
13
|
+
]
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Registry site search client: sends GraphQL queries to the Phactor gateway.
|
|
2
|
+
|
|
3
|
+
The registry holds hundreds of thousands of sites, so this client is built to
|
|
4
|
+
keep load off the API: every guardrail the server enforces is checked here
|
|
5
|
+
first (a bad call never leaves the process), ``iter`` fetches a page only when
|
|
6
|
+
the consumer asks for more and never past ``max_results``, and search pages and
|
|
7
|
+
by-ID lookups are held in a small per-client TTL cache.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from collections import OrderedDict
|
|
15
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from typing import Any, Generic, TypeVar
|
|
18
|
+
|
|
19
|
+
from phactor._graphql import extract_field
|
|
20
|
+
from phactor._http import AsyncHTTPClient, SyncHTTPClient
|
|
21
|
+
from phactor.sites.models import Site, SitePage, SitesByIds
|
|
22
|
+
from phactor.sites.query import SEARCH_SITES_QUERY, SITES_BY_IDS_QUERY
|
|
23
|
+
|
|
24
|
+
# Guardrails mirrored from the Query API; the server rejects anything outside them.
|
|
25
|
+
_MIN_QUERY_LENGTH = 2
|
|
26
|
+
_MAX_QUERY_LENGTH = 100
|
|
27
|
+
_MAX_PAGE_SIZE = 50
|
|
28
|
+
_MAX_IDS = 100
|
|
29
|
+
# Deepest result the server lets a cursor reach.
|
|
30
|
+
_MAX_RESULTS = 500
|
|
31
|
+
|
|
32
|
+
_CACHE_TTL_SECONDS = 60.0
|
|
33
|
+
_PAGE_CACHE_SIZE = 256
|
|
34
|
+
_SITE_CACHE_SIZE = 4096
|
|
35
|
+
|
|
36
|
+
_K = TypeVar("_K")
|
|
37
|
+
_V = TypeVar("_V")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Miss(Enum):
|
|
41
|
+
MISS = "MISS"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _TTLCache(Generic[_K, _V]):
|
|
45
|
+
"""Bounded LRU cache whose entries expire ``ttl`` seconds after they are stored.
|
|
46
|
+
|
|
47
|
+
Every operation holds a ``threading.Lock`` and never awaits, so one instance
|
|
48
|
+
is safe to share across threads and across tasks on an event loop.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, *, ttl: float, maxsize: int, clock: Callable[[], float]) -> None:
|
|
52
|
+
self._ttl = ttl
|
|
53
|
+
self._maxsize = maxsize
|
|
54
|
+
self._clock = clock
|
|
55
|
+
self._entries: OrderedDict[_K, tuple[float, _V]] = OrderedDict()
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def get(self, key: _K) -> _V | _Miss:
|
|
59
|
+
with self._lock:
|
|
60
|
+
entry = self._entries.get(key)
|
|
61
|
+
if entry is None:
|
|
62
|
+
return _Miss.MISS
|
|
63
|
+
expires_at, value = entry
|
|
64
|
+
if self._clock() >= expires_at:
|
|
65
|
+
del self._entries[key]
|
|
66
|
+
return _Miss.MISS
|
|
67
|
+
self._entries.move_to_end(key)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
def set(self, key: _K, value: _V) -> None:
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._entries[key] = (self._clock() + self._ttl, value)
|
|
73
|
+
self._entries.move_to_end(key)
|
|
74
|
+
while len(self._entries) > self._maxsize:
|
|
75
|
+
self._entries.popitem(last=False)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize_query(query: str) -> str:
|
|
79
|
+
text = query.strip()
|
|
80
|
+
if not _MIN_QUERY_LENGTH <= len(text) <= _MAX_QUERY_LENGTH:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"query must be {_MIN_QUERY_LENGTH}..{_MAX_QUERY_LENGTH} characters after "
|
|
83
|
+
f"trimming, got {len(text)}"
|
|
84
|
+
)
|
|
85
|
+
return text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_range(name: str, value: int, maximum: int) -> None:
|
|
89
|
+
if not 1 <= value <= maximum:
|
|
90
|
+
raise ValueError(f"{name} must be between 1 and {maximum}, got {value}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _site_key(site_id: str) -> str:
|
|
94
|
+
# The Query API matches IDs case-insensitively after trimming and returns the
|
|
95
|
+
# stored (lowercase) ID, so requests, cache keys, and results all use this form.
|
|
96
|
+
return site_id.strip().lower()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _normalize_ids(ids: Sequence[str]) -> list[str]:
|
|
100
|
+
if isinstance(ids, str):
|
|
101
|
+
raise ValueError("ids must be a list of site IDs, not a single string")
|
|
102
|
+
unique = list(dict.fromkeys(_site_key(site_id) for site_id in ids))
|
|
103
|
+
if len(unique) > _MAX_IDS:
|
|
104
|
+
raise ValueError(f"ids accepts at most {_MAX_IDS} distinct IDs, got {len(unique)}")
|
|
105
|
+
return unique
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_PageKey = tuple[str, int, str | None]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _PendingGet:
|
|
112
|
+
"""A by-ID lookup split into cache hits and the IDs still to fetch."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, ids: list[str], known: dict[str, Site | None]) -> None:
|
|
115
|
+
self.ids = ids
|
|
116
|
+
self.known = known
|
|
117
|
+
self.to_fetch = [site_id for site_id in ids if site_id not in known]
|
|
118
|
+
|
|
119
|
+
def body(self) -> dict[str, Any]:
|
|
120
|
+
return {"query": SITES_BY_IDS_QUERY, "variables": {"ids": self.to_fetch}}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _SiteLookups:
|
|
124
|
+
"""Validation, caching, and response parsing shared by the sync and async clients."""
|
|
125
|
+
|
|
126
|
+
def __init__(self, *, cache_ttl: float, clock: Callable[[], float]) -> None:
|
|
127
|
+
self._pages: _TTLCache[_PageKey, SitePage] = _TTLCache(
|
|
128
|
+
ttl=cache_ttl, maxsize=_PAGE_CACHE_SIZE, clock=clock
|
|
129
|
+
)
|
|
130
|
+
# ``None`` records an ID the server reported missing.
|
|
131
|
+
self._sites: _TTLCache[str, Site | None] = _TTLCache(
|
|
132
|
+
ttl=cache_ttl, maxsize=_SITE_CACHE_SIZE, clock=clock
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def page_key(self, query: str, limit: int, cursor: str | None) -> _PageKey:
|
|
136
|
+
_check_range("limit", limit, _MAX_PAGE_SIZE)
|
|
137
|
+
return (_normalize_query(query), limit, cursor)
|
|
138
|
+
|
|
139
|
+
def cached_page(self, key: _PageKey) -> SitePage | None:
|
|
140
|
+
page = self._pages.get(key)
|
|
141
|
+
return None if page is _Miss.MISS else _copy_page(page)
|
|
142
|
+
|
|
143
|
+
def search_body(self, key: _PageKey) -> dict[str, Any]:
|
|
144
|
+
query, limit, cursor = key
|
|
145
|
+
return {
|
|
146
|
+
"query": SEARCH_SITES_QUERY,
|
|
147
|
+
"variables": {"query": query, "first": limit, "after": cursor},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def store_page(self, key: _PageKey, response: dict[str, Any]) -> SitePage:
|
|
151
|
+
payload = extract_field(response, "searchSites")
|
|
152
|
+
page = SitePage(
|
|
153
|
+
items=[Site.model_validate(node) for node in payload["nodes"]],
|
|
154
|
+
next_cursor=payload.get("nextCursor"),
|
|
155
|
+
)
|
|
156
|
+
self._pages.set(key, page)
|
|
157
|
+
# Search and by-ID lookups apply the same visibility rules, so every
|
|
158
|
+
# site seen in a search also answers a later ``get`` without a request.
|
|
159
|
+
for site in page.items:
|
|
160
|
+
self._sites.set(_site_key(site.id), site)
|
|
161
|
+
return _copy_page(page)
|
|
162
|
+
|
|
163
|
+
def plan_get(self, ids: Sequence[str]) -> _PendingGet:
|
|
164
|
+
unique = _normalize_ids(ids)
|
|
165
|
+
known: dict[str, Site | None] = {}
|
|
166
|
+
for site_id in unique:
|
|
167
|
+
cached = self._sites.get(site_id)
|
|
168
|
+
if cached is not _Miss.MISS:
|
|
169
|
+
known[site_id] = cached
|
|
170
|
+
return _PendingGet(unique, known)
|
|
171
|
+
|
|
172
|
+
def finish_get(self, pending: _PendingGet, response: dict[str, Any] | None) -> SitesByIds:
|
|
173
|
+
resolved = dict(pending.known)
|
|
174
|
+
if response is not None:
|
|
175
|
+
payload = extract_field(response, "sitesByIds")
|
|
176
|
+
for node in payload["sites"]:
|
|
177
|
+
site = Site.model_validate(node)
|
|
178
|
+
key = _site_key(site.id)
|
|
179
|
+
resolved[key] = site
|
|
180
|
+
self._sites.set(key, site)
|
|
181
|
+
# Anything the server neither returned nor listed is treated as missing too.
|
|
182
|
+
for site_id in pending.to_fetch:
|
|
183
|
+
if site_id not in resolved:
|
|
184
|
+
resolved[site_id] = None
|
|
185
|
+
self._sites.set(site_id, None)
|
|
186
|
+
sites = [s for s in (resolved[site_id] for site_id in pending.ids) if s is not None]
|
|
187
|
+
missing = [site_id for site_id in pending.ids if resolved[site_id] is None]
|
|
188
|
+
return SitesByIds(sites=sites, missing_ids=missing)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _copy_page(page: SitePage) -> SitePage:
|
|
192
|
+
# Sites are frozen, but the list is not: hand every caller its own.
|
|
193
|
+
return SitePage(items=list(page.items), next_cursor=page.next_cursor)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _validate_iter(query: str, max_results: int, page_size: int) -> str:
|
|
197
|
+
_check_range("max_results", max_results, _MAX_RESULTS)
|
|
198
|
+
_check_range("page_size", page_size, _MAX_PAGE_SIZE)
|
|
199
|
+
return _normalize_query(query)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class SitesClient:
|
|
203
|
+
"""Synchronous client for the registry site search API."""
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
http_client: SyncHTTPClient,
|
|
208
|
+
gateway_url: str,
|
|
209
|
+
*,
|
|
210
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
211
|
+
clock: Callable[[], float] = time.monotonic,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._http = http_client
|
|
214
|
+
self._gateway_url = gateway_url
|
|
215
|
+
self._lookups = _SiteLookups(cache_ttl=cache_ttl, clock=clock)
|
|
216
|
+
|
|
217
|
+
def search(self, query: str, *, limit: int = 20, cursor: str | None = None) -> SitePage:
|
|
218
|
+
"""Search the sites visible to the caller by name, city, or ZIP prefix.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
query: Search text, 2..100 characters after trimming.
|
|
222
|
+
limit: Page size, 1..50.
|
|
223
|
+
cursor: ``next_cursor`` from the previous page.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: An argument is outside the guardrails (no request is sent).
|
|
227
|
+
"""
|
|
228
|
+
key = self._lookups.page_key(query, limit, cursor)
|
|
229
|
+
cached = self._lookups.cached_page(key)
|
|
230
|
+
if cached is not None:
|
|
231
|
+
return cached
|
|
232
|
+
response = self._http.post(self._gateway_url, json=self._lookups.search_body(key))
|
|
233
|
+
return self._lookups.store_page(key, response)
|
|
234
|
+
|
|
235
|
+
def iter(self, query: str, *, max_results: int = 100, page_size: int = 50) -> Iterator[Site]:
|
|
236
|
+
"""Lazily yield matching sites, fetching the next page only when it is needed.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
query: Search text, 2..100 characters after trimming.
|
|
240
|
+
max_results: Stop after this many sites, 1..500. The last request asks
|
|
241
|
+
for no more than the remaining count.
|
|
242
|
+
page_size: Sites per request, 1..50.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
ValueError: An argument is outside the guardrails (raised on the call,
|
|
246
|
+
before any request).
|
|
247
|
+
"""
|
|
248
|
+
text = _validate_iter(query, max_results, page_size)
|
|
249
|
+
return self._iter(text, max_results, page_size)
|
|
250
|
+
|
|
251
|
+
def _iter(self, query: str, max_results: int, page_size: int) -> Iterator[Site]:
|
|
252
|
+
remaining = max_results
|
|
253
|
+
cursor: str | None = None
|
|
254
|
+
while True:
|
|
255
|
+
page = self.search(query, limit=min(page_size, remaining), cursor=cursor)
|
|
256
|
+
for site in page.items:
|
|
257
|
+
yield site
|
|
258
|
+
remaining -= 1
|
|
259
|
+
if remaining == 0:
|
|
260
|
+
return
|
|
261
|
+
if page.next_cursor is None or not page.items:
|
|
262
|
+
return
|
|
263
|
+
cursor = page.next_cursor
|
|
264
|
+
|
|
265
|
+
def get(self, ids: Sequence[str]) -> SitesByIds:
|
|
266
|
+
"""Look up sites by registry ID.
|
|
267
|
+
|
|
268
|
+
IDs are matched the way the Query API matches them: surrounding whitespace
|
|
269
|
+
is stripped and case is ignored, so ``" ABC-1 "`` and ``"abc-1"`` name the
|
|
270
|
+
same site.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
ids: Up to 100 distinct site IDs (distinct after normalization).
|
|
274
|
+
Duplicates are collapsed.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
The visible sites in request order, plus the IDs that could not be
|
|
278
|
+
resolved (unknown, not visible to the caller, inactive, or without
|
|
279
|
+
coordinates). ``missing_ids`` holds the normalized form of each ID
|
|
280
|
+
(stripped and lowercased), not the caller's spelling.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
ValueError: More than 100 distinct IDs (no request is sent).
|
|
284
|
+
"""
|
|
285
|
+
pending = self._lookups.plan_get(ids)
|
|
286
|
+
response = (
|
|
287
|
+
self._http.post(self._gateway_url, json=pending.body()) if pending.to_fetch else None
|
|
288
|
+
)
|
|
289
|
+
return self._lookups.finish_get(pending, response)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class AsyncSitesClient:
|
|
293
|
+
"""Async client for the registry site search API."""
|
|
294
|
+
|
|
295
|
+
def __init__(
|
|
296
|
+
self,
|
|
297
|
+
http_client: AsyncHTTPClient,
|
|
298
|
+
gateway_url: str,
|
|
299
|
+
*,
|
|
300
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
301
|
+
clock: Callable[[], float] = time.monotonic,
|
|
302
|
+
ensure_open: Callable[[], None] | None = None,
|
|
303
|
+
) -> None:
|
|
304
|
+
self._http = http_client
|
|
305
|
+
self._gateway_url = gateway_url
|
|
306
|
+
self._lookups = _SiteLookups(cache_ttl=cache_ttl, clock=clock)
|
|
307
|
+
self._ensure_open = ensure_open or (lambda: None)
|
|
308
|
+
|
|
309
|
+
async def search(self, query: str, *, limit: int = 20, cursor: str | None = None) -> SitePage:
|
|
310
|
+
"""Search the sites visible to the caller (async). See ``SitesClient.search``."""
|
|
311
|
+
self._ensure_open()
|
|
312
|
+
key = self._lookups.page_key(query, limit, cursor)
|
|
313
|
+
cached = self._lookups.cached_page(key)
|
|
314
|
+
if cached is not None:
|
|
315
|
+
return cached
|
|
316
|
+
response = await self._http.post(self._gateway_url, json=self._lookups.search_body(key))
|
|
317
|
+
return self._lookups.store_page(key, response)
|
|
318
|
+
|
|
319
|
+
def iter(
|
|
320
|
+
self, query: str, *, max_results: int = 100, page_size: int = 50
|
|
321
|
+
) -> AsyncIterator[Site]:
|
|
322
|
+
"""Lazily yield matching sites (async). See ``SitesClient.iter``.
|
|
323
|
+
|
|
324
|
+
Use with ``async for``; arguments are validated on the call itself.
|
|
325
|
+
"""
|
|
326
|
+
self._ensure_open()
|
|
327
|
+
text = _validate_iter(query, max_results, page_size)
|
|
328
|
+
return self._iter(text, max_results, page_size)
|
|
329
|
+
|
|
330
|
+
async def _iter(self, query: str, max_results: int, page_size: int) -> AsyncIterator[Site]:
|
|
331
|
+
remaining = max_results
|
|
332
|
+
cursor: str | None = None
|
|
333
|
+
while True:
|
|
334
|
+
page = await self.search(query, limit=min(page_size, remaining), cursor=cursor)
|
|
335
|
+
for site in page.items:
|
|
336
|
+
yield site
|
|
337
|
+
remaining -= 1
|
|
338
|
+
if remaining == 0:
|
|
339
|
+
return
|
|
340
|
+
if page.next_cursor is None or not page.items:
|
|
341
|
+
return
|
|
342
|
+
cursor = page.next_cursor
|
|
343
|
+
|
|
344
|
+
async def get(self, ids: Sequence[str]) -> SitesByIds:
|
|
345
|
+
"""Look up sites by registry ID (async). See ``SitesClient.get``."""
|
|
346
|
+
self._ensure_open()
|
|
347
|
+
pending = self._lookups.plan_get(ids)
|
|
348
|
+
response = (
|
|
349
|
+
await self._http.post(self._gateway_url, json=pending.body())
|
|
350
|
+
if pending.to_fetch
|
|
351
|
+
else None
|
|
352
|
+
)
|
|
353
|
+
return self._lookups.finish_get(pending, response)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Pydantic models for the registry site search API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
from pydantic.alias_generators import to_camel
|
|
9
|
+
|
|
10
|
+
from phactor.cohorts.models import LocationTypeEnum, LocationValueInput
|
|
11
|
+
|
|
12
|
+
_MODEL_CONFIG = ConfigDict(populate_by_name=True, alias_generator=to_camel, frozen=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SiteAccess(str, Enum):
|
|
16
|
+
"""Why the caller can see a site."""
|
|
17
|
+
|
|
18
|
+
OWNED = "OWNED" # a RESTRICTED site the caller's tenant owns
|
|
19
|
+
SHARED = "SHARED" # a RESTRICTED site another tenant granted to the caller
|
|
20
|
+
GLOBAL = "GLOBAL" # a PUBLIC site
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Site(BaseModel):
|
|
24
|
+
"""A registry site the caller can see. Only active sites with coordinates are returned."""
|
|
25
|
+
|
|
26
|
+
model_config = _MODEL_CONFIG
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
address: str | None = None
|
|
31
|
+
city: str | None = None
|
|
32
|
+
state: str | None = None
|
|
33
|
+
postal_code: str | None = None
|
|
34
|
+
country: str | None = None
|
|
35
|
+
latitude: float
|
|
36
|
+
longitude: float
|
|
37
|
+
# A value this SDK version does not know yet stays a plain string.
|
|
38
|
+
access: SiteAccess | str = Field(union_mode="left_to_right")
|
|
39
|
+
|
|
40
|
+
def as_location(self, *, radius: int = 10) -> LocationValueInput:
|
|
41
|
+
"""A SITE location value matching patients within ``radius`` miles of this site.
|
|
42
|
+
|
|
43
|
+
``radius=0`` falls back to the connector's 10-mile default.
|
|
44
|
+
"""
|
|
45
|
+
return LocationValueInput(type=LocationTypeEnum.SITE, value=self.id, radius=radius)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SitePage(BaseModel):
|
|
49
|
+
"""One page of ``client.sites.search`` results."""
|
|
50
|
+
|
|
51
|
+
model_config = _MODEL_CONFIG
|
|
52
|
+
|
|
53
|
+
items: list[Site]
|
|
54
|
+
# Pass back as ``cursor`` to fetch the next page; ``None`` on the last page.
|
|
55
|
+
next_cursor: str | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SitesByIds(BaseModel):
|
|
59
|
+
"""Result of ``client.sites.get``, in the order the IDs were requested."""
|
|
60
|
+
|
|
61
|
+
model_config = _MODEL_CONFIG
|
|
62
|
+
|
|
63
|
+
sites: list[Site]
|
|
64
|
+
# Requested IDs that do not exist, are not visible to the caller, are
|
|
65
|
+
# inactive, or have no coordinates, in normalized form (stripped, lowercased).
|
|
66
|
+
missing_ids: list[str]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""GraphQL documents for registry site search."""
|
|
2
|
+
|
|
3
|
+
_SITE_FIELDS = """
|
|
4
|
+
id
|
|
5
|
+
name
|
|
6
|
+
address
|
|
7
|
+
city
|
|
8
|
+
state
|
|
9
|
+
postalCode
|
|
10
|
+
country
|
|
11
|
+
latitude
|
|
12
|
+
longitude
|
|
13
|
+
access
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
SEARCH_SITES_QUERY = f"""
|
|
17
|
+
query SearchSites($query: String!, $first: Int, $after: String) {{
|
|
18
|
+
searchSites(query: $query, first: $first, after: $after) {{
|
|
19
|
+
nodes {{{_SITE_FIELDS} }}
|
|
20
|
+
nextCursor
|
|
21
|
+
}}
|
|
22
|
+
}}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
SITES_BY_IDS_QUERY = f"""
|
|
26
|
+
query SitesByIds($ids: [ID!]!) {{
|
|
27
|
+
sitesByIds(ids: $ids) {{
|
|
28
|
+
sites {{{_SITE_FIELDS} }}
|
|
29
|
+
missingIds
|
|
30
|
+
}}
|
|
31
|
+
}}
|
|
32
|
+
"""
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|