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.
Files changed (26) hide show
  1. {phactor-0.2.0 → phactor-0.3.2}/CHANGELOG.md +61 -0
  2. {phactor-0.2.0 → phactor-0.3.2}/PKG-INFO +48 -1
  3. {phactor-0.2.0 → phactor-0.3.2}/README.md +47 -0
  4. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/__init__.py +6 -0
  5. phactor-0.3.2/src/phactor/_graphql.py +37 -0
  6. phactor-0.3.2/src/phactor/_version.py +1 -0
  7. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/auth.py +2 -6
  8. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/client.py +6 -0
  9. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/client.py +39 -82
  10. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/models.py +56 -14
  11. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/exceptions.py +19 -0
  12. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/pandas.py +2 -2
  13. phactor-0.3.2/src/phactor/sites/__init__.py +13 -0
  14. phactor-0.3.2/src/phactor/sites/client.py +353 -0
  15. phactor-0.3.2/src/phactor/sites/models.py +66 -0
  16. phactor-0.3.2/src/phactor/sites/query.py +32 -0
  17. phactor-0.2.0/src/phactor/_version.py +0 -1
  18. {phactor-0.2.0 → phactor-0.3.2}/.gitignore +0 -0
  19. {phactor-0.2.0 → phactor-0.3.2}/LICENSE +0 -0
  20. {phactor-0.2.0 → phactor-0.3.2}/pyproject.toml +0 -0
  21. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/_http.py +0 -0
  22. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/__init__.py +0 -0
  23. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/builder.py +0 -0
  24. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/cohorts/query.py +0 -0
  25. {phactor-0.2.0 → phactor-0.3.2}/src/phactor/config.py +0 -0
  26. {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.0
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
- serialized_groups = []
80
- for group in cohort_groups:
81
- if isinstance(group, CohortGroupInput):
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
- if cohort_group_operator is not None:
94
- variables["input"]["cohortGroupOperator"] = (
95
- cohort_group_operator.value
96
- if isinstance(cohort_group_operator, Enum)
97
- else cohort_group_operator
98
- )
99
-
100
- if study_context is not None:
101
- if isinstance(study_context, StudyContextInput):
102
- variables["input"]["studyContext"] = study_context.model_dump(
103
- by_alias=True, exclude_none=True
104
- )
105
- else:
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
- if data.get("errors"):
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" # Normal
181
- H = "H" # High
182
- L = "L" # Low
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" # Abnormal (use when no high/low direction applies)
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
- NO_TRANSLATION = "NO_TRANSLATION"
233
- NO_PATH = "NO_PATH"
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
- domain: CriterionErrorDomain
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