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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Cohort analysis sub-package."""
|
|
2
|
+
|
|
3
|
+
from phactor.cohorts.builder import CohortBuilder, GroupBuilder, Proposition
|
|
4
|
+
from phactor.cohorts.client import AsyncCohortsClient, CohortsClient
|
|
5
|
+
from phactor.cohorts.models import (
|
|
6
|
+
AdherenceCriteriaInput,
|
|
7
|
+
AgeRangeInput,
|
|
8
|
+
AllergiesInput,
|
|
9
|
+
AllergyCriticality,
|
|
10
|
+
AllergyInput,
|
|
11
|
+
AllergyTypeEnum,
|
|
12
|
+
AnalyzeCohortsInput,
|
|
13
|
+
AnalyzeCohortsPayload,
|
|
14
|
+
ClinicalMeasurementCategory,
|
|
15
|
+
ClinicalMeasurementInput,
|
|
16
|
+
CohortGroupInput,
|
|
17
|
+
CohortGroupOperator,
|
|
18
|
+
CohortGroupResult,
|
|
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
|
+
GenderValue,
|
|
34
|
+
GlobalFiltersInput,
|
|
35
|
+
GroupingInput,
|
|
36
|
+
ImmunizationInput,
|
|
37
|
+
ImmunizationsInput,
|
|
38
|
+
ImmunizationTypeEnum,
|
|
39
|
+
InclusionGroupOperator,
|
|
40
|
+
InterpretationCode,
|
|
41
|
+
LabValueInput,
|
|
42
|
+
LocationInput,
|
|
43
|
+
LocationTypeEnum,
|
|
44
|
+
LocationValueInput,
|
|
45
|
+
MedicationInput,
|
|
46
|
+
MedicationsInput,
|
|
47
|
+
MedicationStatus,
|
|
48
|
+
MedicationTypeEnum,
|
|
49
|
+
PdcFilterInput,
|
|
50
|
+
ProcedureInput,
|
|
51
|
+
ProcedureOutcome,
|
|
52
|
+
ProceduresInput,
|
|
53
|
+
ProcedureTypeEnum,
|
|
54
|
+
PropositionInput,
|
|
55
|
+
ProviderCohortAnalysis,
|
|
56
|
+
ProviderMeta,
|
|
57
|
+
ProximityMode,
|
|
58
|
+
RaceValue,
|
|
59
|
+
ResultGrouping,
|
|
60
|
+
SiteInput,
|
|
61
|
+
StabilityStatusEnum,
|
|
62
|
+
StableDoseFilterInput,
|
|
63
|
+
StudyContextInput,
|
|
64
|
+
TimingInput,
|
|
65
|
+
TimingOperator,
|
|
66
|
+
TimingReference,
|
|
67
|
+
TimingUnit,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
# Builder + clients
|
|
72
|
+
"CohortBuilder",
|
|
73
|
+
"GroupBuilder",
|
|
74
|
+
"Proposition",
|
|
75
|
+
"CohortsClient",
|
|
76
|
+
"AsyncCohortsClient",
|
|
77
|
+
# Enums
|
|
78
|
+
"TimingOperator",
|
|
79
|
+
"TimingUnit",
|
|
80
|
+
"TimingReference",
|
|
81
|
+
"ComparisonOperatorEnum",
|
|
82
|
+
"GenderValue",
|
|
83
|
+
"RaceValue",
|
|
84
|
+
"LocationTypeEnum",
|
|
85
|
+
"ConditionTypeEnum",
|
|
86
|
+
"ConditionClinicalStatus",
|
|
87
|
+
"ConditionSeverity",
|
|
88
|
+
"MedicationTypeEnum",
|
|
89
|
+
"MedicationStatus",
|
|
90
|
+
"ProcedureTypeEnum",
|
|
91
|
+
"ProcedureOutcome",
|
|
92
|
+
"AllergyTypeEnum",
|
|
93
|
+
"AllergyCriticality",
|
|
94
|
+
"ImmunizationTypeEnum",
|
|
95
|
+
"InterpretationCode",
|
|
96
|
+
"ClinicalMeasurementCategory",
|
|
97
|
+
"StabilityStatusEnum",
|
|
98
|
+
"CohortGroupOperator",
|
|
99
|
+
"CriteriaGroupOperator",
|
|
100
|
+
"InclusionGroupOperator",
|
|
101
|
+
"CohortGroupOperator",
|
|
102
|
+
"CriterionErrorDomain",
|
|
103
|
+
"CriterionErrorReason",
|
|
104
|
+
"ResultGrouping",
|
|
105
|
+
"ProximityMode",
|
|
106
|
+
# Input models
|
|
107
|
+
"TimingInput",
|
|
108
|
+
"AgeRangeInput",
|
|
109
|
+
"CoordsInput",
|
|
110
|
+
"LocationValueInput",
|
|
111
|
+
"LocationInput",
|
|
112
|
+
"ConditionStageInput",
|
|
113
|
+
"ConditionInput",
|
|
114
|
+
"ConditionsInput",
|
|
115
|
+
"PdcFilterInput",
|
|
116
|
+
"StableDoseFilterInput",
|
|
117
|
+
"AdherenceCriteriaInput",
|
|
118
|
+
"MedicationInput",
|
|
119
|
+
"MedicationsInput",
|
|
120
|
+
"ProcedureInput",
|
|
121
|
+
"ProceduresInput",
|
|
122
|
+
"AllergyInput",
|
|
123
|
+
"AllergiesInput",
|
|
124
|
+
"ImmunizationInput",
|
|
125
|
+
"ImmunizationsInput",
|
|
126
|
+
"LabValueInput",
|
|
127
|
+
"ClinicalMeasurementInput",
|
|
128
|
+
"PropositionInput",
|
|
129
|
+
"CriteriaGroupInput",
|
|
130
|
+
"CohortGroupInput",
|
|
131
|
+
"StudyContextInput",
|
|
132
|
+
"SiteInput",
|
|
133
|
+
"GroupingInput",
|
|
134
|
+
"GlobalFiltersInput",
|
|
135
|
+
"AnalyzeCohortsInput",
|
|
136
|
+
# Response models
|
|
137
|
+
"AnalyzeCohortsPayload",
|
|
138
|
+
"CohortGroupResult",
|
|
139
|
+
"CriteriaGroupResult",
|
|
140
|
+
"CriterionError",
|
|
141
|
+
"ProviderCohortAnalysis",
|
|
142
|
+
"ProviderMeta",
|
|
143
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Fluent builder API for constructing cohort analysis inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from phactor.cohorts.models import (
|
|
8
|
+
CohortGroupInput,
|
|
9
|
+
CriteriaGroupInput,
|
|
10
|
+
CriteriaGroupOperator,
|
|
11
|
+
InclusionGroupOperator,
|
|
12
|
+
PropositionInput,
|
|
13
|
+
)
|
|
14
|
+
from phactor.exceptions import ValidationError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Proposition:
|
|
18
|
+
"""Convenience wrapper for building PropositionInput from keyword arguments.
|
|
19
|
+
|
|
20
|
+
Accepts the same fields as PropositionInput but also plain dicts
|
|
21
|
+
for nested types (age, conditions, medications, etc.).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
25
|
+
self._data = kwargs
|
|
26
|
+
|
|
27
|
+
def to_model(self) -> PropositionInput:
|
|
28
|
+
return PropositionInput.model_validate(self._data)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return self.to_model().model_dump(by_alias=True, exclude_none=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GroupBuilder:
|
|
35
|
+
"""Builds a single inclusion or exclusion criteria group."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
parent: CohortBuilder,
|
|
40
|
+
name: str | None,
|
|
41
|
+
operator: CriteriaGroupOperator,
|
|
42
|
+
*,
|
|
43
|
+
is_exclusion: bool = False,
|
|
44
|
+
some_threshold: int | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._parent = parent
|
|
47
|
+
self._name = name
|
|
48
|
+
self._operator = operator
|
|
49
|
+
self._is_exclusion = is_exclusion
|
|
50
|
+
self._some_threshold = some_threshold
|
|
51
|
+
self._propositions: list[PropositionInput] = []
|
|
52
|
+
|
|
53
|
+
def add(self, proposition: Proposition | PropositionInput | dict[str, Any]) -> GroupBuilder:
|
|
54
|
+
"""Add a proposition to this criteria group."""
|
|
55
|
+
if isinstance(proposition, Proposition):
|
|
56
|
+
self._propositions.append(proposition.to_model())
|
|
57
|
+
elif isinstance(proposition, dict):
|
|
58
|
+
self._propositions.append(PropositionInput.model_validate(proposition))
|
|
59
|
+
else:
|
|
60
|
+
self._propositions.append(proposition)
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def done(self) -> CohortBuilder:
|
|
64
|
+
"""Finalize this group and return to the parent builder."""
|
|
65
|
+
group = CriteriaGroupInput(
|
|
66
|
+
name=self._name,
|
|
67
|
+
operator=self._operator,
|
|
68
|
+
some_threshold=self._some_threshold,
|
|
69
|
+
propositions=self._propositions,
|
|
70
|
+
)
|
|
71
|
+
if self._is_exclusion:
|
|
72
|
+
self._parent._exclusion_groups.append(group)
|
|
73
|
+
else:
|
|
74
|
+
self._parent._inclusion_groups.append(group)
|
|
75
|
+
return self._parent
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CohortBuilder:
|
|
79
|
+
"""Fluent API for building a CohortGroupInput.
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
cohort = (
|
|
83
|
+
CohortBuilder(inclusion_operator="AND")
|
|
84
|
+
.include("Demographics", operator="AND")
|
|
85
|
+
.add(Proposition(age={"from": 18, "to": 65}))
|
|
86
|
+
.done()
|
|
87
|
+
.exclude("Exclusions", operator="OR")
|
|
88
|
+
.add(Proposition(conditions={"values": [...]}))
|
|
89
|
+
.done()
|
|
90
|
+
.build()
|
|
91
|
+
)
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
inclusion_operator: str | InclusionGroupOperator = InclusionGroupOperator.AND,
|
|
98
|
+
) -> None:
|
|
99
|
+
self._inclusion_operator = (
|
|
100
|
+
InclusionGroupOperator(inclusion_operator)
|
|
101
|
+
if isinstance(inclusion_operator, str)
|
|
102
|
+
else inclusion_operator
|
|
103
|
+
)
|
|
104
|
+
self._inclusion_groups: list[CriteriaGroupInput] = []
|
|
105
|
+
self._exclusion_groups: list[CriteriaGroupInput] = []
|
|
106
|
+
|
|
107
|
+
def include(
|
|
108
|
+
self,
|
|
109
|
+
name: str | None = None,
|
|
110
|
+
*,
|
|
111
|
+
operator: str | CriteriaGroupOperator = CriteriaGroupOperator.AND,
|
|
112
|
+
some_threshold: int | None = None,
|
|
113
|
+
) -> GroupBuilder:
|
|
114
|
+
"""Start building an inclusion criteria group."""
|
|
115
|
+
op = CriteriaGroupOperator(operator) if isinstance(operator, str) else operator
|
|
116
|
+
return GroupBuilder(self, name, op, is_exclusion=False, some_threshold=some_threshold)
|
|
117
|
+
|
|
118
|
+
def exclude(
|
|
119
|
+
self,
|
|
120
|
+
name: str | None = None,
|
|
121
|
+
*,
|
|
122
|
+
operator: str | CriteriaGroupOperator = CriteriaGroupOperator.AND,
|
|
123
|
+
some_threshold: int | None = None,
|
|
124
|
+
) -> GroupBuilder:
|
|
125
|
+
"""Start building an exclusion criteria group."""
|
|
126
|
+
op = CriteriaGroupOperator(operator) if isinstance(operator, str) else operator
|
|
127
|
+
return GroupBuilder(self, name, op, is_exclusion=True, some_threshold=some_threshold)
|
|
128
|
+
|
|
129
|
+
def build(self) -> dict[str, Any]:
|
|
130
|
+
"""Validate and return the cohort group as a camelCase dict."""
|
|
131
|
+
if not self._inclusion_groups and not self._exclusion_groups:
|
|
132
|
+
raise ValidationError("Cohort must have at least one inclusion or exclusion group")
|
|
133
|
+
|
|
134
|
+
for group in [*self._inclusion_groups, *self._exclusion_groups]:
|
|
135
|
+
if not group.propositions:
|
|
136
|
+
raise ValidationError(
|
|
137
|
+
f"Criteria group '{group.name or '(unnamed)'}' has no propositions"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
cohort = CohortGroupInput(
|
|
141
|
+
inclusion_group_operator=self._inclusion_operator,
|
|
142
|
+
inclusion_groups=self._inclusion_groups or None,
|
|
143
|
+
exclusion_groups=self._exclusion_groups or None,
|
|
144
|
+
)
|
|
145
|
+
return cohort.model_dump(by_alias=True, exclude_none=True)
|
|
146
|
+
|
|
147
|
+
def build_model(self) -> CohortGroupInput:
|
|
148
|
+
"""Validate and return as a CohortGroupInput model."""
|
|
149
|
+
if not self._inclusion_groups and not self._exclusion_groups:
|
|
150
|
+
raise ValidationError("Cohort must have at least one inclusion or exclusion group")
|
|
151
|
+
|
|
152
|
+
return CohortGroupInput(
|
|
153
|
+
inclusion_group_operator=self._inclusion_operator,
|
|
154
|
+
inclusion_groups=self._inclusion_groups or None,
|
|
155
|
+
exclusion_groups=self._exclusion_groups or None,
|
|
156
|
+
)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Cohort analysis client — sends GraphQL queries to the Phactor gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from phactor._http import AsyncHTTPClient, SyncHTTPClient
|
|
12
|
+
from phactor.cohorts.models import (
|
|
13
|
+
AnalyzeCohortsPayload,
|
|
14
|
+
CohortGroupInput,
|
|
15
|
+
CohortGroupOperator,
|
|
16
|
+
GlobalFiltersInput,
|
|
17
|
+
StudyContextInput,
|
|
18
|
+
)
|
|
19
|
+
from phactor.cohorts.query import ANALYZE_COHORTS_QUERY
|
|
20
|
+
from phactor.exceptions import GraphQLError
|
|
21
|
+
|
|
22
|
+
# Opt-in instrumentation: when PHACTOR_TIMING is set to a truthy value, every
|
|
23
|
+
# analyzeCohorts call emits one line to stderr summarizing the wire round-trip
|
|
24
|
+
# time and the request shape. The QA harness in .local/qa-verify-dev.sh sets
|
|
25
|
+
# this so each captured `jan_*.txt` file contains timing alongside cohort
|
|
26
|
+
# sizes — diffing two runs surfaces perf regressions / wins without any
|
|
27
|
+
# script-level instrumentation. Off by default; SDK consumers see no change.
|
|
28
|
+
_TIMING_ENV_VAR = "PHACTOR_TIMING"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _timing_enabled() -> bool:
|
|
32
|
+
"""True when PHACTOR_TIMING is set to anything truthy."""
|
|
33
|
+
return os.environ.get(_TIMING_ENV_VAR, "").lower() in ("1", "true", "yes", "on")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _emit_timing(
|
|
37
|
+
elapsed_ms: float,
|
|
38
|
+
variables: dict[str, Any],
|
|
39
|
+
error: BaseException | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Write a single line to stderr describing the call."""
|
|
42
|
+
payload = variables.get("input", {})
|
|
43
|
+
providers = len(payload.get("providers") or [])
|
|
44
|
+
cohort_groups = len(payload.get("cohortGroups") or [])
|
|
45
|
+
flags: list[str] = []
|
|
46
|
+
if payload.get("includeImpactAnalysis"):
|
|
47
|
+
flags.append("impact")
|
|
48
|
+
if payload.get("includeDemographicBreakdown"):
|
|
49
|
+
flags.append("demo")
|
|
50
|
+
if payload.get("includeCombinedDemographicBreakdown"):
|
|
51
|
+
flags.append("combined-demo")
|
|
52
|
+
if payload.get("grouping"):
|
|
53
|
+
flags.append("grouping")
|
|
54
|
+
if payload.get("globalFilters"):
|
|
55
|
+
flags.append("global-filters")
|
|
56
|
+
extras = f" flags={','.join(flags)}" if flags else ""
|
|
57
|
+
suffix = f" error={type(error).__name__}" if error is not None else ""
|
|
58
|
+
print(
|
|
59
|
+
f"[phactor-sdk timing] analyzeCohorts elapsed_ms={elapsed_ms:.0f}"
|
|
60
|
+
f" providers={providers} cohort_groups={cohort_groups}{extras}{suffix}",
|
|
61
|
+
file=sys.stderr,
|
|
62
|
+
flush=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_variables(
|
|
67
|
+
providers: list[str],
|
|
68
|
+
cohort_groups: list[CohortGroupInput | dict[str, Any]],
|
|
69
|
+
cohort_group_operator: CohortGroupOperator | str | None = None,
|
|
70
|
+
study_context: StudyContextInput | dict[str, Any] | None = None,
|
|
71
|
+
include_impact_analysis: bool | None = None,
|
|
72
|
+
include_demographic_breakdown: bool | None = None,
|
|
73
|
+
age_buckets: list[dict[str, int]] | None = None,
|
|
74
|
+
include_combined_demographic_breakdown: bool | None = None,
|
|
75
|
+
grouping: dict[str, Any] | None = None,
|
|
76
|
+
global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""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
|
+
}
|
|
91
|
+
}
|
|
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
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_response(data: dict[str, Any]) -> AnalyzeCohortsPayload:
|
|
137
|
+
"""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)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class CohortsClient:
|
|
168
|
+
"""Synchronous client for cohort analysis queries."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, http_client: SyncHTTPClient, gateway_url: str) -> None:
|
|
171
|
+
self._http = http_client
|
|
172
|
+
self._gateway_url = gateway_url
|
|
173
|
+
|
|
174
|
+
def analyze(
|
|
175
|
+
self,
|
|
176
|
+
providers: list[str],
|
|
177
|
+
cohort_groups: list[CohortGroupInput | dict[str, Any]],
|
|
178
|
+
*,
|
|
179
|
+
cohort_group_operator: CohortGroupOperator | str | None = None,
|
|
180
|
+
study_context: StudyContextInput | dict[str, Any] | None = None,
|
|
181
|
+
include_impact_analysis: bool | None = None,
|
|
182
|
+
include_demographic_breakdown: bool | None = None,
|
|
183
|
+
age_buckets: list[dict[str, int]] | None = None,
|
|
184
|
+
include_combined_demographic_breakdown: bool | None = None,
|
|
185
|
+
grouping: dict[str, Any] | None = None,
|
|
186
|
+
global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
|
|
187
|
+
) -> AnalyzeCohortsPayload:
|
|
188
|
+
"""Run a cohort feasibility analysis.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
providers: List of provider UUIDs to query.
|
|
192
|
+
cohort_groups: Cohort group definitions (Pydantic models or dicts).
|
|
193
|
+
cohort_group_operator: Optional top-level operator (AND/OR) combining
|
|
194
|
+
multiple cohort_groups. Defaults to the server's behavior when
|
|
195
|
+
omitted.
|
|
196
|
+
study_context: Optional study context metadata.
|
|
197
|
+
include_impact_analysis: Include per-criterion impact analysis.
|
|
198
|
+
include_demographic_breakdown: Include demographic breakdowns.
|
|
199
|
+
age_buckets: Custom age range buckets for demographic breakdown.
|
|
200
|
+
include_combined_demographic_breakdown:
|
|
201
|
+
Include combined-population demographic breakdown.
|
|
202
|
+
grouping: Geographic grouping configuration dict.
|
|
203
|
+
global_filters: Optional global filters applied to all cohort groups
|
|
204
|
+
(e.g. restrict the study to specific states or geographies).
|
|
205
|
+
Returns:
|
|
206
|
+
AnalyzeCohortsPayload with analysis results.
|
|
207
|
+
"""
|
|
208
|
+
variables = _build_variables(
|
|
209
|
+
providers,
|
|
210
|
+
cohort_groups,
|
|
211
|
+
cohort_group_operator,
|
|
212
|
+
study_context,
|
|
213
|
+
include_impact_analysis,
|
|
214
|
+
include_demographic_breakdown,
|
|
215
|
+
age_buckets,
|
|
216
|
+
include_combined_demographic_breakdown,
|
|
217
|
+
grouping,
|
|
218
|
+
global_filters,
|
|
219
|
+
)
|
|
220
|
+
body = {"query": ANALYZE_COHORTS_QUERY, "variables": variables}
|
|
221
|
+
if _timing_enabled():
|
|
222
|
+
t0 = time.perf_counter()
|
|
223
|
+
error: BaseException | None = None
|
|
224
|
+
try:
|
|
225
|
+
response = self._http.post(self._gateway_url, json=body)
|
|
226
|
+
except BaseException as exc:
|
|
227
|
+
error = exc
|
|
228
|
+
raise
|
|
229
|
+
finally:
|
|
230
|
+
_emit_timing((time.perf_counter() - t0) * 1000.0, variables, error)
|
|
231
|
+
return _parse_response(response)
|
|
232
|
+
response = self._http.post(self._gateway_url, json=body)
|
|
233
|
+
return _parse_response(response)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class AsyncCohortsClient:
|
|
237
|
+
"""Async client for cohort analysis queries."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, http_client: AsyncHTTPClient, gateway_url: str) -> None:
|
|
240
|
+
self._http = http_client
|
|
241
|
+
self._gateway_url = gateway_url
|
|
242
|
+
|
|
243
|
+
async def analyze(
|
|
244
|
+
self,
|
|
245
|
+
providers: list[str],
|
|
246
|
+
cohort_groups: list[CohortGroupInput | dict[str, Any]],
|
|
247
|
+
*,
|
|
248
|
+
cohort_group_operator: CohortGroupOperator | str | None = None,
|
|
249
|
+
study_context: StudyContextInput | dict[str, Any] | None = None,
|
|
250
|
+
include_impact_analysis: bool | None = None,
|
|
251
|
+
include_demographic_breakdown: bool | None = None,
|
|
252
|
+
age_buckets: list[dict[str, int]] | None = None,
|
|
253
|
+
include_combined_demographic_breakdown: bool | None = None,
|
|
254
|
+
grouping: dict[str, Any] | None = None,
|
|
255
|
+
global_filters: GlobalFiltersInput | dict[str, Any] | None = None,
|
|
256
|
+
) -> AnalyzeCohortsPayload:
|
|
257
|
+
"""Run a cohort feasibility analysis (async)."""
|
|
258
|
+
variables = _build_variables(
|
|
259
|
+
providers,
|
|
260
|
+
cohort_groups,
|
|
261
|
+
cohort_group_operator,
|
|
262
|
+
study_context,
|
|
263
|
+
include_impact_analysis,
|
|
264
|
+
include_demographic_breakdown,
|
|
265
|
+
age_buckets,
|
|
266
|
+
include_combined_demographic_breakdown,
|
|
267
|
+
grouping,
|
|
268
|
+
global_filters,
|
|
269
|
+
)
|
|
270
|
+
body = {"query": ANALYZE_COHORTS_QUERY, "variables": variables}
|
|
271
|
+
if _timing_enabled():
|
|
272
|
+
t0 = time.perf_counter()
|
|
273
|
+
error: BaseException | None = None
|
|
274
|
+
try:
|
|
275
|
+
response = await self._http.post(self._gateway_url, json=body)
|
|
276
|
+
except BaseException as exc:
|
|
277
|
+
error = exc
|
|
278
|
+
raise
|
|
279
|
+
finally:
|
|
280
|
+
_emit_timing((time.perf_counter() - t0) * 1000.0, variables, error)
|
|
281
|
+
return _parse_response(response)
|
|
282
|
+
response = await self._http.post(self._gateway_url, json=body)
|
|
283
|
+
return _parse_response(response)
|