"""Interactive editor for sequencing barcode regular expressions.
Barcode regexes are not filename regexes. The sequencing pipeline requires
three specifically named capture groups (``columnID``, ``grna`` and
``rowID``), so the generic filename-regex editor cannot validate this field.
This module keeps the compact settings row while providing a focused dialog
that compiles the expression and previews its captures against a pasted read.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
from typing import Dict
from PySide6.QtCore import Qt, Signal
from ..i18n import tr
from PySide6.QtGui import QFontDatabase
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPlainTextEdit,
QSizePolicy,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
)
from ...settings import DEFAULT_BARCODE_REGEX
REQUIRED_BARCODE_GROUPS = ("columnID", "grna", "rowID")
EXAMPLE_BARCODE_READ = (
"TCATAGGCTGCTGTAAACGTACTATAATGATATTACGAC"
"AACTTAGAAGCAATGTCG"
)
@dataclass(frozen=True)
[docs]
class BarcodeRegexResult:
"""Validation result shown by the inline field and test dialog.
:param valid: whether the pattern (and the sample, when one was given)
passed.
:param message: one-line explanation to show beside the field.
:param captures: the ``columnID``, ``grna`` and ``rowID`` values the
sample produced; empty when no sample was matched.
"""
valid: bool
message: str
captures: Dict[str, str]
[docs]
def evaluate_barcode_regex(pattern: str, sample: str = "") -> BarcodeRegexResult:
"""Compile a barcode regex and optionally apply it to one sequence.
A usable mapping expression must define every named group consumed by
:mod:`spacr.sequencing`. When a sample is supplied it must also match and
produce a non-empty value for each required group.
:param pattern: the regular expression, stripped of surrounding
whitespace; it must define the named groups ``columnID``, ``grna``
and ``rowID``.
:param sample: an optional read to match from its start with
``re.match``; all whitespace is removed first, and empty skips the
match.
"""
pattern = str(pattern or "").strip()
if not pattern:
return BarcodeRegexResult(False, "Enter a regular expression.", {})
try:
compiled = re.compile(pattern)
except re.error as exc:
return BarcodeRegexResult(False, f"Regex error: {exc}", {})
missing = [
name for name in REQUIRED_BARCODE_GROUPS
if name not in compiled.groupindex
]
if missing:
return BarcodeRegexResult(
False,
"Missing named group" + ("s" if len(missing) != 1 else "")
+ ": " + ", ".join(missing),
{},
)
sample = "".join(str(sample or "").split())
if not sample:
return BarcodeRegexResult(
True,
"Valid regex with columnID, grna and rowID groups.",
{},
)
match = compiled.match(sample)
if match is None:
return BarcodeRegexResult(False, "The sample sequence did not match.", {})
captures = {
name: match.group(name) or ""
for name in REQUIRED_BARCODE_GROUPS
}
empty = [name for name, value in captures.items() if not value]
if empty:
return BarcodeRegexResult(
False,
"Matched, but these groups were empty: " + ", ".join(empty),
captures,
)
return BarcodeRegexResult(True, "Sample matched successfully.", captures)
[docs]
class BarcodeRegexDialog(QDialog):
"""Edit and test a barcode regex against a representative read.
:param initial_regex: the expression to open with. Empty opens the field
empty; the dialog proposes nothing until the user tests a read.
:param parent: parent widget.
"""
def __init__(self, initial_regex: str = "", parent=None):
"""Build the barcode-regex tester.
Sized in scaled pixels rather than raw ones: a size set from Python does
not grow with the stylesheet's font size, and at the 200% scale the
prose inside wrapped to more height than the window had.
:param initial_regex: the pattern to open with; empty uses spaCR's
default.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self.setWindowTitle("spaCR — Barcode regex tester")
from ..preferences import scaled_px
self.setMinimumSize(scaled_px(760), scaled_px(430))
self.regex = ""
outer = QVBoxLayout(self)
intro = QLabel(
"<b>Barcode extraction regex</b><br>"
"The expression must contain the named groups "
"<code>columnID</code>, <code>grna</code> and "
"<code>rowID</code>. Paste one extracted read window below to "
"see exactly what will be written to the mapping table."
)
intro.setTextFormat(Qt.RichText)
intro.setWordWrap(True)
intro.setSizePolicy(QSizePolicy.Preferred,
QSizePolicy.Minimum)
outer.addWidget(intro)
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self._regex_input = QLineEdit(self)
self._regex_input.setObjectName("BarcodeRegexPattern")
self._regex_input.setFont(mono)
self._regex_input.setText(initial_regex or DEFAULT_BARCODE_REGEX)
outer.addWidget(self._regex_input)
actions = QHBoxLayout()
self._default_button = QPushButton("Reset spaCR default", self)
self._example_button = QPushButton(tr("Load test data"), self)
actions.addWidget(self._default_button)
actions.addWidget(self._example_button)
actions.addStretch(1)
outer.addLayout(actions)
outer.addWidget(QLabel("Sample read or extracted sequence window:"))
self._sample_input = QPlainTextEdit(self)
self._sample_input.setObjectName("BarcodeRegexSample")
self._sample_input.setFont(mono)
self._sample_input.setMaximumHeight(90)
self._sample_input.setPlaceholderText(
"Paste a DNA sequence here, or click “Load test data”."
)
outer.addWidget(self._sample_input)
self._status = QLabel(self)
self._status.setObjectName("BarcodeRegexStatus")
self._status.setWordWrap(True)
self._status.setSizePolicy(QSizePolicy.Preferred,
QSizePolicy.Minimum)
outer.addWidget(self._status)
outer.addWidget(QLabel("Captured values:"))
self._captures = QPlainTextEdit(self)
self._captures.setObjectName("BarcodeRegexCaptures")
self._captures.setReadOnly(True)
self._captures.setFont(mono)
outer.addWidget(self._captures, 1)
self._buttons = QDialogButtonBox(
QDialogButtonBox.Save | QDialogButtonBox.Cancel,
parent=self,
)
self._buttons.accepted.connect(self._save)
self._buttons.rejected.connect(self.reject)
outer.addWidget(self._buttons)
self._regex_input.textChanged.connect(self._refresh)
self._sample_input.textChanged.connect(self._refresh)
self._default_button.clicked.connect(
lambda: self._regex_input.setText(DEFAULT_BARCODE_REGEX)
)
self._example_button.clicked.connect(
lambda: self._sample_input.setPlainText(EXAMPLE_BARCODE_READ)
)
self._refresh()
def _refresh(self) -> None:
"""Re-evaluate the pattern against the pasted read and show the captures.
The status line carries the verdict and is repolished so its style
follows it, and the captures pane shows exactly what would be written to
the mapping table.
"""
result = evaluate_barcode_regex(
self._regex_input.text(),
self._sample_input.toPlainText(),
)
symbol = "✓" if result.valid else "⚠"
self._status.setText(f"{symbol} {result.message}")
self._status.setProperty("valid", result.valid)
self._status.style().unpolish(self._status)
self._status.style().polish(self._status)
if result.captures:
self._captures.setPlainText(
"\n".join(
f"{name:<8} {result.captures.get(name, '')}"
for name in REQUIRED_BARCODE_GROUPS
)
)
else:
self._captures.setPlainText(
"Add a sample sequence to preview the three captures."
)
self._buttons.button(QDialogButtonBox.Save).setEnabled(result.valid)
def _save(self) -> None:
"""Accept the pattern, but only while it is valid.
An invalid pattern does nothing rather than closing: saving one that
cannot compile would fail on the next run instead of here.
"""
result = evaluate_barcode_regex(
self._regex_input.text(),
self._sample_input.toPlainText(),
)
if not result.valid:
return
self.regex = self._regex_input.text().strip()
self.accept()