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 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