scinterop 0.1.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.
- scinterop-0.1.0/PKG-INFO +778 -0
- scinterop-0.1.0/README.md +748 -0
- scinterop-0.1.0/pyproject.toml +52 -0
- scinterop-0.1.0/scinterop/__init__.py +204 -0
- scinterop-0.1.0/scinterop/cache.py +139 -0
- scinterop-0.1.0/scinterop/cli.py +186 -0
- scinterop-0.1.0/scinterop/detect.py +147 -0
- scinterop-0.1.0/scinterop/errors.py +72 -0
- scinterop-0.1.0/scinterop/h5ad.py +182 -0
- scinterop-0.1.0/scinterop/mtx.py +226 -0
- scinterop-0.1.0/scinterop/provenance.py +160 -0
- scinterop-0.1.0/scinterop/python_runner.py +168 -0
- scinterop-0.1.0/scinterop/r_runner.py +163 -0
- scinterop-0.1.0/scinterop/rds.py +409 -0
- scinterop-0.1.0/scinterop/schema.py +146 -0
- scinterop-0.1.0/scinterop/tests/__init__.py +0 -0
- scinterop-0.1.0/scinterop/tests/test_cache.py +55 -0
- scinterop-0.1.0/scinterop/tests/test_detect.py +59 -0
- scinterop-0.1.0/scinterop/tests/test_h5ad.py +69 -0
- scinterop-0.1.0/scinterop/tests/test_mtx.py +54 -0
- scinterop-0.1.0/scinterop/tests/test_provenance.py +74 -0
- scinterop-0.1.0/scinterop/tests/test_schema.py +89 -0
- scinterop-0.1.0/scinterop/tests/test_validate.py +87 -0
- scinterop-0.1.0/scinterop/validate.py +151 -0
- scinterop-0.1.0/scinterop.egg-info/PKG-INFO +778 -0
- scinterop-0.1.0/scinterop.egg-info/SOURCES.txt +29 -0
- scinterop-0.1.0/scinterop.egg-info/dependency_links.txt +1 -0
- scinterop-0.1.0/scinterop.egg-info/entry_points.txt +2 -0
- scinterop-0.1.0/scinterop.egg-info/requires.txt +12 -0
- scinterop-0.1.0/scinterop.egg-info/top_level.txt +1 -0
- scinterop-0.1.0/setup.cfg +4 -0
scinterop-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scinterop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Single-cell data interoperability between R and Python formats
|
|
5
|
+
Author: Qwi813
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/imuhdawood/scinterop
|
|
8
|
+
Project-URL: Repository, https://github.com/imuhdawood/scinterop
|
|
9
|
+
Project-URL: Issues, https://github.com/imuhdawood/scinterop/issues
|
|
10
|
+
Keywords: single-cell,bioinformatics,seurat,anndata,h5ad,rds,mtx
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: numpy>=1.20
|
|
21
|
+
Requires-Dist: scipy>=1.8
|
|
22
|
+
Requires-Dist: pandas>=1.5
|
|
23
|
+
Provides-Extra: anndata
|
|
24
|
+
Requires-Dist: anndata>=0.10; extra == "anndata"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
28
|
+
Requires-Dist: mkdocs; extra == "dev"
|
|
29
|
+
Requires-Dist: mkdocs-material; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# scinterop — Single-Cell Interoperability
|
|
32
|
+
|
|
33
|
+
Bidirectional conversion between single-cell data formats (H5AD, 10X MTX, RDS/Seurat) with explicit environment paths, provenance logging, and scratch management.
|
|
34
|
+
|
|
35
|
+
**Core design principles:**
|
|
36
|
+
- No implicit global state — all paths are explicit
|
|
37
|
+
- Separate format adapters from execution runners
|
|
38
|
+
- No `rpy2` dependency — R bridge uses subprocess + intermediate H5AD
|
|
39
|
+
- All failures localize to the adapter that failed
|
|
40
|
+
- Every conversion logs a JSON provenance record
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Table of Contents
|
|
45
|
+
|
|
46
|
+
- [Install](#install)
|
|
47
|
+
- [Architecture overview](#architecture-overview)
|
|
48
|
+
- [CanonicalObject — the core data structure](#canonicalobject--the-core-data-structure)
|
|
49
|
+
- [Format detection](#format-detection)
|
|
50
|
+
- [Format adapters](#format-adapters)
|
|
51
|
+
- [H5AD adapter (`scinterop.h5ad`)](#h5ad-adapter-scinteroph5ad)
|
|
52
|
+
- [10X MTX adapter (`scinterop.mtx`)](#10x-mtx-adapter-scinteromtx)
|
|
53
|
+
- [RDS adapter (`scinterop.rds`)](#rds-adapter-scinterords)
|
|
54
|
+
- [External script runners](#external-script-runners)
|
|
55
|
+
- [R runner (`scinterop.r_runner`)](#r-runner-scinteropr_runner)
|
|
56
|
+
- [Python runner (`scinterop.python_runner`)](#python-runner-scinteroppython_runner)
|
|
57
|
+
- [Scratch manager (`scinterop.cache`)](#scratch-manager-scinteropcache)
|
|
58
|
+
- [Provenance logging](#provenance-logging)
|
|
59
|
+
- [Validation](#validation)
|
|
60
|
+
- [Error hierarchy](#error-hierarchy)
|
|
61
|
+
- [CLI reference](#cli-reference)
|
|
62
|
+
- [Environment variables](#environment-variables)
|
|
63
|
+
- [Smoke test](#smoke-test)
|
|
64
|
+
- [Extending with a new format](#extending-with-a-new-format)
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
### Quick start (conda)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Create or use existing scinterop conda environment
|
|
74
|
+
conda activate scinterop
|
|
75
|
+
|
|
76
|
+
# Install scinterop package
|
|
77
|
+
pip install /path/to/scinterop
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Full environment (with R/Seurat support)
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
# Create from the lockfile
|
|
84
|
+
conda env create -f environment.yml
|
|
85
|
+
|
|
86
|
+
# Or install R packages into an existing env:
|
|
87
|
+
conda install -n scinterop r-base r-seurat r-matrix r-anndata \
|
|
88
|
+
-c conda-forge -c bioconda -y
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Minimal (pip only)
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# From source
|
|
95
|
+
pip install /path/to/scinterop
|
|
96
|
+
|
|
97
|
+
# Editable install (for development)
|
|
98
|
+
pip install -e /path/to/scinterop
|
|
99
|
+
|
|
100
|
+
# Install with H5AD support (optional)
|
|
101
|
+
pip install anndata
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Runtime dependencies:** `numpy`, `scipy`, `pandas` (always required).
|
|
105
|
+
|
|
106
|
+
**Optional dependencies:** `anndata` (required for H5AD adapter); `R` with `Seurat` + `anndata` R packages (required for RDS adapter).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Architecture overview
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
┌────────────────────────────────────────────────────────────────────┐
|
|
114
|
+
│ scinterop.read() / convert() │
|
|
115
|
+
│ (auto-detect + dispatch) │
|
|
116
|
+
├──────────┬──────────┬──────────┬──────────┬──────────┬────────────┤
|
|
117
|
+
│ detect │ validate │ h5ad │ mtx │ rds │ provenance │
|
|
118
|
+
│ .py │ .py │ .py │ .py │ .py │ .py │
|
|
119
|
+
├──────────┴──────────┴──────────┴──────────┴──────────┴────────────┤
|
|
120
|
+
│ r_runner.py │ python_runner.py │ cache.py │ schema.py │
|
|
121
|
+
│ (subprocess) │ (subprocess/conda) │ (scratch) │ (dataclass) │
|
|
122
|
+
└───────────────┴────────────────────┴────────────┴────────────────┘
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Three API layers (all consistent):**
|
|
126
|
+
|
|
127
|
+
| Layer | Example | Purpose |
|
|
128
|
+
|-------|---------|---------|
|
|
129
|
+
| Object methods | `obj.to_seurat("out.rds")` | Convenience on CanonicalObject |
|
|
130
|
+
| Top-level auto-detect | `si.convert("in.h5ad", "out.rds")` | One-shot format conversion |
|
|
131
|
+
| Per-format explicit | `si.rds.write(obj, "out.rds")` | Fine control when you know the format |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## CanonicalObject — the core data structure
|
|
136
|
+
|
|
137
|
+
A plain Python dataclass with no external dependencies beyond `numpy`, `scipy`, `pandas`. This is the neutral representation that all adapters convert to and from.
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from scinterop import CanonicalObject
|
|
141
|
+
import numpy as np
|
|
142
|
+
import pandas as pd
|
|
143
|
+
from scipy import sparse as sp
|
|
144
|
+
|
|
145
|
+
obj = CanonicalObject(
|
|
146
|
+
X=sp.csr_matrix(np.random.poisson(0.5, size=(100, 2000))),
|
|
147
|
+
obs=pd.DataFrame({"cell_type": ["T cell"] * 100}),
|
|
148
|
+
var=pd.DataFrame(index=[f"gene_{i}" for i in range(2000)]),
|
|
149
|
+
obsm={"X_pca": np.random.randn(100, 10)},
|
|
150
|
+
layers={"counts": sp.csr_matrix(np.random.poisson(0.5, size=(100, 2000)))},
|
|
151
|
+
uns={"genome": "hg38", "params": {"n_pcs": 10}},
|
|
152
|
+
raw=CanonicalObject(X=np.random.poisson(0.5, size=(100, 2000))),
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Fields
|
|
157
|
+
|
|
158
|
+
| Field | Type | Required | Description |
|
|
159
|
+
|-------|------|----------|-------------|
|
|
160
|
+
| `X` | `np.ndarray` or `sp.spmatrix` | Yes | Expression matrix (cells × genes) |
|
|
161
|
+
| `obs` | `pd.DataFrame` | No | Cell-level metadata, one row per cell |
|
|
162
|
+
| `var` | `pd.DataFrame` | No | Gene-level metadata, one row per gene |
|
|
163
|
+
| `obsm` | `dict[str, np.ndarray]` | No | Multi-dimensional cell embeddings (X_pca, X_umap, etc.) |
|
|
164
|
+
| `layers` | `dict[str, np.ndarray or sp.spmatrix]` | No | Additional expression matrices, same shape as X |
|
|
165
|
+
| `uns` | `dict` | No | Unstructured metadata (parameters, colors, etc.) |
|
|
166
|
+
| `raw` | `CanonicalObject or None` | No | Raw counts reference |
|
|
167
|
+
|
|
168
|
+
### Properties and methods
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
obj.shape # -> (n_cells, n_genes)
|
|
172
|
+
obj.n_cells # -> int
|
|
173
|
+
obj.n_genes # -> int
|
|
174
|
+
obj.copy() # -> deep copy
|
|
175
|
+
|
|
176
|
+
# Export convenience methods (delegate to format adapters)
|
|
177
|
+
obj.to_anndata("export.h5ad")
|
|
178
|
+
obj.to_seurat("export.rds") # Seurat object in RDS
|
|
179
|
+
obj.to_rds("export.rds") # Plain R list in RDS
|
|
180
|
+
obj.to_rds("export.rds", seurat=True) # Same as to_seurat()
|
|
181
|
+
obj.to_mtx("export_mtx/") # 10X-style MTX directory
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Auto-validation
|
|
185
|
+
|
|
186
|
+
The constructor validates that `obs` and `var` lengths match `X.shape`. See [Validation](#validation) for explicit checks.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Format detection
|
|
191
|
+
|
|
192
|
+
The `detect()` function identifies the format of a file or directory without reading the full data.
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
from scinterop import detect
|
|
196
|
+
|
|
197
|
+
result = detect("data.h5ad")
|
|
198
|
+
result.fmt # -> "h5ad"
|
|
199
|
+
result.path # -> Path("data.h5ad")
|
|
200
|
+
result.details # -> {"extension": ".h5ad"}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Detection logic
|
|
204
|
+
|
|
205
|
+
1. **If path is a directory**: check for 10X MTX trio files (`matrix.mtx[.gz]`, `barcodes.tsv[.gz]`, `features.tsv[.gz]`)
|
|
206
|
+
2. **If file exists**: check extension, optionally peek HDF5 contents for anndata vs Seurat signatures
|
|
207
|
+
3. **If file doesn't exist but has extension**: infer format from extension alone (useful for planning conversions)
|
|
208
|
+
4. **Anything else**: raise `DetectionError` with supported formats listed
|
|
209
|
+
|
|
210
|
+
### Supported formats
|
|
211
|
+
|
|
212
|
+
| Extension | Format constant | Also matches |
|
|
213
|
+
|-----------|----------------|--------------|
|
|
214
|
+
| `.h5ad` | `"h5ad"` | `.h5` (with anndata signature) |
|
|
215
|
+
| `.rds` | `"rds"` | — |
|
|
216
|
+
| `.mtx` | `"mtx"` | `.mtx.gz` |
|
|
217
|
+
| Directory | `"mtx"` | Contains 10X trio files |
|
|
218
|
+
|
|
219
|
+
### Detection errors
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
try:
|
|
223
|
+
result = detect("file.xyz")
|
|
224
|
+
except DetectionError as e:
|
|
225
|
+
print(e) # "Cannot determine format from path: file.xyz. Supported extensions: .h5ad, .rds, .mtx"
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Format adapters
|
|
231
|
+
|
|
232
|
+
Each format adapter is a standalone module with `read()` and `write()` functions.
|
|
233
|
+
They are callable directly or via the top-level `read()`/`convert()` API.
|
|
234
|
+
|
|
235
|
+
### H5AD adapter (`scinterop.h5ad`)
|
|
236
|
+
|
|
237
|
+
Read and write AnnData H5AD files. Requires `anndata` pip package.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
import scinterop as si
|
|
241
|
+
|
|
242
|
+
# Read H5AD
|
|
243
|
+
obj = si.h5ad.read_h5ad("data.h5ad")
|
|
244
|
+
|
|
245
|
+
# Write H5AD (auto-adds .h5ad extension if missing)
|
|
246
|
+
si.h5ad.write_h5ad(obj, "output.h5ad")
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**What gets mapped:**
|
|
250
|
+
| AnnData slot | CanonicalObject field |
|
|
251
|
+
|--------------|----------------------|
|
|
252
|
+
| `X` | `X` |
|
|
253
|
+
| `obs` | `obs` |
|
|
254
|
+
| `var` | `var` |
|
|
255
|
+
| `obsm` | `obsm` |
|
|
256
|
+
| `layers` | `layers` |
|
|
257
|
+
| `uns` | `uns` |
|
|
258
|
+
| `raw` | `raw` (recursively) |
|
|
259
|
+
|
|
260
|
+
If `obs` or `var` are empty DataFrames, default indices (`cell_0`, `gene_0`, ...) are auto-generated for writing.
|
|
261
|
+
|
|
262
|
+
**Error example:**
|
|
263
|
+
```
|
|
264
|
+
H5adAdapterError: Failed to read H5AD file 'corrupt.h5ad': Unable to open file (File signature not found)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
### 10X MTX adapter (`scinterop.mtx`)
|
|
270
|
+
|
|
271
|
+
Read and write 10X Genomics MTX format (directory with `matrix.mtx`, `barcodes.tsv`, `features.tsv`).
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
# Read from 10X output directory
|
|
275
|
+
obj = si.mtx.read_mtx("cellranger_output/")
|
|
276
|
+
|
|
277
|
+
# Read single .mtx file (no barcodes/features — auto-generates names)
|
|
278
|
+
obj = si.mtx.read_mtx("matrix.mtx.gz")
|
|
279
|
+
|
|
280
|
+
# Write to 10X format
|
|
281
|
+
si.mtx.write_mtx(obj, "output_mtx/")
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**Convention handling:**
|
|
285
|
+
- Files use the 10X convention **genes × cells** (features in rows, cells in columns)
|
|
286
|
+
- CanonicalObject stores **cells × genes**
|
|
287
|
+
- The adapter transposes automatically on both read and write
|
|
288
|
+
|
|
289
|
+
**Output directory structure:**
|
|
290
|
+
```
|
|
291
|
+
output_mtx/
|
|
292
|
+
├── matrix.mtx # Sparse matrix (genes × cells)
|
|
293
|
+
├── barcodes.tsv # Cell barcodes
|
|
294
|
+
└── features.tsv # Gene identifiers (tab-delimited: id\tname\t"Gene Expression")
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
**Error example:**
|
|
298
|
+
```
|
|
299
|
+
MtxAdapterError: 10X MTX directory 'empty_dir/' is missing: matrix.mtx[.gz], barcodes.tsv[.gz], features.tsv[.gz]
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
### RDS adapter (`scinterop.rds`)
|
|
305
|
+
|
|
306
|
+
Read and write RDS files via a subprocess R bridge. No `rpy2` dependency.
|
|
307
|
+
|
|
308
|
+
**How it works:**
|
|
309
|
+
1. The adapter writes a temporary R script as a template string
|
|
310
|
+
2. Runs it via `scinterop.r_runner.run_r()` using `Rscript`
|
|
311
|
+
3. The R script uses the `anndata` R package to convert between RDS and H5AD
|
|
312
|
+
4. The intermediate H5AD is read/written by the H5AD adapter
|
|
313
|
+
5. Temp files are cleaned up via the scratch manager
|
|
314
|
+
|
|
315
|
+
```
|
|
316
|
+
write_rds:
|
|
317
|
+
CanonicalObject → [temp.h5ad] → R script reads H5AD, builds Seurat/list → output.rds
|
|
318
|
+
|
|
319
|
+
read_rds:
|
|
320
|
+
input.rds → R script reads object, saves as H5AD → [temp.h5ad] → CanonicalObject
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
# Write as Seurat object
|
|
325
|
+
si.rds.write_rds(obj, "seurat.rds", seurat=True)
|
|
326
|
+
obj.to_seurat("seurat.rds") # same
|
|
327
|
+
|
|
328
|
+
# Write as plain R list (components: X, obs, var, obsm, layers, uns)
|
|
329
|
+
si.rds.write_rds(obj, "data.rds", seurat=False)
|
|
330
|
+
obj.to_rds("data.rds") # same (default: seurat=False)
|
|
331
|
+
|
|
332
|
+
# Read RDS (auto-detects Seurat vs list)
|
|
333
|
+
obj = si.rds.read_rds("seurat_object.rds")
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
**What the R bridge does for Seurat output:**
|
|
337
|
+
1. Transposes expression matrix (cells×genes → genes×cells)
|
|
338
|
+
2. Converts to `dgCMatrix` (column-compressed sparse)
|
|
339
|
+
3. Creates `SeuratObject` with counts and metadata
|
|
340
|
+
4. Adds reductions from `obsm` (maps `X_pca` → `pca` DimReduc, etc.)
|
|
341
|
+
|
|
342
|
+
**What the R bridge does for R list output:**
|
|
343
|
+
1. Saves `X`, `obs`, `var`, `obsm`, `layers`, `uns` as a named R list
|
|
344
|
+
2. Access from R with `result$X`, `result$obs`, etc.
|
|
345
|
+
|
|
346
|
+
**Required R packages:**
|
|
347
|
+
- Reading: `anndata` (for both Seurat and list)
|
|
348
|
+
- Writing (Seurat): `anndata`, `Seurat`, `Matrix`
|
|
349
|
+
- Writing (list): `anndata`
|
|
350
|
+
|
|
351
|
+
**Installing R dependencies (conda — recommended):**
|
|
352
|
+
```bash
|
|
353
|
+
conda install -n scinterop r-base r-seurat r-matrix r-anndata \
|
|
354
|
+
-c conda-forge -c bioconda -y
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
This installs R 4.5 + Seurat 5.5 + Matrix + anndata R package in your scinterop env — no separate CRAN install or compilation needed.
|
|
358
|
+
|
|
359
|
+
**Verification:**
|
|
360
|
+
```bash
|
|
361
|
+
conda run -n scinterop R -e 'library(anndata); library(Seurat); cat("OK\n")'
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The R script fails with a clear message if packages are missing:
|
|
365
|
+
```
|
|
366
|
+
RdsAdapterError: Failed to write RDS file 'out.rds': R script failed with code 1.
|
|
367
|
+
stderr:
|
|
368
|
+
Error: R package 'Seurat' is required. Install with: install.packages('Seurat')
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**Seurat v5 compatibility:**
|
|
372
|
+
The adapter auto-detects the SeuratObject version at runtime and uses the correct API — `layer="counts"` for Seurat v5+, `slot="counts"` for Seurat v4.
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## External script runners
|
|
377
|
+
|
|
378
|
+
Run arbitrary R or Python scripts with explicit environment paths.
|
|
379
|
+
All output is captured to strings and optionally to log files.
|
|
380
|
+
|
|
381
|
+
### R runner (`scinterop.r_runner`)
|
|
382
|
+
|
|
383
|
+
```python
|
|
384
|
+
from scinterop import run_r
|
|
385
|
+
|
|
386
|
+
result = run_r(
|
|
387
|
+
"library(Seurat); obj <- readRDS('data.rds'); print(dim(obj))",
|
|
388
|
+
r_exe="/path/to/Rscript",
|
|
389
|
+
log_path="/tmp/r_log.txt",
|
|
390
|
+
timeout=300,
|
|
391
|
+
)
|
|
392
|
+
print(result.stdout)
|
|
393
|
+
print(result.stderr)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The `r_exe` parameter is resolved in this order:
|
|
397
|
+
1. Explicit argument: `r_exe="/opt/R/4.3/bin/Rscript"`
|
|
398
|
+
2. Environment variable: `SCINTEROP_R_EXE`
|
|
399
|
+
3. Default: `Rscript` (must be on PATH)
|
|
400
|
+
|
|
401
|
+
### Python runner (`scinterop.python_runner`)
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
from scinterop import run_python
|
|
405
|
+
|
|
406
|
+
# Direct python
|
|
407
|
+
result = run_python("script.py", python_exe="/opt/python/3.11/bin/python")
|
|
408
|
+
|
|
409
|
+
# Conda environment
|
|
410
|
+
result = run_python(
|
|
411
|
+
"import scanpy as sc; adata = sc.read_h5ad('data.h5ad'); print(adata)",
|
|
412
|
+
conda_env="sc-env",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# With arguments
|
|
416
|
+
result = run_python("script.py", args=["--input", "data.h5ad", "--output", "out.rds"])
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The `python_exe` / `conda_env` resolution:
|
|
420
|
+
1. If `conda_env` is set → use `conda run -n <env> python`
|
|
421
|
+
2. If `python_exe` is set → use it directly
|
|
422
|
+
3. Fallback: `SCINTEROP_PYTHON_EXE` env var → `python`
|
|
423
|
+
|
|
424
|
+
### Error handling for both runners
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
try:
|
|
428
|
+
run_r("nonexistent_command()")
|
|
429
|
+
except RExecError as e:
|
|
430
|
+
print(e) # "R script failed with code 1. stderr: ..."
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Both runners raise `ExecError` on:
|
|
434
|
+
- Non-zero return code (with stderr in the error message)
|
|
435
|
+
- Timeout (configurable, default 600s)
|
|
436
|
+
- Executable not found (with hint to set env var)
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Scratch manager (`scinterop.cache`)
|
|
441
|
+
|
|
442
|
+
Manages temporary directories for intermediate files during conversion.
|
|
443
|
+
|
|
444
|
+
```python
|
|
445
|
+
from scinterop.cache import ScratchManager
|
|
446
|
+
|
|
447
|
+
mgr = ScratchManager() # Uses SCINTEROP_SCRATCH or /tmp/scinterop_USER/
|
|
448
|
+
|
|
449
|
+
ctx = mgr.tempdir("my_conversion")
|
|
450
|
+
# ctx.path -> PosixPath('/tmp/scinterop_user/my_conversion_a1b2c3d4e5f6')
|
|
451
|
+
|
|
452
|
+
# Write a temp file
|
|
453
|
+
script_path = mgr.write_script(ctx, "print('hello')", "myscript")
|
|
454
|
+
|
|
455
|
+
# Clean up (removes directory)
|
|
456
|
+
mgr.cleanup(ctx)
|
|
457
|
+
|
|
458
|
+
# Or keep for debugging (controlled by SCINTEROP_DEBUG=1)
|
|
459
|
+
mgr.cleanup(ctx, keep=True)
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
**SCINTEROP_DEBUG behaviour:**
|
|
463
|
+
- `SCINTEROP_DEBUG=1` → all temp files are preserved after conversion
|
|
464
|
+
- Default (unset) → temp files are removed on success
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## Provenance logging
|
|
469
|
+
|
|
470
|
+
Every conversion creates a JSON record with timestamps, versions, and system info.
|
|
471
|
+
|
|
472
|
+
```python
|
|
473
|
+
from scinterop.provenance import write_conversion_record, read_provenance_log
|
|
474
|
+
|
|
475
|
+
# Write a record (appends to provenance.jsonl in log_dir)
|
|
476
|
+
write_conversion_record(
|
|
477
|
+
input_path="/data/input.h5ad",
|
|
478
|
+
input_format="h5ad",
|
|
479
|
+
output_path="/data/output.rds",
|
|
480
|
+
output_format="rds",
|
|
481
|
+
success=True,
|
|
482
|
+
runtime_s=12.5,
|
|
483
|
+
log_dir="/data/logs/",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Read back all records
|
|
487
|
+
records = read_provenance_log("/data/logs/provenance.jsonl")
|
|
488
|
+
for r in records:
|
|
489
|
+
print(r["timestamp"], r["input_format"], "->", r["output_format"])
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Record format:**
|
|
493
|
+
```json
|
|
494
|
+
{
|
|
495
|
+
"timestamp": "2026-07-09T14:30:00+00:00",
|
|
496
|
+
"package": "scinterop",
|
|
497
|
+
"version": "0.1.0",
|
|
498
|
+
"input_path": "/data/input.h5ad",
|
|
499
|
+
"input_format": "h5ad",
|
|
500
|
+
"output_path": "/data/output.rds",
|
|
501
|
+
"output_format": "rds",
|
|
502
|
+
"success": true,
|
|
503
|
+
"runtime_seconds": 12.5,
|
|
504
|
+
"system": {
|
|
505
|
+
"platform": "Linux",
|
|
506
|
+
"release": "5.14.0-1.el9.x86_64",
|
|
507
|
+
"python": "3.12.12",
|
|
508
|
+
"hostname": "compute-node-01"
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
The `read()` and `convert()` functions at the top level automatically write provenance records.
|
|
514
|
+
|
|
515
|
+
---
|
|
516
|
+
|
|
517
|
+
## Validation
|
|
518
|
+
|
|
519
|
+
Structural validation of a `CanonicalObject` without loading format-specific libraries.
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
from scinterop import validate, assert_valid, ValidationError
|
|
523
|
+
|
|
524
|
+
result = validate(obj)
|
|
525
|
+
result.valid # True / False
|
|
526
|
+
result.errors # list of error strings
|
|
527
|
+
|
|
528
|
+
# Raises ValidationError on first issue
|
|
529
|
+
assert_valid(obj)
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
**Checks performed:**
|
|
533
|
+
|
|
534
|
+
| Check | Condition | Error message |
|
|
535
|
+
|-------|-----------|---------------|
|
|
536
|
+
| Type | Is it a `CanonicalObject`? | `Expected CanonicalObject, got <type>` |
|
|
537
|
+
| X type | `np.ndarray` or `sp.spmatrix`? | `X must be numpy array or sparse matrix` |
|
|
538
|
+
| X dims | 2D? | `X must be 2-dimensional, got shape ...` |
|
|
539
|
+
| obs length | Matches X rows? | `obs has N rows but X has M cells` |
|
|
540
|
+
| var length | Matches X columns? | `var has N rows but X has M genes` |
|
|
541
|
+
| obsm | All 2D with same N rows? | `obsm['X_pca'] has N rows but X has M cells` |
|
|
542
|
+
| layers | Same shape as X? | `layers['counts'] shape ... != X shape ...` |
|
|
543
|
+
| raw | Valid CanonicalObject? | `raw object is invalid: ...` |
|
|
544
|
+
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
## Error hierarchy
|
|
548
|
+
|
|
549
|
+
```
|
|
550
|
+
ScinteropError (base)
|
|
551
|
+
├── DetectionError # Format detection failure
|
|
552
|
+
├── FormatAdapterError # Base for all adapter errors
|
|
553
|
+
│ ├── MtxAdapterError # MTX read/write failure
|
|
554
|
+
│ ├── H5adAdapterError # H5AD read/write failure
|
|
555
|
+
│ └── RdsAdapterError # RDS read/write failure
|
|
556
|
+
├── ValidationError # Object validation failure
|
|
557
|
+
├── ExecError # Base for execution errors
|
|
558
|
+
│ ├── RExecError # R script failure
|
|
559
|
+
│ └── PythonExecError # Python script failure
|
|
560
|
+
├── CacheError # Scratch directory failure
|
|
561
|
+
└── ProvenanceError # Provenance log failure
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Every error includes the specific location and cause:
|
|
565
|
+
|
|
566
|
+
```python
|
|
567
|
+
try:
|
|
568
|
+
si.read("nonexistent.h5ad")
|
|
569
|
+
except FormatAdapterError as e:
|
|
570
|
+
print(type(e).__name__, ":", e)
|
|
571
|
+
# H5adAdapterError : Failed to read H5AD file 'nonexistent.h5ad': [Errno 2] No such file or directory
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## CLI reference
|
|
577
|
+
|
|
578
|
+
```bash
|
|
579
|
+
# Detect format of a file or directory
|
|
580
|
+
scinterop detect data.h5ad
|
|
581
|
+
scinterop detect cellranger_output/
|
|
582
|
+
|
|
583
|
+
# Convert formats
|
|
584
|
+
scinterop convert input.h5ad output.rds --seurat
|
|
585
|
+
scinterop convert input.h5ad output_mtx/
|
|
586
|
+
scinterop convert input.rds output.h5ad --r-exe /opt/R/4.3/bin/Rscript
|
|
587
|
+
|
|
588
|
+
# Convert options:
|
|
589
|
+
# --seurat When writing RDS, create a Seurat object (not plain R list)
|
|
590
|
+
# --r-exe PATH Path to Rscript (overrides SCINTEROP_R_EXE)
|
|
591
|
+
# --python-exe Path to Python (overrides SCINTEROP_PYTHON_EXE)
|
|
592
|
+
# --debug Keep temporary files for debugging
|
|
593
|
+
|
|
594
|
+
# Run an external script
|
|
595
|
+
scinterop run analysis.R --executor r --exe /opt/R/4.3/bin/Rscript
|
|
596
|
+
scinterop run analysis.py --executor python --conda-env sc-env
|
|
597
|
+
scinterop run analysis.py --executor python --exe /opt/python/3.11/bin/python
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
## Environment variables
|
|
603
|
+
|
|
604
|
+
| Variable | Default | Description |
|
|
605
|
+
|----------|---------|-------------|
|
|
606
|
+
| `SCINTEROP_R_EXE` | `Rscript` | Path to Rscript executable |
|
|
607
|
+
| `SCINTEROP_PYTHON_EXE` | `python` | Path to Python executable |
|
|
608
|
+
| `SCINTEROP_CONDA_EXE` | `conda` | Conda/micromamba executable (for conda-run mode) |
|
|
609
|
+
| `SCINTEROP_SCRATCH` | `/tmp/scinterop_$USER` | Base directory for temporary files |
|
|
610
|
+
| `SCINTEROP_DEBUG` | (unset) | Set to `1`/`true`/`yes` to keep temp files on success |
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## Smoke test
|
|
615
|
+
|
|
616
|
+
Run this to verify the entire package works end-to-end without any external dependencies (no R, no anndata):
|
|
617
|
+
|
|
618
|
+
```python
|
|
619
|
+
"""
|
|
620
|
+
Smoke test: run with:
|
|
621
|
+
PYTHONPATH=/path/to/scinterop python smoke_test.py
|
|
622
|
+
"""
|
|
623
|
+
import scinterop as si
|
|
624
|
+
import numpy as np
|
|
625
|
+
import pandas as pd
|
|
626
|
+
from scipy import sparse as sp
|
|
627
|
+
import tempfile
|
|
628
|
+
import os
|
|
629
|
+
|
|
630
|
+
print("=" * 50)
|
|
631
|
+
print("scinterop smoke test")
|
|
632
|
+
print("=" * 50)
|
|
633
|
+
|
|
634
|
+
# 1. Create a CanonicalObject
|
|
635
|
+
np.random.seed(42)
|
|
636
|
+
X = sp.csr_matrix(np.random.poisson(0.5, size=(20, 10)).astype(np.float64))
|
|
637
|
+
obs = pd.DataFrame({"cluster": ["A", "B"] * 10}, index=[f"cell_{i}" for i in range(20)])
|
|
638
|
+
var = pd.DataFrame(index=[f"gene_{i}" for i in range(10)])
|
|
639
|
+
obsm = {"X_pca": np.random.randn(20, 3)}
|
|
640
|
+
obj = si.CanonicalObject(X=X, obs=obs, var=var, obsm=obsm)
|
|
641
|
+
print(f"[OK] Created CanonicalObject: {obj.shape}")
|
|
642
|
+
|
|
643
|
+
# 2. Validate
|
|
644
|
+
result = si.validate(obj)
|
|
645
|
+
assert result.valid, f"Validation failed: {result.errors}"
|
|
646
|
+
si.assert_valid(obj)
|
|
647
|
+
print("[OK] Validation passed")
|
|
648
|
+
|
|
649
|
+
# 3. Detect format
|
|
650
|
+
for path in ["test.h5ad", "test.rds", "matrix.mtx", "matrix.mtx.gz"]:
|
|
651
|
+
d = si.detect(path)
|
|
652
|
+
assert d.fmt, f"Detection failed for {path}"
|
|
653
|
+
print("[OK] Format detection works on all extensions")
|
|
654
|
+
|
|
655
|
+
# 4. MTX round-trip
|
|
656
|
+
tmpdir = tempfile.mkdtemp()
|
|
657
|
+
si.mtx.write_mtx(obj, tmpdir)
|
|
658
|
+
obj2 = si.mtx.read_mtx(tmpdir)
|
|
659
|
+
assert obj2.shape == (20, 10), f"Shape mismatch: {obj2.shape}"
|
|
660
|
+
assert np.allclose(obj.X.toarray(), obj2.X.toarray()), "X mismatch"
|
|
661
|
+
print("[OK] MTX write + read round-trip")
|
|
662
|
+
|
|
663
|
+
# 5. Top-level read (MTX)
|
|
664
|
+
obj3 = si.read(tmpdir)
|
|
665
|
+
assert obj3.shape == (20, 10)
|
|
666
|
+
print("[OK] si.read() on MTX directory")
|
|
667
|
+
|
|
668
|
+
# 6. MTX → MTX convert (via si.read + si.mtx.write, no actual conversion needed)
|
|
669
|
+
tmpdir2 = tempfile.mkdtemp()
|
|
670
|
+
si.convert(tmpdir, tmpdir2)
|
|
671
|
+
obj4 = si.read(tmpdir2)
|
|
672
|
+
assert obj4.shape == (20, 10)
|
|
673
|
+
print("[OK] si.convert() MTX → MTX")
|
|
674
|
+
|
|
675
|
+
# 7. Provenance
|
|
676
|
+
log_dir = tempfile.mkdtemp()
|
|
677
|
+
from scinterop.provenance import write_conversion_record, read_provenance_log
|
|
678
|
+
write_conversion_record(
|
|
679
|
+
input_path="/smoke/test.h5ad",
|
|
680
|
+
input_format="h5ad",
|
|
681
|
+
output_path="/smoke/test.rds",
|
|
682
|
+
output_format="rds",
|
|
683
|
+
success=True,
|
|
684
|
+
runtime_s=0.5,
|
|
685
|
+
log_dir=log_dir,
|
|
686
|
+
)
|
|
687
|
+
records = read_provenance_log(os.path.join(log_dir, "provenance.jsonl"))
|
|
688
|
+
assert len(records) == 1
|
|
689
|
+
assert records[0]["success"] is True
|
|
690
|
+
print("[OK] Provenance logging")
|
|
691
|
+
|
|
692
|
+
# 8. Cache manager
|
|
693
|
+
from scinterop.cache import ScratchManager
|
|
694
|
+
mgr = ScratchManager(base=tempfile.mkdtemp())
|
|
695
|
+
ctx = mgr.tempdir("smoke")
|
|
696
|
+
assert ctx.path.exists()
|
|
697
|
+
mgr.cleanup(ctx, keep=False)
|
|
698
|
+
assert not ctx.path.exists()
|
|
699
|
+
print("[OK] Cache manager (create + cleanup)")
|
|
700
|
+
|
|
701
|
+
# 9. Object convenience methods
|
|
702
|
+
assert obj.to_anndata is not None
|
|
703
|
+
assert obj.to_seurat is not None
|
|
704
|
+
assert obj.to_mtx is not None
|
|
705
|
+
print("[OK] Object .to_* methods exist")
|
|
706
|
+
|
|
707
|
+
# 10. Copy
|
|
708
|
+
obj_copy = obj.copy()
|
|
709
|
+
assert obj_copy.shape == obj.shape
|
|
710
|
+
print("[OK] Object copy")
|
|
711
|
+
|
|
712
|
+
print()
|
|
713
|
+
print("=" * 50)
|
|
714
|
+
print("All smoke tests passed!")
|
|
715
|
+
print("=" * 50)
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
**To run with H5AD support:**
|
|
719
|
+
```bash
|
|
720
|
+
pip install anndata
|
|
721
|
+
PYTHONPATH=/path/to/scinterop python smoke_test.py
|
|
722
|
+
# Also run the H5AD-specific tests:
|
|
723
|
+
python -m pytest /path/to/scinterop/scinterop/tests/test_h5ad.py -v
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**To run the full test suite:**
|
|
727
|
+
```bash
|
|
728
|
+
cd /path/to/scinterop
|
|
729
|
+
pip install -e .[dev] # installs pytest
|
|
730
|
+
python -m pytest
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
---
|
|
734
|
+
|
|
735
|
+
## Extending with a new format
|
|
736
|
+
|
|
737
|
+
Adding support for a new format (e.g., Loom, h5seurat, Zarr) requires:
|
|
738
|
+
|
|
739
|
+
1. **Create the adapter module** with two functions:
|
|
740
|
+
|
|
741
|
+
```python
|
|
742
|
+
# scinterop/loom.py
|
|
743
|
+
from scinterop.schema import CanonicalObject
|
|
744
|
+
from scinterop.errors import FormatAdapterError
|
|
745
|
+
|
|
746
|
+
def read_loom(path):
|
|
747
|
+
"""Read Loom file -> CanonicalObject"""
|
|
748
|
+
# ... parsing logic ...
|
|
749
|
+
return CanonicalObject(X=..., obs=..., var=...)
|
|
750
|
+
|
|
751
|
+
def write_loom(obj, path):
|
|
752
|
+
"""CanonicalObject -> Loom file"""
|
|
753
|
+
# ... writing logic ...
|
|
754
|
+
return str(path)
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
2. **Register in `detect.py`** — add the extension to `EXTENSION_MAP`:
|
|
758
|
+
|
|
759
|
+
```python
|
|
760
|
+
EXTENSION_MAP = {
|
|
761
|
+
".h5ad": "h5ad",
|
|
762
|
+
".rds": "rds",
|
|
763
|
+
".mtx": "mtx",
|
|
764
|
+
".loom": "loom", # new
|
|
765
|
+
}
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
3. **Register in `__init__.py`** — add the format to `read()` and `convert()`:
|
|
769
|
+
|
|
770
|
+
```python
|
|
771
|
+
def read(path, **kwargs):
|
|
772
|
+
...
|
|
773
|
+
elif fmt == "loom":
|
|
774
|
+
return loom.read_loom(path, **kwargs)
|
|
775
|
+
...
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
That's it. The adapter owns all format-specific logic; the rest of the package is format-agnostic.
|