dysplai 1.0.0__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.
- dysplai-1.0.0/PKG-INFO +171 -0
- dysplai-1.0.0/README.md +131 -0
- dysplai-1.0.0/dysplai/__init__.py +66 -0
- dysplai-1.0.0/dysplai/account.py +33 -0
- dysplai-1.0.0/dysplai/analysis.py +174 -0
- dysplai-1.0.0/dysplai/assays.py +90 -0
- dysplai-1.0.0/dysplai/client.py +225 -0
- dysplai-1.0.0/dysplai/clinical.py +124 -0
- dysplai-1.0.0/dysplai/cohort.py +174 -0
- dysplai-1.0.0/dysplai/counterfactual.py +427 -0
- dysplai-1.0.0/dysplai/experts.py +160 -0
- dysplai-1.0.0/dysplai/graph.py +61 -0
- dysplai-1.0.0/dysplai/imaging.py +324 -0
- dysplai-1.0.0/dysplai/interpretation.py +40 -0
- dysplai-1.0.0/dysplai/model_builder.py +139 -0
- dysplai-1.0.0/dysplai/models/__init__.py +428 -0
- dysplai-1.0.0/dysplai/py.typed +0 -0
- dysplai-1.0.0/dysplai/reports.py +117 -0
- dysplai-1.0.0/dysplai.egg-info/PKG-INFO +171 -0
- dysplai-1.0.0/dysplai.egg-info/SOURCES.txt +35 -0
- dysplai-1.0.0/dysplai.egg-info/dependency_links.txt +1 -0
- dysplai-1.0.0/dysplai.egg-info/requires.txt +19 -0
- dysplai-1.0.0/dysplai.egg-info/top_level.txt +1 -0
- dysplai-1.0.0/pyproject.toml +55 -0
- dysplai-1.0.0/setup.cfg +4 -0
- dysplai-1.0.0/tests/test_account_client.py +111 -0
- dysplai-1.0.0/tests/test_analysis_client.py +149 -0
- dysplai-1.0.0/tests/test_cohort_client.py +66 -0
- dysplai-1.0.0/tests/test_counterfactual_client.py +156 -0
- dysplai-1.0.0/tests/test_experts_graph_assays.py +189 -0
- dysplai-1.0.0/tests/test_model_builder_client.py +96 -0
- dysplai-1.0.0/tests/test_models.py +138 -0
- dysplai-1.0.0/tests/test_openapi_contract.py +238 -0
- dysplai-1.0.0/tests/test_packaging.py +46 -0
- dysplai-1.0.0/tests/test_reports_client.py +76 -0
- dysplai-1.0.0/tests/test_sdk_clients.py +690 -0
- dysplai-1.0.0/tests/test_transport.py +271 -0
dysplai-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dysplai
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: DYSPLAI Oncology Intelligence API — Python SDK (Research Use Only)
|
|
5
|
+
Author: Dysplasia Diagnostics Limited
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://www.dysplasiadx.com
|
|
8
|
+
Project-URL: Documentation, https://www.dysplasiadx.com/developers
|
|
9
|
+
Keywords: oncology,bioinformatics,genomics,transcriptomics,research,api,sdk
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
13
|
+
Classifier: License :: Other/Proprietary License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Requires-Dist: pydantic>=2.0
|
|
26
|
+
Requires-Dist: typing-extensions>=4.0
|
|
27
|
+
Provides-Extra: async
|
|
28
|
+
Requires-Dist: anyio>=4.0; extra == "async"
|
|
29
|
+
Provides-Extra: notebooks
|
|
30
|
+
Requires-Dist: pandas>=2.0; extra == "notebooks"
|
|
31
|
+
Requires-Dist: matplotlib>=3.8; extra == "notebooks"
|
|
32
|
+
Requires-Dist: seaborn>=0.13; extra == "notebooks"
|
|
33
|
+
Requires-Dist: ipywidgets>=8.0; extra == "notebooks"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
37
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
38
|
+
Requires-Dist: pyyaml>=6.0; extra == "dev"
|
|
39
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
40
|
+
|
|
41
|
+
# DYSPLAI Python SDK
|
|
42
|
+
|
|
43
|
+
**For Research Use Only. Not for use in diagnostic procedures.**
|
|
44
|
+
|
|
45
|
+
Typed Python client for the DYSPLAI Oncology Intelligence API. Covers all seven API surfaces with Pydantic v2 response models and a blocking `wait()` helper for long-running analysis jobs.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install dysplai
|
|
51
|
+
# with notebook dependencies:
|
|
52
|
+
pip install "dysplai[notebooks]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from dysplai import DysplaiClient
|
|
59
|
+
|
|
60
|
+
with DysplaiClient(api_key="your-key") as client:
|
|
61
|
+
# Submit a molecular analysis
|
|
62
|
+
job = client.analysis.submit(
|
|
63
|
+
input_uri="gs://your-bucket/sample.csv",
|
|
64
|
+
sample_id="SAMPLE_001",
|
|
65
|
+
cancer_type_hint="lung_adeno",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Wait for analysis to complete (~5–15 min)
|
|
69
|
+
result = client.analysis.wait(job.analysis_id, poll_interval=10)
|
|
70
|
+
|
|
71
|
+
print(result.cancer_type_probabilities)
|
|
72
|
+
print(result.biological_neighbourhood_description)
|
|
73
|
+
print(result.ruo_disclaimer)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API surfaces
|
|
77
|
+
|
|
78
|
+
| Service | Attribute | Methods |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| Analysis | `client.analysis` | `submit`, `status`, `result`, `wait`, `list` |
|
|
81
|
+
| Cohort | `client.cohort` | `summarise`, `compare`, `neighbours` |
|
|
82
|
+
| Counterfactual | `client.counterfactual` | `sensitivity`, `simulate`, `features`, `pathway_shift_search` |
|
|
83
|
+
| Interpretation | `client.interpretation` | `generate`, `classify_intent` |
|
|
84
|
+
| Reports | `client.reports` | `generate`, `status`, `wait`, `download`, `list` |
|
|
85
|
+
| Clinical | `client.clinical` | `parse`, `annotate`, `labels`, `timeline`, `get_mapping` |
|
|
86
|
+
|
|
87
|
+
## Counterfactual example
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
# Pathway sensitivity gradients
|
|
91
|
+
sens = client.counterfactual.sensitivity(result.analysis_id)
|
|
92
|
+
print(sens.validity_panel.interpretation_status)
|
|
93
|
+
for s in sorted(sens.sensitivities, key=lambda x: abs(x.gradient), reverse=True)[:5]:
|
|
94
|
+
print(f" {s.pathway}: {s.gradient:+.4f}")
|
|
95
|
+
|
|
96
|
+
# Simulate atlas shift — conservative perturbation bounds 0.5×–2.0×
|
|
97
|
+
sim = client.counterfactual.simulate(
|
|
98
|
+
analysis_id=result.analysis_id,
|
|
99
|
+
perturbations={"HALLMARK_GLYCOLYSIS": 1.5, "HALLMARK_MYC_TARGETS_V1": 0.7},
|
|
100
|
+
)
|
|
101
|
+
print(sim.atlas_shift.neighbourhood_shift_description)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Cohort comparison
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
summary = client.cohort.summarise(["id-1", "id-2", "id-3"])
|
|
108
|
+
|
|
109
|
+
comparison = client.cohort.compare(
|
|
110
|
+
group_a_analysis_ids=["id-1", "id-2"],
|
|
111
|
+
group_b_analysis_ids=["id-3", "id-4"],
|
|
112
|
+
fdr_threshold=0.05,
|
|
113
|
+
)
|
|
114
|
+
for p in comparison.differential_pathways[:5]:
|
|
115
|
+
print(f"{p.pathway}: delta={p.delta:+.3f} q={p.fdr_q_value:.3e}")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Report generation
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
report = client.reports.generate(result.analysis_id, format="pdf")
|
|
122
|
+
ready = client.reports.wait(report.report_id)
|
|
123
|
+
client.reports.download(ready.report_id, "sample_report.pdf")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Clinical metadata
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
parsed = client.clinical.parse(
|
|
130
|
+
{"diagnosis": "NSCLC", "age_at_diagnosis": 60, "ecog_ps": 1},
|
|
131
|
+
) # direct identifiers are stripped automatically
|
|
132
|
+
client.clinical.annotate(result.analysis_id, clinical_record_id=parsed.clinical_record_id)
|
|
133
|
+
|
|
134
|
+
# Outcome labels + timepoint harmonisation from the same record
|
|
135
|
+
labels = client.clinical.labels("treatment_start", clinical_record_id=parsed.clinical_record_id)
|
|
136
|
+
timeline = client.clinical.timeline(
|
|
137
|
+
[{"clinical_record_id": parsed.clinical_record_id}],
|
|
138
|
+
index_event_type="treatment_start",
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Error handling
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from dysplai import AuthError, QuotaError, NotFoundError, ValidationError
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
result = client.analysis.result("unknown-id")
|
|
149
|
+
except NotFoundError:
|
|
150
|
+
print("Analysis not found")
|
|
151
|
+
except QuotaError:
|
|
152
|
+
print("Quota exceeded")
|
|
153
|
+
except AuthError:
|
|
154
|
+
print("Invalid or expired API key")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Notebooks
|
|
158
|
+
|
|
159
|
+
Five Colab-runnable notebooks are in `sdk/notebooks/`:
|
|
160
|
+
|
|
161
|
+
| Notebook | Topic |
|
|
162
|
+
|---|---|
|
|
163
|
+
| `01_cohort_phenotyping.ipynb` | Cohort summarisation, group comparison, volcano plot |
|
|
164
|
+
| `02_responder_fingerprinting.ipynb` | Submit, wait, pathway fingerprint, atlas neighbours |
|
|
165
|
+
| `03_pathway_counterfactual.ipynb` | Sensitivity gradients, simulate, natural-language shift search |
|
|
166
|
+
| `04_resistance_surveillance.ipynb` | Serial timepoint atlas drift, immune phenotype monitoring |
|
|
167
|
+
| `05_clinical_metadata_enrichment.ipynb` | De-identification, normalisation, batch annotation |
|
|
168
|
+
|
|
169
|
+
## RUO notice
|
|
170
|
+
|
|
171
|
+
All response objects include `ruo_disclaimer: "For Research Use Only. Not for use in diagnostic procedures."` This field is present on every molecular result, cohort output, counterfactual response, interpretation, and report. It is not suppressible.
|
dysplai-1.0.0/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# DYSPLAI Python SDK
|
|
2
|
+
|
|
3
|
+
**For Research Use Only. Not for use in diagnostic procedures.**
|
|
4
|
+
|
|
5
|
+
Typed Python client for the DYSPLAI Oncology Intelligence API. Covers all seven API surfaces with Pydantic v2 response models and a blocking `wait()` helper for long-running analysis jobs.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install dysplai
|
|
11
|
+
# with notebook dependencies:
|
|
12
|
+
pip install "dysplai[notebooks]"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from dysplai import DysplaiClient
|
|
19
|
+
|
|
20
|
+
with DysplaiClient(api_key="your-key") as client:
|
|
21
|
+
# Submit a molecular analysis
|
|
22
|
+
job = client.analysis.submit(
|
|
23
|
+
input_uri="gs://your-bucket/sample.csv",
|
|
24
|
+
sample_id="SAMPLE_001",
|
|
25
|
+
cancer_type_hint="lung_adeno",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Wait for analysis to complete (~5–15 min)
|
|
29
|
+
result = client.analysis.wait(job.analysis_id, poll_interval=10)
|
|
30
|
+
|
|
31
|
+
print(result.cancer_type_probabilities)
|
|
32
|
+
print(result.biological_neighbourhood_description)
|
|
33
|
+
print(result.ruo_disclaimer)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## API surfaces
|
|
37
|
+
|
|
38
|
+
| Service | Attribute | Methods |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| Analysis | `client.analysis` | `submit`, `status`, `result`, `wait`, `list` |
|
|
41
|
+
| Cohort | `client.cohort` | `summarise`, `compare`, `neighbours` |
|
|
42
|
+
| Counterfactual | `client.counterfactual` | `sensitivity`, `simulate`, `features`, `pathway_shift_search` |
|
|
43
|
+
| Interpretation | `client.interpretation` | `generate`, `classify_intent` |
|
|
44
|
+
| Reports | `client.reports` | `generate`, `status`, `wait`, `download`, `list` |
|
|
45
|
+
| Clinical | `client.clinical` | `parse`, `annotate`, `labels`, `timeline`, `get_mapping` |
|
|
46
|
+
|
|
47
|
+
## Counterfactual example
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# Pathway sensitivity gradients
|
|
51
|
+
sens = client.counterfactual.sensitivity(result.analysis_id)
|
|
52
|
+
print(sens.validity_panel.interpretation_status)
|
|
53
|
+
for s in sorted(sens.sensitivities, key=lambda x: abs(x.gradient), reverse=True)[:5]:
|
|
54
|
+
print(f" {s.pathway}: {s.gradient:+.4f}")
|
|
55
|
+
|
|
56
|
+
# Simulate atlas shift — conservative perturbation bounds 0.5×–2.0×
|
|
57
|
+
sim = client.counterfactual.simulate(
|
|
58
|
+
analysis_id=result.analysis_id,
|
|
59
|
+
perturbations={"HALLMARK_GLYCOLYSIS": 1.5, "HALLMARK_MYC_TARGETS_V1": 0.7},
|
|
60
|
+
)
|
|
61
|
+
print(sim.atlas_shift.neighbourhood_shift_description)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Cohort comparison
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
summary = client.cohort.summarise(["id-1", "id-2", "id-3"])
|
|
68
|
+
|
|
69
|
+
comparison = client.cohort.compare(
|
|
70
|
+
group_a_analysis_ids=["id-1", "id-2"],
|
|
71
|
+
group_b_analysis_ids=["id-3", "id-4"],
|
|
72
|
+
fdr_threshold=0.05,
|
|
73
|
+
)
|
|
74
|
+
for p in comparison.differential_pathways[:5]:
|
|
75
|
+
print(f"{p.pathway}: delta={p.delta:+.3f} q={p.fdr_q_value:.3e}")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Report generation
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
report = client.reports.generate(result.analysis_id, format="pdf")
|
|
82
|
+
ready = client.reports.wait(report.report_id)
|
|
83
|
+
client.reports.download(ready.report_id, "sample_report.pdf")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Clinical metadata
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
parsed = client.clinical.parse(
|
|
90
|
+
{"diagnosis": "NSCLC", "age_at_diagnosis": 60, "ecog_ps": 1},
|
|
91
|
+
) # direct identifiers are stripped automatically
|
|
92
|
+
client.clinical.annotate(result.analysis_id, clinical_record_id=parsed.clinical_record_id)
|
|
93
|
+
|
|
94
|
+
# Outcome labels + timepoint harmonisation from the same record
|
|
95
|
+
labels = client.clinical.labels("treatment_start", clinical_record_id=parsed.clinical_record_id)
|
|
96
|
+
timeline = client.clinical.timeline(
|
|
97
|
+
[{"clinical_record_id": parsed.clinical_record_id}],
|
|
98
|
+
index_event_type="treatment_start",
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Error handling
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from dysplai import AuthError, QuotaError, NotFoundError, ValidationError
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
result = client.analysis.result("unknown-id")
|
|
109
|
+
except NotFoundError:
|
|
110
|
+
print("Analysis not found")
|
|
111
|
+
except QuotaError:
|
|
112
|
+
print("Quota exceeded")
|
|
113
|
+
except AuthError:
|
|
114
|
+
print("Invalid or expired API key")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Notebooks
|
|
118
|
+
|
|
119
|
+
Five Colab-runnable notebooks are in `sdk/notebooks/`:
|
|
120
|
+
|
|
121
|
+
| Notebook | Topic |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `01_cohort_phenotyping.ipynb` | Cohort summarisation, group comparison, volcano plot |
|
|
124
|
+
| `02_responder_fingerprinting.ipynb` | Submit, wait, pathway fingerprint, atlas neighbours |
|
|
125
|
+
| `03_pathway_counterfactual.ipynb` | Sensitivity gradients, simulate, natural-language shift search |
|
|
126
|
+
| `04_resistance_surveillance.ipynb` | Serial timepoint atlas drift, immune phenotype monitoring |
|
|
127
|
+
| `05_clinical_metadata_enrichment.ipynb` | De-identification, normalisation, batch annotation |
|
|
128
|
+
|
|
129
|
+
## RUO notice
|
|
130
|
+
|
|
131
|
+
All response objects include `ruo_disclaimer: "For Research Use Only. Not for use in diagnostic procedures."` This field is present on every molecular result, cohort output, counterfactual response, interpretation, and report. It is not suppressible.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""DYSPLAI Oncology Intelligence API — Python SDK.
|
|
2
|
+
|
|
3
|
+
For Research Use Only. Not for use in diagnostic procedures.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
from dysplai import DysplaiClient
|
|
8
|
+
|
|
9
|
+
client = DysplaiClient(api_key="your-key")
|
|
10
|
+
|
|
11
|
+
# Submit and wait for analysis
|
|
12
|
+
job = client.analysis.submit("gs://bucket/sample.csv", input_type="count_matrix",
|
|
13
|
+
sample_id="S001")
|
|
14
|
+
result = client.analysis.wait(job.analysis_id)
|
|
15
|
+
|
|
16
|
+
# Pathway sensitivity
|
|
17
|
+
sens = client.counterfactual.sensitivity(result.analysis_id)
|
|
18
|
+
|
|
19
|
+
# Research interpretation (grounded in an analysis)
|
|
20
|
+
interp = client.interpretation.query(
|
|
21
|
+
"Which pathways are enriched in this molecular neighbourhood?",
|
|
22
|
+
analysis_id=result.analysis_id,
|
|
23
|
+
)
|
|
24
|
+
print(interp.answer)
|
|
25
|
+
"""
|
|
26
|
+
from .client import (
|
|
27
|
+
AuthError,
|
|
28
|
+
DysplaiClient,
|
|
29
|
+
DysplaiError,
|
|
30
|
+
InsufficientCreditsError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
QuotaError,
|
|
33
|
+
ValidationError,
|
|
34
|
+
)
|
|
35
|
+
from .models import (
|
|
36
|
+
AnalysisResult,
|
|
37
|
+
AnalysisSubmitResponse,
|
|
38
|
+
ClinicalAnnotateResponse,
|
|
39
|
+
ClinicalParseResponse,
|
|
40
|
+
CohortCompareResponse,
|
|
41
|
+
CohortSummaryResponse,
|
|
42
|
+
CreditBalance,
|
|
43
|
+
Identity,
|
|
44
|
+
InterpretationResponse,
|
|
45
|
+
NeighboursResponse,
|
|
46
|
+
ReportMetadata,
|
|
47
|
+
SensitivityResponse,
|
|
48
|
+
SimulationResponse,
|
|
49
|
+
UsageSummary,
|
|
50
|
+
ValidityPanel,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__version__ = "1.0.0"
|
|
54
|
+
__all__ = [
|
|
55
|
+
"DysplaiClient",
|
|
56
|
+
"DysplaiError", "AuthError", "QuotaError", "InsufficientCreditsError",
|
|
57
|
+
"NotFoundError", "ValidationError",
|
|
58
|
+
"ValidityPanel",
|
|
59
|
+
"AnalysisSubmitResponse", "AnalysisResult",
|
|
60
|
+
"CohortSummaryResponse", "CohortCompareResponse", "NeighboursResponse",
|
|
61
|
+
"SensitivityResponse", "SimulationResponse",
|
|
62
|
+
"InterpretationResponse",
|
|
63
|
+
"ReportMetadata",
|
|
64
|
+
"ClinicalParseResponse", "ClinicalAnnotateResponse",
|
|
65
|
+
"Identity", "CreditBalance", "UsageSummary",
|
|
66
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Account service client — identity, metered usage, and credit balance.
|
|
2
|
+
|
|
3
|
+
Wraps the two gateway-exposed account endpoints:
|
|
4
|
+
|
|
5
|
+
- ``GET /v1/whoami`` — the tenant + capability scopes behind the calling key.
|
|
6
|
+
- ``GET /v1/usage`` — current-period metered usage against limits, plus the
|
|
7
|
+
credit balance when billing is active.
|
|
8
|
+
|
|
9
|
+
Credits are the billing currency: a request that needs more than the
|
|
10
|
+
available balance is rejected with HTTP 402 (``InsufficientCreditsError``), so
|
|
11
|
+
``balance()`` is the pre-flight a caller uses to check headroom.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .client import _Client
|
|
16
|
+
from .models import CreditBalance, Identity, UsageSummary
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AccountService:
|
|
20
|
+
def __init__(self, http: _Client) -> None:
|
|
21
|
+
self._http = http
|
|
22
|
+
|
|
23
|
+
def whoami(self) -> Identity:
|
|
24
|
+
"""Identify the tenant and capability scopes behind the calling key."""
|
|
25
|
+
return Identity.model_validate(self._http.get("/v1/whoami"))
|
|
26
|
+
|
|
27
|
+
def usage(self) -> UsageSummary:
|
|
28
|
+
"""Current billing-period metered usage, limits, and credit balance."""
|
|
29
|
+
return UsageSummary.model_validate(self._http.get("/v1/usage"))
|
|
30
|
+
|
|
31
|
+
def balance(self) -> CreditBalance | None:
|
|
32
|
+
"""Convenience: the credit balance, or None when billing is inactive."""
|
|
33
|
+
return self.usage().credits
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Analysis service client — submission, polling, and result retrieval."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import builtins
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .client import _Client
|
|
8
|
+
from .models import AnalysisResult, AnalysisSubmitResponse
|
|
9
|
+
|
|
10
|
+
_BASE = "/v1/analyses"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AnalysisService:
|
|
14
|
+
def __init__(self, http: _Client) -> None:
|
|
15
|
+
self._http = http
|
|
16
|
+
|
|
17
|
+
def submit(
|
|
18
|
+
self,
|
|
19
|
+
input_uri: str,
|
|
20
|
+
input_type: str,
|
|
21
|
+
sample_id: str = "",
|
|
22
|
+
atlas_version: str | None = None,
|
|
23
|
+
config: dict | None = None,
|
|
24
|
+
qc_profile: str | None = None,
|
|
25
|
+
assay_context: str | None = None,
|
|
26
|
+
genome_build: str | None = None,
|
|
27
|
+
webhook_url: str | None = None,
|
|
28
|
+
) -> AnalysisSubmitResponse:
|
|
29
|
+
"""Submit a new analysis. Returns immediately with analysis_id and 'queued' status.
|
|
30
|
+
|
|
31
|
+
input_type: one of fastq | fastq_paired | bam | count_matrix |
|
|
32
|
+
expression_tsv | stringtie_gtf.
|
|
33
|
+
"""
|
|
34
|
+
body: dict = {"input_uri": input_uri, "input_type": input_type}
|
|
35
|
+
if sample_id:
|
|
36
|
+
body["sample_id"] = sample_id
|
|
37
|
+
if atlas_version:
|
|
38
|
+
body["atlas_version"] = atlas_version
|
|
39
|
+
if config:
|
|
40
|
+
body["config"] = config
|
|
41
|
+
if qc_profile:
|
|
42
|
+
body["qc_profile"] = qc_profile
|
|
43
|
+
if assay_context:
|
|
44
|
+
body["assay_context"] = assay_context
|
|
45
|
+
if genome_build:
|
|
46
|
+
body["genome_build"] = genome_build
|
|
47
|
+
if webhook_url:
|
|
48
|
+
body["webhook_url"] = webhook_url
|
|
49
|
+
data = self._http.post(_BASE, json=body)
|
|
50
|
+
return AnalysisSubmitResponse.model_validate(data)
|
|
51
|
+
|
|
52
|
+
def submit_batch(
|
|
53
|
+
self,
|
|
54
|
+
manifest_content: str = "",
|
|
55
|
+
manifest_uri: str = "",
|
|
56
|
+
pipeline_profile: str = "comprehensive",
|
|
57
|
+
qc_profile: str = "auto",
|
|
58
|
+
assay_context: str = "solid_tumour",
|
|
59
|
+
genome_build: str = "GRCh38",
|
|
60
|
+
atlas_version: str | None = None,
|
|
61
|
+
webhook_url: str | None = None,
|
|
62
|
+
) -> dict:
|
|
63
|
+
"""Submit a samplesheet for multi-sample analysis.
|
|
64
|
+
|
|
65
|
+
Provide the nf-core-style TSV samplesheet inline via ``manifest_content``
|
|
66
|
+
or by staged ``manifest_uri`` (gs://) — exactly one. Each row is fanned
|
|
67
|
+
out to an independent analysis sharing a ``batch_id``. The other fields
|
|
68
|
+
are defaults applied to rows that don't override them in their own column.
|
|
69
|
+
|
|
70
|
+
Returns the batch envelope: {batch_id, count, analyses: [...]}. Poll
|
|
71
|
+
progress with ``batch_status(batch_id)``.
|
|
72
|
+
"""
|
|
73
|
+
if bool(manifest_content) == bool(manifest_uri):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Provide exactly one of manifest_content or manifest_uri."
|
|
76
|
+
)
|
|
77
|
+
body: dict = {
|
|
78
|
+
"pipeline_profile": pipeline_profile,
|
|
79
|
+
"qc_profile": qc_profile,
|
|
80
|
+
"assay_context": assay_context,
|
|
81
|
+
"genome_build": genome_build,
|
|
82
|
+
}
|
|
83
|
+
if manifest_content:
|
|
84
|
+
body["manifest_content"] = manifest_content
|
|
85
|
+
if manifest_uri:
|
|
86
|
+
body["manifest_uri"] = manifest_uri
|
|
87
|
+
if atlas_version:
|
|
88
|
+
body["atlas_version"] = atlas_version
|
|
89
|
+
if webhook_url:
|
|
90
|
+
body["webhook_url"] = webhook_url
|
|
91
|
+
return self._http.post(f"{_BASE}/batch", json=body)
|
|
92
|
+
|
|
93
|
+
def stage(self, local_path: str, input_type: str = "") -> str:
|
|
94
|
+
"""Upload a LOCAL file into tenant-scoped GCS and return its gs:// URI.
|
|
95
|
+
|
|
96
|
+
Requests a short-lived signed URL from the API, then uploads the bytes
|
|
97
|
+
directly to GCS (never through the API). The returned URI can be passed
|
|
98
|
+
straight to ``submit`` / ``submit_batch``.
|
|
99
|
+
|
|
100
|
+
input_type (optional) validates the filename extension server-side.
|
|
101
|
+
"""
|
|
102
|
+
import os
|
|
103
|
+
|
|
104
|
+
filename = os.path.basename(local_path)
|
|
105
|
+
body: dict = {"filename": filename}
|
|
106
|
+
if input_type:
|
|
107
|
+
body["input_type"] = input_type
|
|
108
|
+
staged = self._http.post("/v1/uploads", json=body)
|
|
109
|
+
|
|
110
|
+
with open(local_path, "rb") as fh:
|
|
111
|
+
data = fh.read()
|
|
112
|
+
self._http.put_binary(staged["upload_url"], data)
|
|
113
|
+
return staged["gcs_uri"]
|
|
114
|
+
|
|
115
|
+
def submit_local(
|
|
116
|
+
self,
|
|
117
|
+
local_path: str,
|
|
118
|
+
input_type: str,
|
|
119
|
+
sample_id: str = "",
|
|
120
|
+
**kwargs,
|
|
121
|
+
) -> AnalysisSubmitResponse:
|
|
122
|
+
"""Stage a LOCAL file to GCS, then submit it for analysis in one call.
|
|
123
|
+
|
|
124
|
+
Convenience wrapper over ``stage`` + ``submit`` for callers who have a
|
|
125
|
+
file on disk rather than a pre-staged gs:// URI.
|
|
126
|
+
"""
|
|
127
|
+
gcs_uri = self.stage(local_path, input_type=input_type)
|
|
128
|
+
return self.submit(gcs_uri, input_type, sample_id=sample_id, **kwargs)
|
|
129
|
+
|
|
130
|
+
def batch_status(self, batch_id: str) -> dict:
|
|
131
|
+
"""Roll-up status for every analysis in a samplesheet batch."""
|
|
132
|
+
return self._http.get(f"{_BASE}/batch/{batch_id}")
|
|
133
|
+
|
|
134
|
+
def status(self, analysis_id: str) -> AnalysisResult:
|
|
135
|
+
"""Poll the status of an in-progress analysis."""
|
|
136
|
+
data = self._http.get(f"{_BASE}/{analysis_id}")
|
|
137
|
+
return AnalysisResult.model_validate(data)
|
|
138
|
+
|
|
139
|
+
def result(self, analysis_id: str) -> AnalysisResult:
|
|
140
|
+
"""Retrieve the completed result for an analysis."""
|
|
141
|
+
data = self._http.get(f"{_BASE}/{analysis_id}/results")
|
|
142
|
+
return AnalysisResult.model_validate(data)
|
|
143
|
+
|
|
144
|
+
def wait(
|
|
145
|
+
self,
|
|
146
|
+
analysis_id: str,
|
|
147
|
+
poll_interval: float = 5.0,
|
|
148
|
+
timeout: float = 600.0,
|
|
149
|
+
) -> AnalysisResult:
|
|
150
|
+
"""Block until the analysis completes or timeout is reached.
|
|
151
|
+
|
|
152
|
+
Polls status every poll_interval seconds. Raises TimeoutError if the
|
|
153
|
+
analysis is still pending after timeout seconds.
|
|
154
|
+
"""
|
|
155
|
+
deadline = time.monotonic() + timeout
|
|
156
|
+
while True:
|
|
157
|
+
result = self.status(analysis_id)
|
|
158
|
+
if result.status == "completed":
|
|
159
|
+
return self.result(analysis_id)
|
|
160
|
+
if result.status == "failed":
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
f"Analysis {analysis_id} failed. Check logs for details."
|
|
163
|
+
)
|
|
164
|
+
if time.monotonic() > deadline:
|
|
165
|
+
raise TimeoutError(
|
|
166
|
+
f"Analysis {analysis_id} did not complete within {timeout}s. "
|
|
167
|
+
f"Last status: {result.status}"
|
|
168
|
+
)
|
|
169
|
+
time.sleep(poll_interval)
|
|
170
|
+
|
|
171
|
+
def list(self, limit: int = 20, offset: int = 0) -> builtins.list[AnalysisResult]:
|
|
172
|
+
"""List analyses for this tenant, most recent first."""
|
|
173
|
+
data = self._http.get(f"{_BASE}", params={"limit": limit, "offset": offset})
|
|
174
|
+
return [AnalysisResult.model_validate(r) for r in data.get("analyses", [])]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Assay-design client — primer design over back-splice junctions.
|
|
2
|
+
|
|
3
|
+
Submit BSJ coordinates, poll the job, fetch the designed assays. Coordinates are
|
|
4
|
+
``chr:start-end:strand`` against the genome build you name — ``GRCh38`` by
|
|
5
|
+
default, and getting this wrong is not a validation error, it is a silently
|
|
6
|
+
wrong design: hg19 coordinates against a GRCh38 reference resolve to different
|
|
7
|
+
sequence and produce primers for the wrong locus.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .client import _Client
|
|
15
|
+
|
|
16
|
+
_BASE = "/v1/assays"
|
|
17
|
+
|
|
18
|
+
#: Terminal states. Compared case-insensitively — the service answers COMPLETED
|
|
19
|
+
#: in upper case, and a lower-case comparison here silently never matches, which
|
|
20
|
+
#: turns a finished job into a poll loop that runs to its timeout.
|
|
21
|
+
_TERMINAL = {"completed", "failed", "cancelled"}
|
|
22
|
+
_SUCCESS = {"completed"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AssayDesignService:
|
|
26
|
+
def __init__(self, http: _Client) -> None:
|
|
27
|
+
self._http = http
|
|
28
|
+
|
|
29
|
+
def design(
|
|
30
|
+
self,
|
|
31
|
+
coordinates: list[str],
|
|
32
|
+
*,
|
|
33
|
+
assay_mode: str = "auto",
|
|
34
|
+
genome_build: str = "GRCh38",
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Submit BSJ coordinates for primer design. Returns the job.
|
|
37
|
+
|
|
38
|
+
``coordinates`` are ``chr:start-end:strand``, 1 to 500 of them.
|
|
39
|
+
|
|
40
|
+
``assay_mode`` is one of:
|
|
41
|
+
``co_amplification`` — Mode A, shared reverse primer, 3 primers
|
|
42
|
+
``linear_specific`` — Mode B, independent, 4 primers
|
|
43
|
+
``auto`` — A, then B, then an independent fallback
|
|
44
|
+
|
|
45
|
+
Billing is base plus a charge per started block of junctions, so batching
|
|
46
|
+
related coordinates into one job costs less than one job each.
|
|
47
|
+
"""
|
|
48
|
+
if not coordinates:
|
|
49
|
+
raise ValueError("at least one BSJ coordinate is required")
|
|
50
|
+
return self._http.post(f"{_BASE}/design", json={
|
|
51
|
+
"coordinates": list(coordinates),
|
|
52
|
+
"assay_mode": assay_mode,
|
|
53
|
+
"genome_build": genome_build,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
def get(self, job_id: str) -> dict[str, Any]:
|
|
57
|
+
"""Current state of a design job."""
|
|
58
|
+
return self._http.get(f"{_BASE}/{job_id}")
|
|
59
|
+
|
|
60
|
+
def results(self, job_id: str) -> dict[str, Any]:
|
|
61
|
+
"""Designed assays for a completed job."""
|
|
62
|
+
return self._http.get(f"{_BASE}/{job_id}/results")
|
|
63
|
+
|
|
64
|
+
def wait(
|
|
65
|
+
self,
|
|
66
|
+
job_id: str,
|
|
67
|
+
*,
|
|
68
|
+
poll_interval: float = 10.0,
|
|
69
|
+
timeout: float = 1200.0,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Block until the job reaches a terminal state, then return its results.
|
|
72
|
+
|
|
73
|
+
Raises ``TimeoutError`` if the window closes first, and ``RuntimeError``
|
|
74
|
+
if the job ends in a non-success terminal state — the job's own status is
|
|
75
|
+
the message, so a failure is not mistaken for an empty result set.
|
|
76
|
+
"""
|
|
77
|
+
deadline = time.monotonic() + timeout
|
|
78
|
+
while True:
|
|
79
|
+
job = self.get(job_id)
|
|
80
|
+
state = str(job.get("status") or "").lower()
|
|
81
|
+
if state in _TERMINAL:
|
|
82
|
+
if state in _SUCCESS:
|
|
83
|
+
return self.results(job_id)
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
f"assay job {job_id} ended {job.get('status')}: "
|
|
86
|
+
f"{job.get('error_message') or 'no reason given'}")
|
|
87
|
+
if time.monotonic() >= deadline:
|
|
88
|
+
raise TimeoutError(
|
|
89
|
+
f"assay job {job_id} still {job.get('status')} after {timeout:.0f}s")
|
|
90
|
+
time.sleep(poll_interval)
|