From 1091623d0e17d487b280f71f3b8785d770439d32 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Fri, 23 Oct 2026 15:32:11 +0200 Subject: [PATCH 1/6] Replace auxiliary R scripts with Python output tools --- .github/workflows/python-output-tools.yml | 27 +++ AGENTS.md | 2 +- ChangeLog | 24 ++ Makefile.am | 2 +- R/IntaRNA_CSV_p-value.R | 150 ------------- R/IntaRNA_plotRegions.R | 194 ---------------- R/Makefile.am | 8 - R/README.md | 65 ------ R/plotRegions.example.png | Bin 72563 -> 0 bytes README.md | 65 +++--- configure.ac | 1 - python/IntaRNA_CSV_p-value.py | 137 ++++++++++++ python/IntaRNA_plotRegions.py | 187 ++++++++++++++++ python/Makefile.am | 14 +- python/README.md | 140 +++++++++++- python/examples/regions.csv | 6 + python/examples/regions.params | 9 + python/plotRegions.example.png | Bin 0 -> 34194 bytes python/requirements-output-tools.txt | 5 + python/tests/test_output_tools.py | 257 ++++++++++++++++++++++ 20 files changed, 828 insertions(+), 465 deletions(-) create mode 100644 .github/workflows/python-output-tools.yml delete mode 100644 R/IntaRNA_CSV_p-value.R delete mode 100644 R/IntaRNA_plotRegions.R delete mode 100644 R/Makefile.am delete mode 100644 R/README.md delete mode 100644 R/plotRegions.example.png create mode 100755 python/IntaRNA_CSV_p-value.py create mode 100755 python/IntaRNA_plotRegions.py create mode 100644 python/examples/regions.csv create mode 100644 python/examples/regions.params create mode 100644 python/plotRegions.example.png create mode 100644 python/requirements-output-tools.txt create mode 100644 python/tests/test_output_tools.py diff --git a/.github/workflows/python-output-tools.yml b/.github/workflows/python-output-tools.yml new file mode 100644 index 00000000..9a456bb4 --- /dev/null +++ b/.github/workflows/python-output-tools.yml @@ -0,0 +1,27 @@ +name: Python output tools + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + output-tools: + runs-on: ubuntu-24.04 + strategy: + matrix: + include: + - python: '3.10' + dependencies: numpy==1.23.5 scipy==1.9.3 matplotlib==3.6.3 + - python: '3.13' + dependencies: -r python/requirements-output-tools.txt + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - run: python -m pip install ${{ matrix.dependencies }} + - run: python -m unittest discover -s python/tests -v + env: + MPLBACKEND: Agg diff --git a/AGENTS.md b/AGENTS.md index fa368888..4a900620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ with the code and build files when their conventions change. - `src/bin/`: the executable and `CommandLineParsing` for CLI options and wiring. - `tests/`: Catch-based API tests; `tests/data/`: CLI parameters and reference results exercised by `tests/runIntaRNA.sh`. -- `python/`, `R/`: companion tools; consult their local documentation +- `python/`: companion tools; consult their local documentation when changing them. - [README.md](README.md): user-facing installation, options and library usage. [doc/refactor/](doc/refactor/) records earlier analyses and measurements; diff --git a/ChangeLog b/ChangeLog index 35ce0126..e70e42f3 100644 --- a/ChangeLog +++ b/ChangeLog @@ -210,6 +210,11 @@ and accessibility models, and the output can include distinct structures, energies, restricted partition sums, and trackers. +# Auxiliary Python output tools + +- replace both R scripts with Python GEV/FDR estimation and region plotting; + document optional dependencies, migration and regression tests (issue #295) + # IntaRNA_plotRegions.R - fix deprecation warning @@ -226,6 +231,25 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261023 Alexander Mitrofanov + * python/IntaRNA_CSV_p-value.py, python/IntaRNA_plotRegions.py + + Replace the R tools with standalone Python scripts preserving positional + arguments, negated-energy GEV tails, seven-digit rounding/BH correction, + ordered region rows, inclusive coverage and plotting parameter files. + + Validate input and fitting; publish CSV atomically, preserve original CSV + fields, and support all previous figure formats without a display. + * python/tests/test_output_tools.py, .github/workflows/python-output-tools.yml + + Test analytical tails, known GEV samples, BH, CSV errors/round trips, + plotting parameters, coverage and all seven output formats in Python CI. + * python/README.md, python/requirements-output-tools.txt, python/examples/*, + python/plotRegions.example.png, README.md, AGENTS.md + + Document Python dependencies, migration, statistical and rendering + differences, parameter defaults and examples; replace README R snippets. + * Makefile.am, configure.ac, python/Makefile.am, R/* + * Install/distribute the Python replacements, documentation and fixtures. + - Remove the R scripts and their build directory. + * Resolves https://github.com/BackofenLab/IntaRNA/issues/295 + 261023 Alexander Mitrofanov * python/IntaRNA-fuse.py, tests/testIntaRNAFuse.py, tests/runIntaRNAFuse.sh + Port the non-overlapping-site compatibility check and joint-accessibility diff --git a/Makefile.am b/Makefile.am index 1387c875..a44f9d79 100644 --- a/Makefile.am +++ b/Makefile.am @@ -5,7 +5,7 @@ export GCC_COLORS ?= "error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01" # sub directories to check for Makefiles -SUBDIRS = src python R tests doc . +SUBDIRS = src python tests doc . # list of all personalities to be installed PERSONALITIES=@PERSONALITIES@ diff --git a/R/IntaRNA_CSV_p-value.R b/R/IntaRNA_CSV_p-value.R deleted file mode 100644 index 23d45326..00000000 --- a/R/IntaRNA_CSV_p-value.R +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env Rscript - -#################################################################### -# Computes p-values and false discovery rates (fdr following Benjamin+Hochberg) -# by fitting a GEV on the energy values computed by IntaRNA. -# Note, such p-value estimates are only useful for genome-wide predictions. -# -# arguments: [ = ] [ = E] -# -# 1 = ";"-separated CSV output of IntaRNA -# 2 = file name to write the extended CSV output to (2 new columns) -# 3 = the column name that holds the energy values to be fitted -# -# example call: -# -# Rscript --vanilla IntaRNA_CSV_p-value.R predictions.csv -# -# This script is part of the IntaRNA source code package. See -# respective licence and documentation for further information. -# -# https://github.com/BackofenLab/IntaRNA -# -#################################################################### - - -#################################################################### -# get command line arguments -#################################################################### - -args = commandArgs(trailingOnly=TRUE) -# check and parse -if (length(args)<1) { stop("call with [ = ] [ = E]", call.=FALSE) } - -# get input file = IntaRNA csv output -intarnaOutputFile = args[1]; -if (!file.exists(intarnaOutputFile )) { stop("intarna-csv-output file '", intarnaOutputFile, "' does not exist!", call.=FALSE) } - -# get output file -outFile = intarnaOutputFile; -if (length(args)>=2) { - outFile = args[2] -} - -# set column to get energies from -colNameE = "E" -# get column name from argument if present -if (length(args)>=3) { - colNameE = args[3] -} - -# column delimiter used in CSV input / output -csvColSep = ";" - -# number of digits of p-values -pValPrec = 7 - -#################################################################### -# fits a generalized extreme value distribution to the given energy data -# adopted from 'gev' function of 'evir' library -# @param energy the IntaRNA energy values to fit (a vector) -# @return the fitting parameters xi, mu, and sigma -gevFitting <- function (energy) -#################################################################### -{ - n.all <- NA - energy <- as.numeric(-energy) - n <- length(energy) - sigma0 <- sqrt(6 * var(energy))/pi - mu0 <- mean(energy) - 0.57722 * sigma0 - xi0 <- 0.1 - theta <- c(xi0, sigma0, mu0) - negloglik <- function(theta, tmp) { - y <- 1 + (theta[1] * (tmp - theta[3]))/theta[2] - if ((theta[2] < 0) || (min(y) < 0)) - out <- 1e+06 - else { - term1 <- length(tmp) * logb(theta[2]) - term2 <- sum((1 + 1/theta[1]) * logb(y)) - term3 <- sum(y^(-1/theta[1])) - out <- term1 + term2 + term3 - } - out - } - # compute fit - fit <- optim(theta, negloglik, hessian = TRUE, tmp = energy) - if (fit$convergence) - warning("gev fit optimization may not have succeeded") - - return( list( xi=fit$par[1], sigma=fit$par[2], mu=fit$par[3] ) ) -} - - -#################################################################### -# computes p-values for the given energy values and GEV distribution -# adopted from 'pgev' function of 'evir' library -# @param energy IntaRNA energy values -# @param gev GEV parameters -# @return p-values for each energy value -gevPvalue <- function (energy, gev=list( xi = 1, mu = 0, sigma = 1) ) -#################################################################### -{ - return ( 1 - exp( - (1 + (gev$xi * ((-energy) - gev$mu))/gev$sigma)^(-1 /gev$xi))) -} - - - -#################################################################### -# parse IntaRNA CSV -#################################################################### - -d = read.csv( intarnaOutputFile, sep=csvColSep ) - -# check if energy column present -if (!is.element(colNameE, colnames(d))) { - stop("'",colNameE,"' is not among the column names of '",intarnaOutputFile,"'", call.=FALSE); -} -# check if unique -if (sum(colnames(d) == colNameE)>1) { - stop("column name '",colNameE,"' occurs more than once in '",intarnaOutputFile,"'", call.=FALSE); -} - - -#################################################################### -# fit p-values -#################################################################### - -# get energies to fit -E = d[,colnames(d) == colNameE] - - -# fit negated energies -gevfit <- gevFitting(E) # fitten - -# get rounded pValue -pVal <- round( gevPvalue( E, gevfit ), digits=pValPrec ) -# get rounded fdr -fdr <- round( p.adjust(pVal, method="BH"), digits=pValPrec ) - -#################################################################### -# write output -#################################################################### - -o = cbind( d, pVal, fdr ) -colnames(o)[ncol(o)-1] = "p-value" -colnames(o)[ncol(o)] = "fdr" - -write.table( o, outFile, sep=csvColSep, row.names=FALSE, col.names = TRUE, quote=FALSE ) - - -#################################################################EOF diff --git a/R/IntaRNA_plotRegions.R b/R/IntaRNA_plotRegions.R deleted file mode 100644 index a805f5ea..00000000 --- a/R/IntaRNA_plotRegions.R +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env Rscript - -#################################################################### -# Visualization of sequence regions covered by RNA-RNA interactions -# predicted by IntaRNA. -# -# arguments: <1|2> -# -# 1 = ";"-separated CSV output of IntaRNA -# 2 <1|2|paramFile> = suffix of "start,end,id" CSV cols to plot -# or paramFile containing '='-separated assignments for -# id, start, end, title, xvline, xmin, xmax -# 3 = file name of the output figure suffixed -# by one of ".pdf",".png",".svg",".eps",".ps",".jpeg",".tiff" -# -# example call: -# -# Rscript --vanilla plotRegions.R pred.csv 1 regions.png -# -# This script is part of the IntaRNA source code package. See -# respective licence and documentation for further information. -# -# https://github.com/BackofenLab/IntaRNA -# -#################################################################### - - -#################################################################### -# check and load dependencies -#################################################################### - -options(warn=-1) -suppressPackageStartupMessages(library(ggplot2)) -suppressPackageStartupMessages(library(ggalt)) -suppressPackageStartupMessages(library(cowplot)) # cowplot starts with a note -options(warn=0) - -theme_set(theme_cowplot()) - -#################################################################### -# get command line arguments -#################################################################### - -args = commandArgs(trailingOnly=TRUE) -# check and parse -if (length(args)!=3) { stop("call with <1|2|paramFile> ", call.=FALSE) } - -intarnaOutputFile = args[1]; -if (!file.exists(intarnaOutputFile )) { stop("intarna-csv-output file '", intarnaOutputFile, "' does not exist!", call.=FALSE) } - -# init columns to plot -id = NA -start = NA -end = NA -title = NULL -# if set to some x-position, this will trigger the plotting of a vertical line at that location -xVline = NA; -xmin = NA; -xmax = NA; -rowsMax = 200; -# set columns to plot -seqNr = args[2]; -if (seqNr == "1" || seqNr == "2") { - id = paste("id",seqNr,sep=""); - start = paste("start",seqNr,sep=""); - end = paste("end",seqNr,sep=""); -} else { - if (!file.exists(seqNr )) { stop("second call argument as to be '1' or '2' to specify which regions to plot or the name of the parameter file to parse"); } - p = read.table(seqNr, header=FALSE, row.names = 1, sep="=", quote = "", strip.white=TRUE, blank.lines.skip=TRUE, comment.char="#") - # set parsed data - id = as.character(p["id",1]) - start = as.character(p["start",1]) - end = as.character(p["end",1]) - title = as.character(p["title",1]) - xVline = as.numeric(as.character(p["xvline",1])) - xmin = as.numeric(as.character(p["xmin",1])) - xmax = as.numeric(as.character(p["xmax",1])) - rowsMax = as.numeric(as.character(p["rows",1])) -} - -outFile = args[3]; -fileExtensions = c(".pdf",".png",".svg",".eps",".ps",".jpeg",".tiff"); -outFileExtOk = FALSE; -for( ext in fileExtensions ) { outFileExtOk = outFileExtOk || endsWith(outFile,ext); } -if ( !outFileExtOk ) {stop(" has to have one of the following file extensions ",paste(fileExtensions,sep=" "), call.=FALSE);} - -#################################################################### -# parse IntaRNA output -#################################################################### - -d = read.csv2( intarnaOutputFile ) -# check if all columns present -for( x in c(id,start,end)) { - if (!is.element(x, colnames(d))) { - stop("'",id,"' is not among the column names of '",intarnaOutputFile,"'", call.=FALSE); - } -} - -# reduce to rows of interest -rowsMax = min(rowsMax, nrow(d)) -d = d[1:rowsMax,] - -#################################################################### -# create count plot -#################################################################### - -allPos = c(); -for( i in 1:nrow(d) ) { - allPos = c( allPos, d[i,start]:d[i,end] ); -} -allPos = as.data.frame(allPos,ncol=1) -#allPos # DEBUG OUT - -if ( is.na(xmin) ) { - xmin = min(allPos) -} -if ( is.na(xmax) ) { - xmax = max(allPos) -} - -coveragePlot = - ggplot( allPos, aes(x=allPos, stat(count))) + - geom_density() + - ylab("coverage") + - xlab( ifelse( is.null(title) , "position" , title ) ) + - scale_y_continuous(position = "right", expand=expansion(mult = c(0, .02))) + - scale_x_continuous(expand = c(0, 0), limits=c(xmin,xmax)); - -if (!is.null(title)) { - coveragePlot = coveragePlot + theme( axis.title.x=element_text(hjust = 0.5, face = "bold")); -} else { - coveragePlot = coveragePlot + theme( axis.title.x=element_blank()); -} - -# plot vertical line if requested -if ( ! is.na(xVline) && xVline >= xmin && xVline <= xmax ) { - coveragePlot = coveragePlot + - geom_vline(aes(xintercept=xVline)); -} - -#################################################################### -# create region plot -#################################################################### - -dRegion = data.frame() -dRegion[1:nrow(d),1:3] = d[,c(start,end,id)] -dRegion[1:nrow(d),4] = factor(sprintf("%08d",nrow(d):1)) -colnames(dRegion) = c("start","end","id","idx"); -#dRegion # DEBUG OUT - -yLabelScale = 0.6 # if you have to alter for more/less sequence IDs per inch; see below - -regionPlot = - ggplot(dRegion, aes(x=start,xend=end,y=idx)) + - geom_dumbbell(color="dodgerblue", size=2) + - xlab("position") + - scale_x_continuous( expand = c(0, 0), limits=c(xmin,xmax) ) + - ylab("") + - scale_y_discrete(position = "right", breaks=dRegion$idx, labels=dRegion$id) + - geom_vline(aes(xintercept=(xmin))) + - theme(panel.grid.major.y=element_line(linewidth=0.7,color="lightgray") - , axis.text.y=element_text(size=rel(yLabelScale)) - #, plot.title = element_blank() - ) - -if ( ! is.na(xVline)) { - regionPlot = regionPlot + - geom_vline(aes(xintercept=xVline)); -} - -#################################################################### -# plot to file -#################################################################### - -plotWidth = 6 -plotHeightDensity = 2 -plotHeight = plotHeightDensity + max(2,nrow(d)/9) -plotHeightDensityRel = plotHeightDensity / plotHeight - -options(warn=-1) # disable warnings of unneeded rows -plot_grid( coveragePlot, regionPlot - , nrow=2, ncol=1 - , align = "hv" - , axis = "r" - , rel_heights= c( plotHeightDensityRel, 1.0-plotHeightDensityRel) -) -options(warn=0) # reenable warnings - -ggsave( outFile - , width= plotWidth - , height= plotHeight -); - -#############################################################EOF diff --git a/R/Makefile.am b/R/Makefile.am deleted file mode 100644 index f0562006..00000000 --- a/R/Makefile.am +++ /dev/null @@ -1,8 +0,0 @@ - -# scripts to be installed in $(bindir) and distributed - -dist_bin_SCRIPTS = \ - IntaRNA_CSV_p-value.R \ - IntaRNA_plotRegions.R - - diff --git a/R/README.md b/R/README.md deleted file mode 100644 index 41162344..00000000 --- a/R/README.md +++ /dev/null @@ -1,65 +0,0 @@ - -# Auxiliary R scripts of the IntaRNA package - -- [IntaRNA_CSV_p-value.R](#intarna_csv_p-valuer) -- [IntaRNA_plotRegions.R](#intarna_plotregionsr) - - -# `IntaRNA_CSV_p-value.R` -### p-value estimates of IntaRNA energies from present energy distribution - -For genome-wide target sRNA predictions, we assume the set of energy values -predicted for all target sufficiently diverse to be used as a background energy -model for minimum energies of putative target sequences. Thus, we can fit a -generalized extreme value (GEV) distribution to the data that is subsequently -used to estimate p-values for each energy. - -The `IntaRNA_CSV_p-value.R` script takes an IntaRNA CSV output file (assumed to be -sufficiently large and sane enough for GEV fitting) to compute respective -p-value estimates. The output consists of the input table extended with a -`p-value` column. If no output file is given or in- and output file names are -equal, the input file is overwritten! -You can (optionally) specify the column name for which p-values are to be -estimated. Example calls are given below. - -```bash -# overwriting the input file with p-value-extended table -Rscript --vanilla IntaRNA_CSV_p-value.R IntaRNA-output.csv -# creating a new output file for p-value extension -Rscript --vanilla IntaRNA_CSV_p-value.R IntaRNA-output.csv IntaRNA-output-with-pValue.csv -# computing p-values for normalized energies (has to be present in file IN.csv) -Rscript --vanilla IntaRNA_CSV_p-value.R IN.csv IN-pValue.csv E_norm -``` - - -# `IntaRNA_plotRegions.R` -### Visualization of RRI-covered regions - -To visualize sequences' regions covered by RNA-RNA interactions predicted by -IntaRNA, you can use `IntaRNA_plotRegions.R` by providing the following arguments (in -the given order) - -1. CSV-IntaRNA output file (semicolon separated) covering the columns `start,end,id` - with suffix `1` or `2` to plot target or query regions, respectively -2. `1` or `2` to select whether to plot target or query regions -3. output file name with a file-format-specific suffix from `.pdf`, `.png`, - `.svg`, `.eps`, `.ps`, `.jpeg`, `.tiff` - -An example is given below, when calling -```bash -Rscript --vanilla IntaRNA_plotRegions.R pred.csv 1 plotRegions.example.png -``` - -with `pred.csv` containing -``` -id1;start1;end1;id2;start2;end2 -b0001;266;273;query;116;123 -b0002;204;231;query;85;111 -b0003;229;262;query;96;125 -b0004;265;300;query;10;38 -b0005;281;295;query;5;22 -``` - -will produce the output -![IntaRNA_plotRegions.R example](plotRegions.example.png) - diff --git a/R/plotRegions.example.png b/R/plotRegions.example.png deleted file mode 100644 index 5f6f080beca16326bcd3e5e239d5a2e0aca12601..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72563 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 values with 0 -d <- read.table("MYPAIRMINE.csv",header=T,sep=";"); -d <- d[,2:ncol(d)]; -d[is.na(d)] = 0; -d[d>0] = 0; -# plot -image( 1:nrow(d), 1:ncol(d), as.matrix(d), col = heat.colors(100), xlab="index in sequence 1", ylab="index in sequence 2"); -box(); +This information can be visualized with Python, NumPy and Matplotlib (see the +[optional Python dependencies](python/README.md#dependencies-and-installation)). +The following example uses the coordinate labels from the CSV and masks missing +or positive energies as zero: + +```python +import csv +import numpy as np +import matplotlib.pyplot as plt + +with open("MYPAIRMINE.csv", newline="") as handle: + rows = list(csv.reader(handle, delimiter=";")) +# Labels have the form nucleotide_position; split at the final underscore. +query = [int(label.rsplit("_", 1)[1]) for label in rows[0][1:]] +target = [int(row[0].rsplit("_", 1)[1]) for row in rows[1:]] +energy = np.array([[float(value) if value != "NA" else 0 for value in row[1:]] + for row in rows[1:]]) +energy = np.minimum(energy, 0) +figure, axis = plt.subplots() +heatmap = axis.pcolormesh(target, query, energy.T, shading="nearest", cmap="hot") +axis.set_xlabel("index in sequence 1") +axis.set_ylabel("index in sequence 2") +figure.colorbar(heatmap, ax=axis, label="energy [kcal/mol]") +figure.savefig("pair-minE.png", dpi=150, bbox_inches="tight") ``` The following plot (for the [minimal energy profile](#profileMinE) example from -above) reveals, that the alternative stable (*E*<0) interactions all involve the -mfe-site in the second sequence and are thus less likely to occure. +above) reveals that the alternative stable (*E*<0) interactions all involve the +MFE site in the second sequence and are thus less likely to occur. ![Minimal interaction energy index pair information](./doc/figures/pair-minE.png) -A more sophisticated plot can be done using `ggplot2` from the `tidyverse` package. - -```R -library(tidyverse) - -read_delim("pairMinE-t1q1.csv", delim=";") %>% - replace(is.na(.), 0) %>% - rename( target = minE ) %>% - pivot_longer( cols=-target, names_to = "query", values_to = "E" ) %>% - separate( query, into = c("qSeq","qIdx"), sep = "_") %>% - separate( target, into = c("tSeq","tIdx"), sep = "_") %>% - mutate(across(c(tIdx,qIdx,E),as.numeric)) %>% - mutate(E = ifelse( E>0, 0, E)) %>% - ggplot(aes(x=tIdx,y=qIdx, fill=E)) + - geom_tile() + - scale_fill_gradient(low="blue",high="red") -``` +For interval and coverage plots from standard interaction CSV output, use +[IntaRNA_plotRegions.py](python/README.md#intarna_plotregionspy). [![up](doc/figures/icon-up.28.png) back to overview](#overview) diff --git a/configure.ac b/configure.ac index fd470280..00d86401 100644 --- a/configure.ac +++ b/configure.ac @@ -570,7 +570,6 @@ AC_CONFIG_FILES([src/Makefile]) AC_CONFIG_FILES([src/IntaRNA/Makefile]) AC_CONFIG_FILES([src/IntaRNA/intarna_config.h]) AC_CONFIG_FILES([src/bin/Makefile]) -AC_CONFIG_FILES([R/Makefile]) AC_CONFIG_FILES([python/Makefile]) AC_CONFIG_FILES([python/bin/Makefile]) AC_CONFIG_FILES([python/intarnapvalue/Makefile]) diff --git a/python/IntaRNA_CSV_p-value.py b/python/IntaRNA_CSV_p-value.py new file mode 100755 index 00000000..151267fb --- /dev/null +++ b/python/IntaRNA_CSV_p-value.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Append genome-wide GEV p-values and Benjamini-Hochberg FDRs to IntaRNA CSV.""" + +import argparse +import csv +import math +import os +from pathlib import Path +import shutil +import tempfile + + +def fit_gev(energies): + """Fit negated energies; return SciPy's (shape=-xi, location, scale).""" + import numpy as np + from scipy.optimize import minimize + from scipy.stats import genextreme + + # The background must contain enough distinct, finite observations to fit. + values = np.asarray(energies, dtype=float) + if not np.all(np.isfinite(values)) or np.unique(values).size < 3: + raise ValueError("GEV fitting requires at least three distinct finite energies") + data = -values + scale = float(np.std(data, ddof=1) * math.sqrt(6) / math.pi) + location = float(np.mean(data) - 0.57722 * scale) + if not math.isfinite(scale) or scale <= 0 or not math.isfinite(location): + raise ValueError("energy range is too large for GEV fitting") + + # Retain the R script's initial xi=0.1 and moment-based scale/location. + # SciPy uses the opposite shape sign and handles the Gumbel limit (xi=0). + def optimizer(function, initial, args=(), disp=0): + result = minimize(function, initial, args=args, method="Nelder-Mead", + options={"maxiter": 10000, "maxfev": 20000, + "xatol": 1e-8, "fatol": 1e-8}) + if not result.success: + raise ValueError("GEV fit did not converge: " + result.message) + return result.x + + params = genextreme.fit(data, -0.1, loc=location, scale=scale, optimizer=optimizer) + if (not np.all(np.isfinite(params)) or params[2] <= 0 + or not np.all(np.isfinite(genextreme.logpdf(data, *params)))): + raise ValueError("GEV fit is not finite or does not cover all energies") + return params + + +def gev_pvalues(energies, params): + """Return P(E <= observed E), using the upper tail of the negated energies.""" + import numpy as np + from scipy.stats import genextreme + + # sf avoids cancellation for significant interactions and handles support ends. + values = genextreme.sf(-np.asarray(energies, dtype=float), *params) + if not np.all(np.isfinite(values)): + raise ValueError("GEV fit produced nonfinite p-values") + return values + + +def adjust_bh(pvalues): + """Adjust p-values by Benjamini-Hochberg, retaining the original row order.""" + result = [0.0] * len(pvalues) + previous = 1.0 + order = sorted(range(len(pvalues)), key=lambda i: pvalues[i]) + for rank in range(len(order), 0, -1): + index = order[rank - 1] + previous = min(previous, pvalues[index] * len(order) / rank) + result[index] = previous + return result + + +def extend_csv(source, destination, energy_column="E"): + """Validate and fit the entire table before atomically publishing the output.""" + with open(source, newline="", encoding="utf-8-sig") as handle: + reader = csv.reader(handle, delimiter=";", strict=True) + header = next(reader, []) + if not header or len(set(header)) != len(header): + raise ValueError("CSV must have a nonempty header with unique column names") + if energy_column not in header: + raise ValueError("energy column {!r} is missing".format(energy_column)) + if "p-value" in header or "fdr" in header: + raise ValueError("CSV already contains a 'p-value' or 'fdr' column") + column = header.index(energy_column) + rows, energies = [], [] + for row in reader: + if not row: # Allow blank lines, as in read.csv(). + continue + if len(row) != len(header): + raise ValueError("CSV line {} has the wrong number of fields".format(reader.line_num)) + try: + energy = float(row[column]) + except ValueError: + raise ValueError("invalid energy on CSV line {}".format(reader.line_num)) from None + if not math.isfinite(energy): + raise ValueError("nonfinite energy on CSV line {}".format(reader.line_num)) + rows.append(row) + energies.append(energy) + + # Preserve the historical seven-decimal rounding *before* BH adjustment. + pvalues = [round(float(p), 7) for p in gev_pvalues(energies, fit_gev(energies))] + fdrs = adjust_bh(pvalues) + destination = Path(destination).resolve() + temporary = None + try: + with tempfile.NamedTemporaryFile(mode="w", newline="", encoding="utf-8", + dir=destination.parent, delete=False) as handle: + temporary = Path(handle.name) + writer = csv.writer(handle, delimiter=";", lineterminator="\n") + writer.writerow(header + ["p-value", "fdr"]) + for row, pvalue, fdr in zip(rows, pvalues, fdrs): + writer.writerow(row + [format(pvalue, ".7f"), format(fdr, ".7f")]) + if destination.exists(): + shutil.copymode(destination, temporary) + os.replace(temporary, destination) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, epilog=( + "Use a sufficiently large, diverse genome-wide prediction set as the background. " + "If output is omitted, input is overwritten after a successful fit. " + "Requires Python 3.10+, NumPy and SciPy (see python/requirements-output-tools.txt).")) + parser.add_argument("input", help="semicolon-separated IntaRNA CSV") + parser.add_argument("output", nargs="?", help="output CSV (default: overwrite input)") + parser.add_argument("energy_column", nargs="?", default="E", help="energy column (default: E)") + args = parser.parse_args(argv) + try: + extend_csv(args.input, args.output or args.input, args.energy_column) + except ImportError as error: + parser.exit(1, "Missing Python dependency: {}. Install numpy and scipy.\n".format(error)) + except (OSError, ValueError, RuntimeError, csv.Error) as error: + parser.exit(1, "Error: {}\n".format(error)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/IntaRNA_plotRegions.py b/python/IntaRNA_plotRegions.py new file mode 100755 index 00000000..448a11fb --- /dev/null +++ b/python/IntaRNA_plotRegions.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Plot IntaRNA interaction regions and their smoothed nucleotide coverage.""" + +import argparse +import csv +import math +from pathlib import Path + + +FORMATS = {".pdf", ".png", ".svg", ".eps", ".ps", ".jpeg", ".tiff"} + + +def read_parameters(selector): + """Read a strand selector or the original key=value plotting parameters.""" + parameters = {"title": None, "xvline": None, "xmin": None, "xmax": None, "rows": 200} + if selector in ("1", "2"): + parameters.update({key: key + selector for key in ("id", "start", "end")}) + return parameters + + # Comments and optional values retain the original parameter-file syntax. + seen = set() + with open(selector, encoding="utf-8-sig") as handle: + for line_number, line in enumerate(handle, 1): + line = line.split("#", 1)[0].strip() + if not line: + continue + key, separator, value = line.partition("=") + key, value = key.strip(), value.strip() + if not separator or key not in {"id", "start", "end", *parameters}: + raise ValueError("unknown or malformed parameter on line {}".format(line_number)) + if key in seen: + raise ValueError("duplicate parameter: " + key) + seen.add(key) + parameters[key] = value + for key in ("id", "start", "end"): + if not parameters.get(key): + raise ValueError("parameter file must define " + key) + for key in ("xmin", "xmax", "xvline"): + value = parameters[key] + if value is None or value in ("", "NA"): + parameters[key] = None + else: + parameters[key] = float(value) + if not math.isfinite(parameters[key]): + raise ValueError(key + " must be finite") + try: + parameters["rows"] = int(parameters["rows"]) + except ValueError: + raise ValueError("rows must be a positive integer") from None + if parameters["rows"] < 1: + raise ValueError("rows must be a positive integer") + return parameters + + +def read_regions(source, parameters): + """Read the selected rows in input order, keeping inclusive signed positions.""" + regions = [] + with open(source, newline="", encoding="utf-8-sig") as handle: + reader = csv.reader(handle, delimiter=";", strict=True) + header = next(reader, []) + if not header or len(set(header)) != len(header): + raise ValueError("CSV must have a nonempty header with unique column names") + for key in ("id", "start", "end"): + if parameters[key] not in header: + raise ValueError("missing CSV column: " + parameters[key]) + columns = [header.index(parameters[key]) for key in ("id", "start", "end")] + for row in reader: + if not row: + continue + if len(row) != len(header): + raise ValueError("CSV line {} has the wrong number of fields".format(reader.line_num)) + try: + start, end = (int(row[index]) for index in columns[1:]) + except ValueError: + raise ValueError("positions must be integers on CSV line {}".format(reader.line_num)) from None + regions.append((row[columns[0]], start, end)) + if len(regions) == parameters["rows"]: + break + if not regions: + raise ValueError("CSV contains no interaction regions") + return regions + + +def coverage_density(regions, xmin, xmax): + """Gaussian count density of inclusive covered positions inside the x limits.""" + import numpy as np + from scipy.stats import norm + + # ggplot's original scale limits filter positions before estimating density. + positions = [np.arange(max(min(start, end), math.ceil(xmin)), + min(max(start, end), math.floor(xmax)) + 1, dtype=float) + for _, start, end in regions] + positions = np.concatenate(positions) + x = np.linspace(xmin, xmax, 512) + if positions.size == 0: + return x, np.zeros_like(x) + + # R's default bw.nrd0 bandwidth, including its zero-spread fallback. + spread = float(np.std(positions, ddof=1)) if positions.size > 1 else 0.0 + iqr = float(np.subtract(*np.percentile(positions, [75, 25]))) + bandwidth = 0.9 * (min(spread, iqr / 1.34) or spread or abs(positions[0]) or 1.0) + bandwidth *= positions.size ** -0.2 + # Evaluate in chunks so the number of plotted bases does not multiply memory by 512. + density = np.zeros_like(x) + for offset in range(0, len(positions), 4096): + distances = (x[:, None] - positions[None, offset:offset + 4096]) / bandwidth + density += norm.pdf(distances).sum(axis=1) / bandwidth + return x, density + + +def create_figure(regions, parameters): + """Create aligned coverage and region panels without requiring a display.""" + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + # Preserve the input ordering and independent rows even for duplicate IDs. + xmin = parameters["xmin"] + xmax = parameters["xmax"] + if xmin is None: + xmin = min(min(start, end) for _, start, end in regions) + if xmax is None: + xmax = max(max(start, end) for _, start, end in regions) + if xmin > xmax: + raise ValueError("xmin must not exceed xmax") + if xmin == xmax: + xmin, xmax = xmin - 0.5, xmax + 0.5 + x, density = coverage_density(regions, xmin, xmax) + region_height = max(2, len(regions) / 9) + figure, (coverage, intervals) = plt.subplots( + 2, 1, figsize=(6, 2 + region_height), sharex=True, + gridspec_kw={"height_ratios": [2, region_height]}, layout="constrained") + coverage.plot(x, density, color="black", linewidth=1) + coverage.set_ylabel("coverage") + coverage.set_ylim(bottom=0) + if parameters["title"]: + coverage.set_title(parameters["title"], fontweight="bold") + + for index, (_, start, end) in enumerate(regions): + intervals.plot([start, end], [index, index], color="dodgerblue", + linewidth=2, marker="o", markersize=3) + intervals.set_yticks(range(len(regions)), labels=[region[0] for region in regions], fontsize=7) + intervals.set_ylim(len(regions) - 0.5, -0.5) + intervals.grid(axis="y", color="lightgray", linewidth=0.7) + intervals.set_axisbelow(True) + intervals.set_xlabel("position") + intervals.axvline(xmin, color="black", linewidth=0.7) + for axis in (coverage, intervals): + axis.set_xlim(xmin, xmax) + axis.yaxis.tick_right() + axis.yaxis.set_label_position("right") + axis.spines["top"].set_visible(False) + axis.spines["left"].set_visible(False) + line = parameters["xvline"] + if line is not None and xmin <= line <= xmax: + axis.axvline(line, color="black", linewidth=0.7) + return figure + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, epilog=( + "Parameter files accept id, start, end, title, xvline, xmin, xmax and rows (default: 200). " + "Requires Python 3.10+, NumPy, SciPy and Matplotlib; see python/requirements-output-tools.txt.")) + parser.add_argument("input", help="semicolon-separated IntaRNA CSV") + parser.add_argument("selector", help="1 (target), 2 (query), or a key=value parameter file") + parser.add_argument("output", help="plot filename: " + ", ".join(sorted(FORMATS))) + args = parser.parse_args(argv) + try: + if Path(args.output).suffix.lower() not in FORMATS: + raise ValueError("unsupported output extension; choose " + ", ".join(sorted(FORMATS))) + parameters = read_parameters(args.selector) + regions = read_regions(args.input, parameters) + figure = create_figure(regions, parameters) + import matplotlib.pyplot as plt + try: + figure.savefig(args.output, dpi=150) + finally: + plt.close(figure) + except ImportError as error: + parser.exit(1, "Missing Python dependency: {}. Install numpy, scipy and matplotlib.\n".format(error)) + except (OSError, ValueError, RuntimeError, csv.Error) as error: + parser.exit(1, "Error: {}\n".format(error)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/python/Makefile.am b/python/Makefile.am index 681ba63e..0b7f7ded 100644 --- a/python/Makefile.am +++ b/python/Makefile.am @@ -7,9 +7,9 @@ SUBDIRS = copomus intarnapvalue bin dist_bin_SCRIPTS = \ CopomuS.py \ - IntaRNA-fuse.py - -EXTRA_DIST = README.md + IntaRNA-fuse.py \ + IntaRNA_CSV_p-value.py \ + IntaRNA_plotRegions.py # auxiliary scripts to be part of distribution noinst_PYTHON = setup.py @@ -30,3 +30,11 @@ install-exec-hook: endif endif + +# Optional output-analysis tools; dependencies are installed by the user. +EXTRA_DIST = README.md requirements-output-tools.txt plotRegions.example.png \ + examples/regions.csv examples/regions.params tests/test_output_tools.py + +.PHONY: check-output-tools +check-output-tools: + $(PYTHON) -m unittest discover -s $(srcdir)/tests -v diff --git a/python/README.md b/python/README.md index bf69709e..561aeb8c 100644 --- a/python/README.md +++ b/python/README.md @@ -1,5 +1,131 @@ +# Auxiliary Python tools -# auxiliary tools +## Output analysis and visualization + +The former R tools are now standalone Python scripts in this folder. Replace +`Rscript --vanilla IntaRNA_CSV_p-value.R` with +`python3 IntaRNA_CSV_p-value.py`, and `Rscript --vanilla IntaRNA_plotRegions.R` +with `python3 IntaRNA_plotRegions.py`. Positional arguments are unchanged. +R and the R packages ggplot2, ggalt and cowplot are no longer needed. + +### Dependencies and installation + +Both scripts require **Python 3.10 or newer** and **NumPy >= 1.23**, +**SciPy >= 1.9**; plotting additionally requires **Matplotlib >= 3.6**. +From the repository root, install these optional dependencies in your Python +environment: + +```bash +python3 -m pip install -r python/requirements-output-tools.txt +``` + +Run the scripts directly from the checkout as shown below. `make install` also +installs both executable `.py` scripts in the configured `bin` directory; their +`#!/usr/bin/env python3` interpreter must have the dependencies installed. +Autotools does not install these Python dependencies. They are optional for the +C++ IntaRNA predictor. These scripts are independent of the `intarnapvalue` +package installed by `python/setup.py`. + +### `IntaRNA_CSV_p-value.py` + +Estimate p-values for a **genome-wide** target screen from its observed energy +distribution. A sufficiently large and diverse collection of target energies is +required to serve as a background. This is not a significance test calibrated +for a small selection of interactions or a single target/query pair. + +The script reads semicolon-separated IntaRNA CSV, fits a generalized extreme +value (GEV) distribution to the **negated** energies by maximum likelihood, and +appends `p-value` and `fdr` columns. More negative energies receive smaller +p-values. FDRs use the Benjamini-Hochberg correction over all input rows. As in +the former script, p-values are rounded to seven decimal places **before** the +correction, and FDRs are also written with seven decimal places. + +```bash +# Overwrite the input only after successful validation and fitting. +python3 python/IntaRNA_CSV_p-value.py IntaRNA-output.csv +# Write a separate output file. +python3 python/IntaRNA_CSV_p-value.py IntaRNA-output.csv IntaRNA-output-with-pValue.csv +# Fit an existing custom energy column. +python3 python/IntaRNA_CSV_p-value.py IN.csv IN-pValue.csv E_norm +``` + +The default energy column is `E`. All original field values, column names and +row order are retained; fields containing semicolons or quotes are CSV-quoted. +Empty tables, fewer than three distinct energies, missing/duplicate columns, +nonfinite or nonnumeric energies, malformed rows, existing `p-value`/`fdr` +columns and unsuccessful fits are errors. The distinct-value check is a numerical +minimum, not a guarantee of a statistically adequate background. Errors leave +existing output files untouched. + +SciPy uses the opposite GEV shape sign to the old R/evir convention +(`c = -xi`); the script accounts for this and evaluates the survival function +of the negated energies. This also handles the zero-shape Gumbel limit and +extreme tails. The statistical model and initial estimates are retained, but +SciPy's optimizer and convergence tolerances can produce slightly different +fitted values from R; outputs are not guaranteed to match digit for digit. +See [SciPy's GEV documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.genextreme.html). + +### `IntaRNA_plotRegions.py` + +Visualize the regions covered by IntaRNA interactions in two aligned panels: +smoothed nucleotide coverage above and one blue interval per interaction below. +The first CSV row appears at the top. Repeated IDs remain separate rows. Both +interval endpoints are included in the coverage calculation; signed coordinates +and intervals specified in reverse order are supported. + +Arguments, in order: + +1. Semicolon-separated IntaRNA CSV containing `id1,start1,end1` or + `id2,start2,end2`. +2. `1` for target regions, `2` for query regions, or a parameter-file path. +3. Output filename ending in `.pdf`, `.png`, `.svg`, `.eps`, `.ps`, `.jpeg` + or `.tiff`. + +```bash +python3 python/IntaRNA_plotRegions.py python/examples/regions.csv 1 regions.png +python3 python/IntaRNA_plotRegions.py python/examples/regions.csv 2 query-regions.svg +python3 python/IntaRNA_plotRegions.py python/examples/regions.csv python/examples/regions.params query-regions.pdf +``` + +The [example CSV](examples/regions.csv) produces: + +![Interaction regions plotted with Python](plotRegions.example.png) + +By default, the first **200** interactions are plotted. A parameter file accepts +one `key = value` assignment per line, whitespace, blank lines and `#` comments. +`id`, `start` and `end` are required CSV column names; all other keys are optional: + +| Key | Meaning | Default | +| --- | --- | --- | +| `id` | CSV column used for row labels | Required | +| `start` | CSV column for integer interval starts | Required | +| `end` | CSV column for integer interval ends | Required | +| `title` | Title above the coverage panel | No title | +| `xvline` | Vertical reference position in both panels | None | +| `xmin` | Left x-axis limit | Smallest covered position | +| `xmax` | Right x-axis limit | Largest covered position | +| `rows` | Positive maximum number of interactions | 200 | + +An example is provided in [examples/regions.params](examples/regions.params). +Blank or `NA` values for optional x positions use the defaults. The coverage +curve uses a Gaussian kernel with R's original `bw.nrd0` bandwidth rule, scaled +by the number of covered bases (a smoothed count, not a fraction). As in the R +plot, x limits filter coverage samples before estimating the curve. Matplotlib +replaces the R graphics backend, so fonts, margins and density interpolation +can differ. Plots are generated without a graphical display. + +### Tests + +After installing the optional dependencies, run from the repository root: + +```bash +python3 -m unittest discover -s python/tests -v +``` + +In a configured build, `make -C python check-output-tools` runs the same tests. +They cover GEV formulas and known-distribution fitting, BH correction, CSV +preservation and errors, region coordinates and parameters, and every output +format. These tests also run in the Python output-tools CI workflow. ## [IntaRNA-fuse.py](IntaRNA-fuse.py) @@ -67,12 +193,12 @@ produce a nonzero exit status and a diagnostic on stderr, with no result CSV. ## [IntaRNApvalue](intarnapvalue) -A tool for calculating p-values of IntaRNA energy scores. +Calculate p-values using energies from **shuffled sequences**. This is a +separate workflow from fitting an existing genome-wide CSV with +`IntaRNA_CSV_p-value.py`; see its own dependency and usage documentation. ## [CopomuS](copomus) -The Compensatory Point Mutation Selector helps to identify the most promising -base pairs from an IntaRNA mfe prediction to verify the interaction via -compensatory mutation experiments. - - +The Compensatory Point Mutation Selector helps identify promising base pairs +from an IntaRNA MFE prediction for validation through compensatory mutation +experiments. See its own dependency and usage documentation. diff --git a/python/examples/regions.csv b/python/examples/regions.csv new file mode 100644 index 00000000..5da6d985 --- /dev/null +++ b/python/examples/regions.csv @@ -0,0 +1,6 @@ +id1;start1;end1;id2;start2;end2 +b0001;266;273;query;116;123 +b0002;204;231;query;85;111 +b0003;229;262;query;96;125 +b0004;265;300;query;10;38 +b0005;281;295;query;5;22 diff --git a/python/examples/regions.params b/python/examples/regions.params new file mode 100644 index 00000000..a56400af --- /dev/null +++ b/python/examples/regions.params @@ -0,0 +1,9 @@ +# Plot query regions in CSV order. +id = id2 +start = start2 +end = end2 +title = Query interaction regions +xvline = 50 +xmin = 1 +xmax = 130 +rows = 200 diff --git a/python/plotRegions.example.png b/python/plotRegions.example.png new file mode 100644 index 0000000000000000000000000000000000000000..cdd0961b969a13ea4e4c17e239a007e8a619b026 GIT binary patch literal 34194 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 3.10. The C++ predictor does not need these packages. +numpy>=1.23 +scipy>=1.9 +matplotlib>=3.6 diff --git a/python/tests/test_output_tools.py b/python/tests/test_output_tools.py new file mode 100644 index 00000000..2064535c --- /dev/null +++ b/python/tests/test_output_tools.py @@ -0,0 +1,257 @@ +"""Regression tests for the Python replacements of the R output tools.""" + +import csv +import importlib.util +import math +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] + + +def load_script(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +pvalue = load_script("csv_pvalue", "IntaRNA_CSV_p-value.py") +plot = load_script("plot_regions", "IntaRNA_plotRegions.py") + + +class OutputToolsTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.path = Path(self.directory.name) + + def write(self, name, text): + path = self.path / name + path.write_text(text, encoding="utf-8") + return path + + def run_cli(self, script, *arguments, success=True): + result = subprocess.run([sys.executable, str(ROOT / script), *map(str, arguments)], + capture_output=True, text=True, + env={**os.environ, "MPLCONFIGDIR": str(self.path / "mpl"), + "DISPLAY": ""}) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + self.assertNotIn("Traceback", result.stderr) + return result + + def energy_table(self, column="E"): + # Deterministic GEV quantiles: independent inverse-CDF formula, xi=0.2. + probabilities = (np.arange(1000) + 0.5) / 1000 + energies = -(10 + 2 * ((-np.log(probabilities)) ** -0.2 - 1) / 0.2) + source = self.path / "energies.csv" + with source.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle, delimiter=";") + writer.writerow(["id", column, "annotation"]) + for index, energy in enumerate(energies): + writer.writerow(["{:04}".format(index), energy, 'RNA; "α"']) + return source, energies + + def test_gev_tail_sign_gumbel_and_endpoints(self): + energies = np.array([-3.0, -8.0, -12.0, -40.0]) + for xi in (-0.2, 0.2): + # Original R/evir formula, using explicit xi rather than SciPy's c. + y = 1 + xi * (-energies[:3] - 10) / 2 + expected = -np.expm1(-y ** (-1 / xi)) + np.testing.assert_allclose(pvalue.gev_pvalues(energies[:3], (-xi, 10, 2)), expected) + expected = -np.expm1(-np.exp((energies + 10) / 2)) + np.testing.assert_allclose(pvalue.gev_pvalues(energies, (0, 10, 2)), expected) + np.testing.assert_equal(pvalue.gev_pvalues([-100, 100], (0.2, 10, 2)), [0, 1]) + # Tiny upper tails must not be lost to 1-cdf cancellation. + self.assertGreater(pvalue.gev_pvalues([-60], (0, 0, 1))[0], 0) + + def test_fit_recovers_known_distribution(self): + _, energies = self.energy_table() + params = pvalue.fit_gev(energies) + np.testing.assert_allclose(params, [-0.2, 10, 2], atol=0.015, rtol=0) + expected = 1 - (np.arange(1000) + 0.5) / 1000 + np.testing.assert_allclose(pvalue.gev_pvalues(energies, params), expected, atol=0.002) + + def test_bh_unsorted_ties_and_bounds(self): + np.testing.assert_allclose(pvalue.adjust_bh([0.04, 0.01, 0.03, 0.01, 1, 0]), + [0.048, 0.02, 0.045, 0.02, 1, 0]) + + def test_legacy_r_reference(self): + # R 4.4.3, original R/IntaRNA_CSV_p-value.R at 2c4d0ec, 1000 inverse-CDF + # quantiles per shape, location=10, scale=2. Compare rows 0/99/499/899/999. + # Optimizers differ; BH can amplify the seven-decimal tail differences. + references = { + -0.2: ([0.9994943, 0.9006078, 0.5007587, 0.1001753, 0.0004651], + [0.9994943, 0.9994715, 0.9994715, 0.9918347, 0.4651]), + 0.0: ([0.9995020, 0.9006536, 0.5005593, 0.1003176, 0.0004918], + [0.9995020, 0.9995020, 0.9991204, 0.9932436, 0.4918]), + 0.2: ([0.9995087, 0.9006690, 0.5003434, 0.1003388, 0.0004986], + [0.9995087, 0.9995087, 0.9986894, 0.9934535, 0.4986]), + } + quantiles = (np.arange(1000) + 0.5) / 1000 + for xi, (expected_p, expected_fdr) in references.items(): + with self.subTest(xi=xi): + energies = (-(10 + 2 * ((-np.log(quantiles)) ** -xi - 1) / xi) + if xi else -(10 - 2 * np.log(-np.log(quantiles)))) + actual = np.round(pvalue.gev_pvalues(energies, pvalue.fit_gev(energies)), 7) + fdr = np.round(pvalue.adjust_bh(actual), 7) + indices = [0, 99, 499, 899, 999] + np.testing.assert_allclose(actual[indices], expected_p, atol=1e-4, rtol=0) + np.testing.assert_allclose(fdr[indices], expected_fdr, atol=0.002, rtol=0) + + def test_csv_default_in_place_preserves_fields(self): + source, energies = self.energy_table() + source.chmod(0o640) + self.run_cli("IntaRNA_CSV_p-value.py", source) + with source.open(newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle, delimiter=";") + rows = list(reader) + self.assertEqual(reader.fieldnames, ["id", "E", "annotation", "p-value", "fdr"]) + self.assertEqual(len(rows), 1000) + self.assertEqual(rows[0]["id"], "0000") + self.assertEqual(rows[0]["annotation"], 'RNA; "α"') + self.assertEqual(float(rows[0]["E"]), energies[0]) + self.assertLess(float(rows[-1]["p-value"]), float(rows[0]["p-value"])) + self.assertTrue(all(0 <= float(row["p-value"]) <= float(row["fdr"]) <= 1 for row in rows)) + self.assertEqual(source.stat().st_mode & 0o777, 0o640) + self.assertNotIn(b"\r", source.read_bytes()) + + def test_csv_separate_output_custom_energy_and_rounding(self): + source, _ = self.energy_table("normalized energy") + before = source.read_bytes() + output = self.path / "extended.csv" + self.run_cli("IntaRNA_CSV_p-value.py", source, output, "normalized energy") + self.assertEqual(source.read_bytes(), before) + with output.open(newline="") as handle: + rows = list(csv.DictReader(handle, delimiter=";")) + expected = pvalue.adjust_bh([float(row["p-value"]) for row in rows]) + self.assertEqual([row["fdr"] for row in rows], [format(value, ".7f") for value in expected]) + + def test_invalid_csv_does_not_replace_input_or_output(self): + invalid = ["", "id;score\na;-5\n", "E;E\n-5;-6\n", "E\n", + "E\n-5\n", "E\n-5\n-5\n-5\n", "E\n-5\n-6\nNA\n", + "E\n-5\n-6\nnan\n", "E\n-5\n-6\ninf\n", "E\n-5;extra\n", + "E;p-value\n-5;0.1\n"] + for index, content in enumerate(invalid): + with self.subTest(content=content): + source = self.write("invalid{}.csv".format(index), content) + output = self.write("output.csv", "keep me\n") + for destination in (source, output): + before = destination.read_bytes() + with self.assertRaises(ValueError): + pvalue.extend_csv(source, destination) + self.assertEqual(destination.read_bytes(), before) + + def test_fit_failure_does_not_overwrite(self): + source, _ = self.energy_table() + before = source.read_bytes() + with patch.object(pvalue, "fit_gev", side_effect=ValueError("fit failed")): + with self.assertRaisesRegex(ValueError, "fit failed"): + pvalue.extend_csv(source, source) + self.assertEqual(source.read_bytes(), before) + + def test_publish_failure_cleans_temporary_output(self): + source, _ = self.energy_table() + before = source.read_bytes() + with patch.object(pvalue.os, "replace", side_effect=OSError("cannot replace")): + with self.assertRaises(OSError): + pvalue.extend_csv(source, source) + self.assertEqual(source.read_bytes(), before) + self.assertEqual(list(self.path.iterdir()), [source]) + + def test_cli_errors_and_help(self): + for script in ("IntaRNA_CSV_p-value.py", "IntaRNA_plotRegions.py"): + self.assertIn("usage:", self.run_cli(script, "--help").stdout) + self.run_cli(script, success=False) + bad = self.write("bad.csv", "E\nNaN\n") + self.assertIn("nonfinite", self.run_cli("IntaRNA_CSV_p-value.py", bad, success=False).stderr) + self.run_cli("IntaRNA_plotRegions.py", bad, "1", self.path / "out.png", success=False) + self.run_cli("IntaRNA_plotRegions.py", bad, "1", self.path / "out.xyz", success=False) + + def test_region_selectors_and_default_row_limit(self): + source = ROOT / "examples/regions.csv" + self.assertEqual(plot.read_regions(source, plot.read_parameters("1"))[0], ("b0001", 266, 273)) + self.assertEqual(plot.read_regions(source, plot.read_parameters("2"))[0], ("query", 116, 123)) + source = self.write("many.csv", "id1;start1;end1\n" + "001;2;5\n" * 205) + regions = plot.read_regions(source, plot.read_parameters("1")) + self.assertEqual(len(regions), 200) + self.assertEqual(regions[0][0], "001") + + def test_parameters_custom_columns_comments_defaults_and_limits(self): + path = self.write("plot.params", "# comment\nid = name\nstart = left\nend = right\n" + "title = custom title\nxmin = -5\nxmax = 8\nxvline = 0\nrows = 2\n") + parameters = plot.read_parameters(str(path)) + source = self.write("custom.csv", "name;left;right\nx;4;-2\nx;0;0\ny;10;15\n") + regions = plot.read_regions(source, parameters) + self.assertEqual(regions, [("x", 4, -2), ("x", 0, 0)]) + figure = plot.create_figure(regions, parameters) + import matplotlib.pyplot as plt + self.addCleanup(plt.close, figure) + coverage, intervals = figure.axes + self.assertEqual(coverage.get_title(), "custom title") + self.assertEqual(intervals.get_xlim(), (-5, 8)) + self.assertEqual([tick.get_text() for tick in intervals.get_yticklabels()], ["x", "x"]) + self.assertEqual(list(intervals.lines[0].get_xdata()), [4, -2]) + self.assertEqual(list(intervals.lines[-1].get_xdata()), [0, 0]) + minimal = self.write("minimal.params", "id=name\nstart=left\nend=right\n") + self.assertEqual(plot.read_parameters(str(minimal))["rows"], 200) + + def test_invalid_parameters_and_regions(self): + for content in ("id=x\n", "id=x\nstart=s\nend=e\nrows=0\n", + "id=x\nstart=s\nend=e\nrows=2.5\n", "id=x\nid=y\n", + "id=x\nstart=s\nend=e\nxmin=nan\n", "id=x\nunknown=3\n"): + with self.subTest(content=content): + path = self.write("invalid.params", content) + with self.assertRaises(ValueError): + plot.read_parameters(str(path)) + for content in ("", "id1;start1;end1\n", "id1;start1\na;3\n", + "id1;start1;end1\na;3;NA\n", "id1;start1;end1\na;3.5;4\n", + "id1;start1;end1\na;3;4;extra\n"): + with self.subTest(content=content): + with self.assertRaises(ValueError): + plot.read_regions(self.write("invalid.csv", content), plot.read_parameters("1")) + + def test_density_inclusive_reversed_clipped_and_count_scaled(self): + regions = [("a", -1, 1), ("b", 1, 0)] + x, actual = plot.coverage_density(regions, -1, 1) + # Five bases including both endpoints, with repeated covered positions. + bases = np.array([-1, 0, 1, 1, 0]) + bandwidth = 0.9 * min(np.std(bases, ddof=1), 1 / 1.34) * len(bases) ** -0.2 + expected = sum(np.exp(-0.5 * ((x - base) / bandwidth) ** 2) + / (math.sqrt(2 * math.pi) * bandwidth) for base in bases) + np.testing.assert_allclose(actual, expected) + # X limits remove out-of-range samples before density estimation. + np.testing.assert_allclose(plot.coverage_density(regions, 0, 1)[1], + plot.coverage_density([("a", 0, 1), ("b", 1, 0)], 0, 1)[1]) + self.assertTrue(np.all(plot.coverage_density(regions, 10, 20)[1] == 0)) + + def test_single_base_region(self): + parameters = plot.read_parameters("1") + figure = plot.create_figure([("one", 0, 0)], parameters) + import matplotlib.pyplot as plt + self.addCleanup(plt.close, figure) + self.assertEqual(figure.axes[0].get_xlim(), (-0.5, 0.5)) + self.assertTrue(np.all(np.isfinite(figure.axes[0].lines[0].get_ydata()))) + + def test_every_output_format_without_display(self): + signatures = {".png": b"\x89PNG", ".pdf": b"%PDF", ".svg": b" Date: Sat, 24 Oct 2026 00:32:03 +0200 Subject: [PATCH 2/6] Share Python companion-tool I/O and retain both migrations --- ChangeLog | 12 +++ python/IntaRNA-fuse.py | 41 +---------- python/IntaRNA_CSV_p-value.py | 38 +++------- python/IntaRNA_plotRegions.py | 13 ++-- python/intarna_tools.py | 118 ++++++++++++++++++++++++++++++ python/tests/test_output_tools.py | 3 +- tests/testIntaRNAFuse.py | 2 + 7 files changed, 152 insertions(+), 75 deletions(-) create mode 100644 python/intarna_tools.py diff --git a/ChangeLog b/ChangeLog index e70e42f3..7bbe5bad 100644 --- a/ChangeLog +++ b/ChangeLog @@ -68,6 +68,9 @@ ## Technical changes and Optimizations +- share Python companion-tool command execution, input validation and atomic + output handling after rebasing onto IntaRNA-fuse (PR #298 review) + - reuse MFE seed-extension scratch matrices and bound predecessor scans in exact and heuristic prediction; evaluate physical energies directly (PR #276 review) @@ -231,6 +234,15 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261024 Alexander Mitrofanov + * python/intarna_tools.py, python/IntaRNA-fuse.py, python/IntaRNA_CSV_p-value.py, + python/IntaRNA_plotRegions.py, python/Makefile.am, tests/testIntaRNAFuse.py, + python/tests/test_output_tools.py + + Share dependency-free executable lookup, checked subprocess calls, RNA/FASTA + validation, CSV validation and atomic text publication between Python tools. + * Rebase onto feat/294-python-intarna-fuse; retain both migrations, their + installation rules and regression suites (PR #298). + 261023 Alexander Mitrofanov * python/IntaRNA_CSV_p-value.py, python/IntaRNA_plotRegions.py + Replace the R tools with standalone Python scripts preserving positional diff --git a/python/IntaRNA-fuse.py b/python/IntaRNA-fuse.py index abfc24d8..25e0844c 100755 --- a/python/IntaRNA-fuse.py +++ b/python/IntaRNA-fuse.py @@ -11,11 +11,12 @@ import io from pathlib import Path import re -import shutil -import subprocess import sys +from intarna_tools import find_program, positive_integer, run_command + + ESSENTIAL_COLUMNS = ('E', 'start1', 'end1', 'start2', 'end2', 'ED1', 'ED2', 'E_init') DEFAULT_COLUMNS = ESSENTIAL_COLUMNS + ('hybridDB',) @@ -27,13 +28,6 @@ def sequence(value): return value -def positive_integer(value): - number = int(value) - if number < 1: - raise argparse.ArgumentTypeError('expected a positive integer') - return number - - def parse_args(argv=None): parser = argparse.ArgumentParser( description='Report non-overlapping IntaRNA sites and compatible fused interactions.', @@ -55,35 +49,6 @@ def parse_args(argv=None): return args -def find_program(name): - """Prefer installed siblings, then the source-tree binary, then PATH.""" - script_dir = Path(__file__).resolve().parent - candidates = [script_dir / name] - if name == 'IntaRNA': - candidates.append(script_dir.parent / 'src' / 'bin' / name) - for candidate in candidates: - found = shutil.which(str(candidate)) - if found: - return found - found = shutil.which(name) - if not found: - raise RuntimeError('{} executable not found beside the script or on PATH'.format(name)) - return found - - -def run_command(command, input_text=None): - """Pass sequences and constraints without shell interpretation; check failures.""" - result = subprocess.run(command, input=input_text, universal_newlines=True, - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - if result.returncode: - detail = (result.stderr or result.stdout).strip() - raise RuntimeError('{} exited with status {}: {}'.format( - Path(command[0]).name, result.returncode, detail)) - if result.stderr: - sys.stderr.write(result.stderr) - return result.stdout - - def energy(value): """Read a finite energy without introducing binary rounding in the sum.""" try: diff --git a/python/IntaRNA_CSV_p-value.py b/python/IntaRNA_CSV_p-value.py index 151267fb..c1957c09 100755 --- a/python/IntaRNA_CSV_p-value.py +++ b/python/IntaRNA_CSV_p-value.py @@ -4,10 +4,9 @@ import argparse import csv import math -import os -from pathlib import Path -import shutil -import tempfile + + +from intarna_tools import atomic_text_output, csv_header, csv_rows def fit_gev(energies): @@ -71,20 +70,14 @@ def extend_csv(source, destination, energy_column="E"): """Validate and fit the entire table before atomically publishing the output.""" with open(source, newline="", encoding="utf-8-sig") as handle: reader = csv.reader(handle, delimiter=";", strict=True) - header = next(reader, []) - if not header or len(set(header)) != len(header): - raise ValueError("CSV must have a nonempty header with unique column names") + header = csv_header(reader) if energy_column not in header: raise ValueError("energy column {!r} is missing".format(energy_column)) if "p-value" in header or "fdr" in header: raise ValueError("CSV already contains a 'p-value' or 'fdr' column") column = header.index(energy_column) rows, energies = [], [] - for row in reader: - if not row: # Allow blank lines, as in read.csv(). - continue - if len(row) != len(header): - raise ValueError("CSV line {} has the wrong number of fields".format(reader.line_num)) + for row in csv_rows(reader, header): try: energy = float(row[column]) except ValueError: @@ -97,22 +90,11 @@ def extend_csv(source, destination, energy_column="E"): # Preserve the historical seven-decimal rounding *before* BH adjustment. pvalues = [round(float(p), 7) for p in gev_pvalues(energies, fit_gev(energies))] fdrs = adjust_bh(pvalues) - destination = Path(destination).resolve() - temporary = None - try: - with tempfile.NamedTemporaryFile(mode="w", newline="", encoding="utf-8", - dir=destination.parent, delete=False) as handle: - temporary = Path(handle.name) - writer = csv.writer(handle, delimiter=";", lineterminator="\n") - writer.writerow(header + ["p-value", "fdr"]) - for row, pvalue, fdr in zip(rows, pvalues, fdrs): - writer.writerow(row + [format(pvalue, ".7f"), format(fdr, ".7f")]) - if destination.exists(): - shutil.copymode(destination, temporary) - os.replace(temporary, destination) - finally: - if temporary is not None and temporary.exists(): - temporary.unlink() + with atomic_text_output(destination) as handle: + writer = csv.writer(handle, delimiter=";", lineterminator="\n") + writer.writerow(header + ["p-value", "fdr"]) + for row, pvalue, fdr in zip(rows, pvalues, fdrs): + writer.writerow(row + [format(pvalue, ".7f"), format(fdr, ".7f")]) def main(argv=None): diff --git a/python/IntaRNA_plotRegions.py b/python/IntaRNA_plotRegions.py index 448a11fb..2cb59bdb 100755 --- a/python/IntaRNA_plotRegions.py +++ b/python/IntaRNA_plotRegions.py @@ -7,6 +7,9 @@ from pathlib import Path +from intarna_tools import csv_header, csv_rows + + FORMATS = {".pdf", ".png", ".svg", ".eps", ".ps", ".jpeg", ".tiff"} @@ -57,18 +60,12 @@ def read_regions(source, parameters): regions = [] with open(source, newline="", encoding="utf-8-sig") as handle: reader = csv.reader(handle, delimiter=";", strict=True) - header = next(reader, []) - if not header or len(set(header)) != len(header): - raise ValueError("CSV must have a nonempty header with unique column names") + header = csv_header(reader) for key in ("id", "start", "end"): if parameters[key] not in header: raise ValueError("missing CSV column: " + parameters[key]) columns = [header.index(parameters[key]) for key in ("id", "start", "end")] - for row in reader: - if not row: - continue - if len(row) != len(header): - raise ValueError("CSV line {} has the wrong number of fields".format(reader.line_num)) + for row in csv_rows(reader, header): try: start, end = (int(row[index]) for index in columns[1:]) except ValueError: diff --git a/python/intarna_tools.py b/python/intarna_tools.py new file mode 100644 index 00000000..cccf6a27 --- /dev/null +++ b/python/intarna_tools.py @@ -0,0 +1,118 @@ +"""Shared, dependency-free I/O for the IntaRNA Python companion tools.""" + +import argparse +from contextlib import contextmanager +from pathlib import Path +import os +import re +import shutil +import subprocess +import sys +import tempfile + + +def positive_integer(value): + """argparse type for counts that must be positive.""" + number = int(value) + if number < 1: + raise argparse.ArgumentTypeError('expected a positive integer') + return number + + +def nonnegative_integer(value): + """argparse type for thread counts (zero means automatic).""" + number = int(value) + if number < 0: + raise argparse.ArgumentTypeError('expected a nonnegative integer') + return number + + +def normalize_sequence(value, *, allow_n=False): + """Validate a nonempty literal RNA/DNA sequence and normalize to RNA.""" + alphabet = 'ACGUTN' if allow_n else 'ACGUT' + if not re.fullmatch(f'[{alphabet}{alphabet.lower()}]+', value): + raise ValueError(f'expected a nonempty sequence containing only {alphabet}') + return value.upper().replace('T', 'U') + + +def read_fasta(filename): + """Read exactly one FASTA record, including wrapped sequence lines.""" + with open(filename, encoding='utf-8-sig') as handle: + lines = [line.strip() for line in handle if line.strip()] + if not lines or not lines[0].startswith('>'): + raise ValueError(f'{filename}: expected a FASTA header') + if any(line.startswith('>') for line in lines[1:]): + raise ValueError(f'{filename}: expected exactly one FASTA record') + return normalize_sequence(''.join(lines[1:])) + + +def read_sequence(value): + """Accept a literal RNA/DNA sequence or a single-record FASTA file.""" + # Test sequences first: long literals can exceed the filesystem name limit. + if re.fullmatch('[ACGUTacgut]+', value): + return normalize_sequence(value) + return read_fasta(value) if Path(value).is_file() else normalize_sequence(value) + + +def find_program(name, script_path=__file__): + """Prefer executable siblings, then the source-tree IntaRNA, then PATH.""" + directory = Path(script_path).resolve().parent + candidates = [directory / name] + if name == 'IntaRNA': + candidates.append(directory.parent / 'src' / 'bin' / name) + for candidate in candidates: + found = shutil.which(str(candidate)) + if found: + return found + found = shutil.which(name) + if not found: + raise RuntimeError(f'{name} executable not found beside the script or on PATH') + return found + + +def run_command(command, input_text=None): + """Run an argument vector without a shell; report command failures once.""" + result = subprocess.run(command, input=input_text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if result.returncode: + detail = (result.stderr or result.stdout).strip() + raise RuntimeError(f'{Path(command[0]).name} exited with status {result.returncode}: {detail}') + if result.stderr: + sys.stderr.write(result.stderr) + return result.stdout + + +def csv_header(reader): + """Read a nonempty CSV header with distinct, nonempty names.""" + header = next(reader, []) + if not header or not all(header) or len(set(header)) != len(header): + raise ValueError('CSV must have a nonempty header with unique column names') + return header + + +def csv_rows(reader, header): + """Yield nonblank rows, rejecting truncated or extra fields.""" + for row in reader: + if not row: + continue + if len(row) != len(header): + raise ValueError(f'CSV line {reader.line_num} has the wrong number of fields') + yield row + + +@contextmanager +def atomic_text_output(destination): + """Publish UTF-8/LF text only on success, preserving an existing file's mode.""" + destination = Path(destination).resolve() + temporary = None + try: + with tempfile.NamedTemporaryFile(mode='w', newline='', encoding='utf-8', + dir=destination.parent, delete=False) as handle: + temporary = Path(handle.name) + yield handle + if destination.exists(): + shutil.copymode(destination, temporary) + os.replace(temporary, destination) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) diff --git a/python/tests/test_output_tools.py b/python/tests/test_output_tools.py index 2064535c..d4275962 100644 --- a/python/tests/test_output_tools.py +++ b/python/tests/test_output_tools.py @@ -14,6 +14,7 @@ import numpy as np ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) def load_script(name, filename): @@ -160,7 +161,7 @@ def test_fit_failure_does_not_overwrite(self): def test_publish_failure_cleans_temporary_output(self): source, _ = self.energy_table() before = source.read_bytes() - with patch.object(pvalue.os, "replace", side_effect=OSError("cannot replace")): + with patch("intarna_tools.os.replace", side_effect=OSError("cannot replace")): with self.assertRaises(OSError): pvalue.extend_csv(source, source) self.assertEqual(source.read_bytes(), before) diff --git a/tests/testIntaRNAFuse.py b/tests/testIntaRNAFuse.py index 8b781721..192680da 100644 --- a/tests/testIntaRNAFuse.py +++ b/tests/testIntaRNAFuse.py @@ -62,6 +62,7 @@ def setUp(self): self.script = self.root / 'scripts' / SCRIPT.name self.script.parent.mkdir() shutil.copyfile(SCRIPT, self.script) + shutil.copyfile(SCRIPT.parent / "intarna_tools.py", self.script.parent / "intarna_tools.py") self.bin = self.root / 'tools with spaces' self.bin.mkdir() for name in ('IntaRNA', 'RNAfold'): @@ -218,6 +219,7 @@ def test_oxys_fhla_energy_against_independent_tool_calls(self): with tempfile.TemporaryDirectory(prefix='native fuse ') as tmp: script = Path(tmp) / SCRIPT.name shutil.copyfile(SCRIPT, script) + shutil.copyfile(SCRIPT.parent / "intarna_tools.py", script.parent / "intarna_tools.py") env = dict(os.environ, PATH=str(binary.parent) + os.pathsep + os.environ['PATH']) output = csv_rows(subprocess.check_output( [sys.executable, str(script), '-t', target, '-q', query, '-n', '3'], From 45d01f2fb096617da60edbc6671b4afb9433df07 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Sat, 24 Oct 2026 00:35:12 +0200 Subject: [PATCH 3/6] Fix CopomuS mutation boundaries, scoring and profile reuse --- ChangeLog | 16 +++ python/CopomuS.py | 140 +++++++++++---------- python/copomus/IntaRNA.py | 172 +++++++++----------------- python/copomus/README.md | 42 +++++-- python/copomus/candidate_filters.py | 18 ++- python/copomus/candidate_selectors.py | 47 ++----- python/copomus/indexing.py | 6 +- python/copomus/measures.py | 33 +++-- python/copomus/mutation.py | 29 ++++- python/copomus/mutation_generators.py | 10 +- python/tests/test_copomus.py | 144 +++++++++++++++++++++ 11 files changed, 395 insertions(+), 262 deletions(-) create mode 100644 python/tests/test_copomus.py diff --git a/ChangeLog b/ChangeLog index 7bbe5bad..5a7f7cf0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,9 @@ ## Interface and handling +- fix CopomuS explicit mutations, GC filters, signed indices, zero scores and + shared profiles; validate inputs and preserve output on failure (PR #298) + - orient probability SVGs from the upper-left corner and highlight every MFE pair, splitting seed/MFE cell borders into orange and green (PR #259 review) @@ -234,6 +237,19 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261024 Alexander Mitrofanov + * python/CopomuS.py, python/copomus/*.py, python/tests/test_copomus.py + * Validate sequences/FASTA, mutation positions and bases, delimiters and counts; + repair the explicit-selector call signature and CG/GC filter mismatch. + * Preserve zero energies and quoted CSV identifiers; fix signed-index conversion, + neighboring pairs across zero, and minimum-delta ordering at/below zero. + * Share profile contents across accessibility measures, clean private temporary + files, and reuse the wildtype prediction for all candidates (PR #298). + * Report errors on stderr and replace output only after successful computation. + * python/copomus/README.md, python/Makefile.am + * Correct option names, defaults, Python requirements and mutation/ranking docs; + distribute the focused regressions. + 261024 Alexander Mitrofanov * python/intarna_tools.py, python/IntaRNA-fuse.py, python/IntaRNA_CSV_p-value.py, python/IntaRNA_plotRegions.py, python/Makefile.am, tests/testIntaRNAFuse.py, diff --git a/python/CopomuS.py b/python/CopomuS.py index c5b9d7f3..4e653598 100644 --- a/python/CopomuS.py +++ b/python/CopomuS.py @@ -5,16 +5,16 @@ __intarna_min__ = '3.1.2' -__python_min__ = '3.7.0' +__python_min__ = '3.10.0' import os import sys -from platform import python_version import csv -from tempfile import gettempdir -from argparse import ArgumentParser, FileType, SUPPRESS +from tempfile import TemporaryDirectory +from pathlib import Path +from argparse import ArgumentParser, SUPPRESS from typing import List from copomus.mutation import Mutation from copomus.IntaRNA import IntaRNA @@ -23,9 +23,7 @@ from copomus.mutation_generators import get_generator -if IntaRNA.version_tuple(python_version()) < IntaRNA.version_tuple(__python_min__): - sys.stderr.write(f'CopomuS requires python >= {__python_min__}\n') - sys.exit(1) +from intarna_tools import atomic_text_output, nonnegative_integer, read_sequence class CopomuS: @@ -45,9 +43,9 @@ def __init__(self): self.alpha, self.beta = 0.0, 0.0 self.threads = 0 - def main(self): + def main(self, argv=None): # PARSE COMMAND LINE ARGS - self.parse_cmd_args() + self.parse_cmd_args(argv) # CHECK INTARNA BIN AND VERSION IntaRNA.check_binary(__intarna_min__) @@ -64,8 +62,7 @@ def main(self): bp = func(bp, self.query, self.target, self.qidxpos0, self.tidxpos0) if not bp: - sys.stderr.write('There are no base pair candidates left after filtering!\n') - sys.exit(1) + raise ValueError('There are no base pair candidates left after filtering') # CREATE MUTATIONS if not self.mutation_encoding: @@ -89,7 +86,7 @@ def main(self): # OUTPUT self.output_csv(mutations) - def parse_cmd_args(self): + def parse_cmd_args(self, argv=None): defaults = { 'measures': ['mfeCover', 'E', 'minDeltaE'], 'selector': 'mfe', @@ -120,14 +117,14 @@ def parse_cmd_args(self): default=[], help='Filters candidate base pairs, can be used multiple times. ' f"(Default: {defaults['filters']})", - choices=['GU', 'AU', 'CG', 'lp', 'lpMfe', 'he', 'heMfe']) + choices=['GU', 'AU', 'CG', 'GC', 'lp', 'lpMfe', 'he', 'heMfe']) parser.add_argument('-g', '--generator', dest='mutation_generator', default=defaults['generator'], choices=['flip', 'any'], help='Defines the method used for generating mutated sequences. ' f"(Default: {defaults['generator']})") parser.add_argument('--mutationEncoding', dest='mutation_encoding', help='Allows direct candidate selection by specifying a mutation encoding. ' 'Overwrites options -s, -f, and -g') - parser.add_argument('-o', '--output', dest='output', nargs='?', type=FileType('w'), default=sys.stdout, + parser.add_argument('-o', '--output', dest='output', default=None, help='Which file the output should be written to. (Default: STDOUT)') parser.add_argument('-d', '--delimiter', dest='delimiter', default='\t', help='Defines the delimiter used to separate columns in the output, default tab. ' @@ -135,60 +132,57 @@ def parse_cmd_args(self): parser.add_argument('-p', '--parameterFile', dest='param_file', default='', help='Optional parameter file for IntaRNA to provide further parameters and ' 'prediction constraints.') - parser.add_argument('--threads', dest='threads', default=defaults['threads'], type=int, + parser.add_argument('--threads', dest='threads', default=defaults['threads'], type=nonnegative_integer, help='Threads used for IntaRNA call') parser.add_argument('--alpha', dest='alpha', type=float, default=defaults['alpha'], help=SUPPRESS) parser.add_argument('--beta', dest='beta', type=float, default=defaults['beta'], help=SUPPRESS) - args = parser.parse_args() - - mm = [] # filter out any duplicate entries for measures - args.measures = defaults['measures'] if not args.measures else args.measures - for m in args.measures: - if m not in mm: - mm.append(m) - args.measures = mm - - ff = [] # filter out any duplicate entries for filters - args.candidate_filters = defaults['filters'] if not args.candidate_filters else args.candidate_filters - for f in args.candidate_filters: - if f not in ff: - ff.append(f) - args.candidate_filters = ff - - args.query = args.query.upper().replace('T', 'U') # capitalize sequences and replace T -> U - args.target = args.target.upper().replace('T', 'U') - - if args.param_file and not os.path.exists(args.param_file): - sys.stderr.write(f'Cannot find provided parameter file: {args.param_file}\n') - sys.exit(1) - - for key, value in args.__dict__.items(): - self.__setattr__(key, value) + args = parser.parse_args(argv) + args.measures = list(dict.fromkeys(args.measures or defaults['measures'])) + args.candidate_filters = list(dict.fromkeys(args.candidate_filters)) + try: + args.query = read_sequence(args.query) + args.target = read_sequence(args.target) + if args.param_file and not Path(args.param_file).is_file(): + raise ValueError(f'Cannot find provided parameter file: {args.param_file}') + if len(args.delimiter) != 1 or args.delimiter in '\r\n\0': + raise ValueError('delimiter must be one non-newline character') + except (OSError, ValueError) as error: + parser.error(str(error)) + for key, value in vars(args).items(): + setattr(self, key, value) def run_tests(self, mutations: List[Mutation]): - for mut in mutations: - outcsvcols = list(set([j for i in [mm.outcsvcols for mm in mut.measures] for j in i])) # combine lists - outcsvcols = ','.join(outcsvcols) # join columns - outcsvcols = 'id1' if not outcsvcols else outcsvcols # if no col -> select id1 - - out_pipes = [j for i in [m.out_pipes for m in mut.measures] for j in i] # combine lists - - # create args for IntaRNA call - out_param = [f'--out={x}:{os.path.join(gettempdir(), f"CopomuS_{x}")}_{os.getpid()}.temp' for x in out_pipes] - - for mode in ['ww', 'wm', 'mw', 'mm']: # go through each mutation mode - q = mut.qw if mode[0] == 'w' else mut.qm # get actual sequences - t = mut.tw if mode[1] == 'w' else mut.tm - - i = IntaRNA() # call IntaRNA, we have 4 calls per base pair, one for each mutation mode - data = i.run(q, t, mut.qidxpos0, mut.tidxpos0, outcsvcols, self.threads, 1, self.param_file, out_param) - - for m in mut.measures: # add results to each measure - m.calculate_score(data, mode, mut, self.param_file) - - for m in mut.measures: - m.post_compute() + # Evaluate wildtype once: its sequences and requested outputs are identical + # for every candidate. Profile contents are immutable and shared by measures. + wildtype = None + with TemporaryDirectory(prefix='CopomuS-') as directory: + for mut in mutations: + columns = list(dict.fromkeys(c for m in mut.measures for c in m.outcsvcols)) + pipes = list(dict.fromkeys(p for m in mut.measures for p in m.out_pipes)) + paths = {pipe: Path(directory) / pipe for pipe in pipes} + out_params = [f'--out={pipe}:{path}' for pipe, path in paths.items()] + for mode in ('ww', 'wm', 'mw', 'mm'): + if mode == 'ww' and wildtype is not None: + data, profiles = wildtype + else: + query = mut.qw if mode[0] == 'w' else mut.qm + target = mut.tw if mode[1] == 'w' else mut.tm + # Remove prior outputs so a missing profile cannot reuse stale data. + for path in paths.values(): + path.unlink(missing_ok=True) + data = IntaRNA().run(query, target, mut.qidxpos0, mut.tidxpos0, + ','.join(columns) or 'id1', self.threads, + 1, self.param_file, out_params) + profiles = {pipe: path.read_text(encoding='utf-8') + for pipe, path in paths.items()} + if mode == 'ww': + wildtype = data, profiles + for measure in mut.measures: + measure.profiles = profiles + measure.calculate_score(data, mode, mut, self.param_file) + for measure in mut.measures: + measure.post_compute() def calculate_ranks(self, mutations: List[Mutation]): # Calculate total ranking order @@ -215,8 +209,16 @@ def calculate_ranks(self, mutations: List[Mutation]): mutations[mut_id].ranks[m_id] = rank def output_csv(self, mutations: List[Mutation]): + # Open a destination only after all predictions and ranking succeeded. + if self.output: + with atomic_text_output(self.output) as handle: + self.write_csv(mutations, handle) + else: + self.write_csv(mutations, sys.stdout) + + def write_csv(self, mutations: List[Mutation], handle): # CREATE CSV WRITER - writer = csv.writer(self.output, delimiter=self.delimiter) + writer = csv.writer(handle, delimiter=self.delimiter, lineterminator="\n") # WRITE CSV headers = [j for i in @@ -237,6 +239,14 @@ def output_csv(self, mutations: List[Mutation]): writer.writerow([str(m), m.ranks['total'], m.query_n, m.target_n, m.bp_ww, m.bp_mm] + values) # write csv +def main(argv=None): + try: + CopomuS().main(argv) + except (OSError, ValueError, RuntimeError, csv.Error) as error: + print(f'Error: {error}', file=sys.stderr) + return 1 + return 0 + + if __name__ == '__main__': - c = CopomuS() - c.main() + raise SystemExit(main()) diff --git a/python/copomus/IntaRNA.py b/python/copomus/IntaRNA.py index 4481a43c..8ad1d5d2 100644 --- a/python/copomus/IntaRNA.py +++ b/python/copomus/IntaRNA.py @@ -1,135 +1,81 @@ # Copyright 2019 # Author: Fabio Gutmann -import sys -from typing import Dict, Union, List -from subprocess import Popen, PIPE, run +"""Checked IntaRNA subprocess and CSV adapter used by CopomuS.""" + +import csv +import io +import math +import re +from intarna_tools import csv_header, csv_rows, find_program, run_command class IntaRNA: - """IntaRNA python3 wrapper""" - def __init__(self): - pass + """IntaRNA Python wrapper; no-interaction output is represented by False.""" @staticmethod - def version_tuple(versionstring: str): - """ - Decomposes a version string into a tuple of entries - :param versionstring: The string encoding of a version, i.e. dot-separated numbers - """ - return tuple(map(int, versionstring.split('.'))) + def version_tuple(versionstring): + """Read numeric release components, accepting development suffixes.""" + match = re.match(r'(\d+)\.(\d+)\.(\d+)', versionstring) + if not match: + raise ValueError(f'Unrecognized IntaRNA version: {versionstring}') + return tuple(map(int, match.groups())) @staticmethod - def check_binary(required_version: str): - """ - Checks weather or not the IntaRNA binary can be found in PATH and exits with return code 1 if not found. - :param required_version: The minimum required version for IntaRNA - """ - p = run('IntaRNA --version', shell=True, stdout=PIPE, stderr=PIPE, universal_newlines=True) - if p.returncode: - sys.stderr.write('Please add the IntaRNA binary to your PATH.\n') - sys.exit(1) - if IntaRNA.version_tuple(p.stdout.split('\n')[0].split()[1]) < IntaRNA.version_tuple(required_version): - sys.stderr.write(f'This tool requires IntaRNA version {required_version}, please upgrade\n') - sys.exit(1) + def check_binary(required_version): + """Check executable availability and the minimum supported release.""" + output = run_command([find_program('IntaRNA'), '--version']) + match = re.search(r'IntaRNA\s+(\d+\.\d+\.\d+\S*)', output) + if not match or IntaRNA.version_tuple(match[1]) < IntaRNA.version_tuple(required_version): + raise RuntimeError(f'This tool requires IntaRNA version {required_version} or newer') - def run(self, query: str, target: str, qidxpos0: int, tidxpos0: int, outcsvcols: str, threads: int, n: int = 1, - param_file: str = '', extra_params: list = '', raw_stdout: bool = False) -> \ - Union[Dict[str, any], List[Dict[str, any]], str]: - """ - Runs IntaRNA on a pair of sequences and returns a dictionary with the output - :param query: The query sequence - :param target: The target sequence - :param qidxpos0: The starting index for the query sequence - :param tidxpos0: The starting index for the target sequence - :param outcsvcols: The csvcols returned by IntaRNA - :param threads: Thread count used by IntaRNA - :param n: The number of suboptimal interactions returned - :param param_file: Optional path to a file with additional parameters - :param extra_params: Optional list of additional parameters - :param raw_stdout: Weather or not stdout should be returned in raw format, not as a dict - :return: Dictionary with column name as key and payload as value - """ - if not extra_params: - extra_params = [] - param_file = f'--parameterFile={param_file}' if param_file else '' - p = Popen(['IntaRNA', '-q', query, '-t', target, '--outMode=C', f'--outcsvcols={outcsvcols}', - f'--qIdxPos0={qidxpos0}', f'--tIdxPos0={tidxpos0}', f'--outNumber={n}', f'--threads={threads}', param_file] - + extra_params, stdout=PIPE, stderr=PIPE, universal_newlines=True) - - stdout, stderr = p.communicate() - if p.returncode: - sys.stderr.write(f'IntaRNA Error: {stdout} {stderr}\n') - sys.exit(p.returncode) - if not raw_stdout: - if n == 1: - d = self.output_to_dict(stdout) - if type(d) != bool: - return d[0] - else: - return d - return self.output_to_dict(stdout) - else: + def run(self, query, target, qidxpos0, tidxpos0, outcsvcols, threads, n=1, + param_file='', extra_params=None, raw_stdout=False): + """Return one typed CSV row, all suboptimal rows, or raw stdout.""" + command = [find_program('IntaRNA'), '-q', query, '-t', target, '--outMode=C', + f'--outCsvCols={outcsvcols}', f'--qIdxPos0={qidxpos0}', + f'--tIdxPos0={tidxpos0}', f'--outNumber={n}', f'--threads={threads}'] + if param_file: + command.append(f'--parameterFile={param_file}') + stdout = run_command(command + list(extra_params or [])) + if raw_stdout: return stdout + rows = self.output_to_dict(stdout) + return rows[0] if rows and n == 1 else rows @staticmethod - def output_to_dict(data: str) -> Union[List[Dict[str, any]], bool]: - """ - Transforms IntaRNA output into a dictionary - :param data: Raw IntaRNA output - :return: dictionary with colname as key and payload as value (casted into fitting type) - >>> d = IntaRNA.output_to_dict('start1;end1;id1;id2;E\\n34;42;target;query;-4.7\\n') - >>> d + def output_to_dict(data): + r"""Parse quoted CSV, preserving IDs and numeric zero; only NA/empty is missing. + + >>> IntaRNA.output_to_dict('start1;end1;id1;id2;E\n34;42;target;query;-4.7\n') [{'start1': 34, 'end1': 42, 'id1': 'target', 'id2': 'query', 'E': -4.7}] - >>> [type(x) for x in d[0].values()] - [, , , , ] """ - subopts = [] - - lines = data.rstrip().split('\n') - if len(lines) == 2: - headers, entries = lines - entries = [entries] - elif len(lines) > 2: - headers = lines[0] - entries = lines[1:] - else: - headers = lines[0] - entries = [''] - headers = headers.split(';') - - for values in entries: - d = {} - values = values.split(';') - if len(headers) != len(values): # no interaction between query and target - return False - - for i, h in enumerate(headers): + reader = csv.reader(io.StringIO(data), delimiter=';', strict=True) + header = csv_header(reader) + rows = [] + for fields in csv_rows(reader, header): + row = {} + for name, value in zip(header, fields): + if name in ('id1', 'id2', 'seq1', 'seq2', 'subseq1', 'subseq2'): + row[name] = value + continue + if value in ('', 'NA'): + row[name] = None + continue try: - value = int(values[i]) # try to cast to int + number = int(value) except ValueError: try: - value = float(values[i]) # try to cast to float + number = float(value) except ValueError: - value = values[i] # leave as string - d[h] = value - subopts.append(d) - - for subopt in subopts: - for k, v in subopt.items(): - if not v: - subopt[k] = None - - return subopts + number = value + if isinstance(number, float) and not math.isfinite(number): + raise ValueError(f'IntaRNA returned a nonfinite {name}') + row[name] = number + rows.append(row) + return rows or False @staticmethod - def to_fasta(sequences: Dict[str, str]) -> str: - """ - Transforms sequences into a FASTA string - :param sequences: A dictionary with sequence name as key and raw sequence as tuple - :return: A FASTA string containing all sequences - """ - fasta_str = '' - for name, seq in sequences.items(): - fasta_str += f'>{name}\n{seq}\n' - return fasta_str + def to_fasta(sequences): + """Format a mapping of record names to sequences.""" + return ''.join(f'>{name}\n{seq}\n' for name, seq in sequences.items()) diff --git a/python/copomus/README.md b/python/copomus/README.md index bc237923..87930a00 100644 --- a/python/copomus/README.md +++ b/python/copomus/README.md @@ -18,12 +18,12 @@ CopomuS is highly configurable for specific purposes, see below for an explanati - [Mutation Generators](#mutation-generators) ## Dependencies -- [IntaRNA](https://github.com/BackofenLab/IntaRNA) >= 3.1.2 (needs to be in PATH) -- [python](https://www.python.org/) >= 3.7.0 +- [IntaRNA](https://github.com/BackofenLab/IntaRNA) >= 3.1.2 (beside the installed scripts, in `src/bin`, or on PATH) +- [python](https://www.python.org/) >= 3.10 ## Usage ```console -./CopomoS.py -q -t +python3 python/CopomuS.py -q -t mutation rank qIndex tIndex bpWildtype bpMutated mfeCover_rank mfeCover_ww mfeCover_wm mfeCover_mw mfeCover_mm mfeCover_is_valid E_rank E_ww E_wm E_mw E_mm E_is_valid minDeltaE_rank minDeltaE_wm/ww minDeltaE_mw/ww minDeltaE_wm/mm minDeltaE_mw/mm minDeltaE_ww/mm minDeltaE_min U4A&A11U 1 4 11 UA AU 1 True False False True 1 1 -9.32 -5.43 -7.25 -8.46 1 3 3.89 2.07 3.03 1.21 -0.86 1.21 @@ -37,26 +37,46 @@ G15C&C2G 2 15 2 GC CG 1 True True False True 1 2 -9.32 -12.37 -8.12 -12.2 0 5 -3 | Flag | Value | Default | Description | | :------------------ |:---------------------- | :------ | :-------------------- | | -h, --help | | | Gives detailed command help. | -| -q, --query | sequence | | Query as a raw sequence. | -| -t, --target | sequence | | Target as a raw sequence. | -| -m, --measure | {E, ed1, ed2, maxED, mfeCover, mfeOverlap, spotProb, qAccessProfile, tAccessProfile, qEnergyProfile, tEnergyProfile} | E | The measure used to calculate the rank of a base pair. Can be used multiple times. In that case, base pairs are ranked in order of measurements specified. | +| -q, --query | sequence or FASTA | | Query as RNA/DNA or a single-record FASTA file. | +| -t, --target | sequence or FASTA | | Target as RNA/DNA or a single-record FASTA file. | +| -m, --measure | {E, minDeltaE, mfeCover, Eqi, Eti, EDqi, cEDqi} | mfeCover, E, minDeltaE | The measure used to calculate the rank of a base pair. Can be used multiple times. In that case, base pairs are ranked in order of measurements specified. | | --qIdxPos0 | integer | 1 | (Optional) starting index for the query. | | --tIdxPos0 | integer | 1 | (Optional) starting index for the target. | -| -c, --candidateSelection | {mfe, mfeSO} | mfe | (Optional) method by which base pair candidates are selected from the sequences. | -| -f, --candidateFilters | {GU, AU, CG, lp, lpMfe, he, heMfe} | None | (Optional) filter by which base pair candidates are filtered. Can be used multiple times to combine filters. | +| -s, --candidateSelection | {mfe, mfeSO} | mfe | (Optional) method by which base pair candidates are selected from the sequences. | +| -f, --candidateFilter | {GU, AU, CG, GC, lp, lpMfe, he, heMfe} | None | (Optional) filter by which base pair candidates are filtered. Can be used multiple times to combine filters. | | -g, --generator | {flip, any} | flip | (Optional) method used to generate mutated sequences by which base pairs are ranked. | -| --mutationEncoding | string (ex. G1C&U7G) | | (Optional) allows specific selection of candidates by specifying a mutation encoding. If this option is specified, -c, -f and -g will be ignored. | +| --mutationEncoding | string (ex. G1C&U7G) | | (Optional) allows specific selection of candidates by specifying a mutation encoding. If this option is specified, -s, -f and -g will be ignored. | | -o, --output | file name | STDOUT | (Optional) file to which the output should be written to. | | -d, --delimiter | character | "\t" (tab) | (Optional) delimiter used to separate csv output. | | -p, --parameterFile | file name | None | (optional) parameter file to be used in IntaRNA calls to further guide predictions. | -| -t, --threads | integer | 0 (all) | (Optional) thread count used by IntaRNA. | +| --threads | integer | 0 (all) | (Optional) thread count used by IntaRNA. | + +Input sequences are normalized to uppercase RNA. FASTA files must contain exactly +one nonempty record; wrapped lines are supported. Negative indexing skips zero, +as in IntaRNA; `--qIdxPos0=0` / `--tIdxPos0=0` explicitly use zero-based indexing. +Mutation encodings are query-first, e.g. `G1C&C2G`, and comma-separated for multiple +candidates. Positions must lie inside the sequences, wildtype bases must match, +and both bases must change. Quote encodings in the shell because they contain `&`. +`GC` and `CG` are aliases for the same filter. + +Output is tab-separated UTF-8 with LF line endings by default. Numeric zero is a +valid score; unavailable scores are `NA`. Errors go to stderr with nonzero status. +An output file is replaced only after all predictions and rankings succeed. +Temporary profiles are private to one invocation, shared between measures, and +cleaned up on success or failure. All candidates reuse one wildtype prediction. +For example, from the repository root: + +```bash +python3 python/CopomuS.py -q query.fa -t target.fa -m EDqi -m cEDqi -m Eqi -o mutations.tsv +python3 python/CopomuS.py -q GC -t GC --mutationEncoding 'G1C&C2G' -m E +``` #### Measures | Name | Description | | :---- | :-------------------------------------------| | E | (class) Interaction Energy between query and target. | -| minDeltaE | (sort) Energy difference between different combinations of mutation types (ww, wm, mw, mm). | +| minDeltaE | (sort) Minimum of wm−ww, mw−ww, wm−mm and mw−mm; larger is better, including zero/negative values. | | mfeCover | (class) Mutation position has to be in MFE. | | Eqi | (class) Energy profile on query at mutation position. | | Eti | (class) Energy profile on target at mutation position. | diff --git a/python/copomus/candidate_filters.py b/python/copomus/candidate_filters.py index 03899120..1a6dada9 100644 --- a/python/copomus/candidate_filters.py +++ b/python/copomus/candidate_filters.py @@ -2,7 +2,7 @@ # Author: Fabio Gutmann from typing import Tuple, List -from copomus.indexing import idx_to_array_index +from copomus.indexing import idx_to_array_index, add_sub_idx def _filter_bp_type(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, tidxpos0: int, bp_type='') -> \ @@ -34,7 +34,7 @@ def _filter_bp_type(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, t return new_bps -def _neighbors_in_mfe(q_index: int, t_index: int, bps: List[Tuple[int, int]]) -> int: +def _neighbors_in_mfe(q_index: int, t_index: int, bps: List[Tuple[int, int]], qidxpos0=1, tidxpos0=1) -> int: """ Counts how many neighboring base pairs are in the MFE region :param q_index: Base pair index on the query @@ -48,7 +48,9 @@ def _neighbors_in_mfe(q_index: int, t_index: int, bps: List[Tuple[int, int]]) -> """ count = 0 for q_o, t_o in [(+1, -1), (-1, +1)]: # get neighbors by offset - if (q_index+q_o, t_index+t_o) in bps: + q_neighbor = q_index + q_o if qidxpos0 == 0 else add_sub_idx(q_index, q_o) + t_neighbor = t_index + t_o if tidxpos0 == 0 else add_sub_idx(t_index, t_o) + if (q_neighbor, t_neighbor) in bps: count += 1 return count @@ -99,9 +101,10 @@ def filter_gc(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, tidxpos def filter_lp(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, tidxpos0: int) -> List[Tuple[int, int]]: """Filters lonely base pairs that can not stack (both neighbors not in MFE and both neighbors cant pair)""" new_bps = [] + bp_set = set(bps) for q_index, t_index in bps: - if not _neighbors_in_mfe(q_index, t_index, bps) and \ + if not _neighbors_in_mfe(q_index, t_index, bp_set, qidxpos0, tidxpos0) and \ not _neighbors_can_pair(q_index, t_index, q, t, qidxpos0, tidxpos0): continue new_bps.append((q_index, t_index)) @@ -111,9 +114,10 @@ def filter_lp(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, tidxpos def filter_lp_mfe(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, tidxpos0: int) -> List[Tuple[int, int]]: """Filters lonely base pairs in MFE interaction (both neighbors not in MFE)""" new_bps = [] + bp_set = set(bps) for q_index, t_index in bps: - if not _neighbors_in_mfe(q_index, t_index, bps): + if not _neighbors_in_mfe(q_index, t_index, bp_set, qidxpos0, tidxpos0): continue new_bps.append((q_index, t_index)) return new_bps @@ -133,9 +137,10 @@ def filter_he(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, tidxpos def filter_he_mfe(bps: List[Tuple[int, int]], q: str, t: str, qidxpos0: int, tidxpos0: int) -> List[Tuple[int, int]]: """Filters helix ends (One neighbor in MFE, the other one is not)""" new_bps = [] + bp_set = set(bps) for q_index, t_index in bps: - if _neighbors_in_mfe(q_index, t_index, bps) == 1: + if _neighbors_in_mfe(q_index, t_index, bp_set, qidxpos0, tidxpos0) == 1: continue new_bps.append((q_index, t_index)) return new_bps @@ -146,6 +151,7 @@ def get_filter(string: str) -> any: 'GU': filter_gu, 'AU': filter_au, 'GC': filter_gc, + 'CG': filter_gc, 'lp': filter_lp, 'lpMfe': filter_lp_mfe, 'he': filter_he, diff --git a/python/copomus/candidate_selectors.py b/python/copomus/candidate_selectors.py index fbcb8e1e..2f3eeca7 100644 --- a/python/copomus/candidate_selectors.py +++ b/python/copomus/candidate_selectors.py @@ -1,10 +1,9 @@ # Copyright 2019 # Author: Fabio Gutmann -import sys from typing import List, Tuple from copomus.IntaRNA import IntaRNA -from copomus.indexing import idx_to_array_index as idx_aidx +from copomus.mutation import parse_mutation_encoding def get_mfe_bps(query: str, target: str, qidxpos0: int, tidxpos0: int, param_file: str, threads: int, **kwargs) -> \ @@ -23,8 +22,7 @@ def get_mfe_bps(query: str, target: str, qidxpos0: int, tidxpos0: int, param_fil i = IntaRNA() data = i.run(query, target, qidxpos0, tidxpos0, 'bpList', threads, param_file=param_file) if not data or not data['bpList']: - print('No favorable interaction between the specified sequences!') - sys.exit(0) + return [] for t in data['bpList'].strip().split(':'): t_index, q_index = [int(x) for x in t.strip('(').strip(')').split(',')] candidates.append((q_index, t_index)) # note we do query first, then target @@ -47,46 +45,23 @@ def get_mfe_bps_so(query: str, target: str, qidxpos0: int, tidxpos0: int, param_ i = IntaRNA() data = i.run(query, target, qidxpos0, tidxpos0, 'start1,end1,start2,end2', threads, param_file=param_file) if not data: - print('No favorable interaction between the specified sequences!') - sys.exit(0) + return [] data = i.run(query, target, qidxpos0, tidxpos0, 'bpList', threads, 1000, param_file, ['--qRegion', f"{data['start2']}-{data['end2']}", '--tRegion', f"{data['start1']}-{data['end1']}"]) - for subopt in data: + seen = set() + for subopt in data or []: for t in subopt['bpList'].strip().split(':'): t_index, q_index = [int(x) for x in t.strip('(').strip(')').split(',')] - if (q_index, t_index) not in candidates: + if (q_index, t_index) not in seen: + seen.add((q_index, t_index)) candidates.append((q_index, t_index)) # note we do query first, then target return candidates -def get_mutation_enc(query: str, target: str, qidxpos0: int, tidxpos0: int, param_file: str, **kwargs) \ - -> List[Tuple[int, int]]: - """ - Only selects the base pairs specified by the mutation encoding - :param query: The query sequence - :param target: The target sequence - :param qidxpos0: The counting start index for the query - :param tidxpos0: The counting start index for the target - :param param_file: Parameter file used for IntaRNA call - :return: List of tuples of indexes in form (query_index, target_index) with each tuple representing a base pair - """ - try: - candidates = [] - enc = kwargs['enc'] - for mut in enc.split(','): # split block mutations into single mutations - q, t = mut.split('&') - q_index = int(q[1:len(q) - 1]) # mutation indices - t_index = int(t[1:len(t) - 1]) - - if query[idx_aidx(q_index, qidxpos0)] != q[0] or target[idx_aidx(t_index, tidxpos0)] != t[0]: - print('The encoding you specified does not match with the sequences you specified!') - sys.exit(1) - candidates.append((q_index, t_index)) - return candidates - - except (IndexError, ValueError, KeyError): - print('Error parsing the encoding you specified!') - sys.exit(1) +def get_mutation_enc(query, target, qidxpos0, tidxpos0, param_file, threads=0, **kwargs): + """Validate explicit mutations and return their query/target coordinates.""" + return [(q, t) for q, t, _ in parse_mutation_encoding( + kwargs['enc'], query, target, qidxpos0, tidxpos0)] def get_selector(string: str) -> any: diff --git a/python/copomus/indexing.py b/python/copomus/indexing.py index 6b08be81..e3e48c27 100644 --- a/python/copomus/indexing.py +++ b/python/copomus/indexing.py @@ -15,7 +15,9 @@ def idx_to_array_index(idx: int, idxpos0: int): >>> idx_to_array_index(1, -1) 1 """ - return idx - idxpos0 - (1 if (idxpos0 < 0 < idx or idx < 0 < idxpos0) else 0) + if idxpos0 < 0 and idx == 0: + raise ValueError('index zero is skipped for negative sequence offsets') + return idx - idxpos0 - (1 if idxpos0 < 0 < idx else 0) def array_index_to_idx(i: int, idxpos0: int): @@ -31,7 +33,7 @@ def array_index_to_idx(i: int, idxpos0: int): >>> array_index_to_idx(1, -1) 1 """ - return idxpos0 + i + (1 if (idxpos0 < 0 < i or i < 0 < idxpos0) else 0) + return idxpos0 + i + (1 if idxpos0 < 0 <= idxpos0 + i else 0) def add_sub_idx(i1: int, change: int): diff --git a/python/copomus/measures.py b/python/copomus/measures.py index 05f0341f..78a5487b 100644 --- a/python/copomus/measures.py +++ b/python/copomus/measures.py @@ -1,8 +1,6 @@ # Copyright 2019 # Author: Fabio Gutmann -import os -from tempfile import gettempdir from copomus.mutation import Mutation from copomus.indexing import idx_to_array_index @@ -21,17 +19,12 @@ def post_compute(self): pass def read_temp_file(self): - data = [] - for pipe in self.out_pipes: - file_name = f'CopomuS_{pipe}_{os.getpid()}.temp' - with open(os.path.join(gettempdir(), file_name), 'r') as f: - data.append(f.read()) - os.remove(os.path.join(gettempdir(), file_name)) - return data + """Return profiles shared by all measures for this prediction.""" + return [self.profiles[pipe] for pipe in self.out_pipes] # noinspection PyTypeChecker def is_valid(self, alpha: float, beta: float = None) -> bool: - beta = alpha if not beta else beta + beta = alpha if beta is None else beta res = (self.score['ww'] < 0 and self.score['mm'] < 0 and (self.score['mw'] >= 0 or @@ -92,14 +85,14 @@ def __init__(self): def post_compute(self): score = { - 'wm/ww': round(self.score['wm'] - self.score['ww'], 2) if self.score['wm'] and self.score['ww'] else '', - 'mw/ww': round(self.score['mw'] - self.score['ww'], 2) if self.score['mw'] and self.score['ww'] else '', - 'wm/mm': round(self.score['wm'] - self.score['mm'], 2) if self.score['wm'] and self.score['mm'] else '', - 'mw/mm': round(self.score['mw'] - self.score['mm'], 2) if self.score['mw'] and self.score['mm'] else '', - 'ww/mm': round(self.score['ww'] - self.score['mm'], 2) if self.score['ww'] and self.score['mm'] else '' + 'wm/ww': round(self.score['wm'] - self.score['ww'], 2) if self.score['wm'] is not None and self.score['ww'] is not None else None, + 'mw/ww': round(self.score['mw'] - self.score['ww'], 2) if self.score['mw'] is not None and self.score['ww'] is not None else None, + 'wm/mm': round(self.score['wm'] - self.score['mm'], 2) if self.score['wm'] is not None and self.score['mm'] is not None else None, + 'mw/mm': round(self.score['mw'] - self.score['mm'], 2) if self.score['mw'] is not None and self.score['mm'] is not None else None, + 'ww/mm': round(self.score['ww'] - self.score['mm'], 2) if self.score['ww'] is not None and self.score['mm'] is not None else None } self.score = score - entries = [v for k, v in self.score.items() if v != '' and k != 'ww/mm'] + entries = [v for k, v in self.score.items() if v is not None and k != 'ww/mm'] self.score['min'] = min(entries) if entries else None def __lt__(self, other): @@ -107,7 +100,7 @@ def __lt__(self, other): return False elif other.non_i(): return True - return self.score['min'] > 0 > other.score['min'] or 0 < other.score['min'] < self.score['min'] + return self.score['min'] > other.score['min'] def __eq__(self, other): if self.non_i() and not other.non_i() or not self.non_i() and other.non_i(): @@ -136,6 +129,7 @@ def in_mfe(self): def calculate_score(self, data: dict, mut_type: str, m: Mutation = None, param_file: str = ''): if not data: + self.score[mut_type] = False return mfe_cover = (m.target_n in range(data['start1'], data['end1']+1)) and \ (m.query_n in range(data['start2'], data['end2']+1)) @@ -278,8 +272,9 @@ def __eq__(self, other): return False if self.non_i() and other.non_i(): return True - return self.is_valid(0) == other.is_valid(0) and \ - self.score['mm'] - self.score['ww'] == other.score['mm'] - other.score['ww'] + return (self.is_valid(0) == other.is_valid(0) and + (not self.is_valid(0) or + self.score['mm'] - self.score['ww'] == other.score['mm'] - other.score['ww'])) class ConstantAccessProfileQuery(Measure): diff --git a/python/copomus/mutation.py b/python/copomus/mutation.py index b33d5372..a9e24b45 100644 --- a/python/copomus/mutation.py +++ b/python/copomus/mutation.py @@ -2,13 +2,14 @@ # Author: Fabio Gutmann from dataclasses import dataclass, field -from typing import List +from typing import Any +import re from copomus.indexing import idx_to_array_index @dataclass() class Mutation: - measures: List[any] + measures: list[Any] qw: str # wildtype sequences tw: str qidxpos0: int # indexing start of query @@ -24,6 +25,10 @@ class Mutation: def __post_init__(self): mut_pos_q = idx_to_array_index(self.query_n, self.qidxpos0) mut_pos_t = idx_to_array_index(self.target_n, self.tidxpos0) + if not 0 <= mut_pos_q < len(self.qw) or not 0 <= mut_pos_t < len(self.tw): + raise ValueError('mutation position is outside its sequence') + if not re.fullmatch('[ACGU]{2}', self.bp_mm): + raise ValueError('mutated bases must be A, C, G or U') self.bp_ww = f'{self.qw[mut_pos_q]}{self.tw[mut_pos_t]}' self.qm = self.qw[0:mut_pos_q] + self.bp_mm[0] + self.qw[mut_pos_q+1:] self.tm = self.tw[0:mut_pos_t] + self.bp_mm[1] + self.tw[mut_pos_t+1:] @@ -33,3 +38,23 @@ def __lt__(self, other): def __str__(self): return f'{self.bp_ww[0]}{self.query_n}{self.bp_mm[0]}&{self.bp_ww[1]}{self.target_n}{self.bp_mm[1]}' + + +def parse_mutation_encoding(encoding, query, target, qidxpos0, tidxpos0): + """Validate query-first substitutions without allowing negative array wrapping.""" + mutations = [] + for entry in encoding.split(','): + match = re.fullmatch(r'([ACGU])(-?\d+)([ACGU])&([ACGU])(-?\d+)([ACGU])', entry) + if match is None: + raise ValueError(f'invalid mutation encoding: {entry!r}') + qw, q, qm, tw, t, tm = match.groups() + q, t = int(q), int(t) + qi, ti = idx_to_array_index(q, qidxpos0), idx_to_array_index(t, tidxpos0) + if not 0 <= qi < len(query) or not 0 <= ti < len(target): + raise ValueError('mutation position is outside its sequence') + if query[qi] != qw or target[ti] != tw: + raise ValueError('mutation encoding does not match the wildtype sequences') + if qm == qw or tm == tw: + raise ValueError('both encoded bases must change') + mutations.append((q, t, qm + tm)) + return mutations diff --git a/python/copomus/mutation_generators.py b/python/copomus/mutation_generators.py index 0c97d526..356e51e2 100644 --- a/python/copomus/mutation_generators.py +++ b/python/copomus/mutation_generators.py @@ -4,7 +4,7 @@ from copy import deepcopy from copomus.indexing import idx_to_array_index from copomus.measures import get_measure -from copomus.mutation import Mutation +from copomus.mutation import Mutation, parse_mutation_encoding from typing import List, Tuple @@ -66,13 +66,7 @@ def gen_specific(query: str, target: str, qidxpos0: int, tidxpos0: int, mms: Lis for m in mms: mm.append(get_measure(m, alpha, beta)) - for mut in encoding.split(','): # split block mutations into single mutations - q, t = mut.split('&') - q_index = int(q[1:len(q)-1]) # mutation indices - t_index = int(t[1:len(t)-1]) - - bp_mm = f'{q[len(q)-1]}{t[len(t)-1]}' - + for q_index, t_index, bp_mm in parse_mutation_encoding(encoding, query, target, qidxpos0, tidxpos0): m = Mutation(deepcopy(mm), query, target, qidxpos0, tidxpos0, q_index, t_index, bp_mm) mutations.append(m) return mutations diff --git a/python/tests/test_copomus.py b/python/tests/test_copomus.py new file mode 100644 index 00000000..43b1f3cf --- /dev/null +++ b/python/tests/test_copomus.py @@ -0,0 +1,144 @@ +"""CopomuS regressions for coordinates, mutation selection, profiles and ranking.""" + +import csv +import io +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from CopomuS import CopomuS, main +from copomus.IntaRNA import IntaRNA +from copomus.candidate_filters import get_filter, filter_lp_mfe +from copomus.candidate_selectors import get_mutation_enc +from copomus.indexing import array_index_to_idx, idx_to_array_index, add_sub_idx +from copomus.measures import MinDeltaEnergy, MFECover, Measure +from copomus.mutation import Mutation, parse_mutation_encoding +from copomus.mutation_generators import gen_specific + + +class CopomuSTests(unittest.TestCase): + def test_signed_coordinates_round_trip(self): + for offset, expected in ((-3, [-3, -2, -1, 1, 2]), (0, [0, 1, 2, 3, 4]), + (2, [2, 3, 4, 5, 6])): + for i, value in enumerate(expected): + self.assertEqual(array_index_to_idx(i, offset), value) + self.assertEqual(idx_to_array_index(value, offset), i) + self.assertEqual(add_sub_idx(-1, 1), 1) + self.assertEqual(add_sub_idx(1, -1), -1) + with self.assertRaises(ValueError): + idx_to_array_index(0, -1) + + def test_zero_energies_ids_and_quoted_csv(self): + rows = IntaRNA.output_to_dict('id1;id2;start1;E;ED1;other\n"a;b";001;0;0.0;NA;\n') + self.assertEqual(rows, [{'id1': 'a;b', 'id2': '001', 'start1': 0, 'E': 0.0, + 'ED1': None, 'other': None}]) + for empty in ('id1\n', 'E\n', 'E;ED1\n\n'): + self.assertIs(IntaRNA.output_to_dict(empty), False) + for bad in ('E;E\n1;2\n', 'E;ED1\n1\n', 'E\nnan\n', 'E\ninf\n'): + with self.assertRaises(ValueError): + IntaRNA.output_to_dict(bad) + + def test_version_and_no_empty_subprocess_arguments(self): + self.assertEqual(IntaRNA.version_tuple('4.0.0-dev'), (4, 0, 0)) + with patch('copomus.IntaRNA.find_program', return_value='/tmp/with spaces/IntaRNA'), \ + patch('copomus.IntaRNA.run_command', return_value='E\n0\n') as run: + self.assertEqual(IntaRNA().run('GC', 'GC', 1, 1, 'E', 0), {'E': 0}) + self.assertNotIn('', run.call_args.args[0]) + IntaRNA().run('GC', 'GC', 1, 1, 'E', 0, param_file='with spaces.txt') + self.assertIn('--parameterFile=with spaces.txt', run.call_args.args[0]) + + def test_explicit_mutations_and_bounds(self): + args = ('GC', 'GC', 1, 1) + self.assertEqual(get_mutation_enc(*args, '', 2, enc='G1C&C2G'), [(1, 2)]) + mutations = gen_specific(*args, ['E'], 1, 1, 'G1C&C2G') + self.assertEqual((mutations[0].qm, mutations[0].tm), ('CC', 'GG')) + for bad in ('G0C&C2G', 'C-1G&C2G', 'G3C&C2G', 'G1N&C2G', 'G1G&C2G', + 'A1C&C2G', 'G1C&C2G,', 'bad'): + with self.subTest(bad=bad), self.assertRaises(ValueError): + parse_mutation_encoding(bad, *args) + with self.assertRaises(ValueError): + Mutation([], 'GC', 'GC', 1, 1, -1, 2, 'CG') + mut = gen_specific('AGC', 'GCU', -2, -2, [], 1, 1, 'A-2U&U1A')[0] + self.assertEqual((mut.qm, mut.tm), ('UGC', 'GCA')) + + def test_filters_across_zero_and_gc_alias(self): + self.assertIs(get_filter('GC'), get_filter('CG')) + self.assertEqual(get_filter('CG')([(1, 2)], 'GC', 'GC', 1, 1), []) + pairs = [(-1, 1), (1, -1)] + self.assertEqual(filter_lp_mfe(pairs, 'GC', 'GC', -1, -1), pairs) + zero_pairs = [(0, 1), (1, 0)] + self.assertEqual(filter_lp_mfe(zero_pairs, 'GC', 'GC', 0, 0), zero_pairs) + + def test_zero_differences_total_order_and_missing_scores(self): + measures = [] + for values in ((-3, 0, 0, -3), (-3, -3, -3, -3), (-3, -4, -4, -3)): + measure = MinDeltaEnergy() + measure.score.update(zip(('ww', 'wm', 'mw', 'mm'), values)) + measure.post_compute() + measures.append(measure) + self.assertEqual([m.score['min'] for m in measures], [3, 0, -1]) + self.assertEqual(sorted(measures[::-1]), measures) + missing = MinDeltaEnergy() + missing.post_compute() + self.assertTrue(missing.non_i()) + cover = MFECover() + cover.calculate_score(False, 'wm') + self.assertIs(cover.score['wm'], False) + measure = Measure() + measure.score.update(ww=-5, mm=-2, wm=-1.5, mw=-1.5) + self.assertTrue(measure.is_valid(1, 0)) + + def test_profiles_shared_and_wildtype_reused(self): + app = CopomuS() + app.measures = ['EDqi', 'cEDqi', 'Eqi', 'Eti'] + muts = gen_specific('GC', 'GC', 1, 1, app.measures, 1, 1, 'G1C&C2G,C2G&G1C') + paths_seen = [] + def prediction(*args): + for option in args[-1]: + pipe, filename = option.removeprefix('--out=').split(':', 1) + path = Path(filename) + paths_seen.append(path) + path.write_text('# header\n# columns\n1\t0\n2\t1\n' if pipe == 'qAcc' + else 'idx;nt;E\n1;G;-2\n2;C;-3\n') + return {'id1': 'target'} + with patch.object(IntaRNA, 'run', side_effect=prediction) as run: + app.run_tests(muts) + self.assertEqual(run.call_count, 7) # one wildtype + three per mutation + self.assertTrue(all(not path.exists() for path in paths_seen)) + for mut in muts: + self.assertEqual(mut.measures[0].score, mut.measures[1].score | {'is_valid': 0}) + self.assertIsNotNone(mut.measures[0].score['ww']) + + def test_cli_validation_and_failed_output_preservation(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / 'results.tsv' + output.write_text('previous results') + for extra in (['-q', '', '-t', 'GC'], ['-q', 'GC', '-t', 'GC', '--threads=-1'], + ['-q', 'GC', '-t', 'GC', '-d', ';;']): + with patch('sys.stderr', new_callable=io.StringIO), self.assertRaises(SystemExit): + CopomuS().parse_cmd_args([*extra, '-o', str(output)]) + self.assertEqual(output.read_text(), 'previous results') + with patch.object(IntaRNA, 'check_binary'), \ + patch.object(IntaRNA, 'run', side_effect=RuntimeError('prediction failed')), \ + patch('sys.stderr', new_callable=io.StringIO): + self.assertEqual(main(['-q', 'GC', '-t', 'GC', '--mutationEncoding', 'G1C&C2G', + '-o', str(output)]), 1) + self.assertEqual(output.read_text(), 'previous results') + + def test_cli_explicit_mutation_zero_energy_csv(self): + stdout = io.StringIO() + with patch.object(IntaRNA, 'check_binary'), patch.object(IntaRNA, 'run', return_value={'E': 0}), \ + patch('sys.stdout', stdout): + self.assertEqual(main(['-q', 'GC', '-t', 'GC', '--mutationEncoding', 'G1C&C2G', + '-m', 'minDeltaE']), 0) + row = list(csv.DictReader(io.StringIO(stdout.getvalue()), delimiter='\t'))[0] + self.assertEqual(row['mutation'], 'G1C&C2G') + self.assertEqual(row['minDeltaE_min'], '0') + self.assertNotIn('\r', stdout.getvalue()) + + +if __name__ == '__main__': + unittest.main() From b74038a46ad705388547783a17d41cce2ce4a704 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Sat, 24 Oct 2026 00:38:45 +0200 Subject: [PATCH 4/6] Correct IntaRNApvalue sampling and bound prediction failures --- ChangeLog | 20 ++ python/intarnapvalue/README.md | 173 +++++---- python/intarnapvalue/__main__.py | 6 +- python/intarnapvalue/dinucl_shuffle.py | 38 +- python/intarnapvalue/intarna_pvalue.py | 479 +++++++++++-------------- python/tests/test_intarnapvalue.py | 184 ++++++++++ 6 files changed, 531 insertions(+), 369 deletions(-) create mode 100644 python/tests/test_intarnapvalue.py diff --git a/ChangeLog b/ChangeLog index 5a7f7cf0..e6bbb86a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -14,6 +14,9 @@ ## Interface and handling +- fix IntaRNApvalue both-strand sampling and parameter consistency, bound retries, + validate FASTA/fits and expose empirical p-values (PR #298) + - fix CopomuS explicit mutations, GC filters, signed indices, zero scores and shared profiles; validate inputs and preserve output on failure (PR #298) @@ -237,6 +240,23 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261024 Alexander Mitrofanov + * python/intarnapvalue/intarna_pvalue.py, python/intarnapvalue/__main__.py + * Independently shuffle the correct target and query in pairwise batches; + apply the same parameter file to observed and shuffled predictions. + * Bound favorable-interaction resampling with --max-attempts, fail immediately + on command errors, and validate sample IDs/energies before publication. + * Normalize wrapped single-record FASTA and DNA, validate counts, preserve + zero energies, isolate RNG state and keep score ordering deterministic. + * Evaluate fitted CDFs instead of numerical integration; retain historical + raw-energy models, validate fits and expose empirical/none modes (PR #298). + * python/intarnapvalue/dinucl_shuffle.py, python/tests/test_intarnapvalue.py + * Handle singleton and invalid sequences; remove quadratic front-deletion + from Euler-path construction and support instance-local RNGs. + + Test composition, sampling, bounded errors, constraints and CDF equivalence. + * python/intarnapvalue/README.md, python/Makefile.am + * Document real options, conditional sampling, dependencies and API behavior. + 261024 Alexander Mitrofanov * python/CopomuS.py, python/copomus/*.py, python/tests/test_copomus.py * Validate sequences/FASTA, mutation positions and bases, delimiters and counts; diff --git a/python/intarnapvalue/README.md b/python/intarnapvalue/README.md index 7c67eb11..4b939c99 100644 --- a/python/intarnapvalue/README.md +++ b/python/intarnapvalue/README.md @@ -1,86 +1,107 @@ # IntaRNApvalue -A tool for calculating p-values of IntaRNA energy scores. - -### How it works: -This tool shuffles one or more of the given sequences (depending on shuffle mode) while preserving mono- and dinucleotide frequences. -Thus the resulting sequences are very similar with similar properties to the original one. -It then calculates the IntaRNA interaction scores for these newly generated sequences and uses them to approximate the p-value of the original target/query combination. -This p-value is a score for the likelihood that a sequence with a better interaction score than the original ones can randomly occur. -It can thus be used as a measurement for how good an interaction between two sequences is. - -## Dependencies: -- IntaRNA -##### And if you want to run it with python or compile it yourself: -- Python 3 (>= 3.6) -- numpy -- scipy - -## Installation: -You can either run this tool directly with python, install it with setuptools or run it as a compiled binary. -##### Run with python directly: -You don't have to install it, if you just plan on running it from a certain directory. -Simply copy the "intarnapvalue" folder where you want to run it from. -You have to get the dependencies yourself in this case. - -To use the tool from anywhere however, you need to install it as a python module. -All dependencies (except IntaRNA) will be installed automatically. -This way it can also be installed in a virtual environment. -```console -python setup.py install -``` -##### Run as binary: -Go into the bin directory. You can find pre-compiled builds for linux and windows for x64 directly. -You don't need to install any dependencies for running these, on Windows however you will need Microsoft Visual C++ Redistributable. -If you want to compile it yourself, you need to get the dependencies and PyInstaller. -I had problems with numpy 1.17.0, so I had to use 1.16.4. -Simply run the build.py with the python binary that has the dependencies and PyInstaller installed. -You will find your binary in the build folder. - -## Usage: -Run it as a module without installing (you need to be in the parent dir of intarnapvalue): -```console -python3 -m intarnapvalue -``` +Estimate the significance of an observed IntaRNA energy using a background of +**dinucleotide-preserving shuffled sequences**. This differs from +`IntaRNA_CSV_p-value.py`, which fits the energies of a genome-wide target screen. + +## Installation and invocation -Run it as a module from anywhere with python (need to run setup.py first, see [Installation](#installation)): -```console -python3 -m intarnapvalue +Use Python **3.10+**, IntaRNA on PATH (or beside the companion scripts / in the +checkout's `src/bin`), NumPy >= 1.23 and SciPy >= 1.9 for fitted distributions. +Scores-only and empirical modes need only the standard library and IntaRNA. +From the repository root: + +```bash +python3 -m pip install ./python +python3 -m intarnapvalue --help +# Alternatively, without installing the package: +PYTHONPATH=python python3 -m intarnapvalue --help ``` -Or use as a compiled binary: -```console -./IntaRNApvalue + +`pip` installs NumPy and SciPy automatically. IntaRNA itself must be installed +separately. `--help` works without IntaRNA or scientific Python dependencies. +For an optional standalone executable, install PyInstaller and run +`python3 python/bin/build.py`; see `python/bin/README.md`. + +## Sampling and interpretation + +Each shuffle preserves length, endpoints, and mono/dinucleotide counts. Lowercase +and DNA input are normalized to uppercase RNA. FASTA inputs may wrap across lines +but must contain exactly one nonempty record; multiple records are rejected. +In `b` mode, each target and each query are independently shuffled, and IntaRNA +compares corresponding pairs in bounded batches, not all combinations. + +`--samples` is the number of **reported interactions** collected. Pairs with no +reported interaction under the selected constraints are discarded and replaced. +The resulting p-value is therefore **conditional on a reported interaction**; it +does not estimate the unconditional probability over all shuffled pairs. The +number of discarded pairs is reported on stderr. The default attempt limit is +100 times `--samples`; reaching it is an error, with no partial score output. +Use `--max-attempts` to change that limit. An IntaRNA command failure stops +immediately. A parameter file is applied equally to the observed and shuffled +predictions; output settings enforce one energy per pair. + +The fitted modes retain the historical models on **raw energies**: SciPy +`genextreme` (GEV), `gumbel_l` (left-tail Gumbel), or `norm` (Gaussian). The p-value +is the fitted CDF at the observed energy, replacing numerical PDF integration. +This preserves the former model, including its GEV orientation; the separate +CSV tool fits **negated** energies and has different statistical semantics. +At least three distinct finite energies are required for fitting. This is a +numerical minimum, not assurance of statistical adequacy. Inspect a sufficiently +large background for the intended application. Degenerate fits are errors. + +`-d empirical` (alias `none`) reports the fraction of sampled energies less than +or equal to the observed energy, including ties. It accepts constant samples and +may return zero; its resolution is `1 / samples`, with no pseudocount correction. +Zero interaction energies remain valid. If the original pair has no reported +interaction, p-value mode fails; scores mode can still sample the background. + +## Arguments + +| Option | Default | Meaning | +| --- | --- | --- | +| `-q`, `--query` | Required | Literal RNA/DNA or a single-record FASTA path | +| `-t`, `--target` | Required | Literal RNA/DNA or a single-record FASTA path | +| `-s`, `--samples` | Required | Positive number of favorable shuffled pairs | +| `-m`, `--shuffle-mode` | Required | `q` (query), `t` (target), or `b` (both) | +| `-d`, `--distribution` | `gev` | `gev`, `gumbel`, `gauss`, `empirical`, or `none` | +| `-o`, `--output` | `pvalue` | `pvalue` or `scores` (one energy per line) | +| `-p`, `--parameterFile` | None | IntaRNA parameters used for every prediction | +| `--threads` | `0` | Nonnegative IntaRNA thread count; 0 uses available CPUs | +| `--randSeed` | None | Seed string; repeat with the same inputs/options/software | +| `--max-attempts` | `100 * samples` | Maximum pairs tried; must be at least samples | + +```bash +python3 -m intarnapvalue \ + -q GCUGAAAAACAUAACCCAUAAAAUGCUAGCUGUACCAGGAACCA \ + -t GGUUUCUUCGCCUCUGCGUUCACCAAAGUGUUCACCC \ + -s 1000 -m b --threads 1 --randSeed example -d empirical +python3 -m intarnapvalue -q query.fasta -t target.fasta -s 1000 -m t -o scores > background.txt ``` -Or import it and use it from within other python code: +A seed uses an instance-local RNG and does not reseed Python's global generator. +Corrected both-strand sampling changes results from earlier releases. Batch +results are restored to input order so thread scheduling does not reorder scores. +Diagnostics use stderr; successful stdout contains only the requested numbers. + +## Python API and tests + +Passing an argument list configures an instance without running predictions: + ```python from intarnapvalue.intarna_pvalue import IntaRNApvalue -IntaRNApvalue(['--flag1', 'arg1']) -``` -## Arguments: - -| Flag | Value | Default | Description | -| ------------------ |:---------------------- | :------ | -------------------- | -| -h, --help | | | Gives detailed command help. | -| -q, --query | Sequence or FASTA file | | Query as a raw sequence or a file in FASTA format. Takes the first sequence from the file. | -| -t, --target | Sequence or FASTA file | | Target as a raw sequence or a file in FASTA format. Takes the first sequence from the file. | -| -c, --cardinality | Integer | | How many sequence pairs are randomly permuted and considered for p-value calculation. | -| -m, --shuffle-mode | {q, t, b} | | Which sequence will be shuffled: Query, Target or both. | -| -d, --distribution | {gev, gumbel, gauss} | gev | (optional) The distribution used for p-value calculation: Generalized Extreme Value Distribution, Gumbel Distribution or Gauss. | -| -o, --output | {pvalue, scores} | pvalue | (optional) If set to p-value, will only output p-value. If set to scores, will output every score from randomly generated sequences, but no p-value. | -| --threads | 0 - {max threads} | 0 | (optional) How many threads IntaRNA uses for score calculation. If set to 0 it will use all available threads. | -| --randSeed | Integer | None | (optional) The seed used for generating random sequences. | -| -p, --parameterFile | file name | None | (optional) parameter file to be used in IntaRNA calls to further guide predictions. | - -## Example -An example use-case could look like this with sequences in raw format: -```console -$ python3 -m intarnapvalue --query GCUGAAAAACAUAACCCAUAAAAUGCUAGCUGUACCAGGAACCA --target GGUUUCUUCGCCUCUGCGUUCACCAAAGUGUUCACCC --scores 10000 --shuffle-mode b --threads 0 -0.2421277140114205 +analysis = IntaRNApvalue(['-q', 'GGGGGGGG', '-t', 'CCCCCCCC', + '-s', '10', '-m', 't', '--randSeed', 'example']) +observed = analysis.get_single_score(analysis.query, analysis.target) # float or None +scores, discarded = analysis.get_scores() +if observed is not None: + pvalue = analysis.calculate_pvalue_empirical(observed, scores) ``` -Or you could specify any of the sequences as files: -```console -$ python3 -m intarnapvalue --query query.fasta --target target.fasta --scores 10000 --shuffle-mode b --threads 0 -0.2421277140114205 -``` \ No newline at end of file + +The Python API raises exceptions for failed predictions and invalid data. CLI +argument errors use exit code 2; runtime errors use exit code 1. +From the repository root, run `python3 -m unittest discover -s python/tests -v`. +Tests cover both/single-strand sampling, sample counts, bounded failures, +parameter forwarding, RNG isolation, FASTA validation, zero energies, shuffle +composition, and agreement between CDFs and the former numerical integration. diff --git a/python/intarnapvalue/__main__.py b/python/intarnapvalue/__main__.py index a4eeb080..609ccacd 100644 --- a/python/intarnapvalue/__main__.py +++ b/python/intarnapvalue/__main__.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 - # Copyright 2019 # Author: Fabio Gutmann -from intarnapvalue import intarna_pvalue +from intarnapvalue.intarna_pvalue import main -i = intarna_pvalue.IntaRNApvalue() +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/python/intarnapvalue/dinucl_shuffle.py b/python/intarnapvalue/dinucl_shuffle.py index ff892989..b443ad09 100644 --- a/python/intarnapvalue/dinucl_shuffle.py +++ b/python/intarnapvalue/dinucl_shuffle.py @@ -11,6 +11,8 @@ import sys import random +from collections import deque +from intarna_tools import normalize_sequence def compute_count_and_lists(s): @@ -50,13 +52,14 @@ def compute_count_and_lists(s): assert (nucl_total == len(s)) assert (dinucl_total == len(s)-1) return nucl_cnt, dinucl_cnt, nucl_dict - - -def choose_edge(x, dinucl_cnt): + + +def choose_edge(x, dinucl_cnt, rng=None): + rng = random if rng is None else rng num_in_list = 0 for y in ['A', 'C', 'G', 'U']: num_in_list += dinucl_cnt[x][y] - z = random.random() + z = rng.random() denom = dinucl_cnt[x]['A'] + dinucl_cnt[x]['C'] + dinucl_cnt[x]['G'] + dinucl_cnt[x]['U'] numerator = dinucl_cnt[x]['A'] if z < float(numerator)/float(denom): @@ -93,9 +96,9 @@ def connected_to_last(edge_list, nucl_list, last_ch): if x != last_ch and d[x] == 0: return 0 return 1 - -def eulerian(s): + +def eulerian(s, rng=None): nucl_cnt, dinucl_cnt, nucl_dict = compute_count_and_lists(s) # compute nucleotides appearing in s nucl_list = [] @@ -113,16 +116,17 @@ def eulerian(s): edge_list = [] for x in nucl_list: if x != last_ch: - edge_list.append([x, choose_edge(x, dinucl_cnt)]) + edge_list.append([x, choose_edge(x, dinucl_cnt, rng)]) ok = connected_to_last(edge_list, nucl_list, last_ch) return ok, edge_list, nucl_list, last_ch -def shuffle_edge_list(l): +def shuffle_edge_list(l, rng=None): + rng = random if rng is None else rng n = len(l) barrier = n for i in range(n-1): - z = int(random.random() * barrier) + z = int(rng.random() * barrier) tmp = l[z] l[z] = l[barrier - 1] l[barrier - 1] = tmp @@ -130,11 +134,15 @@ def shuffle_edge_list(l): return l -def dinucl_shuffle(s): +def dinucl_shuffle(s, rng=None): + """Preserve endpoints and mono/dinucleotide counts, using an optional RNG.""" + s = normalize_sequence(s) + if len(s) == 1: + return s ok = 0 edge_list, nucl_list = [], [] # initialize arrays while not ok: - ok, edge_list, nucl_list, last_ch = eulerian(s) + ok, edge_list, nucl_list, last_ch = eulerian(s, rng) nucl_cnt, dinucl_cnt, nucl_dict = compute_count_and_lists(s) # remove last edges from each vertex list, shuffle, then add back @@ -142,22 +150,22 @@ def dinucl_shuffle(s): for x, y in edge_list: nucl_dict[x].remove(y) for x in nucl_list: - shuffle_edge_list(nucl_dict[x]) + shuffle_edge_list(nucl_dict[x], rng) for x, y in edge_list: nucl_dict[x].append(y) # construct the eulerian path + nucl_dict = {base: deque(edges) for base, edges in nucl_dict.items()} l = [s[0]] prev_ch = s[0] for i in range(len(s)-2): - ch = nucl_dict[prev_ch][0] + ch = nucl_dict[prev_ch].popleft() l.append(ch) - del nucl_dict[prev_ch][0] prev_ch = ch l.append(s[-1]) t = ''.join(l) return t - + if __name__ == '__main__': if len(sys.argv) != 2: diff --git a/python/intarnapvalue/intarna_pvalue.py b/python/intarnapvalue/intarna_pvalue.py index c09ae63c..a5120531 100644 --- a/python/intarnapvalue/intarna_pvalue.py +++ b/python/intarnapvalue/intarna_pvalue.py @@ -3,307 +3,236 @@ # Copyright 2019 # Author: Fabio Gutmann -from subprocess import PIPE, Popen, run -import random -import os +"""Estimate interaction-energy significance against dinucleotide shuffles.""" + import argparse +import csv +import io +import math +from pathlib import Path +import random import sys -from typing import List, Tuple -import numpy as np -from scipy.integrate import quad as integ -from scipy.stats import norm as gauss -from scipy.stats import genextreme as gev -from scipy.stats import gumbel_l as gum -from intarnapvalue import dinucl_shuffle +from tempfile import TemporaryDirectory + +from intarna_tools import (csv_header, csv_rows, find_program, nonnegative_integer, + positive_integer, read_fasta, read_sequence, run_command) +from intarnapvalue.dinucl_shuffle import dinucl_shuffle class IntaRNApvalue: + """Configure from argv; explicit argument lists do not run predictions. + + ``get_scores()`` returns favorable shuffled energies and the number of + rejected pairs. The fitted distribution is conditional on a reported + interaction under the supplied IntaRNA constraints. + """ + def __init__(self, test_args=None): - self.bin = self.find_binary() - self.query = '' - self.target = '' - self.samples = 0 - self.shuffle_query = False - self.shuffle_target = False - self.threads = '' - self.dist = '' - self.output = '' - self.parameterFile = '' self.process_cmd_args(test_args) - if not test_args: + self.bin = self.find_binary() + if test_args is None: self.main() - def main(self) -> None: - """The main function""" - if self.output == 'scores': # output scores and exit the process - scores, non_interactions = self.get_scores() - print('\n'.join(iter([str(x) for x in scores]))) - sys.exit(0) - - original_score = self.get_single_score(self.query, self.target) - if not original_score: # exit if given seq has no interaction and not scores output only mode - print('The query/target combination you specified has no favorable interaction') - sys.exit(1) - - scores, non_interactions = self.get_scores() - pvalue = 0.0 - if self.dist == 'gauss': - pvalue = self.calculate_pvalue_gauss(original_score, scores) - elif self.dist == 'none': - pvalue = self.calculate_pvalue_empirical(original_score, scores) - elif self.dist == 'gumbel': - pvalue = self.calculate_pvalue_gumbel(original_score, scores) - elif self.dist == 'gev': - pvalue = self.calculate_pvalue_gev(original_score, scores) - print(pvalue) - @staticmethod - def find_binary() -> str: - """Tries to find the IntaRNA executable and returns path to binary or exits with error code 1 if not found""" - if not run('IntaRNA --version', shell=True, stdout=PIPE, stderr=PIPE).returncode: - #print(run('IntaRNA --version', shell=True, stdout=PIPE).stdout) - return 'IntaRNA' - - # if binary not found in path, search in parent of this script recursively - bin_name = 'IntaRNA.exe' if os.name == 'nt' else 'IntaRNA' - for dir_path, dir_name, file_names in os.walk(os.path.abspath(os.path.join(os.curdir, '..'))): - if bin_name in file_names: - #print(run(os.path.join(dir_path, bin_name)+' --version', shell=True, stdout=PIPE).stdout) - return os.path.join(dir_path, bin_name) - - print('Error: Cannot find IntaRNA binary executable, please add it to your PATH') - sys.exit(1) - - def process_cmd_args(self, test_args=None) -> None: - """Processes all commandline args and sets them as instance variables. - - >>> i = IntaRNApvalue(['-q', 'AGGAUG', '-t', 'UUUAUCGUU', '-s', '10', '-m', 'b', '-d', 'gauss', - ... '--threads', '3']) - >>> i.query - 'AGGAUG' - >>> i.target - 'UUUAUCGUU' - >>> i.samples - 10 - >>> i.shuffle_query - True - >>> i.shuffle_target - True - >>> i.threads - '3' - >>> i.dist - 'gauss' - >>> i.output - 'pvalue' - >>> i = IntaRNApvalue(['-q', 'Z', '-t', 'UUUAUCGUU', '-s', '10', '-m', 'b', '--threads', '3']) - Traceback (most recent call last): - ... - SystemExit: 1 - >>> open('test.fasta', 'w').write('>someseq\\nGACU') - 13 - >>> i = IntaRNApvalue(['-q', 'test.fasta', '-t', 'UUUAUCGUU', '-s', '10', '-m', 'b', '--threads', '3']) - >>> i.query - 'GACU' - >>> os.remove('test.fasta') - """ - parser = argparse.ArgumentParser(description='Calculates p-values of IntaRNA energy scores.') - parser.add_argument('-q', '--query', dest='query', type=str, help='Query sequence', required=True) - parser.add_argument('-t', '--target', dest='target', type=str, help='Target sequence', required=True) - parser.add_argument('-s', '--samples', dest='samples', type=int, required=True, - help='How many sequence pairs are randomly permuted and considered for p-value calculation.') - parser.add_argument('-m', '--shuffle-mode', dest='shuffleMode', required=True, choices=['q', 't', 'b'], - help='Which sequences are going to be shuffled: both, query only or target only.') - parser.add_argument('-p', '--parameterFile', dest='parameterFile', default="", - help='Optional parameter file for IntaRNA provide further IntaRNA parameters and prediction constraints.') - parser.add_argument('-d', '--distribution', dest='dist', choices=['gev', 'gumbel', 'gauss'], default='gev', - help='[gev] Which distribution is fitted and used to calculate the p-value.') - parser.add_argument('-o', '--output', dest='output', choices=['pvalue', 'scores'], default='pvalue', - help='[pvalue] If set to "scores", outputs all IntaRNA scores from random sequences to STDOUT. ' - 'This is useful for piping the scores. Otherwise outputs just the p-value.') - parser.add_argument('--threads', type=str, default='0', help='[0] Sets the amount of threads used for IntaRNA.') - parser.add_argument('--randSeed', type=str, default=None, - help='Random seed to make sequence shuffling and thus output deterministic.') - + def find_binary(): + """Use the same executable lookup as the other companion tools.""" + return find_program('IntaRNA') + + def process_cmd_args(self, test_args=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('-q', '--query', required=True, help='RNA/DNA sequence or single-record FASTA') + parser.add_argument('-t', '--target', required=True, help='RNA/DNA sequence or single-record FASTA') + parser.add_argument('-s', '--samples', type=positive_integer, required=True, + help='number of favorable shuffled pairs to collect') + parser.add_argument('-m', '--shuffle-mode', dest='shuffle_mode', required=True, + choices=['q', 't', 'b'], help='shuffle query, target, or both independently') + parser.add_argument('-p', '--parameterFile', default='', help='IntaRNA parameter file for ALL predictions') + parser.add_argument('-d', '--distribution', dest='dist', + choices=['gev', 'gumbel', 'gauss', 'empirical', 'none'], default='gev', + help='fitted distribution; empirical (alias none) counts scores <= observed') + parser.add_argument('-o', '--output', choices=['pvalue', 'scores'], default='pvalue', + help='print one p-value or one shuffled energy per line') + parser.add_argument('--threads', type=nonnegative_integer, default=0, + help='IntaRNA threads; zero selects all available CPUs') + parser.add_argument('--randSeed', default=None, help='random seed string for reproducible shuffling') + parser.add_argument('--max-attempts', type=positive_integer, + help='maximum shuffled pairs tried (default: 100 times --samples)') args = parser.parse_args(test_args) - - allowed = ['G', 'A', 'C', 'U', 'T'] - if os.path.exists(args.query): - args.query = self.read_fasta_file(args.query) - - if os.path.exists(args.target): - args.target = self.read_fasta_file(args.target) - - if args.parameterFile and not os.path.exists(args.parameterFile): - print("Cannot find provided parameter file '"+args.parameterFile+"'") - sys.exit(1) - - if False in [n in allowed for n in args.query.upper()] or False in [n in allowed for n in args.target.upper()]: - print('A sequence you specified contains illegal characters, allowed: G, A, C, U (T)') - sys.exit(1) - - self.shuffle_query = True if args.shuffleMode in ['b', 'q'] else False - self.shuffle_target = True if args.shuffleMode in ['b', 't'] else False - for key, value in args.__dict__.items(): - self.__setattr__(key, value) - - random.seed(a=args.randSeed) - - @staticmethod - def read_fasta_file(filename: str) -> str: - """Reads a FASTA file and returns the sequence. Exits the program if something goes wrong. - - >>> with open('test.fasta', 'w') as f: - ... f.write('>somename\\nGACUGGAGUGC') - 21 - >>> IntaRNApvalue.read_fasta_file('test.fasta') - 'GACUGGAGUGC' - >>> IntaRNApvalue.read_fasta_file('test2.fasta') - Traceback (most recent call last): - ... - SystemExit: 1 - >>> os.remove('test.fasta') - """ try: - with open(filename) as f: - data = f.read().split('\n') - if not data[0].startswith('>'): - print('Error: {} is not in FASTA format!'.format(filename)) - sys.exit(1) - f.close() - return data[1].upper().replace('T', 'U') - except FileNotFoundError as e: # In theory this can never happen, lets prevent it anyways - print(e) - sys.exit(1) - except IndexError: - print('Error: {} is not in FASTA format!'.format(filename)) - sys.exit(1) + args.query = read_sequence(args.query) + args.target = read_sequence(args.target) + if args.parameterFile and not Path(args.parameterFile).is_file(): + raise ValueError(f'Cannot find provided parameter file: {args.parameterFile}') + args.max_attempts = args.max_attempts or 100 * args.samples + if args.max_attempts < args.samples: + raise ValueError('--max-attempts must be at least --samples') + except (OSError, ValueError) as error: + parser.error(str(error)) + for key, value in vars(args).items(): + setattr(self, key, value) + self.shuffle_query = args.shuffle_mode in ('q', 'b') + self.shuffle_target = args.shuffle_mode in ('t', 'b') + self.rng = random.Random(args.randSeed) + + read_fasta_file = staticmethod(read_fasta) @staticmethod - def shuffle_sequence(seq: str, n: int) -> List[str]: - """Shuffles a sequence n times and returns a list of sequences, duplicate entries are possible - - >>> random.seed('IntaRNA') - >>> IntaRNApvalue.shuffle_sequence('AGGAUGGGGGA', 5) - ['AUGGAGGGGGA', 'AUGGGGAGGGA', 'AGGGGAUGGGA', 'AUGGGGGAGGA', 'AUGGGAGGGGA'] - """ - return [dinucl_shuffle.dinucl_shuffle(seq.upper()) for _ in range(n)] + def shuffle_sequence(seq, n, rng=None): + """Generate n independent, composition-preserving RNA shuffles.""" + if n < 0: + raise ValueError('shuffle count cannot be negative') + return [dinucl_shuffle(seq, rng=rng) for _ in range(n)] @staticmethod - def to_fasta(sequences: List[str]) -> str: - """Combines a list of sequences into a string in FASTA format - - >>> IntaRNApvalue.to_fasta(['AUGGAGGGGGA', 'AUGGGGAGGGA', 'AGGGGAUGGGA', 'AUGGGGGAGGA', 'AUGGGAGGGGA']) - '>0\\nAUGGAGGGGGA\\n>1\\nAUGGGGAGGGA\\n>2\\nAGGGGAUGGGA\\n>3\\nAUGGGGGAGGA\\n>4\\nAUGGGAGGGGA\\n' - """ - fasta_str = '' - n = 0 - for seq in sequences: - fasta_str += '>{}\n{}\n'.format(n, seq) - n += 1 - return fasta_str - - def get_scores(self) -> Tuple[List[float], int]: - """Calculates n IntaRNA energy scores from random sequences with given parameters as class variables""" - scores = [] - missing = self.samples - non_interactions = 0 - paramFile = "" - if self.parameterFile : - paramFile = "--parameterFile="+self.parameterFile - - while missing > 0: - if self.shuffle_query and self.shuffle_target: # shuffle both - query = self.shuffle_sequence(self.query, 1)[0] # get a random query - target = 'STDIN' - shuffles = self.to_fasta(self.shuffle_sequence(self.query, missing)) - elif self.shuffle_query and not self.shuffle_target: # only shuffle query - query = 'STDIN' - target = self.target # target stays the same - shuffles = self.to_fasta(self.shuffle_sequence(self.query, missing)) - else: # only shuffle target - query = self.query # query stays the same - target = 'STDIN' - shuffles = self.to_fasta(self.shuffle_sequence(self.target, missing)) - - p = Popen([self.bin, '-q', query, '-t', target, '--outMode=C', '--outCsvCols=E', '--threads', self.threads, paramFile], - stdout=PIPE, stdin=PIPE, universal_newlines=True) - stdout, stderr = p.communicate(input=shuffles) # send shuffles as STDIN - if p.returncode: # If IntaRNA exits with a returncode != 0, skip this iteration - print("IntaRNA failed (ret={})".format(p.returncode)) - continue - stdout = stdout.split('\n') # split on newline - del stdout[0], stdout[-1] # remove first element aka 'E' and trailing newline element - scores.extend(stdout) # add elements to scores - missing = self.samples - len(scores) - non_interactions += missing # count non-interactions - - # return list with all elements as float and amount of non-interactions - return [float(x) for x in scores], non_interactions - - def get_single_score(self, query, target) -> float: - """Gets an IntaRNA score to a single query/target combination""" - o = run('{} -q {} -t {} --outMode=C --outCsvCols=E --threads {}'.format(self.bin, query, target, self.threads), - stdout=PIPE, stdin=PIPE, shell=True, universal_newlines=True).stdout - if o.startswith('E') and o != 'E\n': # Check that we got a result - return float(o.split('\n')[1]) - else: - return 0 # no interaction + def to_fasta(sequences): + """Number the records so batch results can be reordered deterministically.""" + return ''.join(f'>{index}\n{seq}\n' for index, seq in enumerate(sequences)) + + def _predict(self, query, target, input_text=None, *, pairwise=False): + """Apply identical constraints to observed and shuffled pairs; fail once.""" + command = [self.bin, '-q', query, '-t', target, '--outMode=C', + '--outCsvCols=id1,id2,E', '--outSep=;', '--outNumber=1', + '--outPerRegion=0', f'--outPairwise={int(pairwise)}', + f'--threads={self.threads}'] + if self.parameterFile: + command.append(f'--parameterFile={self.parameterFile}') + reader = csv.reader(io.StringIO(run_command(command, input_text)), delimiter=';', strict=True) + header = csv_header(reader) + if header != ['id1', 'id2', 'E']: + raise ValueError('IntaRNA returned an unexpected CSV header') + rows = [] + for target_id, query_id, value in csv_rows(reader, header): + score = float(value) + if not math.isfinite(score): + raise ValueError('IntaRNA returned a nonfinite energy') + rows.append((target_id, query_id, score)) + return rows + + def get_single_score(self, query, target): + """Return the observed energy, including zero, or None for no interaction.""" + rows = self._predict(query, target) + if len(rows) > 1: + raise ValueError('IntaRNA returned multiple energies for one pair') + return rows[0][2] if rows else None + + def get_scores(self): + """Collect favorable scores in bounded batches; reject invalid/duplicate IDs.""" + scores, attempts = [], 0 + # Two files allow IntaRNA's pairwise mode to pair independent query/target + # shuffles by index, avoiding a Cartesian product and repeated query draws. + with TemporaryDirectory(prefix='IntaRNApvalue-') as directory: + query_file = Path(directory) / 'query.fa' + target_file = Path(directory) / 'target.fa' + while len(scores) < self.samples and attempts < self.max_attempts: + count = min(1000, self.samples - len(scores), self.max_attempts - attempts) + if self.shuffle_query and self.shuffle_target: + query_file.write_text(self.to_fasta(self.shuffle_sequence(self.query, count, self.rng)), + encoding='utf-8') + target_file.write_text(self.to_fasta(self.shuffle_sequence(self.target, count, self.rng)), + encoding='utf-8') + rows = self._predict(str(query_file), str(target_file), pairwise=True) + elif self.shuffle_query: + fasta = self.to_fasta(self.shuffle_sequence(self.query, count, self.rng)) + rows = self._predict('STDIN', self.target, fasta) + else: + fasta = self.to_fasta(self.shuffle_sequence(self.target, count, self.rng)) + rows = self._predict(self.query, 'STDIN', fasta) + batch = {} + for target_id, query_id, score in rows: + identifier = query_id if self.shuffle_query else target_id + index = int(identifier) + if not 0 <= index < count or index in batch: + raise ValueError('IntaRNA returned an invalid or duplicate shuffled-pair ID') + if self.shuffle_query and self.shuffle_target and target_id != query_id: + raise ValueError('IntaRNA returned an unpaired shuffle result') + batch[index] = score + scores.extend(batch[index] for index in sorted(batch)) + attempts += count + if len(scores) != self.samples: + raise RuntimeError(f'Only {len(scores)} of {self.samples} favorable shuffled interactions ' + f'found in {attempts} attempts; adjust constraints or --max-attempts') + return scores, attempts - len(scores) @staticmethod - def calculate_pvalue_empirical(original_score, scores: list = None) -> float: - """Calculates a p-value to a target/query combination empirical with a given amount of shuffle iterations - - >>> i = IntaRNApvalue(['-q', 'AGGAUG', '-t', 'UUUAUCGUU', '--scores', '10', '-m', 'b', '--threads', '3']) - >>> i.calculate_pvalue_empirical(-10.0, [-1.235, -1.435645, -6.234234, -12.999, -15.23, -6.98, -6.23, -2.78]) - 0.25 - """ - return [score <= original_score for score in scores].count(True) / len(scores) + def _sample(original_score, scores, *, fit=False): + """Reject nonfinite, empty or degenerate fitting data before SciPy runs.""" + if not math.isfinite(original_score): + raise ValueError('observed energy must be finite') + values = list(scores) + if not values or not all(math.isfinite(x) for x in values): + raise ValueError('scores must contain finite energies') + if fit and (len(values) < 3 or len(set(values)) < 3): + raise ValueError('distribution fitting needs at least three distinct energies; use empirical otherwise') + return values @staticmethod - def calculate_pvalue_gauss(original_score, scores: list) -> float: - """Calculates a p-value to a target/query combination by int. with a given amount of shuffle iterations by - fitting a gaussian distribution and integrating from -inf to the original score - - >>> i = IntaRNApvalue(['-q', 'AGGAUG', '-t', 'UUUAUCGUU', '--scores', '10', '-m', 'b', '--threads', '3']) - >>> i.calculate_pvalue_gauss(-10.0, [-1.235, -1.435645, -6.234234, -12.999, -15.23, -6.98, -6.23, -2.78]) - 0.2429106747265256 - """ - loc, scale = gauss.fit(scores) - - def f(x): - return gauss.pdf(x, loc=loc, scale=scale) - return integ(f, -np.inf, original_score)[0] + def calculate_pvalue_empirical(original_score, scores): + """Fraction of favorable shuffled energies at least as low as observed.""" + values = IntaRNApvalue._sample(original_score, scores) + return sum(score <= original_score for score in values) / len(values) @staticmethod - def calculate_pvalue_gumbel(original_score: float, scores: list) -> float: - """Calculates a p-value to a target/query combination by int. with a given amount of shuffle iterations by - fitting a gumbel distribution and integrating from -inf to the original score - - >>> i = IntaRNApvalue(['-q', 'AGGAUG', '-t', 'UUUAUCGUU', '--scores', '10', '-m', 'b', '--threads', '3']) - >>> i.calculate_pvalue_gumbel(-10.0, [-1.235, -1.435645, -6.234234, -12.999, -15.23, -6.98, -6.23, -2.78]) - 0.19721934073203196 - """ - loc, scale = gum.fit(scores) - - def f(x): - return gum.pdf(x, loc=loc, scale=scale) - return integ(f, -np.inf, original_score)[0] + def _fitted_pvalue(original_score, scores, name): + from scipy import stats + values = IntaRNApvalue._sample(original_score, scores, fit=True) + distribution = getattr(stats, name) + # Keep the historical fits on raw energies (including the GEV shape). + # The genome-wide CSV tool fits negated energies: a different model. + params = distribution.fit(values) + if not all(math.isfinite(x) for x in params) or params[-1] <= 0: + raise ValueError('distribution fit returned invalid parameters') + if not all(math.isfinite(float(x)) for x in distribution.logpdf(values, *params)): + raise ValueError('distribution fit does not cover all sampled energies') + result = float(distribution.cdf(original_score, *params)) + if not math.isfinite(result) or not 0 <= result <= 1: + raise ValueError('distribution fit returned an invalid p-value') + return result @staticmethod - def calculate_pvalue_gev(original_score: float, scores: list) -> float: - """Calculates a p-value to a target/query combination by int. with a given amount of shuffle iterations by - fitting a generalized extreme value distribution and integrating from -inf to the original score + def calculate_pvalue_gauss(original_score, scores): + return IntaRNApvalue._fitted_pvalue(original_score, scores, 'norm') - >>> i = IntaRNApvalue(['-q', 'AGGAUG', '-t', 'UUUAUCGUU', '--scores', '10', '-m', 'b', '--threads', '3']) - >>> i.calculate_pvalue_gev(-10.0, [-1.235, -1.435645, -6.234234, -12.999, -15.23, -6.98, -6.23, -2.78]) - 0.17611816922560236 - """ - shape, loc, scale = gev.fit(scores) + @staticmethod + def calculate_pvalue_gumbel(original_score, scores): + return IntaRNApvalue._fitted_pvalue(original_score, scores, 'gumbel_l') - def f(x): - return gev.pdf(x, shape, loc=loc, scale=scale) - return integ(f, -np.inf, original_score)[0] + @staticmethod + def calculate_pvalue_gev(original_score, scores): + return IntaRNApvalue._fitted_pvalue(original_score, scores, 'genextreme') + + def main(self): + """Publish output only after every required prediction and fit succeeds.""" + original_score = None + if self.output != 'scores': + original_score = self.get_single_score(self.query, self.target) + if original_score is None: + raise ValueError('The query/target combination has no favorable interaction') + scores, non_interactions = self.get_scores() + if non_interactions: + print(f'Skipped {non_interactions} shuffled pairs without a reported interaction; ' + 'p-values are conditional on reported interactions.', file=sys.stderr) + if self.output == 'scores': + print('\n'.join(map(str, scores))) + else: + calculators = {'gauss': self.calculate_pvalue_gauss, 'gumbel': self.calculate_pvalue_gumbel, + 'gev': self.calculate_pvalue_gev, 'empirical': self.calculate_pvalue_empirical, + 'none': self.calculate_pvalue_empirical} + print(calculators[self.dist](original_score, scores)) + + +def main(argv=None): + """CLI entry point; --help works without IntaRNA, NumPy or SciPy installed.""" + try: + tool = IntaRNApvalue(sys.argv[1:] if argv is None else argv) + tool.main() + except ImportError as error: + print(f'Missing Python dependency: {error}. Install numpy and scipy.', file=sys.stderr) + return 1 + except (OSError, ValueError, RuntimeError, csv.Error) as error: + print(f'Error: {error}', file=sys.stderr) + return 1 + return 0 if __name__ == '__main__': - i = IntaRNApvalue() + raise SystemExit(main()) diff --git a/python/tests/test_intarnapvalue.py b/python/tests/test_intarnapvalue.py new file mode 100644 index 00000000..e48e2a6f --- /dev/null +++ b/python/tests/test_intarnapvalue.py @@ -0,0 +1,184 @@ +"""Sampling and statistical regressions, independent of an installed IntaRNA.""" + +from collections import Counter +import io +import json +import math +import os +from pathlib import Path +import random +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from intarnapvalue.intarna_pvalue import IntaRNApvalue, main +from intarnapvalue.dinucl_shuffle import dinucl_shuffle +from intarna_tools import read_sequence + +QUERY = 'AGGAUGGGGGA' +TARGET = 'CCGCUUCUCGUUCCU' +ARGS = ['-q', QUERY, '-t', TARGET, '-s', '5', '-m', 'b', '--randSeed', 'reproducible'] + + +def fasta_sequences(text): + return text.splitlines()[1::2] + + +class PvalueTests(unittest.TestCase): + def tool(self, *args): + with patch.object(IntaRNApvalue, 'find_binary', return_value='/tmp/IntaRNA with spaces'): + return IntaRNApvalue([*ARGS, *args]) + + def test_normalized_wrapped_single_fasta_and_invalid_inputs(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'seq.fa' + path.write_text('\ufeff>sequence\nacgt\ntgca\n') + self.assertEqual(read_sequence(str(path)), 'ACGUUGCA') + self.assertEqual(read_sequence('a' * 10000), 'A' * 10000) + for invalid in ('', '>empty\n', 'ACGU\n', '>one\nAC\n>two\nGU\n', '>one\nACN\n'): + path.write_text(invalid) + with self.assertRaises(ValueError): + read_sequence(str(path)) + for args in (['-q', ''], ['-q', 'AX'], ['-s', '0'], ['--threads=-1'], ['--max-attempts', '4']): + with patch('sys.stderr', new_callable=io.StringIO), self.assertRaises(SystemExit): + self.tool(*args) + + def test_shuffle_composition_endpoints_and_singletons(self): + rng = random.Random(13) + for seq in ('A', 'acgt', 'G' * 1000, QUERY, TARGET): + normalized = seq.upper().replace('T', 'U') + for _ in range(30): + shuffled = dinucl_shuffle(seq, rng) + self.assertEqual(len(shuffled), len(normalized)) + self.assertEqual((shuffled[0], shuffled[-1]), (normalized[0], normalized[-1])) + self.assertEqual(Counter(shuffled), Counter(normalized)) + self.assertEqual(Counter(zip(shuffled, shuffled[1:])), + Counter(zip(normalized, normalized[1:]))) + for seq in ('', 'N', 'AX'): + with self.assertRaises(ValueError): + dinucl_shuffle(seq) + + def test_local_rng_deterministic_and_no_global_reseed(self): + state = random.getstate() + a, b = self.tool(), self.tool() + self.assertEqual(random.getstate(), state) + self.assertEqual(a.shuffle_sequence(QUERY, 20, a.rng), b.shuffle_sequence(QUERY, 20, b.rng)) + self.assertEqual(random.getstate(), state) + + def test_both_strands_independent_pairwise_and_cleanup(self): + tool = self.tool() + seen = [] + def predict(query, target, input_text=None, *, pairwise=False): + self.assertTrue(pairwise) + self.assertIsNone(input_text) + q, t = Path(query), Path(target) + seen.extend((q, t)) + queries, targets = fasta_sequences(q.read_text()), fasta_sequences(t.read_text()) + self.assertEqual(len(queries), 5) + self.assertEqual(len(targets), 5) + self.assertGreater(len(set(queries)), 1) + for sequences, original in ((queries, QUERY), (targets, TARGET)): + for seq in sequences: + self.assertEqual(Counter(seq), Counter(original)) + self.assertEqual(Counter(zip(seq, seq[1:])), Counter(zip(original, original[1:]))) + return [(str(i), str(i), -i) for i in reversed(range(5))] + with patch.object(tool, '_predict', side_effect=predict): + self.assertEqual(tool.get_scores(), ([0, -1, -2, -3, -4], 0)) + self.assertTrue(all(not p.exists() for p in seen)) + + def test_each_single_strand_mode_and_retry_counts(self): + for mode in ('q', 't'): + tool = self.tool('-m', mode) + attempts = [] + def predict(query, target, input_text=None): + self.assertEqual((query, target), ('STDIN', TARGET) if mode == 'q' else (QUERY, 'STDIN')) + seqs = fasta_sequences(input_text) + attempts.append(len(seqs)) + # Reject one pair on the first pass, then fill the remainder. + start = 1 if len(attempts) == 1 else 0 + return [('fixed' if mode == 'q' else str(i), str(i) if mode == 'q' else 'fixed', -i) + for i in range(start, len(seqs))] + with patch.object(tool, '_predict', side_effect=predict): + scores, skipped = tool.get_scores() + self.assertEqual((len(scores), skipped), (5, 1)) + self.assertEqual(attempts, [5, 1]) + + def test_bounded_no_interactions_and_fail_fast(self): + tool = self.tool('--max-attempts', '7') + with patch.object(tool, '_predict', return_value=[]) as run, self.assertRaisesRegex(RuntimeError, '7 attempts'): + tool.get_scores() + self.assertEqual(run.call_count, 2) + with patch.object(tool, '_predict', side_effect=RuntimeError('command failed')) as run, \ + self.assertRaisesRegex(RuntimeError, 'command failed'): + tool.get_scores() + self.assertEqual(run.call_count, 1) + for rows in ([('0', '0', -1), ('0', '0', -2)], [('0', '1', -1)], [('5', '5', -1)]): + with patch.object(tool, '_predict', return_value=rows), self.assertRaises(ValueError): + tool.get_scores() + + def test_parameter_file_all_predictions_and_zero_energy(self): + tool = self.tool() + tool.parameterFile = '/tmp/parameter file with spaces' + with patch('intarnapvalue.intarna_pvalue.run_command', return_value='id1;id2;E\nt;q;0\n') as run: + self.assertEqual(tool.get_single_score(QUERY, TARGET), 0) + tool._predict('STDIN', TARGET, '>0\nAAA\n') + for call in run.call_args_list: + command = call.args[0] + self.assertIn('--parameterFile=/tmp/parameter file with spaces', command) + self.assertIn('--outNumber=1', command) + self.assertNotIn('', command) + for bad in ('E\n-1\n', 'id1;id2;E\na;b;nan\n', 'id1;id2;E\na;b\n'): + with patch('intarnapvalue.intarna_pvalue.run_command', return_value=bad), self.assertRaises(ValueError): + tool.get_single_score(QUERY, TARGET) + with patch('intarnapvalue.intarna_pvalue.run_command', return_value='id1;id2;E\n'): + self.assertIsNone(tool.get_single_score(QUERY, TARGET)) + + def test_cdf_matches_independent_legacy_integration(self): + from scipy import integrate, stats + scores = [-1.235, -1.435645, -6.234234, -12.999, -15.23, -6.98, -6.23, -2.78] + for name, calculate in (('norm', IntaRNApvalue.calculate_pvalue_gauss), + ('gumbel_l', IntaRNApvalue.calculate_pvalue_gumbel), + ('genextreme', IntaRNApvalue.calculate_pvalue_gev)): + distribution = getattr(stats, name) + params = distribution.fit(scores) + expected = integrate.quad(lambda x: distribution.pdf(x, *params), -math.inf, -10)[0] + self.assertAlmostEqual(calculate(-10, scores), expected, places=7) + self.assertLessEqual(calculate(-20, scores), calculate(-10, scores)) + self.assertEqual(IntaRNApvalue.calculate_pvalue_empirical(-10, scores), 0.25) + self.assertEqual(IntaRNApvalue.calculate_pvalue_empirical(0, [0]), 1) + for scores in ([], [float('nan')], [float('inf')], [-1, -1, -1], [-1, -2]): + for method in (IntaRNApvalue.calculate_pvalue_gev, IntaRNApvalue.calculate_pvalue_gauss, + IntaRNApvalue.calculate_pvalue_gumbel): + with self.assertRaises(ValueError): + method(-1, scores) + + def test_cli_help_without_binary_or_dependencies(self): + result = subprocess.run([sys.executable, '-S', '-m', 'intarnapvalue', '--help'], + env={**os.environ, 'PYTHONPATH': str(ROOT), 'PATH': ''}, + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('--samples', result.stdout) + + def test_cli_empirical_scores_and_errors(self): + with patch.object(IntaRNApvalue, 'find_binary', return_value='IntaRNA'), \ + patch.object(IntaRNApvalue, 'get_single_score', return_value=0), \ + patch.object(IntaRNApvalue, 'get_scores', return_value=([-1, 0, -2, -3, -4], 2)), \ + patch('sys.stdout', new_callable=io.StringIO) as stdout, \ + patch('sys.stderr', new_callable=io.StringIO) as stderr: + self.assertEqual(main([*ARGS, '-d', 'empirical']), 0) + self.assertEqual(stdout.getvalue(), '1.0\n') + self.assertIn('conditional', stderr.getvalue()) + with patch.object(IntaRNApvalue, 'find_binary', side_effect=RuntimeError('binary missing')), \ + patch('sys.stdout', new_callable=io.StringIO) as stdout, \ + patch('sys.stderr', new_callable=io.StringIO) as stderr: + self.assertEqual(main(ARGS), 1) + self.assertEqual(stdout.getvalue(), '') + self.assertIn('binary missing', stderr.getvalue()) + + +if __name__ == '__main__': + unittest.main() From 7b1412b505ce5659326616bef8e20b3860dc9406 Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Sat, 24 Oct 2026 00:51:07 +0200 Subject: [PATCH 5/6] Unify Python packaging, documentation and regression coverage --- .github/workflows/build.yml | 4 +- .github/workflows/python-output-tools.yml | 16 +++- .gitignore | 8 ++ ChangeLog | 23 ++++++ configure.ac | 2 +- python/CopomuS.py | 9 ++- python/DEVELOPMENT.md | 91 +++++++++++++++++++++++ python/MANIFEST.in | 6 ++ python/Makefile.am | 37 ++++----- python/README.md | 46 ++++++++++-- python/bin/Makefile.am | 2 + python/bin/README.md | 16 ++++ python/bin/build.py | 34 +++++---- python/copomus/Makefile.am | 2 + python/copomus/measures.py | 51 ++++++------- python/intarna_tools.py | 5 +- python/intarnapvalue/Makefile.am | 2 + python/intarnapvalue/README.md | 4 + python/intarnapvalue/dinucl_shuffle.py | 31 ++++++-- python/intarnapvalue/test.py | 12 +-- python/pyproject.toml | 3 + python/requirements-output-tools.txt | 2 +- python/setup.py | 23 +++--- python/tests/test_common_tools.py | 89 ++++++++++++++++++++++ python/tests/test_copomus.py | 12 +++ python/tests/test_intarnapvalue.py | 11 +++ python/tests/test_native_tools.py | 73 ++++++++++++++++++ 27 files changed, 505 insertions(+), 109 deletions(-) create mode 100644 python/DEVELOPMENT.md create mode 100644 python/MANIFEST.in create mode 100644 python/bin/README.md create mode 100644 python/pyproject.toml create mode 100644 python/tests/test_common_tools.py create mode 100644 python/tests/test_native_tools.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 18668a64..d7f73eab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master] + branches: [master, feat/294-python-intarna-fuse] defaults: run: @@ -105,6 +105,8 @@ jobs: make -j 2 make install make tests -j 2 + python3 -m pip install -r python/requirements-output-tools.txt + PATH="$RUNNER_TEMP/intarna-install/bin:$PATH" make -C python check-python-tools "$RUNNER_TEMP/intarna-install/bin/IntaRNA" -h - name: Compile every installed public header independently diff --git a/.github/workflows/python-output-tools.yml b/.github/workflows/python-output-tools.yml index 9a456bb4..9ee186b1 100644 --- a/.github/workflows/python-output-tools.yml +++ b/.github/workflows/python-output-tools.yml @@ -1,13 +1,12 @@ -name: Python output tools +name: Python companion tools on: push: branches: [master] pull_request: - branches: [master] jobs: - output-tools: + companion-tools: runs-on: ubuntu-24.04 strategy: matrix: @@ -21,7 +20,16 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} - - run: python -m pip install ${{ matrix.dependencies }} + - run: python -m pip install setuptools wheel ${{ matrix.dependencies }} - run: python -m unittest discover -s python/tests -v env: MPLBACKEND: Agg + - run: python tests/testIntaRNAFuse.py + - name: Check installed commands away from the checkout + run: | + python -m venv --system-site-packages "$RUNNER_TEMP/companions" + "$RUNNER_TEMP/companions/bin/python" -m pip install --no-deps --no-build-isolation ./python + cd "$RUNNER_TEMP" + for tool in CopomuS.py IntaRNA-fuse.py IntaRNA_CSV_p-value.py IntaRNA_plotRegions.py IntaRNApvalue; do + "$RUNNER_TEMP/companions/bin/$tool" --help + done diff --git a/.gitignore b/.gitignore index ee819cc0..aa570ee3 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,11 @@ python/intarnapvalue.* /IntaRNA.pc /IntaRNA.exe.stackdump /py-compile + +# Python companion-tool build products +__pycache__/ +*.py[cod] +*.egg-info/ +python/build/ +python/dist/ +python/bin/build/ diff --git a/ChangeLog b/ChangeLog index e6bbb86a..8ec95e1b 100644 --- a/ChangeLog +++ b/ChangeLog @@ -221,6 +221,9 @@ energies, restricted partition sums, and trackers. # Auxiliary Python output tools +- align companion tools on Python 3.10+, declare pip dependencies, install shared + modules, and exercise source/installed/native commands in CI (PR #298) + - replace both R scripts with Python GEV/FDR estimation and region plotting; document optional dependencies, migration and regression tests (issue #295) @@ -240,6 +243,26 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261024 Alexander Mitrofanov + * python/setup.py, python/pyproject.toml, python/MANIFEST.in, python/*/Makefile.am, + python/Makefile.am, configure.ac, python/intarna_tools.py + * Declare Python 3.10+ and NumPy/SciPy dependencies, offer optional plotting, + and install shared helpers alongside standalone scripts and Python modules. + * Replace legacy setup.py installation with an opt-in pip hook honoring DESTDIR + without fetching dependencies or uninstalling packages in another prefix. + * python/bin/build.py, python/intarnapvalue/test.py, python/README.md, + python/bin/README.md, python/DEVELOPMENT.md, python/requirements-output-tools.txt + * Make executable builds independent of the working directory and fail visibly; + refresh documentation, source-test entry point and audit/performance notes. + * Preserve direct invocation of the shuffle utility with argparse validation, + optional sample counts and local random seeds. + * python/copomus/measures.py, python/CopomuS.py, python/tests/*.py, + .github/workflows/{build,python-output-tools}.yml, .gitignore + * Validate shared profiles and thresholds; test malformed/NA profile data, + installed lookup, atomic writes, native sampling and mutation workflows. + * Exercise all companion tools and installation in Python/C++ CI, including + the dependent PR base; exclude Python caches/build outputs (PR #298). + 261024 Alexander Mitrofanov * python/intarnapvalue/intarna_pvalue.py, python/intarnapvalue/__main__.py * Independently shuffle the correct target and query in pairwise batches; diff --git a/configure.ac b/configure.ac index 00d86401..db1bf57b 100644 --- a/configure.ac +++ b/configure.ac @@ -8,7 +8,7 @@ BOOST_REQUIRED_VERSION=1.50.0 # minimal Vienna RNA version providing the logger locking API VRNA_REQUIRED_VERSION=2.7.0 # minimal required version of python interpreter (to install intarnapvalue) -PYTHON_REQUIRED_VERSION=3.6 +PYTHON_REQUIRED_VERSION=3.10 AC_CANONICAL_HOST diff --git a/python/CopomuS.py b/python/CopomuS.py index 4e653598..d2460fc9 100644 --- a/python/CopomuS.py +++ b/python/CopomuS.py @@ -5,9 +5,8 @@ __intarna_min__ = '3.1.2' -__python_min__ = '3.10.0' -import os +import math import sys @@ -98,8 +97,8 @@ def parse_cmd_args(self, argv=None): } parser = ArgumentParser(description='Checks different measures for rating mutations') - parser.add_argument('-q', '--query', dest='query', required=True, help='The query sequence.') - parser.add_argument('-t', '--target', dest='target', required=True, help='The target sequence.') + parser.add_argument('-q', '--query', dest='query', required=True, help='Query RNA/DNA sequence or single-record FASTA file.') + parser.add_argument('-t', '--target', dest='target', required=True, help='Target RNA/DNA sequence or single-record FASTA file.') parser.add_argument('--qIdxPos0', dest='qidxpos0', default=1, type=int, help='The starting index for the query. (Default: 1)') parser.add_argument('--tIdxPos0', dest='tidxpos0', default=1, type=int, @@ -143,6 +142,8 @@ def parse_cmd_args(self, argv=None): try: args.query = read_sequence(args.query) args.target = read_sequence(args.target) + if not all(math.isfinite(value) for value in (args.alpha, args.beta)): + raise ValueError('alpha and beta must be finite') if args.param_file and not Path(args.param_file).is_file(): raise ValueError(f'Cannot find provided parameter file: {args.param_file}') if len(args.delimiter) != 1 or args.delimiter in '\r\n\0': diff --git a/python/DEVELOPMENT.md b/python/DEVELOPMENT.md new file mode 100644 index 00000000..9e079875 --- /dev/null +++ b/python/DEVELOPMENT.md @@ -0,0 +1,91 @@ +# Companion-tool audit (PR #298) + +The branch builds on `feat/294-python-intarna-fuse` (PR #296). The R-to-Python +migration, shared infrastructure, CopomuS fixes, IntaRNApvalue fixes, and +packaging/validation are separate commits. + +## Survey and common conventions + +| Area | Review outcome | +| --- | --- | +| IntaRNA-fuse | Retain its fusion model, decimal energies and literal-sequence interface; share checked subprocess calls and executable lookup. | +| CSV p-values / region plots | Retain positional interfaces and the R-compatible formulas; share strict CSV header/row validation and atomic CSV publication. | +| CopomuS | Share RNA/FASTA validation and subprocess handling; fix explicit mutation dispatch, CG/GC filtering, signed indexing, zero/NA handling, ranking, and profile ownership. | +| IntaRNApvalue | Correct both-strand sampling, consistent constraints and failure handling; share FASTA and executable handling; expose empirical estimates and preserve fitted models. | +| Dinucleotide shuffling | Normalize at entry, handle length one, use an optional RNG, and consume adjacency lists with deques. | +| Packaging / executable builder | Declare Python 3.10 and real scientific dependencies; install shared modules with both pip and Autotools; replace shell-based PyInstaller calls and cleanup tied to the caller's directory. | + +`intarna_tools.py` contains only standard-library I/O and validation. Scientific +calculations stay in their tools: the genome-wide CSV fit uses negated energies, +whereas IntaRNApvalue retains its historical raw-energy fitted distributions. +Combining these fits as one helper would change one tool's statistical model. +Likewise, fusion requires literal sequences for constraints, while CopomuS and +IntaRNApvalue can read a single FASTA record. + +All entry points have side-effect-free imports and `main(argv)` interfaces. +Existing options remain; `GC` is an alias of CopomuS's advertised `CG` filter. +IntaRNApvalue adds `--max-attempts` and `-d empirical` (`none` alias). Sampled +p-values remain conditional on a reported interaction. Errors never enter +numeric/CSV stdout, and CopomuS/output-analysis CSV files are replaced only after +successful computation. See each tool's README for interpretation and examples. + +## Correctness and performance checks + +The Python unittest suite covers malformed/quoted CSV, zero/NA values, signed +coordinate round trips, explicit mutation bounds, all plot formats, independent +shuffle pairing, composition preservation, retry limits, subprocess errors, +parameter forwarding, and old-integration/new-CDF equivalence. Native checks +exercise both/single-strand sampling, deterministic output across thread counts, +profile combinations, explicit mutations and failure limits. The fusion suite +retains its independent synthetic energy oracle and optional native check. + +CopomuS reuses the one wildtype prediction and immutable profile contents. +For N mutation candidates, measurement calls decrease from 4N to 3N+1, with +candidate-selection calls unchanged. A two-candidate regression verifies seven +calls and complete temporary-file cleanup. Filter membership uses sets without +changing candidate order. No native predictor code or defaults are changed. + +Local comparison on Linux, Python 3.12.7, the same GCC 14 release IntaRNA binary, +ViennaRNA 2.7.2, and one IntaRNA thread (three runs each): + +```bash +python3 CopomuS.py \ + -q GCUGAAAAACAUAACCCAUAAAAUGCUAGCUGUACCAGGAACCA \ + -t GGUUUCUUCGCCUCUGCGUUCACCAAAGUGUUCACCC --threads 1 -m E +``` + +Comparing pre-review commit `b4a9c99` with the revised sources, all columns, +mutation order, ranks and energy values match after normalizing line endings. +Wall times measured by `/usr/bin/time -f '%e %M'` were 2.90/2.94/2.97 seconds +before and 2.22/2.29/2.23 seconds after. Peak RSS ranges were 17,704–17,712 KiB +and 17,584–17,712 KiB. These are workload-specific measurements made during a +concurrent build, not a general throughput guarantee. + +For the shuffle loop, use `s = 'AGGAUGGGGGACCUUCUCGUUCCU' * 4000`, reset +`random.seed(7)` before each call, and time `dinucl_shuffle(s)` with +`time.perf_counter()`. Three runs took 0.239–0.243 seconds before and 0.093–0.095 +seconds after, with byte-identical shuffles. Deque consumption removes repeated +front deletion from Python lists; mononucleotide/dinucleotide counts and endpoint +preservation are tested separately. Both versions retain adjacency storage +linear in sequence length. + +## Reproducing validation + +```bash +python3 -m pip install -r python/requirements-output-tools.txt +python3 -m unittest discover -s python/tests -v +python3 tests/testIntaRNAFuse.py +bash autotools-init.sh +./configure # supply your compiler and dependency prefixes as needed +make tests -j2 +make -C python check-python-tools +make install +make dist +``` + +Place a native IntaRNA on PATH to enable the optional native Python tests and +RNAfold on PATH for the fusion integration test. CI covers Python 3.10 with +minimum dependency versions, Python 3.13 with current dependencies, installed +entry points, and native execution in the C++ build matrix. PyInstaller itself +is optional; tests check its invocation and failure propagation without building +a platform-specific frozen executable. diff --git a/python/MANIFEST.in b/python/MANIFEST.in new file mode 100644 index 00000000..5d01a021 --- /dev/null +++ b/python/MANIFEST.in @@ -0,0 +1,6 @@ +include README.md DEVELOPMENT.md requirements-output-tools.txt +recursive-include copomus *.md *.jpg +recursive-include intarnapvalue *.md +recursive-include examples *.csv *.params +recursive-include tests *.py +include plotRegions.example.png diff --git a/python/Makefile.am b/python/Makefile.am index 0b7f7ded..722eab9a 100644 --- a/python/Makefile.am +++ b/python/Makefile.am @@ -1,40 +1,33 @@ - -# sub directories to check for Makefiles +# Companion modules and optional executable builder. SUBDIRS = copomus intarnapvalue bin - -# scripts to be installed in $(bindir) directly - dist_bin_SCRIPTS = \ CopomuS.py \ IntaRNA-fuse.py \ IntaRNA_CSV_p-value.py \ IntaRNA_plotRegions.py -# auxiliary scripts to be part of distribution +# Standalone scripts import this module from their installed directory. +dist_pythonhelpers_DATA = intarna_tools.py +pythonhelpersdir = $(bindir) noinst_PYTHON = setup.py -# install python modules if HAVE_PYTHON - if INSTALL_INTARNAPVALUE - -# ensure pip is installed -# create python directory if non-existing with current prefix +# Install modules using the selected interpreter without fetching dependencies. +# Users install NumPy/SciPy in that interpreter's environment as documented. install-exec-hook: - @ $(PYTHON) -m ensurepip --default-pip - @ $(MKDIR_P) -p ${pythondir} - @ export PYTHONPATH=$PYTHONPATH:${pythondir}; \ - $(PYTHON) setup.py install --prefix=${prefix} - + $(PYTHON) -m pip install --ignore-installed --no-deps --no-build-isolation \ + --prefix="$(DESTDIR)$(prefix)" "$(abs_srcdir)" endif - endif -# Optional output-analysis tools; dependencies are installed by the user. -EXTRA_DIST = README.md requirements-output-tools.txt plotRegions.example.png \ - examples/regions.csv examples/regions.params tests/test_output_tools.py +EXTRA_DIST = README.md DEVELOPMENT.md requirements-output-tools.txt pyproject.toml MANIFEST.in \ + plotRegions.example.png examples/regions.csv examples/regions.params \ + tests/test_output_tools.py tests/test_copomus.py tests/test_intarnapvalue.py \ + tests/test_common_tools.py tests/test_native_tools.py -.PHONY: check-output-tools -check-output-tools: +.PHONY: check-output-tools check-python-tools +check-output-tools: check-python-tools +check-python-tools: $(PYTHON) -m unittest discover -s $(srcdir)/tests -v diff --git a/python/README.md b/python/README.md index 561aeb8c..f6d0c219 100644 --- a/python/README.md +++ b/python/README.md @@ -1,5 +1,36 @@ # Auxiliary Python tools +All companion tools require **Python 3.10+**. They use `argparse` for `--help`, +write results to stdout or the requested output file, and report failures on +stderr with a nonzero exit status. Existing positional arguments and option +names remain supported. CopomuS and IntaRNApvalue accept RNA/DNA literals or +one-record FASTA files; IntaRNA-fuse retains literal sequences (including N). + +| Tool | Purpose | Additional dependencies | +| --- | --- | --- | +| [IntaRNA-fuse.py](#intarna-fusepy) | Combine compatible interaction sites | IntaRNA, RNAfold | +| [CopomuS.py](copomus/README.md) | Rank compensatory point mutations | IntaRNA >= 3.1.2 | +| [IntaRNApvalue](intarnapvalue/README.md) | Significance from shuffled sequences | IntaRNA; NumPy/SciPy for fits | +| [IntaRNA_CSV_p-value.py](#intarna_csv_p-valuepy) | Genome-wide energy p-values/FDRs | NumPy, SciPy | +| [IntaRNA_plotRegions.py](#intarna_plotregionspy) | Plot interaction regions | NumPy, SciPy, Matplotlib | + +Run from the checkout or install all commands/modules with +`python3 -m pip install './python[plot]'` from the repository root (omit `[plot]` +if plotting is not needed). Native IntaRNA and RNAfold are installed separately. +`make install` installs the four `.py` scripts with their supporting modules; +`--enable-intarnapvalue` additionally installs the Python package using pip with +no dependency downloads. That optional hook requires pip/setuptools in the +selected Python environment. Install scientific dependencies separately for it. +For a custom prefix outside that interpreter's normal search path, add +`PREFIX/lib/pythonX.Y/site-packages` to `PYTHONPATH` when invoking IntaRNApvalue; +using `pip install` in a virtual environment avoids that manual path setup. +The optional hook does not uninstall an existing package in another prefix. + +`intarna_tools.py` shares checked command execution, executable lookup, input +validation and atomic output between the scripts. Keep it beside scripts when +copying them manually; keep `copomus/` beside `CopomuS.py` as well. Installed +Python packages include the helper automatically. Imports perform no predictions. + ## Output analysis and visualization The former R tools are now standalone Python scripts in this folder. Replace @@ -23,8 +54,8 @@ Run the scripts directly from the checkout as shown below. `make install` also installs both executable `.py` scripts in the configured `bin` directory; their `#!/usr/bin/env python3` interpreter must have the dependencies installed. Autotools does not install these Python dependencies. They are optional for the -C++ IntaRNA predictor. These scripts are independent of the `intarnapvalue` -package installed by `python/setup.py`. +C++ IntaRNA predictor. The fitted CSV and shuffled-sequence workflows have separate statistical models, +although they are distributed together by the Python package. ### `IntaRNA_CSV_p-value.py` @@ -122,10 +153,13 @@ After installing the optional dependencies, run from the repository root: python3 -m unittest discover -s python/tests -v ``` -In a configured build, `make -C python check-output-tools` runs the same tests. +In a configured build, `make -C python check-python-tools` runs the same tests +(`check-output-tools` remains an alias). They cover GEV formulas and known-distribution fitting, BH correction, CSV -preservation and errors, region coordinates and parameters, and every output -format. These tests also run in the Python output-tools CI workflow. +preservation and errors, every plot format, mutation coordinates/ranking/profiles, +shuffled sampling and failure handling. Native smoke tests run when IntaRNA is +available. CI checks minimum/current scientific dependencies on Python 3.10/3.13, +installed command entry points, and native behavior in the C++ build matrix. ## [IntaRNA-fuse.py](IntaRNA-fuse.py) @@ -136,7 +170,7 @@ overlaps the original MFE by at least two nucleotides on **each** strand, it reports a fused interaction using the suboptimal site and that constrained MFE. This preserves the compatibility criterion of the former Perl script. -Requires Python 3.6 or newer (standard library only), IntaRNA, and ViennaRNA's +Requires Python 3.10 or newer (standard library only), IntaRNA, and ViennaRNA's `RNAfold`. `make install` installs `IntaRNA-fuse.py` alongside `IntaRNA`; no pip installation or `--enable-intarnapvalue` is needed. The script looks for executables beside itself and on `PATH`, and also checks `../src/bin/IntaRNA` diff --git a/python/bin/Makefile.am b/python/bin/Makefile.am index e2a46584..f10b522d 100644 --- a/python/bin/Makefile.am +++ b/python/bin/Makefile.am @@ -1,2 +1,4 @@ noinst_PYTHON = build.py + +EXTRA_DIST = README.md diff --git a/python/bin/README.md b/python/bin/README.md new file mode 100644 index 00000000..66c8b2dc --- /dev/null +++ b/python/bin/README.md @@ -0,0 +1,16 @@ +# Optional standalone IntaRNApvalue executable + +From the repository root, in a Python 3.10+ environment: + +```bash +python3 -m pip install ./python pyinstaller +python3 python/bin/build.py +python/bin/build/IntaRNApvalue --help +``` + +The builder uses the invoking interpreter, resolves source paths independently +of the working directory, and creates `python/bin/build/IntaRNApvalue` (with an +`.exe` suffix on Windows). Build metadata is removed automatically. An existing +executable with that name is replaced by PyInstaller. Build on each target OS; +no precompiled binaries are shipped. The resulting executable still needs the +native IntaRNA program beside it or on PATH. diff --git a/python/bin/build.py b/python/bin/build.py index 6ee071a3..c46ec090 100644 --- a/python/bin/build.py +++ b/python/bin/build.py @@ -1,25 +1,27 @@ #!/usr/bin/env python3 - # Copyright 2019 # Author: Fabio Gutmann -import os -import sys +"""Build IntaRNApvalue with PyInstaller without relying on the working directory.""" + +from pathlib import Path import subprocess -from shutil import rmtree, move +import sys +from tempfile import TemporaryDirectory -if __name__ == '__main__': - build_dir = os.path.abspath(os.path.join(__file__, os.pardir)) - main = os.path.abspath(os.path.join(__file__, os.pardir, os.pardir, 'intarnapvalue', '__main__.py')) +def main(): + directory = Path(__file__).resolve().parent + source = directory.parent + # Keep build metadata private; leave only the requested binary in bin/build. + with TemporaryDirectory(prefix='IntaRNApvalue-build-') as temporary: + subprocess.run([sys.executable, '-m', 'PyInstaller', '--noconfirm', '--onefile', + '--name', 'IntaRNApvalue', '--paths', str(source), + '--distpath', str(directory / 'build'), '--workpath', temporary, + '--specpath', temporary, str(source / 'intarnapvalue' / '__main__.py')], + check=True) + return 0 - subprocess.call( f'{sys.executable} -m PyInstaller -y -F "{main}"', shell=True ) - os.remove(os.path.join(build_dir, '__main__.spec')) - rmtree(os.path.join(build_dir, 'build')) - os.mkdir(os.path.join(build_dir, 'build')) - if os.name == 'posix': - move(os.path.join(build_dir, 'dist', '__main__'), os.path.join(build_dir, 'build', 'IntaRNApvalue')) - else: - move(os.path.join(build_dir, 'dist', '__main__.exe'), os.path.join(build_dir, 'build', 'IntaRNApvalue.exe')) - rmtree(os.path.join(build_dir, 'dist')) +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/python/copomus/Makefile.am b/python/copomus/Makefile.am index bd5cdd99..0dc9467e 100644 --- a/python/copomus/Makefile.am +++ b/python/copomus/Makefile.am @@ -11,3 +11,5 @@ dist_copomus_DATA = \ copomusdir = $(bindir)/copomus + +EXTRA_DIST = README.md CopomuS-logo.jpg diff --git a/python/copomus/measures.py b/python/copomus/measures.py index 78a5487b..133359b4 100644 --- a/python/copomus/measures.py +++ b/python/copomus/measures.py @@ -1,6 +1,7 @@ # Copyright 2019 # Author: Fabio Gutmann +import math from copomus.mutation import Mutation from copomus.indexing import idx_to_array_index @@ -18,9 +19,19 @@ def calculate_score(self, data: dict, mut_type: str, m: Mutation = None, param_f def post_compute(self): pass - def read_temp_file(self): - """Return profiles shared by all measures for this prediction.""" - return [self.profiles[pipe] for pipe in self.out_pipes] + def profile_value(self, pipe, index): + """Read a finite profile energy or NA, rejecting malformed/missing rows.""" + header_lines, delimiter, column = (2, '\t', 1) if pipe == 'qAcc' else (1, ';', 2) + try: + value = self.profiles[pipe].splitlines()[index + header_lines].split(delimiter)[column] + except (KeyError, IndexError) as error: + raise ValueError(f'IntaRNA returned an incomplete {pipe} profile') from error + if value == 'NA': + return None + number = float(value) + if not math.isfinite(number): + raise ValueError(f'IntaRNA returned a nonfinite {pipe} profile energy') + return number # noinspection PyTypeChecker def is_valid(self, alpha: float, beta: float = None) -> bool: @@ -168,14 +179,8 @@ def post_compute(self): self.score['is_valid'] = int(self.is_valid(self.alpha, self.beta)) def calculate_score(self, data: dict, mut_type: str, m: Mutation = None, param_file: str = ''): - data = self.read_temp_file()[0] - data = data.strip().split('\n') - value = data[idx_to_array_index(m.query_n, m.qidxpos0) + 1].split(';')[2] - if value == 'NA': - self.score[mut_type] = None - else: - # noinspection PyTypeChecker - self.score[mut_type] = float(value) + self.score[mut_type] = self.profile_value('qMinE', idx_to_array_index(m.query_n, m.qidxpos0)) + def __lt__(self, other): if self.non_i(): @@ -210,14 +215,8 @@ def post_compute(self): # noinspection PyTypeChecker def calculate_score(self, data: dict, mut_type: str, m: Mutation = None, param_file: str = ''): - data = self.read_temp_file()[0] - data = data.strip().split('\n') - value = data[idx_to_array_index(m.target_n, m.tidxpos0) + 1].split(';')[2] - if value == 'NA': - self.score[mut_type] = None - else: - # noinspection PyTypeChecker - self.score[mut_type] = float(value) + self.score[mut_type] = self.profile_value('tMinE', idx_to_array_index(m.target_n, m.tidxpos0)) + def __lt__(self, other): if self.non_i(): @@ -249,12 +248,9 @@ def post_compute(self): self.score['is_valid'] = int(self.is_valid(0)) def calculate_score(self, data: dict, mut_type: str, m: Mutation = None, param_file: str = ''): - data = self.read_temp_file()[0] - data = data.strip().split('\n') - # noinspection PyTypeChecker - self.score[mut_type] = float(data[idx_to_array_index(m.query_n, m.qidxpos0) + 2].split('\t')[1]) + self.score[mut_type] = self.profile_value('qAcc', idx_to_array_index(m.query_n, m.qidxpos0)) + - # noinspection PyTypeChecker def is_valid(self, alpha: float, beta: float = None, gamma: float = 0) -> bool: return self.score['ww'] < self.score['mm'] @@ -292,12 +288,9 @@ def post_compute(self): self.score['is_valid'] = int(self.is_valid(1)) def calculate_score(self, data: dict, mut_type: str, m: Mutation = None, param_file: str = ''): - data = self.read_temp_file()[0] - data = data.strip().split('\n') - # noinspection PyTypeChecker - self.score[mut_type] = float(data[idx_to_array_index(m.query_n, m.qidxpos0) + 2].split('\t')[1]) + self.score[mut_type] = self.profile_value('qAcc', idx_to_array_index(m.query_n, m.qidxpos0)) + - # noinspection PyTypeChecker def is_valid(self, alpha: float, beta: float = None, gamma: float = 0) -> bool: return abs(self.score['ww'] - self.score['mm']) < alpha diff --git a/python/intarna_tools.py b/python/intarna_tools.py index cccf6a27..6f3b9cfd 100644 --- a/python/intarna_tools.py +++ b/python/intarna_tools.py @@ -56,10 +56,13 @@ def read_sequence(value): def find_program(name, script_path=__file__): """Prefer executable siblings, then the source-tree IntaRNA, then PATH.""" - directory = Path(script_path).resolve().parent + directory = Path(sys.executable if getattr(sys, "frozen", False) else script_path).resolve().parent candidates = [directory / name] if name == 'IntaRNA': candidates.append(directory.parent / 'src' / 'bin' / name) + if script_path == __file__ and not getattr(sys, 'frozen', False): + # pip installs the shared module in site-packages, separate from bin. + candidates.append(Path(sys.argv[0]).absolute().parent / name) for candidate in candidates: found = shutil.which(str(candidate)) if found: diff --git a/python/intarnapvalue/Makefile.am b/python/intarnapvalue/Makefile.am index aef8d658..a2749a4c 100644 --- a/python/intarnapvalue/Makefile.am +++ b/python/intarnapvalue/Makefile.am @@ -5,3 +5,5 @@ noinst_PYTHON = \ dinucl_shuffle.py \ intarna_pvalue.py \ test.py + +EXTRA_DIST = README.md diff --git a/python/intarnapvalue/README.md b/python/intarnapvalue/README.md index 4b939c99..1fd00b0f 100644 --- a/python/intarnapvalue/README.md +++ b/python/intarnapvalue/README.md @@ -84,6 +84,10 @@ Corrected both-strand sampling changes results from earlier releases. Batch results are restored to input order so thread scheduling does not reorder scores. Diagnostics use stderr; successful stdout contains only the requested numbers. +The internal shuffle utility also remains directly runnable: +`python3 python/intarnapvalue/dinucl_shuffle.py ACGU --samples 10 --randSeed example` +from the repository root (default: 4000 shuffles). + ## Python API and tests Passing an argument list configures an instance without running predictions: diff --git a/python/intarnapvalue/dinucl_shuffle.py b/python/intarnapvalue/dinucl_shuffle.py index b443ad09..ca419d9a 100644 --- a/python/intarnapvalue/dinucl_shuffle.py +++ b/python/intarnapvalue/dinucl_shuffle.py @@ -9,10 +9,15 @@ # right fashion. +import argparse +from pathlib import Path import sys import random from collections import deque -from intarna_tools import normalize_sequence +# Preserve direct invocation of this historical standalone utility. +if __name__ == '__main__' and not __package__: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from intarna_tools import normalize_sequence, positive_integer def compute_count_and_lists(s): @@ -167,10 +172,22 @@ def dinucl_shuffle(s, rng=None): return t +def main(argv=None): + """Print composition-preserving shuffles of one RNA/DNA sequence.""" + parser = argparse.ArgumentParser(description=main.__doc__) + parser.add_argument('sequence') + parser.add_argument('-s', '--samples', type=positive_integer, default=4000) + parser.add_argument('--randSeed', help='random seed string') + args = parser.parse_args(argv) + try: + sequence = normalize_sequence(args.sequence) + except ValueError as error: + parser.error(str(error)) + rng = random.Random(args.randSeed) + for _ in range(args.samples): + print(dinucl_shuffle(sequence, rng)) + return 0 + + if __name__ == '__main__': - if len(sys.argv) != 2: - print("Usage: ./dinucl_shuffle.py ") - sys.exit(1) - seq = sys.argv[1] - for _ in range(4000): - print(dinucl_shuffle(seq)) + raise SystemExit(main()) diff --git a/python/intarnapvalue/test.py b/python/intarnapvalue/test.py index 9bbea0fe..9e33e70e 100644 --- a/python/intarnapvalue/test.py +++ b/python/intarnapvalue/test.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 - # Copyright 2019 # Author: Fabio Gutmann -from intarnapvalue import intarna_pvalue +"""Compatibility entry point for the source-tree p-value regression tests.""" -if __name__ == '__main__': - import doctest - doctest.testmod(intarna_pvalue) +from pathlib import Path +import unittest +if __name__ == '__main__': + tests = Path(__file__).resolve().parents[1] / 'tests' + suite = unittest.defaultTestLoader.discover(str(tests), pattern='test_intarnapvalue.py') + raise SystemExit(not unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful()) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..09977b5b --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" diff --git a/python/requirements-output-tools.txt b/python/requirements-output-tools.txt index 098b2bd0..7bc0ac84 100644 --- a/python/requirements-output-tools.txt +++ b/python/requirements-output-tools.txt @@ -1,4 +1,4 @@ -# Optional dependencies for IntaRNA_CSV_p-value.py and IntaRNA_plotRegions.py. +# Optional scientific dependencies for IntaRNA Python companion tools. # Requires Python >= 3.10. The C++ predictor does not need these packages. numpy>=1.23 scipy>=1.9 diff --git a/python/setup.py b/python/setup.py index 96ac0a86..695cff8a 100644 --- a/python/setup.py +++ b/python/setup.py @@ -1,24 +1,23 @@ #!/usr/bin/env python3 - +"""Install IntaRNA's companion modules and command-line tools.""" from setuptools import setup setup( name='intarnapvalue', version='0.1', - description='Calculates p-values of IntaRNA energy scores', + description='IntaRNA energy significance, mutation selection and output tools', author='Fabio Gutmann', - python_requires='>=3.6.0', + python_requires='>=3.10', url='https://github.com/BackofenLab/IntaRNA/', - packages=['intarnapvalue'], -# install_requires=['scipy', 'numpy'], - include_package_data=True, + packages=['intarnapvalue', 'copomus'], + py_modules=['intarna_tools'], + scripts=['CopomuS.py', 'IntaRNA-fuse.py', 'IntaRNA_CSV_p-value.py', 'IntaRNA_plotRegions.py'], + entry_points={'console_scripts': ['IntaRNApvalue=intarnapvalue.intarna_pvalue:main']}, + install_requires=['numpy>=1.23', 'scipy>=1.9'], + extras_require={'plot': ['matplotlib>=3.6']}, classifiers=[ - 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy' - ] + 'Programming Language :: Python :: 3 :: Only', + ], ) diff --git a/python/tests/test_common_tools.py b/python/tests/test_common_tools.py new file mode 100644 index 00000000..b7337d17 --- /dev/null +++ b/python/tests/test_common_tools.py @@ -0,0 +1,89 @@ +"""Contracts shared by standalone and installed Python companion commands.""" + +import importlib.util +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from intarna_tools import atomic_text_output, find_program, run_command + + +class CommonToolsTests(unittest.TestCase): + def test_atomic_publication_and_rollback(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / 'result.csv' + output.write_text('old') + output.chmod(0o640) + with self.assertRaises(RuntimeError): + with atomic_text_output(output) as handle: + handle.write('partial') + raise RuntimeError('failed computation') + self.assertEqual(output.read_text(), 'old') + with atomic_text_output(output) as handle: + handle.write('α\n') + self.assertEqual(output.read_bytes(), 'α\n'.encode()) + self.assertEqual(stat.S_IMODE(output.stat().st_mode), 0o640) + self.assertEqual(list(Path(directory).iterdir()), [output]) + + def test_subprocess_arguments_stdin_and_failure(self): + payload = '$(touch SHOULD_NOT_EXIST); quoted spaces' + result = run_command([sys.executable, '-c', 'import sys; print(sys.argv[1]); print(sys.stdin.read())', payload], 'ACGU') + self.assertEqual(result, payload + '\nACGU\n') + with self.assertRaisesRegex(RuntimeError, 'status 7: specific failure'): + run_command([sys.executable, '-c', 'import sys; print("specific failure", file=sys.stderr); sys.exit(7)']) + + def test_lookup_siblings_source_tree_path_and_frozen_executable(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + script = root / 'python' / 'script.py' + source = root / 'src' / 'bin' / 'IntaRNA' + sibling = script.parent / 'IntaRNA' + source.parent.mkdir(parents=True) + sibling.parent.mkdir() + for path in (source, sibling): + path.write_text('#!/bin/sh\nexit 0\n') + path.chmod(0o755) + with patch.dict('os.environ', {'PATH': ''}): + self.assertEqual(find_program('IntaRNA', script), str(sibling)) + sibling.unlink() + self.assertEqual(find_program('IntaRNA', script), str(source)) + source.unlink() + with self.assertRaises(RuntimeError): + find_program('IntaRNA', script) + sibling.write_text('#!/bin/sh\nexit 0\n') + sibling.chmod(0o755) + with patch.object(sys, 'frozen', True, create=True), \ + patch.object(sys, 'executable', str(sibling.parent / 'IntaRNApvalue')): + self.assertEqual(find_program('IntaRNA'), str(sibling)) + + def test_optional_builder_uses_paths_and_propagates_failures(self): + spec = importlib.util.spec_from_file_location('companion_builder', ROOT / 'bin' / 'build.py') + builder = importlib.util.module_from_spec(spec) + spec.loader.exec_module(builder) + with patch.object(builder.subprocess, 'run') as run: + self.assertEqual(builder.main(), 0) + command = run.call_args.args[0] + self.assertEqual(command[:3], [sys.executable, '-m', 'PyInstaller']) + self.assertEqual(command[command.index('--paths') + 1], str(ROOT)) + self.assertTrue(run.call_args.kwargs['check']) + temporary = Path(command[command.index('--workpath') + 1]) + self.assertFalse(temporary.exists()) + with patch.object(builder.subprocess, 'run', side_effect=subprocess.CalledProcessError(7, 'PyInstaller')), \ + self.assertRaises(subprocess.CalledProcessError): + builder.main() + + def test_all_standalone_help_without_optional_dependencies(self): + for name in ('IntaRNA-fuse.py', 'CopomuS.py', 'IntaRNA_CSV_p-value.py', 'IntaRNA_plotRegions.py'): + result = subprocess.run([sys.executable, '-S', str(ROOT / name), '--help'], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('usage:', result.stdout) + + +if __name__ == '__main__': + unittest.main() diff --git a/python/tests/test_copomus.py b/python/tests/test_copomus.py index 43b1f3cf..11ffcfc3 100644 --- a/python/tests/test_copomus.py +++ b/python/tests/test_copomus.py @@ -112,6 +112,18 @@ def prediction(*args): self.assertEqual(mut.measures[0].score, mut.measures[1].score | {'is_valid': 0}) self.assertIsNotNone(mut.measures[0].score['ww']) + def test_profile_na_and_malformed_rows(self): + measure = Measure() + measure.profiles = {'qAcc': '# header\n# columns\n1\tNA\n', + 'qMinE': 'idx;nt;E\n1;G;0\n'} + self.assertIsNone(measure.profile_value('qAcc', 0)) + self.assertEqual(measure.profile_value('qMinE', 0), 0) + with self.assertRaises(ValueError): + measure.profile_value('qAcc', 1) + measure.profiles['qAcc'] = '# header\n# columns\n1\tinf\n' + with self.assertRaises(ValueError): + measure.profile_value('qAcc', 0) + def test_cli_validation_and_failed_output_preservation(self): with tempfile.TemporaryDirectory() as directory: output = Path(directory) / 'results.tsv' diff --git a/python/tests/test_intarnapvalue.py b/python/tests/test_intarnapvalue.py index e48e2a6f..55fa7fbc 100644 --- a/python/tests/test_intarnapvalue.py +++ b/python/tests/test_intarnapvalue.py @@ -156,6 +156,17 @@ def test_cdf_matches_independent_legacy_integration(self): with self.assertRaises(ValueError): method(-1, scores) + def test_standalone_shuffle_cli(self): + script = ROOT / 'intarnapvalue' / 'dinucl_shuffle.py' + for seq, expected in (('a', 'A\nA\n'), ('t', 'U\nU\n')): + result = subprocess.run([sys.executable, '-S', str(script), seq, '--samples', '2'], + cwd=tempfile.gettempdir(), capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, expected) + result = subprocess.run([sys.executable, '-S', str(script), 'AX'], capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertNotIn('Traceback', result.stderr) + def test_cli_help_without_binary_or_dependencies(self): result = subprocess.run([sys.executable, '-S', '-m', 'intarnapvalue', '--help'], env={**os.environ, 'PYTHONPATH': str(ROOT), 'PATH': ''}, diff --git a/python/tests/test_native_tools.py b/python/tests/test_native_tools.py new file mode 100644 index 00000000..1c34d9b0 --- /dev/null +++ b/python/tests/test_native_tools.py @@ -0,0 +1,73 @@ +"""Optional integration tests against a native IntaRNA binary on PATH.""" + +import csv +import io +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +QUERY = 'GCUGAAAAACAUAACCCAUAAAAUGCUAGCUGUACCAGGAACCA' +TARGET = 'GGUUUCUUCGCCUCUGCGUUCACCAAAGUGUUCACCC' + + +@unittest.skipUnless(shutil.which('IntaRNA'), 'native IntaRNA is not on PATH') +class NativeToolsTests(unittest.TestCase): + def run_tool(self, command, *, success=True): + result = subprocess.run([sys.executable, *command], cwd=ROOT.parent, + env={**os.environ, 'PYTHONPATH': str(ROOT)}, + capture_output=True, text=True, timeout=90) + self.assertEqual(result.returncode == 0, success, result.stderr) + self.assertNotIn('Traceback', result.stderr) + return result + + def test_sampling_modes_reproducible_across_threads(self): + for mode in ('q', 't', 'b'): + args = ['-m', 'intarnapvalue', '-q', QUERY, '-t', TARGET, '-s', '5', + '-m', mode, '--randSeed', 'native', '-o', 'scores'] + a = self.run_tool([*args, '--threads', '1']) + b = self.run_tool([*args, '--threads', '2']) + self.assertEqual(a.stdout, b.stdout) + self.assertEqual(len([float(x) for x in a.stdout.splitlines()]), 5) + + def test_native_no_interaction_attempt_limit(self): + result = self.run_tool(['-m', 'intarnapvalue', '-q', 'AAAAAAAA', '-t', 'AAAAAAAA', + '-s', '1', '-m', 'b', '-o', 'scores', '--threads', '1', + '--max-attempts', '2'], success=False) + self.assertEqual(result.stdout, '') + self.assertIn('2 attempts', result.stderr) + + def test_native_parameter_file_and_empirical(self): + with tempfile.TemporaryDirectory() as directory: + params = Path(directory) / 'params with spaces.txt' + params.write_text('seedBP=3\nqAcc=N\ntAcc=N\n') + result = self.run_tool(['-m', 'intarnapvalue', '-q', QUERY, '-t', TARGET, + '-s', '5', '-m', 'b', '--threads', '1', '-d', 'empirical', + '--randSeed', 'test', '-p', str(params)]) + self.assertTrue(0 <= float(result.stdout) <= 1) + + def test_native_copomus_profiles_signed_indices_and_explicit_mutation(self): + result = self.run_tool([str(ROOT / 'CopomuS.py'), '-q', QUERY, '-t', TARGET, + '--threads', '1', '-m', 'EDqi', '-m', 'cEDqi', '-m', 'Eqi', '-m', 'Eti', + '--qIdxPos0', '-20', '--tIdxPos0', '-15', '-f', 'lpMfe']) + rows = list(csv.DictReader(io.StringIO(result.stdout), delimiter='\t')) + self.assertTrue(rows) + for row in rows: + self.assertEqual(row['EDqi_ww'], row['cEDqi_ww']) + self.assertNotEqual(row['qIndex'], '0') + self.assertNotEqual(row['tIndex'], '0') + encoding = rows[0]['mutation'] + result = self.run_tool([str(ROOT / 'CopomuS.py'), '-q', QUERY, '-t', TARGET, + '--threads', '1', '--qIdxPos0', '-20', '--tIdxPos0', '-15', + '--mutationEncoding', encoding]) + explicit = list(csv.DictReader(io.StringIO(result.stdout), delimiter='\t')) + self.assertEqual(len(explicit), 1) + self.assertEqual(explicit[0]['mutation'], encoding) + + +if __name__ == '__main__': + unittest.main() From 0ee401bc6226d1385f5705b99aee4ebf8591860c Mon Sep 17 00:00:00 2001 From: AlexanderMitrofanov Date: Sat, 24 Oct 2026 01:11:32 +0200 Subject: [PATCH 6/6] Fix companion lookup tests for symlinked temporary directories --- ChangeLog | 11 +++++++++++ python/tests/test_common_tools.py | 27 +++++++++++++++++++-------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/ChangeLog b/ChangeLog index 8ec95e1b..18db4a1f 100644 --- a/ChangeLog +++ b/ChangeLog @@ -221,6 +221,9 @@ energies, restricted partition sums, and trackers. # Auxiliary Python output tools +- handle symlinked temporary directories in companion executable-lookup tests, + including macOS /var aliases (PR #298) + - align companion tools on Python 3.10+, declare pip dependencies, install shared modules, and exercise source/installed/native commands in CI (PR #298) @@ -243,6 +246,14 @@ energies, restricted partition sums, and trackers. ################################################################################ ################################################################################ +261024 Alexander Mitrofanov + * python/tests/test_common_tools.py + * Compare executable lookup against canonical fixture paths so macOS /var + aliases do not cause false failures after the helper resolves symlinks. + + Exercise sibling, source-tree fallback, missing and frozen executable + lookup through an explicit directory symlink on every tested platform. + Reproduce and verify the macOS CI failure using an aliased TMPDIR (PR #298). + 261024 Alexander Mitrofanov * python/setup.py, python/pyproject.toml, python/MANIFEST.in, python/*/Makefile.am, python/Makefile.am, configure.ac, python/intarna_tools.py diff --git a/python/tests/test_common_tools.py b/python/tests/test_common_tools.py index b7337d17..e1df35a0 100644 --- a/python/tests/test_common_tools.py +++ b/python/tests/test_common_tools.py @@ -40,27 +40,38 @@ def test_subprocess_arguments_stdin_and_failure(self): def test_lookup_siblings_source_tree_path_and_frozen_executable(self): with tempfile.TemporaryDirectory() as directory: - root = Path(directory) + # macOS temporary directories can already contain symlink components. + root = Path(directory).resolve() script = root / 'python' / 'script.py' source = root / 'src' / 'bin' / 'IntaRNA' sibling = script.parent / 'IntaRNA' source.parent.mkdir(parents=True) sibling.parent.mkdir() + linked_directory = root / 'linked-python' + linked_directory.symlink_to(script.parent, target_is_directory=True) + scripts = (script, linked_directory / script.name) for path in (source, sibling): path.write_text('#!/bin/sh\nexit 0\n') path.chmod(0o755) with patch.dict('os.environ', {'PATH': ''}): - self.assertEqual(find_program('IntaRNA', script), str(sibling)) + for script_path in scripts: + with self.subTest(script=script_path, lookup='sibling'): + self.assertEqual(find_program('IntaRNA', script_path), str(sibling)) sibling.unlink() - self.assertEqual(find_program('IntaRNA', script), str(source)) + for script_path in scripts: + with self.subTest(script=script_path, lookup='source'): + self.assertEqual(find_program('IntaRNA', script_path), str(source)) source.unlink() - with self.assertRaises(RuntimeError): - find_program('IntaRNA', script) + for script_path in scripts: + with self.subTest(script=script_path, lookup='missing'), self.assertRaises(RuntimeError): + find_program('IntaRNA', script_path) sibling.write_text('#!/bin/sh\nexit 0\n') sibling.chmod(0o755) - with patch.object(sys, 'frozen', True, create=True), \ - patch.object(sys, 'executable', str(sibling.parent / 'IntaRNApvalue')): - self.assertEqual(find_program('IntaRNA'), str(sibling)) + for script_path in scripts: + with self.subTest(script=script_path, lookup='frozen'), \ + patch.object(sys, 'frozen', True, create=True), \ + patch.object(sys, 'executable', str(script_path.parent / 'IntaRNApvalue')): + self.assertEqual(find_program('IntaRNA'), str(sibling)) def test_optional_builder_uses_paths_and_propagates_failures(self): spec = importlib.util.spec_from_file_location('companion_builder', ROOT / 'bin' / 'build.py')