auroraomics 0.1.0.dev3__tar.gz → 0.1.0.dev4__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.
- {auroraomics-0.1.0.dev3/src/auroraomics.egg-info → auroraomics-0.1.0.dev4}/PKG-INFO +32 -3
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/README.md +26 -2
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/pyproject.toml +29 -4
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/__init__.py +3 -2
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/cli.py +72 -20
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/__init__.py +1 -1
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/_generated.py +8 -2
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/api.py +137 -30
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/errors.py +1 -1
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/contracts.py +18 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/embed.py +13 -8
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/errors.py +3 -2
- auroraomics-0.1.0.dev4/src/auroraomics/export.py +467 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/mcp/tools.py +10 -5
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/pack.py +8 -1
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/qc.py +3 -3
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/slide.py +3 -2
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4/src/auroraomics.egg-info}/PKG-INFO +32 -3
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/SOURCES.txt +1 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/requires.txt +8 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/LICENSE +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/MANIFEST.in +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/setup.cfg +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_assets/ASSETS.json +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_assets/README.md +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_assets/calibration-tile.png +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/MANIFEST.json +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/public-api-counters.tokens.json +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/public-api-input-kinds.tokens.json +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/public-api.tokens.json +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_text.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/bulk.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/_http.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/credentials.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/jobs.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/uploads.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/genes.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/h5ad.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/mcp/__init__.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/mcp/server.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/postprocess.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/py.typed +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/runtimes/__init__.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/runtimes/deepspotm.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/spatial.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/subsample.py +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/dependency_links.txt +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/entry_points.txt +0 -0
- {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auroraomics
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.dev4
|
|
4
4
|
Summary: Prepare H&E slides on your own machine, submit them to Aurora, and read the spatial gene expression it predicts.
|
|
5
5
|
Author: Kalin Nonchev
|
|
6
6
|
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
@@ -23,15 +23,20 @@ Requires-Dist: pydantic>=2
|
|
|
23
23
|
Requires-Dist: anndata>=0.10
|
|
24
24
|
Requires-Dist: tifffile>=2023.7.10
|
|
25
25
|
Requires-Dist: imagecodecs>=2023.3.16
|
|
26
|
+
Requires-Dist: scipy>1.8
|
|
26
27
|
Provides-Extra: embed
|
|
27
28
|
Requires-Dist: torch>=2.0; extra == "embed"
|
|
28
29
|
Requires-Dist: timm>=1.0; extra == "embed"
|
|
29
30
|
Requires-Dist: transformers>=4.40; extra == "embed"
|
|
30
31
|
Requires-Dist: huggingface-hub>=0.23; extra == "embed"
|
|
32
|
+
Provides-Extra: spatialdata
|
|
33
|
+
Requires-Dist: spatialdata>=0.2; extra == "spatialdata"
|
|
34
|
+
Requires-Dist: setuptools<81; python_version < "3.12" and extra == "spatialdata"
|
|
31
35
|
Provides-Extra: mcp
|
|
32
36
|
Requires-Dist: mcp<3,>=2; extra == "mcp"
|
|
33
37
|
Provides-Extra: dev
|
|
34
38
|
Requires-Dist: auroraomics[mcp]; extra == "dev"
|
|
39
|
+
Requires-Dist: auroraomics[spatialdata]; extra == "dev"
|
|
35
40
|
Requires-Dist: pytest>=7; extra == "dev"
|
|
36
41
|
Requires-Dist: packaging>=22; extra == "dev"
|
|
37
42
|
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
@@ -73,6 +78,9 @@ same code runs on your machine and on the service:
|
|
|
73
78
|
- **`auroraomics.contracts`** — the shared contract values (container layout,
|
|
74
79
|
input-kind caps, result layout) as data, so nothing here re-types a number
|
|
75
80
|
the service also reads.
|
|
81
|
+
- **`auroraomics.export`** — write a result as a SpatialData Zarr store
|
|
82
|
+
*(extra)* or as a folder Seurat loads, with the same matrix, coordinates and
|
|
83
|
+
gene names as the `.h5ad`.
|
|
76
84
|
- **`auroraomics.embed`** *(extra)* — run a pinned image encoder over your
|
|
77
85
|
tiles locally and write the embeddings container, so a prediction can be made
|
|
78
86
|
from numbers instead of pixels.
|
|
@@ -93,8 +101,9 @@ pip install auroraomics
|
|
|
93
101
|
|
|
94
102
|
That is the whole documented path: open a slide, judge and pack its tiles,
|
|
95
103
|
submit, and read the result. One extra adds local embedding extraction
|
|
96
|
-
(`embed`)
|
|
97
|
-
service, not
|
|
104
|
+
(`embed`), and one writes a result as a SpatialData store (`spatialdata`). No
|
|
105
|
+
extra brings the model: predicting gene expression runs on the service, not
|
|
106
|
+
here.
|
|
98
107
|
|
|
99
108
|
## Pack tiles, then look at the report
|
|
100
109
|
|
|
@@ -248,6 +257,26 @@ measured. Such a gene becomes `nan` in `X` and in every layer, not zero: zero
|
|
|
248
257
|
is a measurement, and an unmeasured gene left as one averages, correlates and
|
|
249
258
|
colours a heat map exactly like a prediction.
|
|
250
259
|
|
|
260
|
+
## Read a result in SpatialData or Seurat
|
|
261
|
+
|
|
262
|
+
Every result is an `.h5ad`, which is an HDF5 file: `anndata` reads it, and so
|
|
263
|
+
does any HDF5 library. For tools that read other formats, write it again:
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
auroraomics export result.h5ad --format seurat # result_seurat/, for Read10X
|
|
267
|
+
auroraomics export result.h5ad --format zarr # result.zarr, a SpatialData store
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
from auroraomics.export import write_seurat_dir, write_zarr
|
|
272
|
+
|
|
273
|
+
write_seurat_dir("result.h5ad", "result_seurat")
|
|
274
|
+
write_zarr("result.h5ad", "result.zarr") # pip install "auroraomics[spatialdata]"
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Both keep the result's own values: a gene the model never measured stays blank
|
|
278
|
+
(`nan`) in either format, never zero.
|
|
279
|
+
|
|
251
280
|
## Prepare a bulk RNA profile
|
|
252
281
|
|
|
253
282
|
```python
|
|
@@ -29,6 +29,9 @@ same code runs on your machine and on the service:
|
|
|
29
29
|
- **`auroraomics.contracts`** — the shared contract values (container layout,
|
|
30
30
|
input-kind caps, result layout) as data, so nothing here re-types a number
|
|
31
31
|
the service also reads.
|
|
32
|
+
- **`auroraomics.export`** — write a result as a SpatialData Zarr store
|
|
33
|
+
*(extra)* or as a folder Seurat loads, with the same matrix, coordinates and
|
|
34
|
+
gene names as the `.h5ad`.
|
|
32
35
|
- **`auroraomics.embed`** *(extra)* — run a pinned image encoder over your
|
|
33
36
|
tiles locally and write the embeddings container, so a prediction can be made
|
|
34
37
|
from numbers instead of pixels.
|
|
@@ -49,8 +52,9 @@ pip install auroraomics
|
|
|
49
52
|
|
|
50
53
|
That is the whole documented path: open a slide, judge and pack its tiles,
|
|
51
54
|
submit, and read the result. One extra adds local embedding extraction
|
|
52
|
-
(`embed`)
|
|
53
|
-
service, not
|
|
55
|
+
(`embed`), and one writes a result as a SpatialData store (`spatialdata`). No
|
|
56
|
+
extra brings the model: predicting gene expression runs on the service, not
|
|
57
|
+
here.
|
|
54
58
|
|
|
55
59
|
## Pack tiles, then look at the report
|
|
56
60
|
|
|
@@ -204,6 +208,26 @@ measured. Such a gene becomes `nan` in `X` and in every layer, not zero: zero
|
|
|
204
208
|
is a measurement, and an unmeasured gene left as one averages, correlates and
|
|
205
209
|
colours a heat map exactly like a prediction.
|
|
206
210
|
|
|
211
|
+
## Read a result in SpatialData or Seurat
|
|
212
|
+
|
|
213
|
+
Every result is an `.h5ad`, which is an HDF5 file: `anndata` reads it, and so
|
|
214
|
+
does any HDF5 library. For tools that read other formats, write it again:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
auroraomics export result.h5ad --format seurat # result_seurat/, for Read10X
|
|
218
|
+
auroraomics export result.h5ad --format zarr # result.zarr, a SpatialData store
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from auroraomics.export import write_seurat_dir, write_zarr
|
|
223
|
+
|
|
224
|
+
write_seurat_dir("result.h5ad", "result_seurat")
|
|
225
|
+
write_zarr("result.h5ad", "result.zarr") # pip install "auroraomics[spatialdata]"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Both keep the result's own values: a gene the model never measured stays blank
|
|
229
|
+
(`nan`) in either format, never zero.
|
|
230
|
+
|
|
207
231
|
## Prepare a bulk RNA profile
|
|
208
232
|
|
|
209
233
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "auroraomics"
|
|
7
|
-
version = "0.1.0.
|
|
7
|
+
version = "0.1.0.dev4"
|
|
8
8
|
description = "Prepare H&E slides on your own machine, submit them to Aurora, and read the spatial gene expression it predicts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -64,12 +64,18 @@ dependencies = [
|
|
|
64
64
|
# rather than being handed tiles they cut themselves.
|
|
65
65
|
"tifffile>=2023.7.10",
|
|
66
66
|
"imagecodecs>=2023.3.16",
|
|
67
|
+
# Writing a result as the folder Seurat loads (`auroraomics.export`): the
|
|
68
|
+
# matrix is written by scipy's Matrix Market writer. scipy already arrives
|
|
69
|
+
# behind anndata, so a plain install resolves nothing new for it; it is named
|
|
70
|
+
# because that module imports it directly, at anndata's own lower bound.
|
|
71
|
+
"scipy>1.8",
|
|
67
72
|
]
|
|
68
73
|
|
|
69
74
|
[project.optional-dependencies]
|
|
70
|
-
#
|
|
71
|
-
# the documented path can need,
|
|
72
|
-
#
|
|
75
|
+
# THREE extras, and only one of them is a model runtime: `embed` is the single
|
|
76
|
+
# opt-in the documented path can need, `mcp` is a protocol adapter for an agent,
|
|
77
|
+
# and `spatialdata` writes a result in one more format. Where the first line is
|
|
78
|
+
# drawn is the rest of this comment.
|
|
73
79
|
#
|
|
74
80
|
# There is no model-runtime extra, and that is the boundary rather than an
|
|
75
81
|
# omission. Predicting gene expression runs on the service: the model definition
|
|
@@ -109,6 +115,21 @@ embed = [
|
|
|
109
115
|
"transformers>=4.40",
|
|
110
116
|
"huggingface-hub>=0.23",
|
|
111
117
|
]
|
|
118
|
+
# Writing a result as a SpatialData Zarr store (`auroraomics.export.write_zarr`,
|
|
119
|
+
# `auroraomics export --format zarr`). An extra because it is large — some
|
|
120
|
+
# seventy distributions and 700 MB on top of the core, measured on Python 3.10 —
|
|
121
|
+
# and only someone who wants SpatialData's own format needs it; the Seurat
|
|
122
|
+
# folder and the .h5ad need nothing from it.
|
|
123
|
+
#
|
|
124
|
+
# The setuptools bound is not a preference. Every SpatialData release before
|
|
125
|
+
# 0.8 imports a schema library that reads `pkg_resources`, which setuptools 81
|
|
126
|
+
# removed, so on a fresh environment the import fails with ModuleNotFoundError
|
|
127
|
+
# however the rest resolves. 0.8 dropped that library and requires Python 3.12,
|
|
128
|
+
# so the bound applies exactly where an older release is what pip can choose.
|
|
129
|
+
spatialdata = [
|
|
130
|
+
"spatialdata>=0.2",
|
|
131
|
+
"setuptools<81; python_version < '3.12'",
|
|
132
|
+
]
|
|
112
133
|
# The MCP server: `auroraomics mcp` over stdio, so an agent reaches the same
|
|
113
134
|
# client the CLI does. Only the protocol adapter is here; the tool table and
|
|
114
135
|
# every handler are in the client, which is in the core now, so this extra names
|
|
@@ -135,6 +156,10 @@ dev = [
|
|
|
135
156
|
# that spelled its own bound would be testing a dependency set no user can
|
|
136
157
|
# install. The client needs no reference any more — it is the core.
|
|
137
158
|
"auroraomics[mcp]",
|
|
159
|
+
# The SpatialData extra by reference, for the same reason: the export tests
|
|
160
|
+
# read every store back with SpatialData's own reader, so the environment
|
|
161
|
+
# that runs them installs what a user who asks for the format installs.
|
|
162
|
+
"auroraomics[spatialdata]",
|
|
138
163
|
"pytest>=7",
|
|
139
164
|
# The release guard parses version strings with `packaging.version`, at module
|
|
140
165
|
# scope. It has always resolved, because more than one dependency in this list
|
|
@@ -7,10 +7,11 @@ embeddings computed from them; everything either side of that word is the same:
|
|
|
7
7
|
import auroraomics as ao
|
|
8
8
|
|
|
9
9
|
client = ao.Client()
|
|
10
|
+
card = next(m for m in client.models() if "patches" in m["accepts"]["observations"])
|
|
10
11
|
slide = ao.open_slide("slide.svs")
|
|
11
|
-
report = ao.pack_tiles(slide, "sample.zip", mpp=slide.mpp, patch_um=
|
|
12
|
+
report = ao.pack_tiles(slide, "sample.zip", mpp=slide.mpp, patch_um=card["input_spec"]["patch_um"],
|
|
12
13
|
thresholds=client.qc_thresholds())
|
|
13
|
-
job = client.predict(model=model_id,
|
|
14
|
+
job = client.predict(model=model_id, observation=report)
|
|
14
15
|
job.wait().download("result.h5ad")
|
|
15
16
|
```
|
|
16
17
|
|
|
@@ -30,6 +30,7 @@ from typing import Any, Sequence
|
|
|
30
30
|
|
|
31
31
|
from auroraomics import __version__, contracts
|
|
32
32
|
from auroraomics._text import bounded_repr
|
|
33
|
+
from auroraomics.contracts import COVARIATE_SLOT, OBSERVATION_SLOT, kinds_in
|
|
33
34
|
|
|
34
35
|
from .client import credentials, errors
|
|
35
36
|
from ._text import EMBEDDING_COMPONENT
|
|
@@ -194,12 +195,6 @@ def _ask(prompt: str, *, stdin=None, out: Out | None = None) -> str:
|
|
|
194
195
|
# ── commands ────────────────────────────────────────────────────────────────
|
|
195
196
|
|
|
196
197
|
|
|
197
|
-
OBSERVATION_SLOT = "observations"
|
|
198
|
-
"""The registry slot the sample itself is filed under."""
|
|
199
|
-
|
|
200
|
-
COVARIATE_SLOT = "covariates"
|
|
201
|
-
"""The registry slot for what is known about the sample besides."""
|
|
202
|
-
|
|
203
198
|
OBSERVATION_SHAPE = "KIND=PATH"
|
|
204
199
|
"""How ``--observation`` is written. Named once: it is the flag's metavar in the
|
|
205
200
|
help AND the shape the refusal tells a reader to use, and the two disagreed."""
|
|
@@ -774,11 +769,22 @@ def cmd_submit(args: argparse.Namespace, out: Out) -> int:
|
|
|
774
769
|
f"{', '.join(kinds_in(OBSERVATION_SLOT))}; `auroraomics models` says "
|
|
775
770
|
"which of them a model takes."
|
|
776
771
|
)
|
|
772
|
+
if len(observations) > 1:
|
|
773
|
+
# Refused here, before anything is uploaded: the service runs one
|
|
774
|
+
# observation per job, and the flag stays repeatable only so that this
|
|
775
|
+
# sentence, rather than argparse's, is what a second one meets.
|
|
776
|
+
raise errors.ClientError(
|
|
777
|
+
f"a submission carries exactly one observation, and this one names "
|
|
778
|
+
f"{', '.join(observations)}: pass --observation once. A cohort is one "
|
|
779
|
+
"submission per sample."
|
|
780
|
+
)
|
|
781
|
+
((kind, path),) = observations.items()
|
|
777
782
|
refuse_flags_that_need_wait(args)
|
|
778
783
|
with _client(args) as client:
|
|
779
784
|
job = client.predict(
|
|
780
785
|
model=args.model,
|
|
781
|
-
|
|
786
|
+
observation=path,
|
|
787
|
+
kind=kind,
|
|
782
788
|
covariates=_pairs(args.covariate, "--covariate", COVARIATE_SHAPE, COVARIATE_SLOT),
|
|
783
789
|
land_in_workspace=args.land_in_workspace,
|
|
784
790
|
idempotency_key=args.idempotency_key,
|
|
@@ -824,6 +830,32 @@ def cmd_download(args: argparse.Namespace, out: Out) -> int:
|
|
|
824
830
|
return EXIT_OK
|
|
825
831
|
|
|
826
832
|
|
|
833
|
+
EXPORT_FORMATS: dict[str, str] = {"zarr": ".zarr", "seurat": "_seurat"}
|
|
834
|
+
"""What ``export --format`` takes, and the ending of the name it writes beside
|
|
835
|
+
the result when no ``--output`` is given."""
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def cmd_export(args: argparse.Namespace, out: Out) -> int:
|
|
839
|
+
"""Write a result file as a SpatialData store or as a folder Seurat loads."""
|
|
840
|
+
from .export import ExportError, ExportUnavailable, write_seurat_dir, write_zarr
|
|
841
|
+
|
|
842
|
+
source = Path(args.result)
|
|
843
|
+
target = (
|
|
844
|
+
Path(args.output)
|
|
845
|
+
if args.output
|
|
846
|
+
else source.with_name(source.stem + EXPORT_FORMATS[args.format])
|
|
847
|
+
)
|
|
848
|
+
writer = write_zarr if args.format == "zarr" else write_seurat_dir
|
|
849
|
+
try:
|
|
850
|
+
written = writer(source, target)
|
|
851
|
+
except (ExportError, ExportUnavailable) as exc:
|
|
852
|
+
# A refusal about this machine or this command line, never the
|
|
853
|
+
# service's, so it exits with the code those refusals carry.
|
|
854
|
+
raise errors.ClientError(str(exc)) from exc
|
|
855
|
+
out.emit({"format": args.format, "path": str(written)}, str(written))
|
|
856
|
+
return EXIT_OK
|
|
857
|
+
|
|
858
|
+
|
|
827
859
|
def cmd_mcp(args: argparse.Namespace, out: Out) -> int:
|
|
828
860
|
"""Serve the tool surface over stdio, for an agent."""
|
|
829
861
|
try:
|
|
@@ -856,18 +888,6 @@ def _job_line(job) -> str:
|
|
|
856
888
|
return " ".join(parts)
|
|
857
889
|
|
|
858
890
|
|
|
859
|
-
def kinds_in(slot: str) -> tuple[str, ...]:
|
|
860
|
-
"""The input kinds the registry files under one slot, in its own order.
|
|
861
|
-
|
|
862
|
-
Read from the shipped registry rather than listed here, so a kind the
|
|
863
|
-
service adds is named by the refusals below without an edit. The two slots
|
|
864
|
-
are ``observations`` (the sample itself) and ``covariates`` (what is known
|
|
865
|
-
about it besides).
|
|
866
|
-
"""
|
|
867
|
-
rows = contracts.load(contracts.INPUT_KINDS)["kinds"]
|
|
868
|
-
return tuple(name for name, row in rows.items() if row.get("slot") == slot)
|
|
869
|
-
|
|
870
|
-
|
|
871
891
|
TILE_COST_UNIT = "tiles"
|
|
872
892
|
"""What the registry charges the route that sends pixels by."""
|
|
873
893
|
|
|
@@ -1433,7 +1453,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1433
1453
|
metavar=OBSERVATION_SHAPE,
|
|
1434
1454
|
help=(
|
|
1435
1455
|
"the sample, as KIND=PATH — a file is uploaded, an upload id is used "
|
|
1436
|
-
"as it stands.
|
|
1456
|
+
"as it stands. Required, once: a job reads one sample."
|
|
1437
1457
|
),
|
|
1438
1458
|
)
|
|
1439
1459
|
submit.add_argument(
|
|
@@ -1557,6 +1577,38 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1557
1577
|
)
|
|
1558
1578
|
download.set_defaults(handler=cmd_download)
|
|
1559
1579
|
|
|
1580
|
+
export = commands.add_parser(
|
|
1581
|
+
"export",
|
|
1582
|
+
parents=[common],
|
|
1583
|
+
help="write a result for SpatialData or Seurat",
|
|
1584
|
+
description=_described(
|
|
1585
|
+
"Write a result file in another format, beside it or at --output.\n\n"
|
|
1586
|
+
"--format zarr writes a SpatialData Zarr store: the result is its "
|
|
1587
|
+
"table, and each spot is a circle at its coordinates. It needs the "
|
|
1588
|
+
"spatialdata extra: pip install 'auroraomics[spatialdata]'.\n\n"
|
|
1589
|
+
"--format seurat writes a folder Seurat loads: the matrix in the "
|
|
1590
|
+
"layout Read10X reads, with each spot's coordinates and metadata as "
|
|
1591
|
+
"CSV. A plain install writes it.\n\n"
|
|
1592
|
+
"Nothing that already exists is replaced."
|
|
1593
|
+
),
|
|
1594
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1595
|
+
)
|
|
1596
|
+
export.add_argument("result", metavar="RESULT", help="the .h5ad result file to read")
|
|
1597
|
+
export.add_argument(
|
|
1598
|
+
"--format",
|
|
1599
|
+
required=True,
|
|
1600
|
+
choices=sorted(EXPORT_FORMATS),
|
|
1601
|
+
help="zarr for a SpatialData store, seurat for a folder Seurat loads",
|
|
1602
|
+
)
|
|
1603
|
+
export.add_argument(
|
|
1604
|
+
"--output",
|
|
1605
|
+
"-o",
|
|
1606
|
+
default=None,
|
|
1607
|
+
metavar="PATH",
|
|
1608
|
+
help="where to write it (default: beside RESULT, as NAME.zarr or NAME_seurat)",
|
|
1609
|
+
)
|
|
1610
|
+
export.set_defaults(handler=cmd_export)
|
|
1611
|
+
|
|
1560
1612
|
mcp = commands.add_parser(
|
|
1561
1613
|
"mcp",
|
|
1562
1614
|
parents=[common],
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import auroraomics as ao
|
|
5
5
|
|
|
6
6
|
client = ao.Client() # env, then ~/.config
|
|
7
|
-
job = client.predict(model=model_id,
|
|
7
|
+
job = client.predict(model=model_id, observation="sample.zip", kind="patches")
|
|
8
8
|
adata = job.wait().download()
|
|
9
9
|
```
|
|
10
10
|
|
|
@@ -11,6 +11,10 @@ a contract that ages: the service may add a field in a release this package
|
|
|
11
11
|
predates, and a client that raised on it would break every caller for an
|
|
12
12
|
addition that breaks nothing. Drift in the other direction — a field this
|
|
13
13
|
package expects and the service stopped sending — still fails, at the model.
|
|
14
|
+
|
|
15
|
+
A field the schema marks deprecated is optional here even when the schema lists
|
|
16
|
+
it as required: the service has said it will stop sending it, and the release
|
|
17
|
+
that stops must not break this one.
|
|
14
18
|
"""
|
|
15
19
|
|
|
16
20
|
from __future__ import annotations
|
|
@@ -289,7 +293,8 @@ class Job(_Model):
|
|
|
289
293
|
landed: bool
|
|
290
294
|
lane: str
|
|
291
295
|
model: str
|
|
292
|
-
observation: str
|
|
296
|
+
observation: str | None = None
|
|
297
|
+
observation_kind: str
|
|
293
298
|
queue_position: int | None
|
|
294
299
|
started_at: str | None
|
|
295
300
|
state: str
|
|
@@ -317,7 +322,8 @@ class PredictionBody(_Model):
|
|
|
317
322
|
|
|
318
323
|
covariates: dict[str, str] = Field(default_factory=lambda: {})
|
|
319
324
|
model: str
|
|
320
|
-
|
|
325
|
+
observation: str | None = None
|
|
326
|
+
observations: dict[str, str] | None = None
|
|
321
327
|
options: PredictionOptions = Field(default_factory=lambda: {'land_in_workspace': False})
|
|
322
328
|
|
|
323
329
|
|
|
@@ -14,6 +14,7 @@ instead of a 404.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
+
import warnings
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, Mapping
|
|
19
20
|
|
|
@@ -51,6 +52,8 @@ from . import uploads as uploads_module
|
|
|
51
52
|
from .jobs import Job
|
|
52
53
|
|
|
53
54
|
if TYPE_CHECKING: # pragma: no cover - for the type checker, never at run time
|
|
55
|
+
from auroraomics.embed import EmbedReport
|
|
56
|
+
from auroraomics.pack import PackReport
|
|
54
57
|
from auroraomics.qc import QcThresholds
|
|
55
58
|
|
|
56
59
|
JOBS_PAGE = 50
|
|
@@ -512,22 +515,44 @@ class Client:
|
|
|
512
515
|
self,
|
|
513
516
|
*,
|
|
514
517
|
model: str,
|
|
515
|
-
|
|
518
|
+
observation: PackReport | EmbedReport | str | Path | Any = None,
|
|
519
|
+
kind: str | None = None,
|
|
516
520
|
covariates: Mapping[str, Any] | None = None,
|
|
517
521
|
land_in_workspace: bool = False,
|
|
518
522
|
idempotency_key: str | None = None,
|
|
523
|
+
observations: Mapping[str, Any] | None = None,
|
|
519
524
|
) -> Job:
|
|
520
525
|
"""Submit a prediction and return the job it created.
|
|
521
526
|
|
|
522
|
-
``
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
527
|
+
``observation`` is the one sample the model reads, given in one of three
|
|
528
|
+
ways:
|
|
529
|
+
|
|
530
|
+
* the report [pack_tiles][auroraomics.pack.pack_tiles] or
|
|
531
|
+
[embed_tiles][auroraomics.embed.embed_tiles] returned. It carries the
|
|
532
|
+
file and its kind, so nothing else is needed:
|
|
533
|
+
``predict(model=card["id"], observation=report)``;
|
|
534
|
+
* a path or an open file, with ``kind`` naming which observation kind it
|
|
535
|
+
is: ``observation="sample.zip", kind="patches"``. A path alone is
|
|
536
|
+
refused, because a file name does not say how the file was made;
|
|
537
|
+
* an upload id, from [upload][auroraomics.client.api.Client.upload] or
|
|
538
|
+
``start_upload``. The service reads its kind off the upload, so
|
|
539
|
+
``kind`` may be left out.
|
|
540
|
+
|
|
541
|
+
A report or a path is uploaded first: passing one is the ordinary case,
|
|
542
|
+
and making the caller upload by hand to submit would be a second way to
|
|
543
|
+
do one thing. ``covariates`` maps a covariate KIND to an upload id, a
|
|
544
|
+
path, or, for a kind declared inline, its value.
|
|
545
|
+
|
|
546
|
+
``observations``, a one-entry ``{kind: value}`` mapping, is the earlier
|
|
547
|
+
spelling of ``observation`` and ``kind`` together. It still submits the
|
|
548
|
+
same request, and warns.
|
|
526
549
|
"""
|
|
527
|
-
self.
|
|
550
|
+
kind, value = self._the_observation(observation, kind, observations)
|
|
551
|
+
given = {kind: value} if kind else {}
|
|
552
|
+
self._refuse_what_the_card_refuses(model, given, covariates)
|
|
528
553
|
body: dict[str, Any] = {
|
|
529
554
|
"model": model,
|
|
530
|
-
"
|
|
555
|
+
"observation": self._resolve_one(kind, value),
|
|
531
556
|
"options": {"land_in_workspace": land_in_workspace},
|
|
532
557
|
}
|
|
533
558
|
if covariates:
|
|
@@ -544,6 +569,79 @@ class Client:
|
|
|
544
569
|
retry_after=errors._retry_after(response.headers.get(errors.RETRY_AFTER_HEADER)),
|
|
545
570
|
)
|
|
546
571
|
|
|
572
|
+
def _the_observation(
|
|
573
|
+
self,
|
|
574
|
+
observation: Any,
|
|
575
|
+
kind: str | None,
|
|
576
|
+
observations: Mapping[str, Any] | None,
|
|
577
|
+
) -> tuple[str | None, Any]:
|
|
578
|
+
"""``(kind, value)`` for the one observation, or a refusal naming the fix.
|
|
579
|
+
|
|
580
|
+
Every refusal here is made before anything is uploaded or sent. The kind
|
|
581
|
+
is ``None`` only for an upload id given without one: the service reads
|
|
582
|
+
it off the upload, and nothing here needs it.
|
|
583
|
+
"""
|
|
584
|
+
kinds = list(contracts.kinds_in(contracts.OBSERVATION_SLOT))
|
|
585
|
+
if observations is not None:
|
|
586
|
+
if observation is not None or kind is not None:
|
|
587
|
+
raise errors.ClientError(
|
|
588
|
+
"pass the observation once: observation= (with kind= for a path), or "
|
|
589
|
+
"the earlier observations={kind: value}, not both"
|
|
590
|
+
)
|
|
591
|
+
warnings.warn(
|
|
592
|
+
"observations={kind: value} is the earlier spelling; pass observation= "
|
|
593
|
+
"instead, with the report pack_tiles or embed_tiles returned, or a path "
|
|
594
|
+
"and kind=",
|
|
595
|
+
DeprecationWarning,
|
|
596
|
+
stacklevel=3,
|
|
597
|
+
)
|
|
598
|
+
if len(observations) != 1:
|
|
599
|
+
raise errors.ClientError(
|
|
600
|
+
"a submission carries exactly one observation, and this one names "
|
|
601
|
+
f"{len(observations)}. Pass one, as observation=; a cohort is one "
|
|
602
|
+
"submission per sample."
|
|
603
|
+
)
|
|
604
|
+
((named, value),) = observations.items()
|
|
605
|
+
return str(named), value
|
|
606
|
+
if observation is None:
|
|
607
|
+
raise errors.ClientError(
|
|
608
|
+
"predict needs the observation: pass observation= the report pack_tiles or "
|
|
609
|
+
f"embed_tiles returned, an upload id, or a path with kind= one of "
|
|
610
|
+
f"{_text.listed(kinds)}"
|
|
611
|
+
)
|
|
612
|
+
carried = _report_kind(observation)
|
|
613
|
+
if carried is not None:
|
|
614
|
+
if kind is not None and kind != carried:
|
|
615
|
+
raise errors.ClientError(
|
|
616
|
+
f"the report carries the kind {carried}, and kind= says "
|
|
617
|
+
f"{_text.bounded(kind)}; a report carries its own kind, so leave kind= out"
|
|
618
|
+
)
|
|
619
|
+
return carried, observation.path
|
|
620
|
+
if kind is not None and kind not in kinds:
|
|
621
|
+
raise errors.ClientError(
|
|
622
|
+
f"kind= takes an observation kind, {_text.listed(kinds)}, and was "
|
|
623
|
+
f"given {_text.bounded(kind)}"
|
|
624
|
+
)
|
|
625
|
+
# Asked only without a kind: with one, a file is what kind= says it is.
|
|
626
|
+
source = _upload_source(observation) if kind is None else None
|
|
627
|
+
if source is not None:
|
|
628
|
+
name = getattr(source, "name", None) or "this file"
|
|
629
|
+
raise errors.ClientError(
|
|
630
|
+
f"{_text.bounded(str(Path(str(name)).name))} is a file, and a file does not say "
|
|
631
|
+
f"which kind of observation it is: pass kind= one of {_text.listed(kinds)}, "
|
|
632
|
+
"or pass the report pack_tiles or embed_tiles returned, which carries it"
|
|
633
|
+
)
|
|
634
|
+
return kind, observation
|
|
635
|
+
|
|
636
|
+
def _resolve_one(self, kind: str | None, value: Any) -> str:
|
|
637
|
+
"""One value as the upload id the body sends, uploading it first when it
|
|
638
|
+
is a file. The one upload-or-pass-through rule: the observation goes
|
|
639
|
+
through it, and ``_resolve_inputs`` maps it over the covariates."""
|
|
640
|
+
source = _upload_source(value)
|
|
641
|
+
if source is None:
|
|
642
|
+
return str(value)
|
|
643
|
+
return self.upload(source, kind=str(kind)).upload_id
|
|
644
|
+
|
|
547
645
|
def _refuse_what_the_card_refuses(
|
|
548
646
|
self,
|
|
549
647
|
model: str,
|
|
@@ -566,9 +664,10 @@ class Client:
|
|
|
566
664
|
be read is not a reason to refuse a valid submission, so a failure here
|
|
567
665
|
falls through to the service's own answer.
|
|
568
666
|
|
|
569
|
-
The slot names are the
|
|
570
|
-
|
|
571
|
-
is
|
|
667
|
+
The slot names are the registry's, the keys of a card's ``accepts``:
|
|
668
|
+
``observations`` holds the kinds a card reads, which is why it is
|
|
669
|
+
plural, and the one observation ``predict`` sends is checked against
|
|
670
|
+
it by its kind.
|
|
572
671
|
|
|
573
672
|
And it is skipped when nothing WILL be uploaded. The whole argument for
|
|
574
673
|
reading a catalogue before the submission is the upload it saves, so a
|
|
@@ -633,9 +732,8 @@ class Client:
|
|
|
633
732
|
)
|
|
634
733
|
if not isinstance(reference, Mapping) or not reference.get("reference"):
|
|
635
734
|
return
|
|
636
|
-
upload_prefix = contracts.id_prefixes()["upload"]
|
|
637
735
|
for kind, value in observations.items():
|
|
638
|
-
source = _upload_source(value
|
|
736
|
+
source = _upload_source(value)
|
|
639
737
|
if not isinstance(source, Path):
|
|
640
738
|
continue
|
|
641
739
|
try:
|
|
@@ -662,18 +760,12 @@ class Client:
|
|
|
662
760
|
def _resolve_inputs(self, given: Mapping[str, Any]) -> dict[str, str]:
|
|
663
761
|
"""Upload ids as given; a path or a file object uploaded first.
|
|
664
762
|
|
|
665
|
-
Which of the two a value is, is
|
|
666
|
-
its question — the pre-flight
|
|
667
|
-
|
|
763
|
+
``_resolve_one`` per kind. Which of the two a value is, is
|
|
764
|
+
``_upload_source``'s question and only its question — the pre-flight
|
|
765
|
+
gate above asks the same one, and a second copy of the answer here is
|
|
766
|
+
how the two would come to disagree.
|
|
668
767
|
"""
|
|
669
|
-
|
|
670
|
-
resolved: dict[str, str] = {}
|
|
671
|
-
for kind, value in given.items():
|
|
672
|
-
source = _upload_source(value, upload_prefix)
|
|
673
|
-
resolved[kind] = (
|
|
674
|
-
str(value) if source is None else self.upload(source, kind=kind).upload_id
|
|
675
|
-
)
|
|
676
|
-
return resolved
|
|
768
|
+
return {kind: self._resolve_one(kind, value) for kind, value in given.items()}
|
|
677
769
|
|
|
678
770
|
def _would_upload(self, *slots: Mapping[str, Any] | None) -> bool:
|
|
679
771
|
"""Whether ``_resolve_inputs`` would send bytes for any of these.
|
|
@@ -687,9 +779,8 @@ class Client:
|
|
|
687
779
|
No I/O: the id prefix is the vendored contract's, and
|
|
688
780
|
``_looks_like_a_path`` is a string test with at most a ``stat``.
|
|
689
781
|
"""
|
|
690
|
-
upload_prefix = contracts.id_prefixes()["upload"]
|
|
691
782
|
return any(
|
|
692
|
-
_upload_source(value
|
|
783
|
+
_upload_source(value) is not None
|
|
693
784
|
for slot in slots
|
|
694
785
|
for value in (slot or {}).values()
|
|
695
786
|
)
|
|
@@ -755,12 +846,28 @@ def _bearer(token: str) -> dict[str, str]:
|
|
|
755
846
|
return {"Authorization": f"Bearer {token}"}
|
|
756
847
|
|
|
757
848
|
|
|
758
|
-
def
|
|
849
|
+
def _report_kind(value: Any) -> str | None:
|
|
850
|
+
"""The kind a packer's report carries, or ``None`` for anything else.
|
|
851
|
+
|
|
852
|
+
Read by shape, not by class: both reports have a ``path`` and a ``kind``,
|
|
853
|
+
and importing their modules here to ask ``isinstance`` would make the
|
|
854
|
+
client load the image stack it otherwise never needs. A path, a string, an
|
|
855
|
+
upload id and a file object have no ``kind``, so none of them is mistaken
|
|
856
|
+
for a report.
|
|
857
|
+
"""
|
|
858
|
+
kind = getattr(value, "kind", None)
|
|
859
|
+
if isinstance(kind, str) and isinstance(getattr(value, "path", None), Path):
|
|
860
|
+
return kind
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _upload_source(value: Any) -> Any | None:
|
|
759
865
|
"""What a submission would UPLOAD for this value, or ``None`` if it is an id.
|
|
760
866
|
|
|
761
|
-
The one place the question is answered
|
|
762
|
-
|
|
763
|
-
|
|
867
|
+
The one place the question is answered, and the one place the upload id's
|
|
868
|
+
prefix is read for it. ``_resolve_one`` uploads what comes back and passes
|
|
869
|
+
the value through when it is ``None``; the pre-flight gate asks only whether
|
|
870
|
+
anything comes back at all.
|
|
764
871
|
|
|
765
872
|
A file object is recognised by having ``read``, not by descending from
|
|
766
873
|
``io.IOBase``: a ``BytesIO``, a ``gzip`` reader and a member of an open
|
|
@@ -773,7 +880,7 @@ def _upload_source(value: Any, upload_prefix: str) -> Any | None:
|
|
|
773
880
|
if isinstance(value, Path) or hasattr(value, "read"):
|
|
774
881
|
return value
|
|
775
882
|
text = str(value)
|
|
776
|
-
if text.startswith(
|
|
883
|
+
if text.startswith(contracts.id_prefixes()["upload"]) or not _looks_like_a_path(text):
|
|
777
884
|
return None
|
|
778
885
|
return Path(text)
|
|
779
886
|
|
|
@@ -12,7 +12,7 @@ Catching is by class, at whichever width the caller wants:
|
|
|
12
12
|
|
|
13
13
|
```python
|
|
14
14
|
try:
|
|
15
|
-
job = client.predict(model=model_id,
|
|
15
|
+
job = client.predict(model=model_id, observation="sample.zip", kind="patches")
|
|
16
16
|
except QuotaExceededError as exc: # one code
|
|
17
17
|
wait_until_tomorrow(exc.retry_after)
|
|
18
18
|
except LimitExceededError: # every 413/429 code
|