selexprep 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.
Files changed (55) hide show
  1. selexprep/__init__.py +3 -0
  2. selexprep/_common.py +78 -0
  3. selexprep/_io.py +80 -0
  4. selexprep/benchmark/__init__.py +20 -0
  5. selexprep/benchmark/corpus_audit.py +741 -0
  6. selexprep/benchmark/eligibility.py +472 -0
  7. selexprep/benchmark/equivalence.py +228 -0
  8. selexprep/benchmark/figure_a.py +155 -0
  9. selexprep/benchmark/figure_b.py +164 -0
  10. selexprep/benchmark/metrics.py +1214 -0
  11. selexprep/catalog/__init__.py +24 -0
  12. selexprep/catalog/cli.py +149 -0
  13. selexprep/catalog/data/__init__.py +1 -0
  14. selexprep/catalog/data/bioprojects.csv +294 -0
  15. selexprep/catalog/data/bioprojects_excluded.csv +62 -0
  16. selexprep/catalog/filter.py +97 -0
  17. selexprep/catalog/reader.py +62 -0
  18. selexprep/catalog/rebuild.py +627 -0
  19. selexprep/cli.py +695 -0
  20. selexprep/count/__init__.py +1 -0
  21. selexprep/count/counter.py +639 -0
  22. selexprep/extract/__init__.py +6 -0
  23. selexprep/extract/demux.py +355 -0
  24. selexprep/extract/runner.py +752 -0
  25. selexprep/extract/strand.py +181 -0
  26. selexprep/extract/trim.py +309 -0
  27. selexprep/fetch/__init__.py +27 -0
  28. selexprep/fetch/discover.py +1145 -0
  29. selexprep/fetch/download.py +844 -0
  30. selexprep/fetch/inspect.py +195 -0
  31. selexprep/fetch/library_strategy.py +203 -0
  32. selexprep/fetch/metadata.py +459 -0
  33. selexprep/fetch/plan.py +243 -0
  34. selexprep/fetch/runner.py +403 -0
  35. selexprep/library/__init__.py +25 -0
  36. selexprep/library/adapters.py +127 -0
  37. selexprep/library/audit.py +258 -0
  38. selexprep/library/detect.py +1038 -0
  39. selexprep/library/report.py +393 -0
  40. selexprep/manifest.py +286 -0
  41. selexprep/qc/__init__.py +1 -0
  42. selexprep/qc/consistency.py +320 -0
  43. selexprep/qc/coverage.py +188 -0
  44. selexprep/qc/diversity.py +99 -0
  45. selexprep/qc/flags.py +464 -0
  46. selexprep/qc/plots.py +227 -0
  47. selexprep/qc/readiness.py +887 -0
  48. selexprep/qc/runner.py +156 -0
  49. selexprep/run/__init__.py +5 -0
  50. selexprep/run/runner.py +638 -0
  51. selexprep-0.1.0.dist-info/METADATA +408 -0
  52. selexprep-0.1.0.dist-info/RECORD +55 -0
  53. selexprep-0.1.0.dist-info/WHEEL +4 -0
  54. selexprep-0.1.0.dist-info/entry_points.txt +2 -0
  55. selexprep-0.1.0.dist-info/licenses/LICENSE +21 -0
selexprep/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """selexprep — accession-first preprocessing for public HT-SELEX datasets."""
2
+
3
+ __version__ = "0.1.0"
selexprep/_common.py ADDED
@@ -0,0 +1,78 @@
1
+ """Shared helpers for selexprep modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import logging
7
+ import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ # Canonical per-SRR FASTQ filename conventions produced by kingfisher /
12
+ # sra-toolkit. Single-end runs produce `{srr}.fastq.gz`; paired-end runs
13
+ # produce `{srr}_1.fastq.gz` and `{srr}_2.fastq.gz`. Match EXACTLY to avoid
14
+ # prefix collisions (SRR1234 must not match SRR12345).
15
+
16
+
17
+ def iter_srr_files(root: Path, srr: str, suffix: str = ".fastq.gz") -> list[Path]:
18
+ """Return FASTQ files for `srr` under `root`, matching exact filenames only.
19
+
20
+ Supports both single-end (`{srr}{suffix}`) and paired-end
21
+ (`{srr}_1{suffix}`, `{srr}_2{suffix}`) layouts.
22
+ """
23
+ if not root.exists():
24
+ return []
25
+ valid_names = {f"{srr}{suffix}", f"{srr}_1{suffix}", f"{srr}_2{suffix}"}
26
+ return [p for p in root.rglob(f"*{suffix}") if p.name in valid_names]
27
+
28
+
29
+ def load_csv(path: Path) -> list[dict[str, str]]:
30
+ """Load a CSV file into a list of dicts. Returns `[]` if the file is missing."""
31
+ try:
32
+ with open(path, encoding="utf-8") as f:
33
+ return list(csv.DictReader(f))
34
+ except FileNotFoundError:
35
+ logging.getLogger(__name__).warning("File not found: %s", path)
36
+ return []
37
+
38
+
39
+ def parse_round_number(parquet_path: Path) -> int | str:
40
+ """Extract the round number from a per-round parquet filename.
41
+
42
+ Handles `.counts.parquet` and `.clusters.parquet` suffixes. Returns the
43
+ integer round when the trailing token parses as int, otherwise returns
44
+ the bare stem (so callers can detect and route non-round files).
45
+ """
46
+ stem = parquet_path.stem.replace(".counts", "").replace(".clusters", "")
47
+ tail = stem.split("_")[-1]
48
+ try:
49
+ return int(tail)
50
+ except ValueError:
51
+ return stem
52
+
53
+
54
+ def setup_logging(
55
+ logger_name: str,
56
+ log_dir: Path | None = None,
57
+ log_prefix: str | None = None,
58
+ ) -> logging.Logger:
59
+ """Configure root logging with optional rotating per-run file handler.
60
+
61
+ Idempotent: only attaches a stream handler once. When `log_dir` is given,
62
+ a timestamped log file is added on top of the stream handler.
63
+ """
64
+ root = logging.getLogger()
65
+ if not root.handlers:
66
+ logging.basicConfig(
67
+ level=logging.INFO,
68
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
69
+ handlers=[logging.StreamHandler(sys.stdout)],
70
+ )
71
+ if log_dir is not None:
72
+ log_dir.mkdir(exist_ok=True)
73
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
74
+ prefix = log_prefix or logger_name
75
+ fh = logging.FileHandler(log_dir / f"{prefix}_{ts}.log", encoding="utf-8")
76
+ fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
77
+ root.addHandler(fh)
78
+ return logging.getLogger(logger_name)
selexprep/_io.py ADDED
@@ -0,0 +1,80 @@
1
+ """Deterministic I/O helpers for reproducible-output guarantees.
2
+
3
+ The selexprep manifest promises bit-identical SHA256 across reruns for
4
+ FASTA/FASTQ/TSV/JSON outputs. The default ``gzip.open`` writer embeds the
5
+ current ``mtime`` in the gzip header, which breaks this guarantee silently.
6
+ ``open_gzip_text_deterministic`` forces ``mtime=0`` so the output bytes are
7
+ reproducible across machines and across reruns on the same machine.
8
+
9
+ See the plan's "Reproducible-output discipline" section: every ``.gz`` write
10
+ must go through this module.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import gzip
16
+ import hashlib
17
+ import io
18
+ from collections.abc import Iterator
19
+ from contextlib import contextmanager
20
+ from pathlib import Path
21
+ from typing import IO
22
+
23
+
24
+ def open_gzip_text_deterministic(
25
+ path: Path,
26
+ encoding: str = "utf-8",
27
+ ) -> io.TextIOWrapper:
28
+ """Open a deterministic-gzip writer in text mode.
29
+
30
+ The gzip format normally embeds two non-deterministic bits in the header:
31
+
32
+ 1. ``mtime`` — the wall-clock time of writing.
33
+ 2. ``FNAME`` — the destination filename (so two runs writing to ``a.gz``
34
+ and ``b.gz`` produce different headers even for identical content).
35
+
36
+ We suppress both: ``mtime=0`` and an empty ``filename=""`` (with an
37
+ explicit ``fileobj``) prevent either field from making it into the
38
+ header. The resulting bytes depend only on the payload, so any two
39
+ invocations with the same content yield byte-for-byte identical output
40
+ — which is the property the manifest's SHA256 checks rely on.
41
+
42
+ The returned ``TextIOWrapper`` is the caller's to close. Closing it
43
+ cascades through the inner ``GzipFile`` to the underlying raw file
44
+ because we set ``gz.myfileobj = raw``, which is how stdlib ``gzip``
45
+ flags file ownership for its own close path.
46
+
47
+ Example::
48
+
49
+ with open_gzip_text_deterministic(Path("out.fastq.gz")) as fh:
50
+ fh.write("@read_0\\nACGT\\n+\\nIIII\\n")
51
+ """
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ raw = open(path, "wb") # noqa: SIM115 — closed via GzipFile.myfileobj cascade
54
+ gz = gzip.GzipFile(filename="", mode="wb", mtime=0, fileobj=raw)
55
+ # Force GzipFile.close() to close `raw` for us so the caller-visible close
56
+ # on the TextIOWrapper produces a clean cascade with no file-handle leak.
57
+ gz.myfileobj = raw # type: ignore[attr-defined]
58
+ return io.TextIOWrapper(gz, encoding=encoding, write_through=True)
59
+
60
+
61
+ @contextmanager
62
+ def write_gzip_text_deterministic(
63
+ path: Path,
64
+ encoding: str = "utf-8",
65
+ ) -> Iterator[IO[str]]:
66
+ """Context-managed version of :func:`open_gzip_text_deterministic`."""
67
+ fh = open_gzip_text_deterministic(path, encoding=encoding)
68
+ try:
69
+ yield fh
70
+ finally:
71
+ fh.close()
72
+
73
+
74
+ def sha256_file(path: Path, chunk: int = 1 << 20) -> str:
75
+ """Stream-SHA256 a file in 1 MiB chunks (memory-bounded for multi-GB inputs)."""
76
+ h = hashlib.sha256()
77
+ with open(path, "rb") as fh:
78
+ for block in iter(lambda: fh.read(chunk), b""):
79
+ h.update(block)
80
+ return h.hexdigest()
@@ -0,0 +1,20 @@
1
+ """Benchmark scaffolding for primer-recovery metrics + result tables.
2
+
3
+ Public surface kept minimal — most callers should reach for the named
4
+ functions in the submodules directly.
5
+ """
6
+
7
+ from selexprep.benchmark.corpus_audit import (
8
+ CorpusAuditReport,
9
+ aggregate_audit_from_run_outputs,
10
+ sample_corpus,
11
+ )
12
+ from selexprep.benchmark.equivalence import EquivalenceResult, primer_equivalent
13
+
14
+ __all__ = [
15
+ "CorpusAuditReport",
16
+ "EquivalenceResult",
17
+ "aggregate_audit_from_run_outputs",
18
+ "primer_equivalent",
19
+ "sample_corpus",
20
+ ]