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.
Files changed (37) hide show
  1. dysplai-1.0.0/PKG-INFO +171 -0
  2. dysplai-1.0.0/README.md +131 -0
  3. dysplai-1.0.0/dysplai/__init__.py +66 -0
  4. dysplai-1.0.0/dysplai/account.py +33 -0
  5. dysplai-1.0.0/dysplai/analysis.py +174 -0
  6. dysplai-1.0.0/dysplai/assays.py +90 -0
  7. dysplai-1.0.0/dysplai/client.py +225 -0
  8. dysplai-1.0.0/dysplai/clinical.py +124 -0
  9. dysplai-1.0.0/dysplai/cohort.py +174 -0
  10. dysplai-1.0.0/dysplai/counterfactual.py +427 -0
  11. dysplai-1.0.0/dysplai/experts.py +160 -0
  12. dysplai-1.0.0/dysplai/graph.py +61 -0
  13. dysplai-1.0.0/dysplai/imaging.py +324 -0
  14. dysplai-1.0.0/dysplai/interpretation.py +40 -0
  15. dysplai-1.0.0/dysplai/model_builder.py +139 -0
  16. dysplai-1.0.0/dysplai/models/__init__.py +428 -0
  17. dysplai-1.0.0/dysplai/py.typed +0 -0
  18. dysplai-1.0.0/dysplai/reports.py +117 -0
  19. dysplai-1.0.0/dysplai.egg-info/PKG-INFO +171 -0
  20. dysplai-1.0.0/dysplai.egg-info/SOURCES.txt +35 -0
  21. dysplai-1.0.0/dysplai.egg-info/dependency_links.txt +1 -0
  22. dysplai-1.0.0/dysplai.egg-info/requires.txt +19 -0
  23. dysplai-1.0.0/dysplai.egg-info/top_level.txt +1 -0
  24. dysplai-1.0.0/pyproject.toml +55 -0
  25. dysplai-1.0.0/setup.cfg +4 -0
  26. dysplai-1.0.0/tests/test_account_client.py +111 -0
  27. dysplai-1.0.0/tests/test_analysis_client.py +149 -0
  28. dysplai-1.0.0/tests/test_cohort_client.py +66 -0
  29. dysplai-1.0.0/tests/test_counterfactual_client.py +156 -0
  30. dysplai-1.0.0/tests/test_experts_graph_assays.py +189 -0
  31. dysplai-1.0.0/tests/test_model_builder_client.py +96 -0
  32. dysplai-1.0.0/tests/test_models.py +138 -0
  33. dysplai-1.0.0/tests/test_openapi_contract.py +238 -0
  34. dysplai-1.0.0/tests/test_packaging.py +46 -0
  35. dysplai-1.0.0/tests/test_reports_client.py +76 -0
  36. dysplai-1.0.0/tests/test_sdk_clients.py +690 -0
  37. 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.
@@ -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)