Source code for spacr.qt.widgets.metadata_mapper

"""One-dialog recovery for missing metadata columns."""

from __future__ import annotations

from pathlib import Path
from typing import Iterable, Optional

from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QFileDialog,
    QFormLayout,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

from ...metadata_resolution import (
    MetadataDecision,
    MetadataRequest,
    MetadataResolutionRequired,
    ResolutionResult,
    resolve_metadata_columns,
)
from ...schema import WellParseError, parse_well
from ..dialogs import detach_from_window_manager


[docs] class MetadataColumnDialog(QDialog): """Resolve every missing canonical column in one editable table. :param request: the canonical columns still unresolved, and the candidates offered for each. Held as :attr:`request`, so a caller reads the user's choices back off the same object it passed in. :param parent: parent widget. """ def __init__(self, request: MetadataRequest, parent: Optional[QWidget] = None): """Build the dialog matching a table's columns onto the ones spaCR needs. Every missing column is asked for at once, so the run can continue without restarting. :param request: what is missing, and the example values to preview from. :param parent: parent widget, or ``None``. """ super().__init__(parent) detach_from_window_manager(self) self.request = request self.setWindowTitle("Match metadata columns") from ..preferences import scaled_px self.setMinimumWidth(scaled_px(720)) self._selectors = {} outer = QVBoxLayout(self) intro = QLabel( "spaCR needs the metadata columns below. Choose the column in " "your table that means each one. All missing columns are applied " "together, so the run can continue without restarting.", self) intro.setWordWrap(True) outer.addWidget(intro) grid = QGridLayout() grid.addWidget(QLabel("spaCR column"), 0, 0) grid.addWidget(QLabel("Your column"), 0, 1) grid.addWidget(QLabel("Example values"), 0, 2) for row, target in enumerate(request.missing, start=1): grid.addWidget(QLabel(target, self), row, 0) selector = QComboBox(self) selector.setEditable(True) selector.addItem("") selector.addItems(request.available) guess = request.guesses.get(target) if guess: selector.setCurrentText(guess) self._selectors[target] = selector grid.addWidget(selector, row, 1) example = QLabel("Select a column to preview its values", self) example.setWordWrap(True) grid.addWidget(example, row, 2) selector.currentTextChanged.connect( lambda name, label=example: label.setText( ", ".join(request.examples.get(name, ())) or "—")) outer.addLayout(grid) options = QFormLayout() self.well_selector = QComboBox(self) self.well_selector.addItem("Do not derive") self.well_selector.addItems(request.available) options.addRow("Derive rowID/columnID from well column", self.well_selector) self.well_preview = QLabel("Choose a well column to preview its mapping", self) self.well_preview.setWordWrap(True) options.addRow("Well mapping preview", self.well_preview) self.well_selector.currentTextChanged.connect(self._preview_wells) self.pseudo_selector = QComboBox(self) self.pseudo_selector.addItem("Do not generate pseudo wells") self.pseudo_selector.addItems(request.available) options.addRow("Pseudo wells from condition/folder column", self.pseudo_selector) outer.addLayout(options) self.remember = QCheckBox("Remember this answer for the current run", self) self.remember.setChecked(True) outer.addWidget(self.remember) save_row = QHBoxLayout() self.save_mapping = QCheckBox("Save mapping", self) self.save_path = QLineEdit(self) self.save_path.setPlaceholderText("metadata_column_map.json") browse = QPushButton("Browse…", self) browse.clicked.connect(self._browse) save_row.addWidget(self.save_mapping) save_row.addWidget(self.save_path, 1) save_row.addWidget(browse) outer.addLayout(save_row) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) outer.addWidget(buttons) def _browse(self) -> None: """Ask where to save the mapping, and tick saving when a path is chosen.""" path, _ = QFileDialog.getSaveFileName( self, "Save metadata mapping", self.save_path.text(), "JSON (*.json);;All files (*)") if path: self.save_path.setText(path) self.save_mapping.setChecked(True)
[docs] def decision(self) -> MetadataDecision: """Which source column the user mapped to each metadata field. :returns: the decision. """ mapping = { target: selector.currentText().strip() for target, selector in self._selectors.items() if selector.currentText().strip() } well = self.well_selector.currentText().strip() if self.well_selector.currentIndex() == 0: well = None pseudo = self.pseudo_selector.currentText().strip() if self.pseudo_selector.currentIndex() == 0: pseudo = None path = self.save_path.text().strip() if self.save_mapping.isChecked() else None return MetadataDecision( column_map=mapping, well_column=well, pseudo_source=pseudo, allow_pseudo=bool(pseudo), save_path=path or None, remember=self.remember.isChecked(), )
def _preview_wells(self, source: str) -> None: """Show how the chosen well column's values would be read. Values that will not parse are shown as unrecognised rather than omitted: a column that maps four of its five example wells is exactly the case worth seeing before the run. :param source: the column to preview. """ if self.well_selector.currentIndex() == 0: self.well_preview.setText("Choose a well column to preview its mapping") return preview = [] for value in self.request.examples.get(source, ()): try: row, column = parse_well(value, strict=True) preview.append(f"{value} → {row}/{column}") except WellParseError: preview.append(f"{value} → not recognised") self.well_preview.setText(", ".join(preview) or "No non-empty values to preview")
[docs] def resolve_metadata_with_dialog( frame, required: Iterable[str], *, parent: Optional[QWidget] = None, cache_key: Optional[str] = None) -> ResolutionResult: """Resolve a frame interactively; cancellation remains an explicit stop. :param frame: source metadata frame, passed to :func:`spacr.metadata_resolution.resolve_metadata_columns`. :param required: canonical columns that must be present in the result; a dialog asks for the mapping of each one that cannot be resolved, and cancelling it raises :class:`MetadataResolutionRequired`. """ def prompt(request: MetadataRequest) -> MetadataDecision: """Ask the user how to map one metadata column.""" dialog = MetadataColumnDialog(request, parent) if dialog.exec() != QDialog.DialogCode.Accepted: raise MetadataResolutionRequired(request.missing, request.available) return dialog.decision() return resolve_metadata_columns( frame, required, prompt=prompt, cache_key=cache_key)