captnseq-toolkit 0.1.1__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.
@@ -0,0 +1,18 @@
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ strategy:
7
+ matrix:
8
+ python: ["3.9", "3.12"]
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ with:
12
+ fetch-depth: 0 # setuptools-scm needs tags
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: ${{ matrix.python }}
16
+ - run: pip install -e ".[test]"
17
+ - run: pytest -q
18
+ - run: captnseq-toolkit --version
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ build/
4
+ dist/
5
+ .pytest_cache/
6
+ .venv/
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Initial import of `sam2sites`, `sites2genes` and `essentiality` from the nf-core/captnseq pipeline `bin/` (logic unchanged).
6
+ - The `captnseq-toolkit` command is now a standard argparse CLI with subparsers. `--help`, `--version` and error handling come from argparse, so running with no subcommand or an unknown one exits with status 2 and argparse's usage message. Each tool's options are unchanged.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) The nf-core/captnseq team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: captnseq-toolkit
3
+ Version: 0.1.1
4
+ Summary: Analysis tools for CAPTn-seq transposon-insertion sequencing data (used by nf-core/captnseq)
5
+ Author: Simon Jeanneau
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/simonjeanneau/captnseq-toolkit
8
+ Project-URL: Pipeline, https://github.com/nf-core/proposals/issues/173
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: numpy
13
+ Requires-Dist: pandas
14
+ Requires-Dist: scipy
15
+ Requires-Dist: matplotlib
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # captnseq-toolkit
21
+
22
+ Analysis tools for CAPTn-seq (transposon-insertion sequencing) data. This package holds the
23
+ scientific logic used by the [nf-core/captnseq](https://github.com/nf-core/proposals/issues/173)
24
+ pipeline, so the pipeline itself only contains Nextflow code.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install captnseq-toolkit # once released on PyPI
30
+ conda install -c bioconda captnseq-toolkit # once the Bioconda recipe is merged
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ captnseq-toolkit --help
37
+ captnseq-toolkit sam2sites --help # SAM -> insertion sites (BED + bedGraph)
38
+ captnseq-toolkit sites2genes --help # insertion sites -> per-gene counts
39
+ captnseq-toolkit essentiality --help # per-gene counts -> essential / ambiguous / non-essential
40
+ ```
41
+
42
+ ## Development
43
+
44
+ ```bash
45
+ pip install -e ".[test]"
46
+ pytest
47
+ ```
48
+
49
+ Releases are tagged `vX.Y.Z` (version comes from git tags via setuptools-scm).
@@ -0,0 +1,30 @@
1
+ # captnseq-toolkit
2
+
3
+ Analysis tools for CAPTn-seq (transposon-insertion sequencing) data. This package holds the
4
+ scientific logic used by the [nf-core/captnseq](https://github.com/nf-core/proposals/issues/173)
5
+ pipeline, so the pipeline itself only contains Nextflow code.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install captnseq-toolkit # once released on PyPI
11
+ conda install -c bioconda captnseq-toolkit # once the Bioconda recipe is merged
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ captnseq-toolkit --help
18
+ captnseq-toolkit sam2sites --help # SAM -> insertion sites (BED + bedGraph)
19
+ captnseq-toolkit sites2genes --help # insertion sites -> per-gene counts
20
+ captnseq-toolkit essentiality --help # per-gene counts -> essential / ambiguous / non-essential
21
+ ```
22
+
23
+ ## Development
24
+
25
+ ```bash
26
+ pip install -e ".[test]"
27
+ pytest
28
+ ```
29
+
30
+ Releases are tagged `vX.Y.Z` (version comes from git tags via setuptools-scm).
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "setuptools-scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "captnseq-toolkit"
7
+ dynamic = ["version"]
8
+ description = "Analysis tools for CAPTn-seq transposon-insertion sequencing data (used by nf-core/captnseq)"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Simon Jeanneau" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = [
14
+ "numpy",
15
+ "pandas",
16
+ "scipy",
17
+ "matplotlib",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ test = ["pytest"]
22
+
23
+ [project.scripts]
24
+ captnseq-toolkit = "captnseq_toolkit.cli:main"
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/simonjeanneau/captnseq-toolkit"
28
+ Pipeline = "https://github.com/nf-core/proposals/issues/173"
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
32
+
33
+ [tool.setuptools_scm]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
@@ -0,0 +1,47 @@
1
+ # Draft Bioconda recipe - copy to bioconda/bioconda-recipes/recipes/captnseq-toolkit/meta.yaml
2
+ # once a release exists on PyPI, filling in the sha256 of the sdist.
3
+ {% set name = "captnseq-toolkit" %}
4
+ {% set version = "0.1.0" %}
5
+
6
+ package:
7
+ name: {{ name|lower }}
8
+ version: {{ version }}
9
+
10
+ source:
11
+ url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/captnseq_toolkit-{{ version }}.tar.gz
12
+ sha256: REPLACE_ME
13
+
14
+ build:
15
+ noarch: python
16
+ number: 0
17
+ script: {{ PYTHON }} -m pip install . -vvv --no-deps --no-build-isolation
18
+ entry_points:
19
+ - captnseq-toolkit = captnseq_toolkit.cli:main
20
+
21
+ requirements:
22
+ host:
23
+ - python >=3.9
24
+ - pip
25
+ - setuptools >=64
26
+ - setuptools-scm >=8
27
+ run:
28
+ - python >=3.9
29
+ - numpy
30
+ - pandas
31
+ - scipy
32
+ - matplotlib-base
33
+
34
+ test:
35
+ imports:
36
+ - captnseq_toolkit
37
+ commands:
38
+ - captnseq-toolkit --version
39
+ - captnseq-toolkit sam2sites --help
40
+ - captnseq-toolkit sites2genes --help
41
+ - captnseq-toolkit essentiality --help
42
+
43
+ about:
44
+ home: https://github.com/simonjeanneau/captnseq-toolkit
45
+ license: MIT
46
+ license_file: LICENSE
47
+ summary: Analysis tools for CAPTn-seq transposon-insertion sequencing data
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("captnseq-toolkit")
5
+ except PackageNotFoundError: # not installed
6
+ __version__ = "0+unknown"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from captnseq_toolkit.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,42 @@
1
+ """Top-level `captnseq-toolkit` command: one argparse subcommand per tool."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from captnseq_toolkit import __version__, essentiality, sam2sites, sites2genes
7
+
8
+ # name -> (module, one-line help). Each module provides add_arguments(parser) and run_args(args).
9
+ SUBCOMMANDS = {
10
+ "sam2sites": (sam2sites, "SAM -> insertion sites (BED + bedGraph tracks)"),
11
+ "sites2genes": (sites2genes, "insertion sites -> per-gene insertion counts / index"),
12
+ "essentiality": (essentiality, "per-gene counts -> essential / ambiguous / non-essential calls"),
13
+ }
14
+
15
+
16
+ def build_parser():
17
+ parser = argparse.ArgumentParser(
18
+ prog="captnseq-toolkit",
19
+ description="Analysis tools for CAPTn-seq transposon-insertion sequencing data.",
20
+ )
21
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
22
+ subparsers = parser.add_subparsers(dest="command", metavar="<subcommand>", required=True)
23
+ for name, (module, help_text) in SUBCOMMANDS.items():
24
+ sub = subparsers.add_parser(
25
+ name,
26
+ help=help_text,
27
+ description=module.__doc__,
28
+ formatter_class=argparse.RawDescriptionHelpFormatter,
29
+ )
30
+ module.add_arguments(sub)
31
+ sub.set_defaults(func=module.run_args)
32
+ return parser
33
+
34
+
35
+ def main(argv=None):
36
+ args = build_parser().parse_args(argv)
37
+ args.func(args)
38
+ return 0
39
+
40
+
41
+ if __name__ == "__main__":
42
+ sys.exit(main())
@@ -0,0 +1,209 @@
1
+ """Classify genes as essential / ambiguous / non-essential from sites2genes output
2
+ (Python re-port of tradis_essentiality_wscore.R). Writes QC / changepoint plots as PDF.
3
+
4
+ Ported verbatim from the CAPTn-seq Snakemake pipeline; only the entrypoint changed, plus an
5
+ explicit Agg backend so it runs headless in containers / HPC jobs.
6
+ """
7
+
8
+ # Faithful re-port of tradis_essentiality_wscore.R
9
+ #
10
+ # Fixes vs. the previous python port (tradis_essentiality_wscore.py):
11
+ # 1. lower/upper threshold indexing was off by one bin (0-based numpy index
12
+ # divided directly by 10000, instead of mapped through the same 1-indexed
13
+ # x-grid R uses) -> thresholds were systematically ~0.0001 too low.
14
+ # 2. loess() (local weighted quadratic regression) was replaced by a global
15
+ # degree-3 np.polyfit, which finds a different minimum -> different m.
16
+ # 3. scipy's expon.fit / gamma.fit float the `loc` parameter by default;
17
+ # R's fitdistr assumes loc=0 for both. Now fit with floc=0 to match.
18
+ # 4. First histogram (finding the second maxima) used bins='fd' where R
19
+ # uses breaks=200 equal-width bins.
20
+ # 5. Second histogram (finding the inter-mode minimum m) used a fixed
21
+ # 99-bin linspace where R uses r bins of fixed width 1/2000 (r depends
22
+ # on the data via maxval).
23
+ #
24
+ # ValueError guards (abort_and_output early exits) and the epsilon in the
25
+ # log-odds ratio are kept from the previous python port; R has no equivalent
26
+ # and would simply error on this pathological input.
27
+
28
+ import argparse
29
+
30
+ import pandas as pd
31
+ import numpy as np
32
+ from scipy import stats
33
+ import matplotlib
34
+ matplotlib.use('Agg')
35
+ import matplotlib.pyplot as plt
36
+
37
+
38
+ def loess_predict(x, y, span=0.75, degree=2):
39
+ """Local weighted polynomial regression (R's loess default: span=0.75,
40
+ degree=2, tricube weights), evaluated at the original x points -
41
+ equivalent to R's predict(loess(y ~ x)) with no newdata."""
42
+ x = np.asarray(x, dtype=float)
43
+ y = np.asarray(y, dtype=float)
44
+ n = len(x)
45
+ k = min(max(int(np.floor(span * n)), degree + 1), n)
46
+ fitted = np.empty(n)
47
+ for i in range(n):
48
+ d = np.abs(x - x[i])
49
+ order = np.argsort(d, kind="mergesort")[:k]
50
+ dmax = d[order].max()
51
+ w = np.ones(k) if dmax == 0 else np.clip(1 - (d[order] / dmax) ** 3, 0, None) ** 3
52
+ dx = x[order] - x[i]
53
+ X = np.vstack([dx ** p for p in range(degree + 1)]).T
54
+ sw = np.sqrt(w)
55
+ beta, *_ = np.linalg.lstsq(X * sw[:, None], y[order] * sw, rcond=None)
56
+ fitted[i] = beta[0]
57
+ return fitted
58
+
59
+
60
+ def run(input_file, output_all, output_essen, output_ambig, output_nonessen):
61
+ STM_baseline = pd.read_csv(input_file, sep="\t", header=0)
62
+ input_base = input_file.split(".")[0]
63
+ ii = STM_baseline['ins_index'].values
64
+ STM_baseline['read_index'] = STM_baseline['read_count'] / STM_baseline['gene_length']
65
+
66
+ def abort_and_output(score_E=0, lower=1, upper=99):
67
+ STM_baseline['score_E'] = score_E
68
+ STM_baseline['F_threshold'] = lower
69
+ STM_baseline['N_threshold'] = upper
70
+ STM_baseline.to_csv(output_all, index=False)
71
+ STM_baseline[STM_baseline['ins_index'] < lower].to_csv(output_essen, index=False)
72
+ STM_baseline[STM_baseline['ins_index'] >= upper].to_csv(output_nonessen, index=False)
73
+ if lower < upper:
74
+ STM_baseline[(STM_baseline['ins_index'] >= lower) & (STM_baseline['ins_index'] < upper)].to_csv(output_ambig, index=False)
75
+ else:
76
+ STM_baseline[STM_baseline['ins_index'] >= upper].to_csv(output_ambig, index=False)
77
+ return
78
+
79
+ # Identify second maxima (R: hist(ii, breaks=200))
80
+ h, bin_edges = np.histogram(ii, bins=200, density=True)
81
+ if len(h) < 10:
82
+ abort_and_output()
83
+ return
84
+ maxindex = np.argmax(h[10:])
85
+ midpoints = (bin_edges[:-1] + bin_edges[1:]) / 2
86
+ maxval = midpoints[maxindex + 3]
87
+
88
+ nG = len(STM_baseline['read_count'])
89
+ r = int(np.floor(maxval * 2000))
90
+ if r <= 0:
91
+ abort_and_output()
92
+ return
93
+
94
+ fig1 = plt.figure()
95
+
96
+ # R: hist(ii[I], breaks=(0:r/2000)) -> r bins of fixed width 1/2000
97
+ I = ii < r / 2000
98
+ h1, edges1 = np.histogram(ii[I], bins=np.linspace(0, r / 2000, r + 1))
99
+ loess_curve = loess_predict(np.arange(1, len(h1) + 1), h1)
100
+
101
+ plt.plot(h1, label="Density")
102
+ plt.plot(loess_curve, color='red', lw=2, label="Loess Curve")
103
+ plt.legend()
104
+ plt.title("Density")
105
+
106
+ mid1 = (edges1[:-1] + edges1[1:]) / 2
107
+ m = mid1[np.argmin(loess_curve)]
108
+ I1 = (ii < m) & (ii >= 0)
109
+
110
+ h_full, edges_full = np.histogram(ii, bins='fd')
111
+ I2 = (ii >= m) & (ii < edges_full[np.max(np.where(h_full > 5))])
112
+
113
+ f1 = (np.sum(I1) + np.sum(ii == 0)) / nG
114
+ f2 = np.sum(I2) / nG
115
+
116
+ # Fit exponential and gamma distributions, anchored at loc=0 like R's fitdistr
117
+ try:
118
+ d1 = stats.expon.fit(ii[I1], floc=0)
119
+ d2 = stats.gamma.fit(ii[I2], floc=0)
120
+ except ValueError:
121
+ abort_and_output()
122
+ return
123
+
124
+ # Generate and save the plot
125
+ x_vals = np.linspace(0, max(ii), 500)
126
+ fig2 = plt.figure()
127
+ plt.hist(ii, bins='fd', density=True, label="Data")
128
+ plt.plot(x_vals, f1 * stats.expon.pdf(x_vals, *d1), label="Exponential fit")
129
+ plt.plot(x_vals, f2 * stats.gamma.pdf(x_vals, *d2), label="Gamma fit")
130
+
131
+ # Calculate log-odds ratios (x grid matches R's 1:1000/10000 exactly)
132
+ epsilon = 1e-10 # prevents log(0)/log(inf) on pathological input; no R equivalent
133
+ x_grid = np.arange(1, 1001) / 10000.0
134
+ log_odds = np.log2(
135
+ ((stats.gamma.cdf(x_grid, *d2) *
136
+ (1 - stats.expon.sf(x_grid, *d1))) + epsilon) /
137
+ ((stats.expon.sf(x_grid, *d1) *
138
+ (1 - stats.gamma.cdf(x_grid, *d2))) + epsilon)
139
+ )
140
+
141
+ where_lower = np.where(log_odds < -2)[0]
142
+ where_upper = np.where(log_odds > 2)[0]
143
+ if len(where_lower) == 0 or len(where_upper) == 0:
144
+ abort_and_output()
145
+ return
146
+ lower = x_grid[np.max(where_lower)]
147
+ upper = x_grid[np.min(where_upper)]
148
+
149
+ # Adding lines to the plot for thresholds
150
+ plt.legend()
151
+ plt.axvline(lower, color='red', label="Lower Threshold")
152
+ plt.axvline(upper, color='red', label="Upper Threshold")
153
+ plt.text(lower, 30, f"Essential changepoint: {lower:.4f}", color='red')
154
+ plt.text(upper, 10, f"Ambiguous changepoint: {upper:.4f}", color='red')
155
+ plt.title("Gamma fits")
156
+
157
+ with plt.rc_context():
158
+ from matplotlib.backends.backend_pdf import PdfPages
159
+ with PdfPages(f"{input_base}_QC_and_changepoint_plots.pdf") as pdf:
160
+ pdf.savefig(fig1)
161
+ pdf.savefig(fig2)
162
+ plt.close(fig1)
163
+ plt.close(fig2)
164
+
165
+ # Calculate 'read_index' and 'score_E'
166
+ score_E = []
167
+ for iindex in STM_baseline['ins_index']:
168
+ if iindex <= lower:
169
+ score_E.append(iindex / lower)
170
+ elif lower < iindex <= upper:
171
+ score_E.append((iindex - lower) / lower + 1)
172
+ else:
173
+ score_E.append((iindex - upper) / upper + 2)
174
+
175
+ abort_and_output(score_E, lower, upper)
176
+
177
+
178
+ def add_arguments(parser):
179
+ """Register this subcommand's options on *parser*."""
180
+ parser.add_argument("--input", required=True, help="sites2genes output (genes_insertions.tsv)")
181
+ parser.add_argument("--output-all", required=True)
182
+ parser.add_argument("--output-essen", required=True)
183
+ parser.add_argument("--output-ambig", required=True)
184
+ parser.add_argument("--output-nonessen", required=True)
185
+
186
+
187
+ def run_args(args):
188
+ """Run the subcommand from an ``argparse.Namespace``."""
189
+ run(
190
+ input_file=args.input,
191
+ output_all=args.output_all,
192
+ output_essen=args.output_essen,
193
+ output_ambig=args.output_ambig,
194
+ output_nonessen=args.output_nonessen,
195
+ )
196
+
197
+
198
+ def main(argv=None):
199
+ parser = argparse.ArgumentParser(
200
+ prog="captnseq-toolkit essentiality",
201
+ description=__doc__,
202
+ formatter_class=argparse.RawDescriptionHelpFormatter,
203
+ )
204
+ add_arguments(parser)
205
+ run_args(parser.parse_args(argv))
206
+
207
+
208
+ if __name__ == "__main__":
209
+ main()
@@ -0,0 +1,347 @@
1
+ """Call transposon insertion sites from a SAM file and write BED / bedGraph tracks.
2
+
3
+ Ported verbatim from the CAPTn-seq Snakemake pipeline (pipeline/scripts/sam2sites.py);
4
+ only the entrypoint changed (argparse instead of the injected `snakemake` object).
5
+ """
6
+
7
+ import argparse
8
+ import csv
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+
14
+ def parse_sam_file(samfile: str, aligned_length_threshold: int = 21, pos_offset: tuple = (3, -6)) -> pd.DataFrame:
15
+ """
16
+ Parses a SAM-like file and retains only first-in-pair reads
17
+ with aligned read length above threshold.
18
+
19
+ Determines strand from the SAM flag using bitwise logic.
20
+
21
+ pos_offset: (forward_offset, reverse_offset) position-correction offset,
22
+ empirically determined per transposon/library-prep system - passed in
23
+ from config (sam2sites_params.pos_offset_fwd/pos_offset_rev) rather than
24
+ hardcoded here, since a different experiment's transposon may need
25
+ different values.
26
+ """
27
+ df = pd.read_csv(
28
+ samfile,
29
+ sep="\t",
30
+ header=None,
31
+ usecols=range(6),
32
+ names=["name", "flag", "chr_name", "pos", "score", "cigar"],
33
+ dtype={
34
+ "name": str,
35
+ "flag": int,
36
+ "chr_name": str,
37
+ "pos": int,
38
+ "score": int,
39
+ "cigar": str
40
+ },
41
+ engine="python",
42
+ quoting=csv.QUOTE_NONE,
43
+ on_bad_lines="warn"
44
+ )
45
+
46
+ # Determine strand and filter to first-in-pair reads only
47
+ df["strand"] = df["flag"].apply(lambda x: "-" if x & 0x10 else "+") # 0x10 == True is mapped to the reverse strand
48
+ df["first_in_pair"] = df["flag"].apply(lambda x: bool(x & 0x40)) # 0x40 == True is the first read in a pair
49
+ df = df[df["first_in_pair"]].copy()
50
+
51
+ # use regex to extract each int/marker and build a secondary df holding these info.
52
+ #
53
+ # typic input:
54
+ # ------------
55
+ #
56
+ # 20 42M
57
+ # 25 72M3S
58
+ # Name: cigar
59
+ #
60
+ # typic output:
61
+ # -------------
62
+ #
63
+ # size marker
64
+ # match
65
+ # 20 0 42 M
66
+ # 25 0 72 M
67
+ # 1 3 S
68
+
69
+ # Extract cigar operations
70
+ cigar_components = df["cigar"].str.extractall(r"(?P<size>\d+)(?P<op>[A-Z])")
71
+ cigar_components["size"] = cigar_components["size"].astype(int)
72
+
73
+ # Group cigar operations by index
74
+ cigar_group = cigar_components.groupby(level=0)
75
+
76
+ # First and last CIGAR op per read (used for validity check)
77
+ cigar_first_op = cigar_group["op"].first()
78
+ cigar_last_op = cigar_group["op"].last()
79
+
80
+ # Aligned length = sum of sizes for relevant ops (M, D, N, =, X)
81
+ aligned_ops = cigar_components[cigar_components["op"].isin(["M", "D", "N", "=", "X"])]
82
+ aligned_length = aligned_ops.groupby(level=0)["size"].sum()
83
+
84
+ # Inject back into main df using .map()
85
+ df["cigar_first_op"] = df.index.map(cigar_first_op)
86
+ df["cigar_last_op"] = df.index.map(cigar_last_op)
87
+ df["aligned_read_length"] = df.index.map(aligned_length).fillna(0).astype(int)
88
+
89
+ # Define valid reads based on strand and expected CIGAR structure
90
+ valid_positive = (df["strand"] == "+") & (df["cigar_first_op"] == "M")
91
+ valid_negative = (df["strand"] == "-") & (df["cigar_last_op"] == "M")
92
+ df["valid"] = (valid_positive | valid_negative) & (df["aligned_read_length"] > aligned_length_threshold)
93
+
94
+ # Position correction offset (empirically determined; not derived from a
95
+ # documented formula), passed in from config. (+offset[0]) shifts a
96
+ # forward-strand read's aligned start to the actual insertion site;
97
+ # (offset[1]) does the same for a reverse-strand read's aligned end.
98
+
99
+ # Compute adjusted start position
100
+ valid_df = df[df["valid"]].copy()
101
+ valid_df["start"] = np.where(
102
+ valid_df["strand"] == "+",
103
+ valid_df["pos"] + pos_offset[0],
104
+ valid_df["pos"] + valid_df["aligned_read_length"] + pos_offset[1]
105
+ )
106
+ return valid_df
107
+
108
+
109
+ def filter_reads_by_site_support(sam_df: pd.DataFrame, score_threshold: int) -> pd.DataFrame:
110
+ """
111
+ Retain only reads whose (chr_name, start) occurs with at least `score_threshold` other reads.
112
+ Keeps all columns including QNAMEs (name) and strand info.
113
+ """
114
+ group_keys = ["chr_name", "start"]
115
+ # Count number of reads per position per strand
116
+ site_counts = (
117
+ sam_df.groupby(group_keys + ["strand"])["name"]
118
+ .count()
119
+ .unstack(fill_value=0)
120
+ # CHANGE: unstack() only produces a column for strands actually
121
+ # present in sam_df. If every surviving read happens to be on
122
+ # one strand (plausible for a low-coverage/failing sample),
123
+ # "+" or "-" would be missing entirely and the score_pos/
124
+ # score_neg lookups below would raise a KeyError. Reindexing
125
+ # guarantees both columns always exist, filled with 0.
126
+ .reindex(columns=["+", "-"], fill_value=0)
127
+ .rename(columns={"+": "score_pos", "-": "score_neg"})
128
+ .reset_index()
129
+ )
130
+ # Keep only positions with sufficient read support on either strand
131
+ supported_sites = site_counts[
132
+ (site_counts["score_pos"] > score_threshold) |
133
+ (site_counts["score_neg"] > score_threshold)
134
+ ][["chr_name", "start"]]
135
+ # Inner join back with reads
136
+ filtered_reads = sam_df.merge(supported_sites, on=["chr_name", "start"], how="inner")
137
+ return filtered_reads
138
+
139
+
140
+ def sam_to_site(filtered_dedup_reads: pd.DataFrame) -> pd.DataFrame:
141
+ """
142
+ Convert a filtered and deduplicated read set into strand-specific insertion site counts.
143
+
144
+ Parameters
145
+ ----------
146
+ filtered_dedup_reads : pd.DataFrame
147
+ DataFrame with at least the following columns:
148
+ - 'chr_name': reference sequence
149
+ - 'start': insertion position (already corrected)
150
+ - 'strand': '+' or '-'
151
+ - 'name': QNAME (used for counting insertions)
152
+
153
+ Returns
154
+ -------
155
+ pd.DataFrame
156
+ A DataFrame with one row per (chr_name, start), containing:
157
+ - 'chr_name'
158
+ - 'start'
159
+ - 'end': start + 1 (BED-style interval)
160
+ - 'name': fixed to 'i' (placeholder for BED)
161
+ - 'score_pos': insertion count on + strand
162
+ - 'score_neg': insertion count on - strand
163
+ """
164
+ group_keys = ["chr_name", "start"]
165
+ score_pos = (
166
+ filtered_dedup_reads[filtered_dedup_reads["strand"] == "+"]
167
+ .groupby(group_keys)["name"]
168
+ .count()
169
+ .rename("score_pos")
170
+ )
171
+ score_neg = (
172
+ filtered_dedup_reads[filtered_dedup_reads["strand"] == "-"]
173
+ .groupby(group_keys)["name"]
174
+ .count()
175
+ .rename("score_neg")
176
+ )
177
+
178
+ site_df = pd.concat([score_pos, score_neg], axis=1).reset_index()
179
+ site_df["end"] = site_df["start"] + 1
180
+ site_df["name"] = "i"
181
+ site_df = site_df.fillna({"score_pos": 0, "score_neg": 0})
182
+ site_df = site_df[["chr_name", "start", "end", "name", "score_pos", "score_neg"]]
183
+ site_df = site_df.sort_values(by=["chr_name", "start"])
184
+ return site_df
185
+
186
+
187
+ def site_to_interval(site_df: pd.DataFrame, *, normalization_value: int) -> pd.DataFrame:
188
+ total_reads = site_df["score_pos"].sum() + site_df["score_neg"].sum()
189
+ pos_interval_df = (
190
+ site_df[["chr_name", "start", "end", "name", "score_pos"]]
191
+ .rename(columns={"score_pos": "score"})
192
+ )
193
+ pos_interval_df["strand"] = "+"
194
+ neg_interval_df = (
195
+ site_df[["chr_name", "start", "end", "name", "score_neg"]]
196
+ .rename(columns={"score_neg": "score"})
197
+ )
198
+ neg_interval_df["strand"] = "-"
199
+ interval_df = pd.concat([pos_interval_df, neg_interval_df], ignore_index=True)
200
+ # Normalize scores to target total, clip at 1 to ensure non-zero presence
201
+ interval_df["normalized_score"] = (
202
+ (interval_df["score"] / total_reads * normalization_value)
203
+ .astype(int)
204
+ .clip(lower=1)
205
+ )
206
+ interval_df["fake_score"] = 999 # Placeholder fixed score
207
+ interval_df = interval_df.sort_values(by=["chr_name", "start"])
208
+ interval_df = interval_df[interval_df["score"] != 0]
209
+ return interval_df
210
+
211
+
212
+ def build_bed_file(interval_df: pd.DataFrame, filename):
213
+ """
214
+ bed_file
215
+ --------
216
+
217
+ "chr_name", "start", "end", "name", "fake_score", "strand"
218
+
219
+ pVCR94deltaX3deltaacr2FRT 26 27 i 999 +
220
+ pVCR94deltaX3deltaacr2FRT 47 48 i 999 +
221
+ pVCR94deltaX3deltaacr2FRT 68 69 i 999 +
222
+ pVCR94deltaX3deltaacr2FRT 97 98 i 999 -
223
+ pVCR94deltaX3deltaacr2FRT 245 246 i 999 -
224
+ """
225
+ interval_df[["chr_name", "start", "end", "name", "fake_score", "strand"]].to_csv(
226
+ filename, header=False, index=False, sep="\t"
227
+ )
228
+
229
+
230
+ def build_stranded_unnormalized_bedgraph_file(
231
+ interval_df: pd.DataFrame, filename):
232
+ """
233
+ stranded_unnormalized_bedgraph_file
234
+ --------
235
+
236
+ "chr_name", "start", "end", "score"
237
+
238
+ pVCR94deltaX3deltaacr2FRT 26 27 2400
239
+ pVCR94deltaX3deltaacr2FRT 47 48 60
240
+ pVCR94deltaX3deltaacr2FRT 68 69 60
241
+ pVCR94deltaX3deltaacr2FRT 97 98 1380
242
+ pVCR94deltaX3deltaacr2FRT 245 246 3780
243
+ """
244
+ interval_df[["chr_name", "start", "end", "score"]].to_csv(
245
+ filename, header=False, index=False, sep="\t"
246
+ )
247
+
248
+
249
+ def build_stranded_bedgraph_file(interval_df: pd.DataFrame, filename):
250
+ """
251
+ stranded_bedgraph_file
252
+ --------
253
+
254
+ "chr_name", "start", "end", "normalized_score"
255
+
256
+ pVCR94deltaX3deltaacr2FRT 26 27 2189
257
+ pVCR94deltaX3deltaacr2FRT 47 48 54
258
+ pVCR94deltaX3deltaacr2FRT 47 48 54
259
+ pVCR94deltaX3deltaacr2FRT 68 69 54
260
+ pVCR94deltaX3deltaacr2FRT 97 98 1258
261
+ pVCR94deltaX3deltaacr2FRT 245 246 3448
262
+ """
263
+ interval_df[["chr_name", "start", "end", "normalized_score"]].to_csv(
264
+ filename, header=False, index=False, sep="\t"
265
+ )
266
+
267
+
268
+ def build_unstranded_bedgraph_file(interval_df: pd.DataFrame, filename):
269
+ """
270
+ unstranded_bedgraph_file
271
+ --------
272
+
273
+ "chr_name", "start", "end", "normalized_score"
274
+
275
+ pVCR94deltaX3deltaacr2FRT 26 27 2189
276
+ pVCR94deltaX3deltaacr2FRT 47 48 108<-(score for insertions on the same position, but different strands are merged)
277
+ pVCR94deltaX3deltaacr2FRT 68 69 54
278
+ pVCR94deltaX3deltaacr2FRT 97 98 1258
279
+ pVCR94deltaX3deltaacr2FRT 245 246 3448
280
+ """
281
+ interval_df = interval_df.groupby(["chr_name", "start", "end"], sort=False).agg({'normalized_score': 'sum'}).reset_index()
282
+ interval_df[["chr_name", "start", "end", "normalized_score"]].to_csv(
283
+ filename, header=False, index=False, sep="\t"
284
+ )
285
+
286
+
287
+ def build_unstranded_unnormalized_bedgraph_file(interval_df: pd.DataFrame, filename):
288
+ """
289
+ unstranded_bedgraph_file
290
+ --------
291
+
292
+ "chr_name", "start", "end", "score"
293
+
294
+ pVCR94deltaX3deltaacr2FRT 26 27 2400
295
+ pVCR94deltaX3deltaacr2FRT 47 48 60
296
+ pVCR94deltaX3deltaacr2FRT 68 69 60<-(score for insertions on the same position, but different strands are merged)
297
+ pVCR94deltaX3deltaacr2FRT 97 98 1380
298
+ pVCR94deltaX3deltaacr2FRT 245 246 3780
299
+ """
300
+
301
+ interval_df = interval_df.groupby(["chr_name", "start", "end"], sort=False).agg({'score': 'sum'}).reset_index()
302
+ interval_df[["chr_name", "start", "end", "score"]].to_csv(
303
+ filename, header=False, index=False, sep="\t"
304
+ )
305
+
306
+
307
+ def add_arguments(parser):
308
+ """Register this subcommand's options on *parser*."""
309
+ parser.add_argument("--sam", required=True)
310
+ parser.add_argument("--norm-value", type=int, required=True)
311
+ parser.add_argument("--read-len-threshold", type=int, required=True)
312
+ parser.add_argument("--score-threshold", type=int, required=True)
313
+ parser.add_argument("--pos-offset-fwd", type=int, required=True)
314
+ parser.add_argument("--pos-offset-rev", type=int, required=True)
315
+ parser.add_argument("--bed-output", required=True)
316
+ parser.add_argument("--normalized-output", required=True)
317
+ parser.add_argument("--unstranded-output", required=True)
318
+ parser.add_argument("--unnormalized-output", required=True)
319
+ parser.add_argument("--unstranded-unnormalized-output", required=True)
320
+
321
+
322
+ def run_args(args):
323
+ """Run the subcommand from an ``argparse.Namespace``."""
324
+ pos_offset = (args.pos_offset_fwd, args.pos_offset_rev)
325
+ sam_df = parse_sam_file(args.sam, args.read_len_threshold, pos_offset)
326
+ filtered_reads = filter_reads_by_site_support(sam_df, args.score_threshold)
327
+ site_df = sam_to_site(filtered_reads)
328
+ interval_df = site_to_interval(site_df, normalization_value=args.norm_value)
329
+ build_bed_file(interval_df, args.bed_output)
330
+ build_stranded_bedgraph_file(interval_df, args.normalized_output)
331
+ build_unstranded_bedgraph_file(interval_df, args.unstranded_output)
332
+ build_stranded_unnormalized_bedgraph_file(interval_df, args.unnormalized_output)
333
+ build_unstranded_unnormalized_bedgraph_file(interval_df, args.unstranded_unnormalized_output)
334
+
335
+
336
+ def main(argv=None):
337
+ parser = argparse.ArgumentParser(
338
+ prog="captnseq-toolkit sam2sites",
339
+ description=__doc__,
340
+ formatter_class=argparse.RawDescriptionHelpFormatter,
341
+ )
342
+ add_arguments(parser)
343
+ run_args(parser.parse_args(argv))
344
+
345
+
346
+ if __name__ == "__main__":
347
+ main()
@@ -0,0 +1,126 @@
1
+ """Count insertions per gene from a BED/intersect file, with optional trimming of the
2
+ informative gene region (--length-pc-start / --length-pc-stop).
3
+
4
+ Usage: captnseq-toolkit sites2genes --features CDS.bed --sample intersect.bed
5
+ --length-pc-start 0 --length-pc-stop 100 --output genes_insertions.tsv
6
+
7
+ Ported verbatim from the CAPTn-seq Snakemake pipeline (pipeline_ref/scripts/sites2genes.py);
8
+ only the entrypoint changed.
9
+ """
10
+
11
+ import argparse
12
+ import pandas as pd
13
+
14
+
15
+ def compute_gene_insertions(features_path, sample_path, length_pc_start, length_pc_stop, output_path):
16
+ # Read files
17
+ gene_df = pd.read_csv(
18
+ str(features_path),
19
+ dtype={
20
+ "chr_name": str,
21
+ "start": "Int64",
22
+ "end": "Int64",
23
+ "name": str,
24
+ "score": "Int64",
25
+ "strand": str
26
+ },
27
+ sep="\t",
28
+ header=None,
29
+ names=["chr_name", "start", "end", "name", "score", "strand"],
30
+ )
31
+
32
+ intersect_df = pd.read_csv(
33
+ str(sample_path),
34
+ dtype={
35
+ "chr_name_gene": str,
36
+ "start_gene": "Int64",
37
+ "end_gene": "Int64",
38
+ "name_gene": str,
39
+ "score_gene": "Int64",
40
+ "strand_gene": str,
41
+ "chr_name_insertion": str,
42
+ "start_insertion": "Int64",
43
+ "end_insertion": "Int64",
44
+ "score_insertion": "Int64"
45
+ },
46
+ sep="\t",
47
+ header=None,
48
+ usecols=range(10),
49
+ names=["chr_name_gene", "start_gene", "end_gene", "name_gene", "score_gene", "strand_gene", "chr_name_insertion", "start_insertion", "end_insertion", "score_insertion"],
50
+ )
51
+
52
+ # filter insertions outside of genes (due to insertion duplication and excluding length_pc_start length_pc_end)
53
+ intersect_df.replace({'strand_gene': {"+": "1", "-": "-1"}}, inplace=True)
54
+ gene_df.replace({'strand': {"+": "1", "-": "-1"}}, inplace=True)
55
+
56
+ intersect_df = intersect_df.loc[
57
+ (
58
+ (intersect_df['strand_gene'] == "1") &
59
+ ((intersect_df['start_insertion'] - 5) >= (intersect_df['start_gene'] + ((intersect_df['end_gene'] - intersect_df['start_gene']) * length_pc_start / 100.0))) &
60
+ ((intersect_df['start_insertion'] + 5) <= (intersect_df['end_gene'] - 1 - ((intersect_df['end_gene'] - intersect_df['start_gene']) * (100 - length_pc_stop) / 100.0)))
61
+ )
62
+ |
63
+ (
64
+ (intersect_df['strand_gene'] == "-1") &
65
+ ((intersect_df['start_insertion'] + 5) <= (intersect_df['end_gene'] - 1 - ((intersect_df['end_gene'] - intersect_df['start_gene']) * length_pc_start / 100.0))) &
66
+ ((intersect_df['start_insertion'] - 5) >= (intersect_df['start_gene'] + ((intersect_df['end_gene'] - intersect_df['start_gene']) * (100 - length_pc_stop) / 100.0)))
67
+ )
68
+ ]
69
+
70
+ # make columns required for biotradis toolkit (almost, usually no chr_name)
71
+ gene_df["locus_tag"] = gene_df["name"]
72
+ gene_df["gene_name"] = gene_df["name"]
73
+ gene_df["ncrna"] = 0
74
+ gene_df["read_count"] = 0
75
+ gene_df["ins_index"] = 0.0
76
+ gene_df["gene_length"] = gene_df["end"] - gene_df["start"]
77
+ gene_df["ins_count"] = 0
78
+ gene_df["fcn"] = "NA"
79
+ gene_df = gene_df.set_index('name')
80
+
81
+ # Compute insertion index and read_count
82
+ # Perform groupby and specify the aggregation dictionary
83
+ intersect_df = intersect_df.groupby('name_gene', sort=False).agg({'score_insertion': ['sum', 'mean', 'count']})
84
+ # This creates a MultiIndex in columns, so you need to flatten these for easier access
85
+ intersect_df.columns = ['_'.join(col).strip() for col in intersect_df.columns.values]
86
+ for index, row in intersect_df.iterrows():
87
+ gene_df.loc[index, "read_count"] = row['score_insertion_sum']
88
+ gene_df.loc[index, "ins_count"] = row['score_insertion_count']
89
+ gene_df["ins_index"] = gene_df["ins_count"] / gene_df["gene_length"]
90
+ gene_df[["chr_name", "locus_tag", "gene_name", "ncrna", "start", "end", "strand", "read_count", "ins_index", "gene_length", "ins_count", "fcn"]].to_csv(
91
+ str(output_path), header=True, index=False, sep="\t"
92
+ )
93
+
94
+
95
+ def add_arguments(parser):
96
+ """Register this subcommand's options on *parser*."""
97
+ parser.add_argument("--features", required=True)
98
+ parser.add_argument("--sample", required=True)
99
+ parser.add_argument("--length-pc-start", type=float, required=True)
100
+ parser.add_argument("--length-pc-stop", type=float, required=True)
101
+ parser.add_argument("--output", required=True)
102
+
103
+
104
+ def run_args(args):
105
+ """Run the subcommand from an ``argparse.Namespace``."""
106
+ compute_gene_insertions(
107
+ features_path=args.features,
108
+ sample_path=args.sample,
109
+ length_pc_start=args.length_pc_start,
110
+ length_pc_stop=args.length_pc_stop,
111
+ output_path=args.output,
112
+ )
113
+
114
+
115
+ def main(argv=None):
116
+ parser = argparse.ArgumentParser(
117
+ prog="captnseq-toolkit sites2genes",
118
+ description=__doc__,
119
+ formatter_class=argparse.RawDescriptionHelpFormatter,
120
+ )
121
+ add_arguments(parser)
122
+ run_args(parser.parse_args(argv))
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: captnseq-toolkit
3
+ Version: 0.1.1
4
+ Summary: Analysis tools for CAPTn-seq transposon-insertion sequencing data (used by nf-core/captnseq)
5
+ Author: Simon Jeanneau
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/simonjeanneau/captnseq-toolkit
8
+ Project-URL: Pipeline, https://github.com/nf-core/proposals/issues/173
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: numpy
13
+ Requires-Dist: pandas
14
+ Requires-Dist: scipy
15
+ Requires-Dist: matplotlib
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # captnseq-toolkit
21
+
22
+ Analysis tools for CAPTn-seq (transposon-insertion sequencing) data. This package holds the
23
+ scientific logic used by the [nf-core/captnseq](https://github.com/nf-core/proposals/issues/173)
24
+ pipeline, so the pipeline itself only contains Nextflow code.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install captnseq-toolkit # once released on PyPI
30
+ conda install -c bioconda captnseq-toolkit # once the Bioconda recipe is merged
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```bash
36
+ captnseq-toolkit --help
37
+ captnseq-toolkit sam2sites --help # SAM -> insertion sites (BED + bedGraph)
38
+ captnseq-toolkit sites2genes --help # insertion sites -> per-gene counts
39
+ captnseq-toolkit essentiality --help # per-gene counts -> essential / ambiguous / non-essential
40
+ ```
41
+
42
+ ## Development
43
+
44
+ ```bash
45
+ pip install -e ".[test]"
46
+ pytest
47
+ ```
48
+
49
+ Releases are tagged `vX.Y.Z` (version comes from git tags via setuptools-scm).
@@ -0,0 +1,23 @@
1
+ .gitignore
2
+ CHANGELOG.md
3
+ LICENSE
4
+ README.md
5
+ pyproject.toml
6
+ .github/workflows/ci.yml
7
+ recipe/meta.yaml
8
+ src/captnseq_toolkit/__init__.py
9
+ src/captnseq_toolkit/__main__.py
10
+ src/captnseq_toolkit/cli.py
11
+ src/captnseq_toolkit/essentiality.py
12
+ src/captnseq_toolkit/sam2sites.py
13
+ src/captnseq_toolkit/sites2genes.py
14
+ src/captnseq_toolkit.egg-info/PKG-INFO
15
+ src/captnseq_toolkit.egg-info/SOURCES.txt
16
+ src/captnseq_toolkit.egg-info/dependency_links.txt
17
+ src/captnseq_toolkit.egg-info/entry_points.txt
18
+ src/captnseq_toolkit.egg-info/requires.txt
19
+ src/captnseq_toolkit.egg-info/scm_file_list.json
20
+ src/captnseq_toolkit.egg-info/scm_version.json
21
+ src/captnseq_toolkit.egg-info/top_level.txt
22
+ tests/test_cli.py
23
+ tests/test_sites2genes.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ captnseq-toolkit = captnseq_toolkit.cli:main
@@ -0,0 +1,7 @@
1
+ numpy
2
+ pandas
3
+ scipy
4
+ matplotlib
5
+
6
+ [test]
7
+ pytest
@@ -0,0 +1,19 @@
1
+ {
2
+ "files": [
3
+ ".github/workflows/ci.yml",
4
+ ".gitignore",
5
+ "CHANGELOG.md",
6
+ "LICENSE",
7
+ "README.md",
8
+ "pyproject.toml",
9
+ "recipe/meta.yaml",
10
+ "src/captnseq_toolkit/__init__.py",
11
+ "src/captnseq_toolkit/__main__.py",
12
+ "src/captnseq_toolkit/cli.py",
13
+ "src/captnseq_toolkit/essentiality.py",
14
+ "src/captnseq_toolkit/sam2sites.py",
15
+ "src/captnseq_toolkit/sites2genes.py",
16
+ "tests/test_cli.py",
17
+ "tests/test_sites2genes.py"
18
+ ]
19
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.1.1",
3
+ "distance": 0,
4
+ "node": "ge329bc5a188d033b542ea387db5e85c55475b3c4",
5
+ "dirty": false,
6
+ "branch": "build/bioconda-release",
7
+ "node_date": "2026-10-16"
8
+ }
@@ -0,0 +1 @@
1
+ captnseq_toolkit
@@ -0,0 +1,72 @@
1
+ import subprocess
2
+ import sys
3
+
4
+ import pandas as pd
5
+ import pytest
6
+
7
+ from captnseq_toolkit import __version__
8
+ from captnseq_toolkit.cli import SUBCOMMANDS, main
9
+
10
+
11
+ def test_help_lists_all_subcommands(capsys):
12
+ with pytest.raises(SystemExit) as exc:
13
+ main(["--help"])
14
+ assert exc.value.code == 0
15
+ out = capsys.readouterr().out
16
+ for name in SUBCOMMANDS:
17
+ assert name in out
18
+
19
+
20
+ def test_version(capsys):
21
+ with pytest.raises(SystemExit) as exc:
22
+ main(["--version"])
23
+ assert exc.value.code == 0
24
+ assert capsys.readouterr().out.strip() == f"captnseq-toolkit {__version__}"
25
+
26
+
27
+ def test_no_subcommand_is_an_error(capsys):
28
+ with pytest.raises(SystemExit) as exc:
29
+ main([])
30
+ assert exc.value.code == 2
31
+ assert "required" in capsys.readouterr().err
32
+
33
+
34
+ def test_unknown_subcommand_is_an_error(capsys):
35
+ with pytest.raises(SystemExit) as exc:
36
+ main(["nope"])
37
+ assert exc.value.code == 2
38
+ assert "invalid choice" in capsys.readouterr().err
39
+
40
+
41
+ @pytest.mark.parametrize("name", list(SUBCOMMANDS))
42
+ def test_subcommand_help(name):
43
+ r = subprocess.run([sys.executable, "-m", "captnseq_toolkit", name, "--help"], capture_output=True, text=True)
44
+ assert r.returncode == 0
45
+ assert f"captnseq-toolkit {name}" in r.stdout
46
+
47
+
48
+ @pytest.mark.parametrize("name", list(SUBCOMMANDS))
49
+ def test_subcommand_missing_required_options(name, capsys):
50
+ with pytest.raises(SystemExit) as exc:
51
+ main([name])
52
+ assert exc.value.code == 2
53
+ assert "required" in capsys.readouterr().err
54
+
55
+
56
+ def test_sites2genes_runs_through_top_level_cli(tmp_path):
57
+ features = tmp_path / "cds.bed"
58
+ features.write_text("chr\t0\t1000\tgeneA\t0\t1\n")
59
+ sample = tmp_path / "intersect.bed"
60
+ sample.write_text("chr\t0\t1000\tgeneA\t0\t1\tchr\t500\t501\t10\n")
61
+ out = tmp_path / "out.tsv"
62
+ rc = main([
63
+ "sites2genes",
64
+ "--features", str(features),
65
+ "--sample", str(sample),
66
+ "--length-pc-start", "0",
67
+ "--length-pc-stop", "100",
68
+ "--output", str(out),
69
+ ])
70
+ assert rc == 0
71
+ df = pd.read_csv(out, sep="\t").set_index("gene_name")
72
+ assert df.loc["geneA", "ins_count"] == 1
@@ -0,0 +1,20 @@
1
+ import pandas as pd
2
+
3
+ from captnseq_toolkit.sites2genes import compute_gene_insertions
4
+
5
+
6
+ def test_counts_insertions_inside_gene(tmp_path):
7
+ features = tmp_path / "cds.bed"
8
+ features.write_text("chr\t0\t1000\tgeneA\t0\t1\nchr\t2000\t3000\tgeneB\t0\t1\n")
9
+ # 10 columns: gene BED6 (chr,start,end,name,score,strand) + insertion (chr,start,end,score)
10
+ sample = tmp_path / "intersect.bed"
11
+ sample.write_text(
12
+ "chr\t0\t1000\tgeneA\t0\t1\tchr\t500\t501\t10\n"
13
+ "chr\t0\t1000\tgeneA\t0\t1\tchr\t600\t601\t5\n"
14
+ )
15
+ out = tmp_path / "out.tsv"
16
+ compute_gene_insertions(features, sample, 0, 100, out)
17
+ df = pd.read_csv(out, sep="\t").set_index("gene_name")
18
+ assert df.loc["geneA", "ins_count"] == 2
19
+ assert df.loc["geneA", "read_count"] == 15
20
+ assert df.loc["geneB", "ins_count"] == 0