phactor 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phactor/__init__.py +159 -0
- phactor/_http.py +183 -0
- phactor/_version.py +1 -0
- phactor/auth.py +167 -0
- phactor/client.py +172 -0
- phactor/cohorts/__init__.py +143 -0
- phactor/cohorts/builder.py +156 -0
- phactor/cohorts/client.py +283 -0
- phactor/cohorts/models.py +739 -0
- phactor/cohorts/query.py +98 -0
- phactor/config.py +95 -0
- phactor/exceptions.py +47 -0
- phactor/pandas.py +589 -0
- phactor/py.typed +0 -0
- phactor-0.2.0.dist-info/METADATA +237 -0
- phactor-0.2.0.dist-info/RECORD +18 -0
- phactor-0.2.0.dist-info/WHEEL +4 -0
- phactor-0.2.0.dist-info/licenses/LICENSE +202 -0
phactor/__init__.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Phactor Python SDK — programmatic access to the Phactor clinical trial feasibility platform."""
|
|
2
|
+
|
|
3
|
+
from phactor._version import __version__
|
|
4
|
+
from phactor.client import AsyncPhactorClient, PhactorClient
|
|
5
|
+
from phactor.cohorts.builder import CohortBuilder, Proposition
|
|
6
|
+
from phactor.cohorts.models import (
|
|
7
|
+
AdherenceCriteriaInput,
|
|
8
|
+
AgeRangeInput,
|
|
9
|
+
AllergiesInput,
|
|
10
|
+
AllergyCriticality,
|
|
11
|
+
AllergyInput,
|
|
12
|
+
AllergyTypeEnum,
|
|
13
|
+
AnalyzeCohortsInput,
|
|
14
|
+
AnalyzeCohortsPayload,
|
|
15
|
+
ClinicalMeasurementCategory,
|
|
16
|
+
ClinicalMeasurementInput,
|
|
17
|
+
CohortGroupInput,
|
|
18
|
+
CohortGroupOperator,
|
|
19
|
+
ComparisonOperatorEnum,
|
|
20
|
+
ConditionClinicalStatus,
|
|
21
|
+
ConditionInput,
|
|
22
|
+
ConditionSeverity,
|
|
23
|
+
ConditionsInput,
|
|
24
|
+
ConditionStageInput,
|
|
25
|
+
ConditionTypeEnum,
|
|
26
|
+
CoordsInput,
|
|
27
|
+
CriteriaGroupInput,
|
|
28
|
+
CriteriaGroupOperator,
|
|
29
|
+
CriteriaGroupResult,
|
|
30
|
+
CriterionError,
|
|
31
|
+
CriterionErrorDomain,
|
|
32
|
+
CriterionErrorReason,
|
|
33
|
+
DistanceBand,
|
|
34
|
+
GenderValue,
|
|
35
|
+
GeographicGroup,
|
|
36
|
+
GlobalFiltersInput,
|
|
37
|
+
GroupingInput,
|
|
38
|
+
ImmunizationInput,
|
|
39
|
+
ImmunizationsInput,
|
|
40
|
+
ImmunizationTypeEnum,
|
|
41
|
+
InclusionGroupOperator,
|
|
42
|
+
InterpretationCode,
|
|
43
|
+
LabValueInput,
|
|
44
|
+
LocationInput,
|
|
45
|
+
LocationTypeEnum,
|
|
46
|
+
LocationValueInput,
|
|
47
|
+
MedicationInput,
|
|
48
|
+
MedicationsInput,
|
|
49
|
+
MedicationStatus,
|
|
50
|
+
MedicationTypeEnum,
|
|
51
|
+
PdcFilterInput,
|
|
52
|
+
ProcedureInput,
|
|
53
|
+
ProcedureOutcome,
|
|
54
|
+
ProceduresInput,
|
|
55
|
+
ProcedureTypeEnum,
|
|
56
|
+
PropositionInput,
|
|
57
|
+
ProximityMode,
|
|
58
|
+
RaceValue,
|
|
59
|
+
ResultGrouping,
|
|
60
|
+
SiteDistanceBandResult,
|
|
61
|
+
SiteInput,
|
|
62
|
+
SiteRadiusResult,
|
|
63
|
+
StabilityStatusEnum,
|
|
64
|
+
StableDoseFilterInput,
|
|
65
|
+
TimingInput,
|
|
66
|
+
TimingOperator,
|
|
67
|
+
TimingReference,
|
|
68
|
+
TimingUnit,
|
|
69
|
+
)
|
|
70
|
+
from phactor.config import PhactorConfig
|
|
71
|
+
from phactor.exceptions import (
|
|
72
|
+
AuthenticationError,
|
|
73
|
+
ConnectionError,
|
|
74
|
+
GraphQLError,
|
|
75
|
+
PhactorError,
|
|
76
|
+
TimeoutError,
|
|
77
|
+
ValidationError,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"__version__",
|
|
82
|
+
"AsyncPhactorClient",
|
|
83
|
+
"PhactorClient",
|
|
84
|
+
"PhactorConfig",
|
|
85
|
+
"CohortBuilder",
|
|
86
|
+
"Proposition",
|
|
87
|
+
# Enums
|
|
88
|
+
"TimingOperator",
|
|
89
|
+
"TimingUnit",
|
|
90
|
+
"TimingReference",
|
|
91
|
+
"ComparisonOperatorEnum",
|
|
92
|
+
"GenderValue",
|
|
93
|
+
"RaceValue",
|
|
94
|
+
"LocationTypeEnum",
|
|
95
|
+
"ConditionTypeEnum",
|
|
96
|
+
"ConditionClinicalStatus",
|
|
97
|
+
"ConditionSeverity",
|
|
98
|
+
"MedicationTypeEnum",
|
|
99
|
+
"MedicationStatus",
|
|
100
|
+
"ProcedureTypeEnum",
|
|
101
|
+
"ProcedureOutcome",
|
|
102
|
+
"AllergyTypeEnum",
|
|
103
|
+
"AllergyCriticality",
|
|
104
|
+
"ImmunizationTypeEnum",
|
|
105
|
+
"InterpretationCode",
|
|
106
|
+
"ClinicalMeasurementCategory",
|
|
107
|
+
"StabilityStatusEnum",
|
|
108
|
+
"CriteriaGroupOperator",
|
|
109
|
+
"InclusionGroupOperator",
|
|
110
|
+
"CohortGroupOperator",
|
|
111
|
+
"CriterionErrorDomain",
|
|
112
|
+
"CriterionErrorReason",
|
|
113
|
+
"ResultGrouping",
|
|
114
|
+
"ProximityMode",
|
|
115
|
+
# Input models
|
|
116
|
+
"TimingInput",
|
|
117
|
+
"AgeRangeInput",
|
|
118
|
+
"CoordsInput",
|
|
119
|
+
"LocationValueInput",
|
|
120
|
+
"LocationInput",
|
|
121
|
+
"ConditionStageInput",
|
|
122
|
+
"ConditionInput",
|
|
123
|
+
"ConditionsInput",
|
|
124
|
+
"PdcFilterInput",
|
|
125
|
+
"StableDoseFilterInput",
|
|
126
|
+
"AdherenceCriteriaInput",
|
|
127
|
+
"MedicationInput",
|
|
128
|
+
"MedicationsInput",
|
|
129
|
+
"ProcedureInput",
|
|
130
|
+
"ProceduresInput",
|
|
131
|
+
"AllergyInput",
|
|
132
|
+
"AllergiesInput",
|
|
133
|
+
"ImmunizationInput",
|
|
134
|
+
"ImmunizationsInput",
|
|
135
|
+
"LabValueInput",
|
|
136
|
+
"ClinicalMeasurementInput",
|
|
137
|
+
"PropositionInput",
|
|
138
|
+
"CriteriaGroupInput",
|
|
139
|
+
"CohortGroupInput",
|
|
140
|
+
"SiteInput",
|
|
141
|
+
"GroupingInput",
|
|
142
|
+
"GlobalFiltersInput",
|
|
143
|
+
"AnalyzeCohortsInput",
|
|
144
|
+
# Response models
|
|
145
|
+
"AnalyzeCohortsPayload",
|
|
146
|
+
"CriteriaGroupResult",
|
|
147
|
+
"CriterionError",
|
|
148
|
+
"DistanceBand",
|
|
149
|
+
"GeographicGroup",
|
|
150
|
+
"SiteDistanceBandResult",
|
|
151
|
+
"SiteRadiusResult",
|
|
152
|
+
# Exceptions
|
|
153
|
+
"AuthenticationError",
|
|
154
|
+
"ConnectionError",
|
|
155
|
+
"GraphQLError",
|
|
156
|
+
"PhactorError",
|
|
157
|
+
"TimeoutError",
|
|
158
|
+
"ValidationError",
|
|
159
|
+
]
|
phactor/_http.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""HTTP client wrappers with retry, auth injection, and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from phactor.auth import AsyncTokenManager, TokenManager
|
|
13
|
+
from phactor.exceptions import (
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
ConnectionError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
PhactorError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
ServerError,
|
|
20
|
+
TimeoutError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
|
|
24
|
+
_MAX_RETRIES = 3
|
|
25
|
+
_BASE_DELAY = 0.5
|
|
26
|
+
_MAX_JITTER = 0.25
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _map_status_error(response: httpx.Response) -> PhactorError:
|
|
30
|
+
"""Map an HTTP error status to the appropriate SDK exception."""
|
|
31
|
+
status = response.status_code
|
|
32
|
+
body = response.text
|
|
33
|
+
if status == 401:
|
|
34
|
+
return AuthenticationError(f"Authentication failed: {body}", status_code=status)
|
|
35
|
+
if status == 403:
|
|
36
|
+
return AuthenticationError(f"Forbidden: {body}", status_code=status)
|
|
37
|
+
if status == 404:
|
|
38
|
+
return NotFoundError(f"Not found: {body}", status_code=status)
|
|
39
|
+
if status == 429:
|
|
40
|
+
return RateLimitError(f"Rate limited: {body}", status_code=status)
|
|
41
|
+
if status >= 500:
|
|
42
|
+
return ServerError(f"Server error ({status}): {body}", status_code=status)
|
|
43
|
+
return PhactorError(f"HTTP error ({status}): {body}", status_code=status)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _retry_delay(response: httpx.Response | None, attempt: int) -> float:
|
|
47
|
+
delay: float = _BASE_DELAY * (2**attempt)
|
|
48
|
+
if response is not None:
|
|
49
|
+
retry_after = response.headers.get("Retry-After")
|
|
50
|
+
if retry_after:
|
|
51
|
+
with contextlib.suppress(ValueError):
|
|
52
|
+
delay = float(retry_after)
|
|
53
|
+
return delay + float(random.uniform(0.0, _MAX_JITTER))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SyncHTTPClient:
|
|
57
|
+
"""Synchronous HTTP client with auth injection and retry logic."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
token_manager: TokenManager,
|
|
62
|
+
*,
|
|
63
|
+
timeout: float = 30.0,
|
|
64
|
+
verify: str | bool = True,
|
|
65
|
+
) -> None:
|
|
66
|
+
self._token_manager = token_manager
|
|
67
|
+
self._client = httpx.Client(timeout=timeout, verify=verify)
|
|
68
|
+
|
|
69
|
+
def post(self, url: str, *, json: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""POST JSON with auth headers, retry on transient errors."""
|
|
71
|
+
attempt = 0
|
|
72
|
+
refreshed_token = False
|
|
73
|
+
|
|
74
|
+
while True:
|
|
75
|
+
try:
|
|
76
|
+
headers = self._token_manager.get_auth_headers(self._client)
|
|
77
|
+
response = self._client.post(url, json=json, headers=headers)
|
|
78
|
+
|
|
79
|
+
if response.status_code == 200:
|
|
80
|
+
return response.json() # type: ignore[no-any-return]
|
|
81
|
+
|
|
82
|
+
if response.status_code == 401:
|
|
83
|
+
if not refreshed_token:
|
|
84
|
+
self._token_manager.invalidate()
|
|
85
|
+
refreshed_token = True
|
|
86
|
+
attempt = 0
|
|
87
|
+
continue
|
|
88
|
+
raise _map_status_error(response)
|
|
89
|
+
|
|
90
|
+
if response.status_code in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES - 1:
|
|
91
|
+
time.sleep(_retry_delay(response, attempt))
|
|
92
|
+
attempt += 1
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
raise _map_status_error(response)
|
|
96
|
+
|
|
97
|
+
except httpx.TimeoutException as exc:
|
|
98
|
+
if attempt < _MAX_RETRIES - 1:
|
|
99
|
+
time.sleep(_retry_delay(None, attempt))
|
|
100
|
+
attempt += 1
|
|
101
|
+
continue
|
|
102
|
+
raise TimeoutError(f"Request timed out: {exc}") from exc
|
|
103
|
+
except httpx.ConnectError as exc:
|
|
104
|
+
if attempt < _MAX_RETRIES - 1:
|
|
105
|
+
time.sleep(_retry_delay(None, attempt))
|
|
106
|
+
attempt += 1
|
|
107
|
+
continue
|
|
108
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
109
|
+
except httpx.RequestError as exc:
|
|
110
|
+
if attempt < _MAX_RETRIES - 1:
|
|
111
|
+
time.sleep(_retry_delay(None, attempt))
|
|
112
|
+
attempt += 1
|
|
113
|
+
continue
|
|
114
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
self._client.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AsyncHTTPClient:
|
|
121
|
+
"""Async HTTP client with auth injection and retry logic."""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
token_manager: AsyncTokenManager,
|
|
126
|
+
*,
|
|
127
|
+
timeout: float = 30.0,
|
|
128
|
+
verify: str | bool = True,
|
|
129
|
+
) -> None:
|
|
130
|
+
self._token_manager = token_manager
|
|
131
|
+
self._client = httpx.AsyncClient(timeout=timeout, verify=verify)
|
|
132
|
+
|
|
133
|
+
async def post(self, url: str, *, json: dict[str, Any]) -> dict[str, Any]:
|
|
134
|
+
"""POST JSON with auth headers, retry on transient errors."""
|
|
135
|
+
import asyncio
|
|
136
|
+
|
|
137
|
+
attempt = 0
|
|
138
|
+
refreshed_token = False
|
|
139
|
+
|
|
140
|
+
while True:
|
|
141
|
+
try:
|
|
142
|
+
headers = await self._token_manager.get_auth_headers(self._client)
|
|
143
|
+
response = await self._client.post(url, json=json, headers=headers)
|
|
144
|
+
|
|
145
|
+
if response.status_code == 200:
|
|
146
|
+
return response.json() # type: ignore[no-any-return]
|
|
147
|
+
|
|
148
|
+
if response.status_code == 401:
|
|
149
|
+
if not refreshed_token:
|
|
150
|
+
await self._token_manager.invalidate()
|
|
151
|
+
refreshed_token = True
|
|
152
|
+
attempt = 0
|
|
153
|
+
continue
|
|
154
|
+
raise _map_status_error(response)
|
|
155
|
+
|
|
156
|
+
if response.status_code in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES - 1:
|
|
157
|
+
await asyncio.sleep(_retry_delay(response, attempt))
|
|
158
|
+
attempt += 1
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
raise _map_status_error(response)
|
|
162
|
+
|
|
163
|
+
except httpx.TimeoutException as exc:
|
|
164
|
+
if attempt < _MAX_RETRIES - 1:
|
|
165
|
+
await asyncio.sleep(_retry_delay(None, attempt))
|
|
166
|
+
attempt += 1
|
|
167
|
+
continue
|
|
168
|
+
raise TimeoutError(f"Request timed out: {exc}") from exc
|
|
169
|
+
except httpx.ConnectError as exc:
|
|
170
|
+
if attempt < _MAX_RETRIES - 1:
|
|
171
|
+
await asyncio.sleep(_retry_delay(None, attempt))
|
|
172
|
+
attempt += 1
|
|
173
|
+
continue
|
|
174
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
175
|
+
except httpx.RequestError as exc:
|
|
176
|
+
if attempt < _MAX_RETRIES - 1:
|
|
177
|
+
await asyncio.sleep(_retry_delay(None, attempt))
|
|
178
|
+
attempt += 1
|
|
179
|
+
continue
|
|
180
|
+
raise ConnectionError(f"Connection failed: {exc}") from exc
|
|
181
|
+
|
|
182
|
+
async def close(self) -> None:
|
|
183
|
+
await self._client.aclose()
|
phactor/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
phactor/auth.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Token management for FusionAuth client_credentials flow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import jwt
|
|
12
|
+
|
|
13
|
+
from phactor.config import PhactorConfig
|
|
14
|
+
from phactor.exceptions import AuthenticationError
|
|
15
|
+
|
|
16
|
+
# Match gateway's TOKEN_CACHE_TTL_MS = 55_000
|
|
17
|
+
_TOKEN_CACHE_TTL_S = 55
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cache_ttl(expires_in: float) -> float:
|
|
21
|
+
return max(1.0, min(float(_TOKEN_CACHE_TTL_S), expires_in - 5.0))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class _CachedToken:
|
|
26
|
+
access_token: str
|
|
27
|
+
tenant_id: str
|
|
28
|
+
expires_at: float
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TokenManager:
|
|
32
|
+
"""Acquires and caches OAuth2 client_credentials tokens from FusionAuth."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, config: PhactorConfig) -> None:
|
|
35
|
+
self._config = config
|
|
36
|
+
self._token_url = f"{config.fusionauth_url}/oauth2/token"
|
|
37
|
+
self._scope = (
|
|
38
|
+
f"target-entity:{config.entity_id}:internal:read"
|
|
39
|
+
if config.entity_id
|
|
40
|
+
else None
|
|
41
|
+
)
|
|
42
|
+
self._cached: _CachedToken | None = None
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
|
|
45
|
+
def _is_valid(self) -> bool:
|
|
46
|
+
return self._cached is not None and time.monotonic() < self._cached.expires_at
|
|
47
|
+
|
|
48
|
+
def _request_token(self, client: httpx.Client) -> _CachedToken:
|
|
49
|
+
data: dict[str, str] = {
|
|
50
|
+
"grant_type": "client_credentials",
|
|
51
|
+
"client_id": self._config.client_id,
|
|
52
|
+
"client_secret": self._config.client_secret,
|
|
53
|
+
}
|
|
54
|
+
if self._scope:
|
|
55
|
+
data["scope"] = self._scope
|
|
56
|
+
response = client.post(
|
|
57
|
+
self._token_url,
|
|
58
|
+
data=data,
|
|
59
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
60
|
+
)
|
|
61
|
+
if response.status_code != 200:
|
|
62
|
+
raise AuthenticationError(
|
|
63
|
+
f"Token request failed ({response.status_code}): {response.text}",
|
|
64
|
+
status_code=response.status_code,
|
|
65
|
+
)
|
|
66
|
+
data = response.json()
|
|
67
|
+
access_token = data["access_token"]
|
|
68
|
+
expires_in = float(data.get("expires_in", _TOKEN_CACHE_TTL_S + 5))
|
|
69
|
+
|
|
70
|
+
# Decode JWT without verification to extract tid (tenant ID)
|
|
71
|
+
decoded = jwt.decode(access_token, options={"verify_signature": False})
|
|
72
|
+
tenant_id = decoded.get("tid")
|
|
73
|
+
if not tenant_id:
|
|
74
|
+
raise AuthenticationError("Service token missing tid (tenant ID) claim")
|
|
75
|
+
|
|
76
|
+
ttl = _cache_ttl(expires_in)
|
|
77
|
+
return _CachedToken(
|
|
78
|
+
access_token=access_token,
|
|
79
|
+
tenant_id=tenant_id,
|
|
80
|
+
expires_at=time.monotonic() + ttl,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def get_auth_headers(self, client: httpx.Client) -> dict[str, str]:
|
|
84
|
+
"""Return Authorization and x-tenant-id headers, refreshing if needed."""
|
|
85
|
+
with self._lock:
|
|
86
|
+
if not self._is_valid():
|
|
87
|
+
self._cached = self._request_token(client)
|
|
88
|
+
token = self._cached
|
|
89
|
+
assert token is not None
|
|
90
|
+
return {
|
|
91
|
+
"Authorization": f"Bearer {token.access_token}",
|
|
92
|
+
"x-tenant-id": token.tenant_id,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def invalidate(self) -> None:
|
|
96
|
+
"""Force token refresh on next call."""
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._cached = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AsyncTokenManager:
|
|
102
|
+
"""Async variant of TokenManager."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, config: PhactorConfig) -> None:
|
|
105
|
+
self._config = config
|
|
106
|
+
self._token_url = f"{config.fusionauth_url}/oauth2/token"
|
|
107
|
+
self._scope = (
|
|
108
|
+
f"target-entity:{config.entity_id}:internal:read"
|
|
109
|
+
if config.entity_id
|
|
110
|
+
else None
|
|
111
|
+
)
|
|
112
|
+
self._cached: _CachedToken | None = None
|
|
113
|
+
self._lock = asyncio.Lock()
|
|
114
|
+
|
|
115
|
+
def _is_valid(self) -> bool:
|
|
116
|
+
return self._cached is not None and time.monotonic() < self._cached.expires_at
|
|
117
|
+
|
|
118
|
+
async def _request_token(self, client: httpx.AsyncClient) -> _CachedToken:
|
|
119
|
+
data: dict[str, str] = {
|
|
120
|
+
"grant_type": "client_credentials",
|
|
121
|
+
"client_id": self._config.client_id,
|
|
122
|
+
"client_secret": self._config.client_secret,
|
|
123
|
+
}
|
|
124
|
+
if self._scope:
|
|
125
|
+
data["scope"] = self._scope
|
|
126
|
+
response = await client.post(
|
|
127
|
+
self._token_url,
|
|
128
|
+
data=data,
|
|
129
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
130
|
+
)
|
|
131
|
+
if response.status_code != 200:
|
|
132
|
+
raise AuthenticationError(
|
|
133
|
+
f"Token request failed ({response.status_code}): {response.text}",
|
|
134
|
+
status_code=response.status_code,
|
|
135
|
+
)
|
|
136
|
+
data = response.json()
|
|
137
|
+
access_token = data["access_token"]
|
|
138
|
+
expires_in = float(data.get("expires_in", _TOKEN_CACHE_TTL_S + 5))
|
|
139
|
+
|
|
140
|
+
decoded = jwt.decode(access_token, options={"verify_signature": False})
|
|
141
|
+
tenant_id = decoded.get("tid")
|
|
142
|
+
if not tenant_id:
|
|
143
|
+
raise AuthenticationError("Service token missing tid (tenant ID) claim")
|
|
144
|
+
|
|
145
|
+
ttl = _cache_ttl(expires_in)
|
|
146
|
+
return _CachedToken(
|
|
147
|
+
access_token=access_token,
|
|
148
|
+
tenant_id=tenant_id,
|
|
149
|
+
expires_at=time.monotonic() + ttl,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def get_auth_headers(self, client: httpx.AsyncClient) -> dict[str, str]:
|
|
153
|
+
"""Return Authorization and x-tenant-id headers, refreshing if needed."""
|
|
154
|
+
async with self._lock:
|
|
155
|
+
if not self._is_valid():
|
|
156
|
+
self._cached = await self._request_token(client)
|
|
157
|
+
token = self._cached
|
|
158
|
+
assert token is not None
|
|
159
|
+
return {
|
|
160
|
+
"Authorization": f"Bearer {token.access_token}",
|
|
161
|
+
"x-tenant-id": token.tenant_id,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async def invalidate(self) -> None:
|
|
165
|
+
"""Force token refresh on next call."""
|
|
166
|
+
async with self._lock:
|
|
167
|
+
self._cached = None
|
phactor/client.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Top-level Phactor SDK clients (sync and async)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from phactor._http import AsyncHTTPClient, SyncHTTPClient
|
|
9
|
+
from phactor.auth import AsyncTokenManager, TokenManager
|
|
10
|
+
from phactor.cohorts.client import AsyncCohortsClient, CohortsClient
|
|
11
|
+
from phactor.cohorts.models import (
|
|
12
|
+
AnalyzeCohortsPayload,
|
|
13
|
+
CohortGroupInput,
|
|
14
|
+
CohortGroupOperator,
|
|
15
|
+
GlobalFiltersInput,
|
|
16
|
+
StudyContextInput,
|
|
17
|
+
)
|
|
18
|
+
from phactor.config import PhactorConfig
|
|
19
|
+
from phactor.exceptions import PhactorError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PhactorClient:
|
|
23
|
+
"""Synchronous Phactor SDK client.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
client = PhactorClient(client_id="...", client_secret="...", ...)
|
|
27
|
+
result = client.cohorts.analyze(providers=[...], cohort_groups=[...])
|
|
28
|
+
client.close()
|
|
29
|
+
|
|
30
|
+
Or as a context manager:
|
|
31
|
+
with PhactorClient(...) as client:
|
|
32
|
+
result = client.cohorts.analyze(...)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
client_id: str | None = None,
|
|
39
|
+
client_secret: str | None = None,
|
|
40
|
+
entity_id: str | None = None,
|
|
41
|
+
fusionauth_url: str | None = None,
|
|
42
|
+
gateway_url: str | None = None,
|
|
43
|
+
verify: str | bool | None = None,
|
|
44
|
+
timeout: float | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._config = PhactorConfig.from_kwargs(
|
|
47
|
+
client_id=client_id,
|
|
48
|
+
client_secret=client_secret,
|
|
49
|
+
entity_id=entity_id,
|
|
50
|
+
fusionauth_url=fusionauth_url,
|
|
51
|
+
gateway_url=gateway_url,
|
|
52
|
+
verify=verify,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
|
55
|
+
token_manager = TokenManager(self._config)
|
|
56
|
+
self._http = SyncHTTPClient(
|
|
57
|
+
token_manager, timeout=self._config.timeout, verify=self._config.verify
|
|
58
|
+
)
|
|
59
|
+
self.cohorts = CohortsClient(self._http, self._config.gateway_url)
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
"""Close the underlying HTTP client."""
|
|
63
|
+
self._http.close()
|
|
64
|
+
|
|
65
|
+
def __enter__(self) -> PhactorClient:
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
def __exit__(
|
|
69
|
+
self,
|
|
70
|
+
exc_type: type[BaseException] | None,
|
|
71
|
+
exc_val: BaseException | None,
|
|
72
|
+
exc_tb: TracebackType | None,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.close()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AsyncPhactorClient:
|
|
78
|
+
"""Async Phactor SDK client.
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
async with AsyncPhactorClient(...) as client:
|
|
82
|
+
result = await client.cohorts.analyze(...)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
client_id: str | None = None,
|
|
89
|
+
client_secret: str | None = None,
|
|
90
|
+
entity_id: str | None = None,
|
|
91
|
+
fusionauth_url: str | None = None,
|
|
92
|
+
gateway_url: str | None = None,
|
|
93
|
+
verify: str | bool | None = None,
|
|
94
|
+
timeout: float | None = None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._config = PhactorConfig.from_kwargs(
|
|
97
|
+
client_id=client_id,
|
|
98
|
+
client_secret=client_secret,
|
|
99
|
+
entity_id=entity_id,
|
|
100
|
+
fusionauth_url=fusionauth_url,
|
|
101
|
+
gateway_url=gateway_url,
|
|
102
|
+
verify=verify,
|
|
103
|
+
timeout=timeout,
|
|
104
|
+
)
|
|
105
|
+
self._closed = False
|
|
106
|
+
token_manager = AsyncTokenManager(self._config)
|
|
107
|
+
self._http = AsyncHTTPClient(
|
|
108
|
+
token_manager, timeout=self._config.timeout, verify=self._config.verify
|
|
109
|
+
)
|
|
110
|
+
self._cohorts = AsyncCohortsClient(self._http, self._config.gateway_url)
|
|
111
|
+
self.cohorts = _AsyncCohortsProxy(self)
|
|
112
|
+
|
|
113
|
+
async def close(self) -> None:
|
|
114
|
+
"""Close the underlying HTTP client."""
|
|
115
|
+
if self._closed:
|
|
116
|
+
return
|
|
117
|
+
self._closed = True
|
|
118
|
+
await self._http.close()
|
|
119
|
+
|
|
120
|
+
def _ensure_open(self) -> None:
|
|
121
|
+
if self._closed:
|
|
122
|
+
raise PhactorError("AsyncPhactorClient is closed")
|
|
123
|
+
|
|
124
|
+
async def __aenter__(self) -> AsyncPhactorClient:
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(
|
|
128
|
+
self,
|
|
129
|
+
exc_type: type[BaseException] | None,
|
|
130
|
+
exc_val: BaseException | None,
|
|
131
|
+
exc_tb: TracebackType | None,
|
|
132
|
+
) -> None:
|
|
133
|
+
await self.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class _AsyncCohortsProxy:
|
|
137
|
+
"""Guarded async cohorts facade that fails fast after client close."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, owner: AsyncPhactorClient) -> None:
|
|
140
|
+
self._owner = owner
|
|
141
|
+
|
|
142
|
+
async def analyze(
|
|
143
|
+
self,
|
|
144
|
+
providers: list[str],
|
|
145
|
+
cohort_groups: list[CohortGroupInput | dict[str, Any]],
|
|
146
|
+
*,
|
|
147
|
+
cohort_group_operator: CohortGroupOperator | str | None = None,
|
|
148
|
+
study_context: StudyContextInput | dict[str, Any] | None = None,
|
|
149
|
+
include_impact_analysis: bool | None = None,
|
|
150
|
+
include_demographic_breakdown: bool | None = None,
|
|
151
|
+
age_buckets: list[dict[str, int]] | None = None,
|
|
152
|
+
include_combined_demographic_breakdown: bool | None = None,
|
|
153
|
+
grouping: dict[str, Any] | None = None,
|
|
154
|
+
global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
|
|
155
|
+
) -> AnalyzeCohortsPayload:
|
|
156
|
+
self._owner._ensure_open()
|
|
157
|
+
return await self._owner._cohorts.analyze(
|
|
158
|
+
providers,
|
|
159
|
+
cohort_groups,
|
|
160
|
+
cohort_group_operator=cohort_group_operator,
|
|
161
|
+
study_context=study_context,
|
|
162
|
+
include_impact_analysis=include_impact_analysis,
|
|
163
|
+
include_demographic_breakdown=include_demographic_breakdown,
|
|
164
|
+
age_buckets=age_buckets,
|
|
165
|
+
include_combined_demographic_breakdown=include_combined_demographic_breakdown,
|
|
166
|
+
grouping=grouping,
|
|
167
|
+
global_filters=global_filters,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def __getattr__(self, name: str) -> Any:
|
|
171
|
+
self._owner._ensure_open()
|
|
172
|
+
return getattr(self._owner._cohorts, name)
|