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.
Files changed (49) hide show
  1. {auroraomics-0.1.0.dev3/src/auroraomics.egg-info → auroraomics-0.1.0.dev4}/PKG-INFO +32 -3
  2. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/README.md +26 -2
  3. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/pyproject.toml +29 -4
  4. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/__init__.py +3 -2
  5. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/cli.py +72 -20
  6. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/__init__.py +1 -1
  7. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/_generated.py +8 -2
  8. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/api.py +137 -30
  9. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/errors.py +1 -1
  10. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/contracts.py +18 -0
  11. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/embed.py +13 -8
  12. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/errors.py +3 -2
  13. auroraomics-0.1.0.dev4/src/auroraomics/export.py +467 -0
  14. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/mcp/tools.py +10 -5
  15. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/pack.py +8 -1
  16. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/qc.py +3 -3
  17. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/slide.py +3 -2
  18. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4/src/auroraomics.egg-info}/PKG-INFO +32 -3
  19. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/SOURCES.txt +1 -0
  20. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/requires.txt +8 -0
  21. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/LICENSE +0 -0
  22. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/MANIFEST.in +0 -0
  23. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/setup.cfg +0 -0
  24. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_assets/ASSETS.json +0 -0
  25. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_assets/README.md +0 -0
  26. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_assets/calibration-tile.png +0 -0
  27. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/MANIFEST.json +0 -0
  28. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/public-api-counters.tokens.json +0 -0
  29. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/public-api-input-kinds.tokens.json +0 -0
  30. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_contracts/public-api.tokens.json +0 -0
  31. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/_text.py +0 -0
  32. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/bulk.py +0 -0
  33. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/_http.py +0 -0
  34. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/credentials.py +0 -0
  35. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/jobs.py +0 -0
  36. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/client/uploads.py +0 -0
  37. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/genes.py +0 -0
  38. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/h5ad.py +0 -0
  39. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/mcp/__init__.py +0 -0
  40. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/mcp/server.py +0 -0
  41. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/postprocess.py +0 -0
  42. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/py.typed +0 -0
  43. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/runtimes/__init__.py +0 -0
  44. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/runtimes/deepspotm.py +0 -0
  45. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/spatial.py +0 -0
  46. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics/subsample.py +0 -0
  47. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/dependency_links.txt +0 -0
  48. {auroraomics-0.1.0.dev3 → auroraomics-0.1.0.dev4}/src/auroraomics.egg-info/entry_points.txt +0 -0
  49. {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.dev3
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`). No extra brings the model: predicting gene expression runs on the
97
- service, not here.
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`). No extra brings the model: predicting gene expression runs on the
53
- service, not here.
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.dev3"
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
- # TWO extras, and only one of them is a runtime: `embed` is the single opt-in
71
- # the documented path can need, and `mcp` is a protocol adapter for an agent.
72
- # Where that line is drawn is the whole of this comment.
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=55,
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, observations={"patches": report.path})
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
- observations=observations,
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. Repeatable. Required."
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, observations={"patches": "sample.zip"})
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
- observations: dict[str, str]
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
- observations: Mapping[str, Any],
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
- ``observations`` and ``covariates`` map an input KIND to an upload id or,
523
- for a covariate the registry declares inline, to its value. A path is
524
- uploaded first: passing one is the ordinary case, and making the caller
525
- upload by hand to submit would be a second way to do one thing.
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._refuse_what_the_card_refuses(model, observations, covariates)
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
- "observations": self._resolve_inputs(observations),
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 ones ``predict`` writes into the body above, not
570
- a second vocabulary: what a card accepts is keyed the way a submission
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, upload_prefix)
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 ``_upload_source``'s question and only
666
- its question — the pre-flight gate above asks the same one, and a second
667
- copy of the answer here is how the two would come to disagree.
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
- upload_prefix = contracts.id_prefixes()["upload"]
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, upload_prefix) is not None
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 _upload_source(value: Any, upload_prefix: str) -> Any | None:
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. ``_resolve_inputs`` uploads what
762
- comes back and passes the value through when it is ``None``; the pre-flight
763
- gate asks only whether anything comes back at all.
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(upload_prefix) or not _looks_like_a_path(text):
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, observations={"patches": "sample.zip"})
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