scinterop 0.1.0__py3-none-any.whl
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/__init__.py +204 -0
- scinterop/cache.py +139 -0
- scinterop/cli.py +186 -0
- scinterop/detect.py +147 -0
- scinterop/errors.py +72 -0
- scinterop/h5ad.py +182 -0
- scinterop/mtx.py +226 -0
- scinterop/provenance.py +160 -0
- scinterop/python_runner.py +168 -0
- scinterop/r_runner.py +163 -0
- scinterop/rds.py +409 -0
- scinterop/schema.py +146 -0
- scinterop/tests/__init__.py +0 -0
- scinterop/tests/test_cache.py +55 -0
- scinterop/tests/test_detect.py +59 -0
- scinterop/tests/test_h5ad.py +69 -0
- scinterop/tests/test_mtx.py +54 -0
- scinterop/tests/test_provenance.py +74 -0
- scinterop/tests/test_schema.py +89 -0
- scinterop/tests/test_validate.py +87 -0
- scinterop/validate.py +151 -0
- scinterop-0.1.0.dist-info/METADATA +778 -0
- scinterop-0.1.0.dist-info/RECORD +26 -0
- scinterop-0.1.0.dist-info/WHEEL +5 -0
- scinterop-0.1.0.dist-info/entry_points.txt +2 -0
- scinterop-0.1.0.dist-info/top_level.txt +1 -0
scinterop/__init__.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""scinterop — Single-cell format conversion toolkit.
|
|
2
|
+
|
|
3
|
+
Convert between H5AD (AnnData), RDS (Seurat), and 10X MTX formats
|
|
4
|
+
while preserving expression data, metadata, dimensional reductions,
|
|
5
|
+
and layer information.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from . import errors
|
|
12
|
+
from . import cache
|
|
13
|
+
from . import h5ad
|
|
14
|
+
from . import mtx
|
|
15
|
+
from . import rds
|
|
16
|
+
from . import r_runner
|
|
17
|
+
from . import python_runner
|
|
18
|
+
from . import provenance
|
|
19
|
+
from .schema import CanonicalObject
|
|
20
|
+
from .detect import detect, Detection
|
|
21
|
+
from .validate import validate, assert_valid, ValidationResult
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read(path: str, **kwargs: Any) -> CanonicalObject:
|
|
27
|
+
"""Read a single-cell data file into a CanonicalObject.
|
|
28
|
+
|
|
29
|
+
Automatically detects format and dispatches to the appropriate
|
|
30
|
+
reader (H5AD, RDS, or MTX).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
path: Path to a single-cell data file.
|
|
34
|
+
**kwargs: Forwarded to the format-specific reader.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
A :class:`CanonicalObject`.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
DetectionError: If the format is not recognised.
|
|
41
|
+
"""
|
|
42
|
+
detected = detect(path)
|
|
43
|
+
fmt = detected.fmt
|
|
44
|
+
logger = _get_logger()
|
|
45
|
+
|
|
46
|
+
logger.info("Detected format '%s' for path '%s'", fmt, path)
|
|
47
|
+
|
|
48
|
+
if fmt == "h5ad":
|
|
49
|
+
return h5ad.read_h5ad(path, **kwargs)
|
|
50
|
+
elif fmt == "mtx":
|
|
51
|
+
return mtx.read_mtx(path, **kwargs)
|
|
52
|
+
elif fmt == "rds":
|
|
53
|
+
return rds.read_rds(path, **kwargs)
|
|
54
|
+
else:
|
|
55
|
+
raise errors.DetectionError(
|
|
56
|
+
f"Unsupported format '{fmt}' for path: {path}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def convert(
|
|
61
|
+
input_path: str,
|
|
62
|
+
output_path: str,
|
|
63
|
+
*,
|
|
64
|
+
r_exe: str = None,
|
|
65
|
+
python_exe: str = None,
|
|
66
|
+
seurat: bool = False,
|
|
67
|
+
**kwargs: Any,
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Convert between single-cell data formats.
|
|
70
|
+
|
|
71
|
+
Reads the input, writes the output, and logs a provenance record.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
input_path: Source file path.
|
|
75
|
+
output_path: Destination file or directory path.
|
|
76
|
+
r_exe: Path to Rscript executable.
|
|
77
|
+
python_exe: Path to Python executable.
|
|
78
|
+
seurat: When writing RDS, create a Seurat object.
|
|
79
|
+
**kwargs: Forwarded to the format-specific writer.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The output path string.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
FormatAdapterError: If the output format is not recognised or
|
|
86
|
+
conversion fails.
|
|
87
|
+
"""
|
|
88
|
+
import time
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
|
|
91
|
+
logger = _get_logger()
|
|
92
|
+
t0 = time.time()
|
|
93
|
+
|
|
94
|
+
obj = read(input_path, **kwargs)
|
|
95
|
+
|
|
96
|
+
out = Path(output_path)
|
|
97
|
+
suffix = _resolve_suffix(out)
|
|
98
|
+
|
|
99
|
+
logger.info("Converting to format '%s' at '%s'", suffix, output_path)
|
|
100
|
+
|
|
101
|
+
if suffix == "h5ad":
|
|
102
|
+
h5ad.write_h5ad(obj, output_path, **kwargs)
|
|
103
|
+
elif suffix == "mtx":
|
|
104
|
+
mtx.write_mtx(obj, output_path, **kwargs)
|
|
105
|
+
elif suffix == "rds":
|
|
106
|
+
rds.write_rds(obj, output_path, seurat=seurat, r_exe=r_exe, **kwargs)
|
|
107
|
+
else:
|
|
108
|
+
raise errors.FormatAdapterError(
|
|
109
|
+
f"Unsupported output format for extension '{suffix}': {output_path}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
elapsed = time.time() - t0
|
|
113
|
+
logger.info("Conversion completed in %.2f seconds", elapsed)
|
|
114
|
+
|
|
115
|
+
provenance.write_conversion_record(
|
|
116
|
+
input_path=str(input_path),
|
|
117
|
+
input_format=detect(input_path).fmt,
|
|
118
|
+
output_path=str(output_path),
|
|
119
|
+
output_format=suffix,
|
|
120
|
+
success=True,
|
|
121
|
+
runtime_s=elapsed,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return str(output_path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolve_suffix(path):
|
|
128
|
+
ext = path.suffix.lower()
|
|
129
|
+
if ext == ".h5ad":
|
|
130
|
+
return "h5ad"
|
|
131
|
+
elif ext == ".rds":
|
|
132
|
+
return "rds"
|
|
133
|
+
elif ext == ".gz":
|
|
134
|
+
stem = path.with_suffix("").suffix.lower()
|
|
135
|
+
if stem == ".mtx":
|
|
136
|
+
return "mtx"
|
|
137
|
+
elif ext == ".mtx":
|
|
138
|
+
return "mtx"
|
|
139
|
+
if not path.suffix or path.is_dir() or not path.exists():
|
|
140
|
+
return "mtx"
|
|
141
|
+
raise errors.FormatAdapterError(
|
|
142
|
+
f"Cannot determine output format from path: {path}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_r(script, r_exe=None, **kwargs: Any):
|
|
147
|
+
"""Run an R script via the R runner.
|
|
148
|
+
|
|
149
|
+
Shortcut for :func:`r_runner.run_r`.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
script: Path to a ``.R`` file or inline R code.
|
|
153
|
+
r_exe: Path to Rscript executable.
|
|
154
|
+
**kwargs: Forwarded to :func:`r_runner.run_r`.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
An :class:`r_runner.RExecResult`.
|
|
158
|
+
"""
|
|
159
|
+
return r_runner.run_r(script, r_exe=r_exe, **kwargs)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_python(script, python_exe=None, conda_env=None, **kwargs: Any):
|
|
163
|
+
"""Run a Python script via the Python runner.
|
|
164
|
+
|
|
165
|
+
Shortcut for :func:`python_runner.run_python`.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
script: Path to a ``.py`` file or inline Python code.
|
|
169
|
+
python_exe: Path to Python executable.
|
|
170
|
+
conda_env: Conda environment name.
|
|
171
|
+
**kwargs: Forwarded to :func:`python_runner.run_python`.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A :class:`python_runner.PythonExecResult`.
|
|
175
|
+
"""
|
|
176
|
+
return python_runner.run_python(
|
|
177
|
+
script, python_exe=python_exe, conda_env=conda_env, **kwargs
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _get_logger():
|
|
182
|
+
return logging.getLogger("scinterop")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = [
|
|
186
|
+
"CanonicalObject",
|
|
187
|
+
"Detection",
|
|
188
|
+
"ValidationResult",
|
|
189
|
+
"read",
|
|
190
|
+
"convert",
|
|
191
|
+
"detect",
|
|
192
|
+
"validate",
|
|
193
|
+
"assert_valid",
|
|
194
|
+
"run_r",
|
|
195
|
+
"run_python",
|
|
196
|
+
"errors",
|
|
197
|
+
"cache",
|
|
198
|
+
"h5ad",
|
|
199
|
+
"mtx",
|
|
200
|
+
"rds",
|
|
201
|
+
"r_runner",
|
|
202
|
+
"python_runner",
|
|
203
|
+
"provenance",
|
|
204
|
+
]
|
scinterop/cache.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Scratch-directory management for intermediate files.
|
|
2
|
+
|
|
3
|
+
The :class:`ScratchManager` creates, caches, and cleans up temporary
|
|
4
|
+
directories shared across conversion steps.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from .errors import CacheError
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ScratchContext:
|
|
23
|
+
"""A single scratch directory handle.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
root: The filesystem path of this scratch directory.
|
|
27
|
+
prefix: Human-readable prefix used in the directory name.
|
|
28
|
+
uid: Unique identifier for this context.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
root: Path
|
|
32
|
+
prefix: str
|
|
33
|
+
uid: str
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def path(self) -> Path:
|
|
37
|
+
"""Return the root path of this scratch directory."""
|
|
38
|
+
return self.root
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ScratchManager:
|
|
42
|
+
"""Manage temporary scratch directories for conversion artifacts.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
base: Root directory for scratch dirs. Falls back to
|
|
46
|
+
``SCINTEROP_SCRATCH`` env var or ``/tmp/scinterop_<user>``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, base: str | Path | None = None):
|
|
50
|
+
if base is None:
|
|
51
|
+
base = os.environ.get(
|
|
52
|
+
"SCINTEROP_SCRATCH",
|
|
53
|
+
Path("/tmp") / f"scinterop_{os.environ.get('USER', 'unknown')}",
|
|
54
|
+
)
|
|
55
|
+
self._base = Path(base)
|
|
56
|
+
self._contexts: list[ScratchContext] = []
|
|
57
|
+
self._keep = os.environ.get("SCINTEROP_DEBUG", "").lower() in ("1", "true", "yes")
|
|
58
|
+
|
|
59
|
+
def tempdir(self, prefix: str = "scint") -> ScratchContext:
|
|
60
|
+
"""Create a new scratch subdirectory.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
prefix: Name prefix for the directory.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A :class:`ScratchContext` pointing to the new directory.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
CacheError: If the directory cannot be created.
|
|
70
|
+
"""
|
|
71
|
+
uid = uuid.uuid4().hex[:12]
|
|
72
|
+
path = self._base / f"{prefix}_{uid}"
|
|
73
|
+
try:
|
|
74
|
+
path.mkdir(parents=True, exist_ok=False)
|
|
75
|
+
except FileExistsError:
|
|
76
|
+
uid = uuid.uuid4().hex[:12]
|
|
77
|
+
path = self._base / f"{prefix}_{uid}"
|
|
78
|
+
path.mkdir(parents=True, exist_ok=False)
|
|
79
|
+
except OSError as e:
|
|
80
|
+
raise CacheError(f"Failed to create scratch dir '{path}': {e}") from e
|
|
81
|
+
|
|
82
|
+
ctx = ScratchContext(root=path, prefix=prefix, uid=uid)
|
|
83
|
+
self._contexts.append(ctx)
|
|
84
|
+
logger.info("Created scratch directory: %s", path)
|
|
85
|
+
return ctx
|
|
86
|
+
|
|
87
|
+
def cleanup(self, ctx: ScratchContext, *, keep: bool | None = None) -> None:
|
|
88
|
+
"""Remove a specific scratch directory.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
ctx: The context to clean up.
|
|
92
|
+
keep: Override the debug keep setting. If None, uses
|
|
93
|
+
the instance-level ``_keep``.
|
|
94
|
+
"""
|
|
95
|
+
if keep is None:
|
|
96
|
+
keep = self._keep
|
|
97
|
+
if keep:
|
|
98
|
+
logger.info("Keeping scratch directory (debug mode): %s", ctx.path)
|
|
99
|
+
return
|
|
100
|
+
if ctx.path.exists():
|
|
101
|
+
try:
|
|
102
|
+
shutil.rmtree(ctx.path)
|
|
103
|
+
logger.info("Removed scratch directory: %s", ctx.path)
|
|
104
|
+
except OSError as e:
|
|
105
|
+
logger.warning("Failed to remove scratch directory '%s': %s", ctx.path, e)
|
|
106
|
+
|
|
107
|
+
def cleanup_all(self, *, keep: bool | None = None) -> None:
|
|
108
|
+
"""Remove all tracked scratch directories.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
keep: Override the debug keep setting for all contexts.
|
|
112
|
+
"""
|
|
113
|
+
for ctx in list(self._contexts):
|
|
114
|
+
self.cleanup(ctx, keep=keep)
|
|
115
|
+
self._contexts.clear()
|
|
116
|
+
|
|
117
|
+
def write_script(self, ctx: ScratchContext, content: str, name: str = "script") -> Path:
|
|
118
|
+
"""Write a script file into a scratch directory.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
ctx: Scratch context to write into.
|
|
122
|
+
content: Script text content.
|
|
123
|
+
name: Basename (without extension) for the script file.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Path to the written script file.
|
|
127
|
+
|
|
128
|
+
Raises:
|
|
129
|
+
CacheError: If the file cannot be written.
|
|
130
|
+
"""
|
|
131
|
+
path = ctx.path / f"{name}.R"
|
|
132
|
+
try:
|
|
133
|
+
path.write_text(content)
|
|
134
|
+
logger.debug("Wrote script to %s", path)
|
|
135
|
+
return path
|
|
136
|
+
except OSError as e:
|
|
137
|
+
raise CacheError(
|
|
138
|
+
f"Failed to write script '{path}': {e}"
|
|
139
|
+
) from e
|
scinterop/cli.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Command-line interface for scinterop.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
scinterop detect <path>
|
|
6
|
+
scinterop convert <input> <output> [options]
|
|
7
|
+
scinterop run <script> --executor {r,python} [options]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import __version__, read, convert, run_r, run_python, detect, errors
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("scinterop.cli")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
"""Build the argument parser for the CLI.
|
|
24
|
+
|
|
25
|
+
Subcommands:
|
|
26
|
+
|
|
27
|
+
- ``detect`` — Detect the format of a single-cell data file.
|
|
28
|
+
- ``convert`` — Convert between single-cell formats.
|
|
29
|
+
- ``run`` — Execute an external R or Python script.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A configured ``ArgumentParser``.
|
|
33
|
+
"""
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="scinterop",
|
|
36
|
+
description="Single-cell data interoperability tool",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--version", action="version", version=f"scinterop {__version__}"
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--verbose", "-v", action="store_true", help="Enable debug logging"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
46
|
+
|
|
47
|
+
# detect
|
|
48
|
+
detect_parser = subparsers.add_parser(
|
|
49
|
+
"detect", help="Detect the format of a file or directory"
|
|
50
|
+
)
|
|
51
|
+
detect_parser.add_argument("path", type=str, help="Path to file or directory")
|
|
52
|
+
|
|
53
|
+
# convert
|
|
54
|
+
convert_parser = subparsers.add_parser(
|
|
55
|
+
"convert", help="Convert between single-cell formats"
|
|
56
|
+
)
|
|
57
|
+
convert_parser.add_argument("input", type=str, help="Input file or directory")
|
|
58
|
+
convert_parser.add_argument("output", type=str, help="Output file or directory")
|
|
59
|
+
convert_parser.add_argument(
|
|
60
|
+
"--r-exe", type=str, default=None,
|
|
61
|
+
help="Path to Rscript executable (default: SCINTEROP_R_EXE env or 'Rscript')",
|
|
62
|
+
)
|
|
63
|
+
convert_parser.add_argument(
|
|
64
|
+
"--python-exe", type=str, default=None,
|
|
65
|
+
help="Path to Python executable (default: SCINTEROP_PYTHON_EXE env or 'python')",
|
|
66
|
+
)
|
|
67
|
+
convert_parser.add_argument(
|
|
68
|
+
"--seurat", action="store_true",
|
|
69
|
+
help="When converting to RDS, create a Seurat object instead of a plain R list",
|
|
70
|
+
)
|
|
71
|
+
convert_parser.add_argument(
|
|
72
|
+
"--debug", action="store_true",
|
|
73
|
+
help="Keep temporary files for debugging",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# run
|
|
77
|
+
run_parser = subparsers.add_parser(
|
|
78
|
+
"run", help="Run an external R or Python script"
|
|
79
|
+
)
|
|
80
|
+
run_parser.add_argument("script", type=str, help="Path to script file")
|
|
81
|
+
run_parser.add_argument(
|
|
82
|
+
"--executor", "-e", type=str, required=True,
|
|
83
|
+
choices=["r", "python"],
|
|
84
|
+
help="Script language/executor",
|
|
85
|
+
)
|
|
86
|
+
run_parser.add_argument(
|
|
87
|
+
"--exe", type=str, default=None,
|
|
88
|
+
help="Path to Rscript or Python executable",
|
|
89
|
+
)
|
|
90
|
+
run_parser.add_argument(
|
|
91
|
+
"--conda-env", type=str, default=None,
|
|
92
|
+
help="Conda environment name (Python only)",
|
|
93
|
+
)
|
|
94
|
+
run_parser.add_argument(
|
|
95
|
+
"--args", type=str, nargs=argparse.REMAINDER,
|
|
96
|
+
help="Additional arguments passed to the script",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return parser
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv: list[str] | None = None) -> int:
|
|
103
|
+
"""CLI entry point.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
argv: Command-line arguments (defaults to ``sys.argv[1:]``).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Exit code (0 for success, 1 for errors).
|
|
110
|
+
"""
|
|
111
|
+
parser = build_parser()
|
|
112
|
+
args = parser.parse_args(argv)
|
|
113
|
+
|
|
114
|
+
log_level = logging.DEBUG if args.verbose else logging.INFO
|
|
115
|
+
logging.basicConfig(
|
|
116
|
+
level=log_level,
|
|
117
|
+
format="%(levelname)s | %(name)s | %(message)s",
|
|
118
|
+
stream=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
if args.command == "detect":
|
|
123
|
+
_cmd_detect(args)
|
|
124
|
+
elif args.command == "convert":
|
|
125
|
+
_cmd_convert(args)
|
|
126
|
+
elif args.command == "run":
|
|
127
|
+
_cmd_run(args)
|
|
128
|
+
else:
|
|
129
|
+
parser.print_help()
|
|
130
|
+
return 1
|
|
131
|
+
except errors.ScinteropError as e:
|
|
132
|
+
logger.error(str(e))
|
|
133
|
+
return 1
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.exception("Unexpected error: %s", e)
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cmd_detect(args) -> None:
|
|
142
|
+
result = detect(args.path)
|
|
143
|
+
print(f"Format: {result.fmt}")
|
|
144
|
+
print(f"Path: {result.path}")
|
|
145
|
+
if result.details:
|
|
146
|
+
print("Details:")
|
|
147
|
+
for key, val in result.details.items():
|
|
148
|
+
print(f" {key}: {val}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cmd_convert(args) -> None:
|
|
152
|
+
if args.debug:
|
|
153
|
+
import os as _os
|
|
154
|
+
_os.environ["SCINTEROP_DEBUG"] = "1"
|
|
155
|
+
|
|
156
|
+
result_path = convert(
|
|
157
|
+
args.input,
|
|
158
|
+
args.output,
|
|
159
|
+
r_exe=args.r_exe,
|
|
160
|
+
python_exe=args.python_exe,
|
|
161
|
+
seurat=args.seurat,
|
|
162
|
+
)
|
|
163
|
+
print(f"Output: {result_path}", file=sys.stderr)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cmd_run(args) -> None:
|
|
167
|
+
script = Path(args.script)
|
|
168
|
+
if not script.exists():
|
|
169
|
+
logger.error("Script not found: %s", script)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
|
|
172
|
+
if args.executor == "r":
|
|
173
|
+
result = run_r(script, r_exe=args.exe)
|
|
174
|
+
elif args.executor == "python":
|
|
175
|
+
result = run_python(
|
|
176
|
+
script, python_exe=args.exe, conda_env=args.conda_env,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if result.stdout:
|
|
180
|
+
sys.stdout.write(result.stdout)
|
|
181
|
+
if result.stderr:
|
|
182
|
+
sys.stderr.write(result.stderr)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
sys.exit(main())
|
scinterop/detect.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Auto-detect single-cell data formats from file paths.
|
|
2
|
+
|
|
3
|
+
The :func:`detect` function identifies format by file extension or
|
|
4
|
+
directory contents without reading the full data. Supports H5AD, RDS,
|
|
5
|
+
and 10X MTX formats.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import DetectionError
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
EXTENSION_MAP: dict[str, str] = {
|
|
20
|
+
".h5ad": "h5ad",
|
|
21
|
+
".rds": "rds",
|
|
22
|
+
".mtx": "mtx",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
COMPRESSED_EXTENSIONS: dict[str, str] = {
|
|
26
|
+
".mtx.gz": "mtx",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
MTX_TRIO = {"matrix.mtx", "matrix.mtx.gz", "barcodes.tsv", "barcodes.tsv.gz",
|
|
30
|
+
"features.tsv", "features.tsv.gz"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Detection:
|
|
35
|
+
"""Result of a format detection call.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
fmt: Short format string — ``"h5ad"``, ``"rds"``, or ``"mtx"``.
|
|
39
|
+
path: The resolved path that was detected.
|
|
40
|
+
details: Extra information (e.g. extension matched, trio files found).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
fmt: str
|
|
44
|
+
path: Path
|
|
45
|
+
details: dict[str, Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect(path: str | Path) -> Detection:
|
|
49
|
+
"""Detect the format of a single-cell data file or directory.
|
|
50
|
+
|
|
51
|
+
Resolution order:
|
|
52
|
+
1. If path is a **directory**, look for 10X MTX trio files.
|
|
53
|
+
2. If path is an **existing file**, detect by extension.
|
|
54
|
+
3. If path **does not exist** but has a recognised extension,
|
|
55
|
+
infer from extension alone.
|
|
56
|
+
4. Otherwise raise :class:`DetectionError`.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
path: File path or directory to inspect.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A :class:`Detection` with the format, resolved path, and details.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
DetectionError: If the format cannot be determined.
|
|
66
|
+
"""
|
|
67
|
+
p = Path(path)
|
|
68
|
+
|
|
69
|
+
details: dict[str, Any] = {}
|
|
70
|
+
|
|
71
|
+
if p.is_dir():
|
|
72
|
+
return _detect_directory(p, details)
|
|
73
|
+
|
|
74
|
+
if p.exists():
|
|
75
|
+
return _detect_file(p, details)
|
|
76
|
+
|
|
77
|
+
if p.suffix:
|
|
78
|
+
result = _detect_by_extension(p, details)
|
|
79
|
+
if result is not None:
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
raise DetectionError(
|
|
83
|
+
f"Cannot determine format from path: {p}. "
|
|
84
|
+
f"Supported extensions: {', '.join(EXTENSION_MAP)}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _detect_directory(p: Path, details: dict) -> Detection:
|
|
89
|
+
files = {f.name for f in p.iterdir() if f.is_file()}
|
|
90
|
+
present = MTX_TRIO & files
|
|
91
|
+
n_present = len(present)
|
|
92
|
+
if n_present >= 2:
|
|
93
|
+
details["mtx_trio_found"] = sorted(present)
|
|
94
|
+
details["n_cells_file"] = "barcodes.tsv" in files or "barcodes.tsv.gz" in files
|
|
95
|
+
details["n_genes_file"] = "features.tsv" in files or "features.tsv.gz" in files
|
|
96
|
+
logger.info("Detected 10X MTX format in directory '%s'", p)
|
|
97
|
+
return Detection(fmt="mtx", path=p, details=details)
|
|
98
|
+
|
|
99
|
+
raise DetectionError(
|
|
100
|
+
f"Unrecognized directory format at '{p}'. "
|
|
101
|
+
f"Expected a 10X directory with matrix.mtx, barcodes.tsv, features.tsv. "
|
|
102
|
+
f"Found files: {sorted(files)[:20]}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _detect_file(p: Path, details: dict) -> Detection:
|
|
107
|
+
result = _detect_by_extension(p, details)
|
|
108
|
+
if result is not None:
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
raise DetectionError(
|
|
112
|
+
f"Unrecognized file format at '{p}'. "
|
|
113
|
+
f"Supported: {', '.join(EXTENSION_MAP)}, {', '.join(COMPRESSED_EXTENSIONS)}."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _detect_by_extension(p: Path, details: dict) -> Detection | None:
|
|
118
|
+
name_lower = p.name.lower()
|
|
119
|
+
|
|
120
|
+
for ext, fmt in COMPRESSED_EXTENSIONS.items():
|
|
121
|
+
if name_lower.endswith(ext):
|
|
122
|
+
details["extension"] = ext
|
|
123
|
+
logger.info("Detected format '%s' from extension '%s'", fmt, ext)
|
|
124
|
+
return Detection(fmt=fmt, path=p, details=details)
|
|
125
|
+
|
|
126
|
+
for ext, fmt in EXTENSION_MAP.items():
|
|
127
|
+
if name_lower.endswith(ext):
|
|
128
|
+
details["extension"] = ext
|
|
129
|
+
logger.info("Detected format '%s' from extension '%s'", fmt, ext)
|
|
130
|
+
return Detection(fmt=fmt, path=p, details=details)
|
|
131
|
+
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _peek_h5(p: Path) -> str | None:
|
|
136
|
+
try:
|
|
137
|
+
import h5py
|
|
138
|
+
with h5py.File(p, "r") as f:
|
|
139
|
+
keys = set(f.keys())
|
|
140
|
+
if "X" in keys:
|
|
141
|
+
if "obs" in keys and "var" in keys:
|
|
142
|
+
return "anndata"
|
|
143
|
+
if "assays" in keys:
|
|
144
|
+
return "seurat_h5"
|
|
145
|
+
return f"keys: {sorted(keys)[:10]}"
|
|
146
|
+
except Exception:
|
|
147
|
+
return None
|