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.
Files changed (31) hide show
  1. scinterop-0.1.0/PKG-INFO +778 -0
  2. scinterop-0.1.0/README.md +748 -0
  3. scinterop-0.1.0/pyproject.toml +52 -0
  4. scinterop-0.1.0/scinterop/__init__.py +204 -0
  5. scinterop-0.1.0/scinterop/cache.py +139 -0
  6. scinterop-0.1.0/scinterop/cli.py +186 -0
  7. scinterop-0.1.0/scinterop/detect.py +147 -0
  8. scinterop-0.1.0/scinterop/errors.py +72 -0
  9. scinterop-0.1.0/scinterop/h5ad.py +182 -0
  10. scinterop-0.1.0/scinterop/mtx.py +226 -0
  11. scinterop-0.1.0/scinterop/provenance.py +160 -0
  12. scinterop-0.1.0/scinterop/python_runner.py +168 -0
  13. scinterop-0.1.0/scinterop/r_runner.py +163 -0
  14. scinterop-0.1.0/scinterop/rds.py +409 -0
  15. scinterop-0.1.0/scinterop/schema.py +146 -0
  16. scinterop-0.1.0/scinterop/tests/__init__.py +0 -0
  17. scinterop-0.1.0/scinterop/tests/test_cache.py +55 -0
  18. scinterop-0.1.0/scinterop/tests/test_detect.py +59 -0
  19. scinterop-0.1.0/scinterop/tests/test_h5ad.py +69 -0
  20. scinterop-0.1.0/scinterop/tests/test_mtx.py +54 -0
  21. scinterop-0.1.0/scinterop/tests/test_provenance.py +74 -0
  22. scinterop-0.1.0/scinterop/tests/test_schema.py +89 -0
  23. scinterop-0.1.0/scinterop/tests/test_validate.py +87 -0
  24. scinterop-0.1.0/scinterop/validate.py +151 -0
  25. scinterop-0.1.0/scinterop.egg-info/PKG-INFO +778 -0
  26. scinterop-0.1.0/scinterop.egg-info/SOURCES.txt +29 -0
  27. scinterop-0.1.0/scinterop.egg-info/dependency_links.txt +1 -0
  28. scinterop-0.1.0/scinterop.egg-info/entry_points.txt +2 -0
  29. scinterop-0.1.0/scinterop.egg-info/requires.txt +12 -0
  30. scinterop-0.1.0/scinterop.egg-info/top_level.txt +1 -0
  31. scinterop-0.1.0/setup.cfg +4 -0
@@ -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.