Source code for spacr.qt.widgets.database_set

"""The set of measurement databases a screen is working on.

Where a workflow accepts one database, this widget lets you add, remove, and
inspect several. It uses
:class:`spacr.qt.widgets.table_chip.TableChip`, already used for the Gate
Editor's table working set. Databases get the same control rather than a
second one for the same idea.

Besides holding a list of paths, the widget does the following:

    It describes the merge before the user runs it.

The column set produced by a merge defines the analysis about to run, so every
time the set changes this asks :func:`spacr.multi_database.describe_merge` --
which reads only sqlite metadata and the distinct plate ids -- and puts the
answer on screen: how many rows, how many columns are common, which
measurements are in only some, and whether two databases claim the same plate.

The description is read off the GUI thread. The read is small but can be slow:
it stats every database and then opens each one with sqlite five times over, on
folders the user chose, and a single ``os.path.exists`` under an ``autofs``
mount whose share was asleep has been measured not returning after twenty
seconds. Everything the summary needs is read by :func:`_read_the_merge` on a
worker thread (:mod:`spacr.qt.job_runner`) and painted when it arrives.

A collision is shown, not resolved. Two databases that each hold a ``plate1``
are two experiments, and the widget says so and names them. It does not offer
``on_collision='qualify'``: rewriting ``plate1`` to ``runA-plate1`` makes the
keys unique and hides which experiment a plate came from inside its own id,
where nothing can block on it, test for it or colour by it. The resolutions
offered are the ones that keep the experiment analysable -- remove one of the
databases, or rename the plates.
"""
from __future__ import annotations

import os
from functools import partial
from typing import Callable, List, Optional, Sequence

from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
    QCheckBox, QFileDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout,
    QWidget,
)

from ..theme import SPACING
from .table_chip import TableChip

__all__ = ["DatabaseSetWidget", "database_for_source"]

#: What the Image UMAP and every other ``src``-taking module means by a source
#: root: the plate folder Measure wrote into.
#:
#: The same join :func:`spacr.utils.get_db_paths` performs, spelled out here
#: rather than imported: ``spacr.utils`` pulls torch, and a settings panel must
#: not pay seconds of import to redraw a summary line when a chip is added.
#: ``test_the_folder_join_matches_get_db_paths`` is what keeps the two equal.
MEASUREMENTS_SUFFIX = ("measurements", "measurements.db")


[docs] def database_for_source(source: str, mode: str = "database") -> str: """The database a chosen source names. :param source: what the user picked -- a database file in ``'database'`` mode, a spaCR project/plate folder in ``'folder'`` mode. :param mode: ``'database'`` or ``'folder'``. :returns: the path to open. Nothing is checked for existence; a source whose database is missing is reported by the summary rather than removed from the set behind the user's back. """ if mode == "folder": return os.path.join(str(source).rstrip(os.sep + (os.altsep or "")), *MEASUREMENTS_SUFFIX) return str(source)
def _read_the_merge(paths: Sequence[str], table: str): """Everything the summary line says, read OFF the GUI thread. Returns one of ``('plan', MergePlan)``, ``('missing', (paths, total))`` or ``('error', (total, message))`` -- data rather than painted text, because a worker thread may not touch a widget, and a returned failure rather than a raised one because "that file is not a database" is one of the three answers this summary legitimately has, not a crash. NEITHER HALF CAN COME FROM :mod:`spacr.qt.path_probe`. The stat is what gates the sqlite opens, and the probe answers a path it has not seen OPTIMISTICALLY -- so believing it here would move the twenty-second wait off ``os.path.isfile`` and onto the ``sqlite3.connect`` one line later, which is the same freeze with a longer call chain. TOTAL: every path out of here is one of those three answers, and the import and the stat loop are inside the guard along with the read. A worker that raises delivers NOTHING -- ``JobRunner`` hands a result only to a job that succeeded -- and the summary would then keep the "reading …" placeholder it was given before the submit, which reads as "still working" rather than "this failed". A failure the user can see beats a traceback in a log they never open. """ paths = list(paths) try: from ...multi_database import describe_merge missing = [path for path in paths if not os.path.isfile(path)] if missing: return ("missing", (missing, len(paths))) return ("plan", describe_merge(paths, table)) except Exception as exc: # noqa: BLE001 return ("error", (len(paths), str(exc))) def _the_ones_still_there(sources: Sequence[str]) -> List[str]: """Which of ``sources`` the filesystem still has. Off the GUI thread.""" return [path for path in sources if os.path.exists(path)] def _restored_source_paths(sources, databases, mode): """Resolve explicit saved database associations off the GUI thread.""" present = _the_ones_still_there(sources) overrides = {} if (mode == "folder" and len(databases) == len(sources) and len(set(sources)) == len(sources)): for source, database in zip(sources, databases): original = database_for_source(source, mode) if (database and database != original and os.path.isfile(database) and not os.path.isfile(original)): overrides[source] = database if source not in present: present.append(source) return present, overrides def _threaded_by_default() -> bool: """Whether a new widget reads its databases on a worker thread. True in the application, where that read is what froze it. False inside a pytest session, where ``JobRunner(threaded=False)`` runs the same job inline and emits the same signals in the same order -- the seam that class documents for exactly this -- so a test can press a control and read the summary on the next line instead of pumping an event loop. A test that wants the threaded behaviour, and the regression test for this freeze does, asks for it with ``threaded=True``. The default is where the decision has to live: the application builds this widget inside ``settings_model._widget_for``, which has no such flag of its own to thread through. ``SPACR_PYTEST_SESSION`` is set by the root conftest before collection; ``PYTEST_CURRENT_TEST`` covers a widget built by a test that reached this module some other way. """ return not (os.environ.get("SPACR_PYTEST_SESSION") == "1" or "PYTEST_CURRENT_TEST" in os.environ)
[docs] class DatabaseSetWidget(QWidget): """Add, remove and see the databases a screen will merge. :param value: the initial sources. A bare string is accepted because every module's ``src`` has historically been one, and a settings CSV written before this widget existed still holds one. :param mode: ``'database'`` (the user picks .db files -- the Gate Editor) or ``'folder'`` (the user picks plate folders -- Image UMAP and every other module whose ``src`` is a project root). :param table: the table the merge is described on. ``'cell'`` is the anchor every object join is built on. :param min_items: how many sources may never be removed. The Gate Editor passes 1, because a gate editor with no table is a screen with nothing on it; a settings panel passes 0, because clearing the field is a legitimate thing to want. :param on_colour_by: called with :data:`spacr.multi_database.SOURCE_COLUMN` or ``None`` when the "colour by source" box is toggled. Given by a settings panel that owns a ``color_by`` field; omitted elsewhere, and the box is then not shown. :param parent: parent widget; ownership only. :param threaded: whether the databases are read on a worker thread. ``None`` decides -- see :func:`_threaded_by_default`. Only a test has any reason to say. :param title: the caption on the file dialog this opens. ``None`` picks one from ``mode``, so it only needs giving when the dialog is asking for something more specific than "a database" or "a folder" -- the dialog's title is the only prompt it has. """ value_changed = Signal() def __init__(self, value=None, parent=None, *, mode: str = "database", table: str = "cell", title: Optional[str] = None, min_items: int = 0, on_colour_by: Optional[Callable[[Optional[str]], None]] = None, threaded: Optional[bool] = None): """Build the multi-database chooser. The reads leave the GUI thread deliberately: the summary stats every database and then opens each with sqlite, over paths that are the user's own -- one of them an autofs mount that had not answered a single stat after twenty seconds -- and doing that while a settings panel lays out froze the application. Two runners rather than one, because ``job_finished`` carries a bool and no job identity: a shared runner could not tell whose failure it was being told about, so the restore check failing would clear the summary read's in-flight flag and paint its error over a read still running. :param value: the sources to start with. :param parent: parent widget, or ``None``. :param mode: ``"folder"`` for spaCR project folders, anything else for measurement databases. :param table: which table the merge summary is computed over. :param title: the file-dialog caption; defaults by mode. :param min_items: how many sources must remain; chips below this are not removable. :param on_colour_by: called with the source column when the user asks for the map to be coloured by database, and with ``None`` when they stop. :param threaded: run the reads on a worker thread; ``None`` follows the process default. """ super().__init__(parent) self.setObjectName("DatabaseSetWidget") self._mode = "folder" if mode == "folder" else "database" self._table = table self._min_items = max(0, int(min_items)) self._on_colour_by = on_colour_by self._sources: List[str] = [] self._database_overrides = {} self._restore_generation = 0 self._source_serial = 0 self._source_versions = {} self._plan = None #: THE READS LEAVE THE GUI THREAD. `_read_the_merge` stats every #: database and then opens each of them with sqlite, and the paths are #: the user's own -- an `autofs` mount can fail to answer a single #: stat for twenty seconds. Doing that #: while a settings panel is being laid out froze the application. #: #: `user_visible=False`: nothing here is a run the user started, so it #: must never claim a run banner on Home. from ..job_runner import JobRunner threads = (_threaded_by_default() if threaded is None else bool(threaded)) #: TWO RUNNERS, NOT ONE, and the reason is the same one #: :class:`spacr.qt.widgets.row_exclusion.RowExclusionEditor` has two: #: ``job_finished`` carries a bool and no job identity, so one runner #: shared by the summary read and the restore check cannot tell whose #: failure it is being told about. The restore check failing would #: then let go of the summary read's in-flight flag and paint its #: error over the read still running. ``cancel()`` is likewise all or #: nothing per runner. self._jobs = JobRunner( self, threaded=threads, app_key="database set", user_visible=False) self._presence_jobs = JobRunner( self, threaded=threads, app_key="database set restore", user_visible=False) self._jobs.job_failed.connect(self._read_failed) self._jobs.job_finished.connect(self._read_settled) #: Bumped every time the set changes, so an answer about the set as it #: WAS cannot land on top of the set as it is. self._summary_token = 0 #: One read in flight at a time. Dropping three plate folders is three #: calls to `_rebuild`, and they all ask the same question of the same #: files, so a read arriving mid-flight is REMEMBERED AND RUN ONCE #: rather than queued as three more worker threads. self._reading = False self._read_again = False #: How many databases the read in flight is about, and the last thing #: `job_failed` said about one -- both only so that a read which never #: delivers can still be reported in the words the summary uses. self._reading_count = 0 self._last_read_error = "" self._title = title or ( "Choose one or more spaCR project folders" if self._mode == "folder" else "Choose one or more measurement databases") outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(SPACING["xs"]) row = QHBoxLayout() row.setContentsMargins(0, 0, 0, 0) row.setSpacing(SPACING["xs"]) self.add_button = QPushButton( "Add project folders…" if self._mode == "folder" else "Add databases…", self) self.add_button.setObjectName("DatabaseSetAdd") self.add_button.setToolTip( "Adds to the set rather than replacing it, so three plates can " "be gathered in three trips. Every row of the merged frame " "carries the database it came from, so the map can be coloured " "by source.") self.add_button.clicked.connect(self.choose_sources) row.addWidget(self.add_button) row.addStretch(1) outer.addLayout(row) self._chips = QHBoxLayout() self._chips.setContentsMargins(0, 0, 0, 0) self._chips.setSpacing(SPACING["xs"]) self._chips.addStretch(1) outer.addLayout(self._chips) #: What the merge WOULD cost, before it is performed. self.summary = QLabel("", self) self.summary.setObjectName("DatabaseSetSummary") self.summary.setWordWrap(True) self.summary.setProperty("role", "hint") outer.addWidget(self.summary) self.colour_by_source = QCheckBox( "Colour the map by source database", self) self.colour_by_source.setObjectName("DatabaseSetColourBySource") self.colour_by_source.setToolTip( "Colour each point by the database it came from. This shows " "whether the clusters of a merged embedding are the plates " "rather than biology.") self.colour_by_source.toggled.connect(self._on_colour_toggled) self.colour_by_source.setVisible(False) outer.addWidget(self.colour_by_source) self.set_value(value)
[docs] def get_value(self): """The chosen sources: a bare string for one, a list for several. Not always a list. ``src`` has been a string for every module since spaCR had modules, and it is written to the settings CSV, read by the CLI, replayed by the run journal and joined onto by anything that does ``os.path.join(src, ...)``. Returning ``['/data/plate1']`` where a string was returned before would change what every one of those sees for a user who chose one folder -- exactly the regression the one-element list caused for ``column_csv`` (see ``settings_model.PATH_LIST_SINGLE_KEYS``). Several sources produce a list, which is the shape :func:`spacr.core.generate_image_umap` builds on the first line it touches ``src`` anyway. """ if len(self._sources) == 1: return self._sources[0] return list(self._sources)
[docs] def set_value(self, value) -> None: """Replace the set. Accepts a list, a bare string, or ``None``. :param value: a list of source paths, a single path string, or None; paths are stripped and de-duplicated, and settings placeholders such as ``path`` are dropped. """ self._restore_generation += 1 self._presence_jobs.cancel() self._database_overrides = {} self._sources = self._clean(value) self._source_serial += 1 self._source_versions = dict.fromkeys(self._sources, self._source_serial) self._rebuild()
[docs] def sources(self) -> List[str]: """The chosen sources.""" return list(self._sources)
[docs] def database_paths(self) -> List[str]: """The database each source names -- what a merge would open.""" return [self._database_overrides.get(source, database_for_source(source, self._mode)) for source in self._sources]
[docs] def plan(self): """The last :class:`spacr.multi_database.MergePlan`, or ``None``.""" return self._plan
[docs] def workspace_state(self) -> dict: """The attached set, in order, and the databases behind it. Order is state, not presentation: a merge resolves a column collision in favour of the first source that has it, so the same two databases in the other order are a different merged table. Both the sources and the databases they resolve to are written down. A source is a folder the user chose; the database is the file the merge opens, and it is the one whose absence or edit a restore has to be able to report. """ return { "mode": str(self._mode), "sources": self.sources(), "databases": self.database_paths(), }
[docs] def apply_workspace_state(self, state) -> bool: """Re-attach the set. Returns whether anything was attached. Saved sources retain their order. In folder mode, an explicit matching ``databases`` list can supply a database restored by the workspace core. If the original database is missing and that file exists, the set reads the supplied file while retaining the original source key. This association does not rewrite the source roots returned by :meth:`get_value` or reconstruct a project folder for a new pipeline. Filesystem checks run off the GUI thread. Sources with neither an existing original source nor a usable explicit database are dropped when the answer arrives. Added sources are preserved; replaced, removed and re-added sources cannot receive stale associations. The return value answers "was there anything to attach", not "was any of it still on disk" when the check is asynchronous. The workspace core reports missing or changed files separately. Unthreaded, the check has already run by the time this returns. :param state: dict saved by :meth:`workspace_state`; reads ``sources``, ``mode`` and an optional positionally matching ``databases`` list. A non-dict or empty source set returns False. File verification and carried-path remapping belong to the workspace core. """ if not isinstance(state, dict): return False sources = [str(p) for p in (state.get("sources") or []) if p] if not sources: return False self.set_value(sources) databases = state.get("databases") databases = (tuple(str(path) if path else "" for path in databases) if isinstance(databases, (list, tuple)) else ()) mode = self._mode if state.get("mode", self._mode) == self._mode else "" self._presence_jobs.submit( partial(_restored_source_paths, tuple(sources), databases, mode), partial(self._restored_sources_arrived, self._restore_generation, dict(self._source_versions))) return bool(self._sources)
def _restored_sources_arrived(self, generation, versions, result) -> None: """Accept associations only for the still-current restoration.""" if generation != self._restore_generation: return present, overrides = result checked = tuple(source for source, version in versions.items() if self._source_versions.get(source) == version) overrides = {source: path for source, path in overrides.items() if source in checked} self._present_sources_arrived(checked, present, overrides) def _present_sources_arrived(self, checked, present, overrides=None) -> None: """Drop the restored sources that are not there. GUI thread only. Only the ones this answer was ABOUT are dropped -- a source the user added while the check was in flight was never asked about, and removing it because it is missing from an answer that predates it would be the widget quietly undoing what the user just did. A check abandoned by :meth:`spacr.qt.job_runner.JobRunner.cancel` never reaches here at all: that generation guard is the runner's. """ gone = set(checked) - set(present or ()) keep = [path for path in self._sources if path not in gone] resolved = {source: path for source, path in self._database_overrides.items() if source in keep} resolved.update({source: path for source, path in (overrides or {}).items() if source in keep}) if keep == self._sources and resolved == self._database_overrides: return try: self._sources = keep self._source_versions = {source: version for source, version in self._source_versions.items() if source in keep} self._database_overrides = resolved self._rebuild() self.value_changed.emit() except RuntimeError: pass
[docs] def shutdown(self) -> None: """Stop reading; let no worker outlive the widget. Idempotent. Qt aborts the process when a running QThread is destroyed, and this widget starts them. Public because it is a child -- a settings panel builds it inside a form, and Qt delivers a close event to the window rather than to every widget in it, so a host that wants the reads stopped when the user navigates away has to say so. Not the only line of defence: the QThreads are unparented and retire themselves, and ``JobRunner._relay`` catches the ``RuntimeError`` PySide6 raises when a worker settles after its runner's C++ half has gone. """ self._restore_generation += 1 self._reading = False self._read_again = False self._jobs.shutdown() self._presence_jobs.shutdown()
[docs] def closeEvent(self, event): # noqa: N802 - Qt override """Stop any reading worker before the widget closes. :param event: the close event; it is not inspected, only passed on to the base class after :meth:`shutdown`. """ self.shutdown() super().closeEvent(event)
[docs] def choose_sources(self) -> None: """Open the picker and add what comes back. The dialog is opened at ``""`` -- no remembered root. That is the one reason this stays on the GUI thread while the rest of the file's filesystem work moved off it: there is no path of the user's to stat before the dialog exists, and listing the directory it lands in is the thing the user asked for, in a window that is its own progress indicator. A remembered start directory would put a possibly-sleeping mount back in front of the event loop; do not add one here. """ if self._mode == "folder": folder = QFileDialog.getExistingDirectory(self, self._title, "") chosen = [folder] if folder else [] else: chosen, _ = QFileDialog.getOpenFileNames( self, self._title, "", "Measurements (*.db *.sqlite *.sqlite3);;All files (*)") self.add_sources(chosen)
[docs] def add_sources(self, paths: Sequence[str]) -> int: """Add sources, ignoring the ones already in the set. :param paths: source folders or database paths to append; each is converted with ``str()`` and stripped, and blanks and duplicates are skipped. None adds nothing. :returns: how many were actually added. """ added = 0 for path in paths or []: text = str(path).strip() if text and text not in self._sources: self._sources.append(text) self._source_serial += 1 self._source_versions[text] = self._source_serial added += 1 if added: self._rebuild() self.value_changed.emit() return added
[docs] def remove_source(self, name: str) -> bool: """Drop one member of the set, by its chip's label or its path. The chip carries the label -- ``plate1``, not ``/data/plate1/measurements/measurements.db`` -- because that is what a legend and the provenance column say. Both are accepted so a caller with the path does not have to work out the label first. :param name: the source's path or its chip label; nothing is removed when it matches neither or the set is already at its minimum size. """ target = None if name in self._sources: target = name else: for source, label in zip(self._sources, self._labels()): if label == name: target = source break if target is None or len(self._sources) <= self._min_items: return False self._source_versions.pop(target, None) self._database_overrides.pop(target, None) self._sources.remove(target) self._rebuild() self.value_changed.emit() return True
[docs] def clear(self) -> None: """Empty the set.""" if not self._sources: return self._restore_generation += 1 self._presence_jobs.cancel() self._database_overrides = {} self._source_versions = {} self._sources = [] self._rebuild() self.value_changed.emit()
@staticmethod def _clean(value) -> List[str]: """Normalise a stored value into a list of source paths. The settings placeholders for an unchosen ``src`` are dropped rather than kept: rendering ``path`` as a chip would offer to merge a database called "path". :param value: ``None``, one path, or an iterable of them. :returns: the paths, de-duplicated and in order. """ if value is None: return [] if isinstance(value, str): text = value.strip() return [] if text in ("", "path", "/path", "/path/to/src") else [text] out: List[str] = [] for item in value: text = str(item).strip() if text and text not in out: out.append(text) return out def _labels(self) -> List[str]: """The name each source will carry in the provenance column. Asked of :mod:`spacr.multi_database` rather than computed here, so a chip and the ``source_database`` value it stands for cannot disagree. """ from ...multi_database import source_labels paths = self.database_paths() if not paths: return [] try: return list(source_labels(paths)) except Exception: return [os.path.basename(str(p).rstrip(os.sep)) or str(p) for p in self._sources] def _rebuild(self) -> None: """Redraw the chips, re-read the summary and re-gate the colour switch. The switch appears only with a callback to answer and more than one source -- colouring by database says nothing about a set of one. """ self._rebuild_chips() self._refresh_summary() self.colour_by_source.setVisible( self._on_colour_by is not None and len(self._sources) > 1) def _rebuild_chips(self) -> None: """Rebuild the source chips, keeping the trailing stretch. Chips stop being removable at ``min_items``, so the set cannot be emptied below what the module requires. """ while self._chips.count() > 1: item = self._chips.takeAt(0) widget = item.widget() if widget is not None: widget.setParent(None) widget.deleteLater() removable = len(self._sources) > self._min_items for index, (source, label) in enumerate( zip(self._sources, self._labels())): chip = TableChip(label, self, removable=removable) chip.setToolTip(source) chip.removed.connect(self.remove_source) self._chips.insertWidget(index, chip) def _refresh_summary(self) -> None: """Ask what the merge would cost; paint the answer when it lands. SPLIT IN TWO, and the split is the fix for a frozen application. This half touches widgets only. The half in :func:`_read_the_merge` stats every database and opens each one with sqlite, and it runs on a worker, because those paths are folders the user chose -- see the module docstring for the twenty seconds one of them took to answer. Nothing the user was shown has gone: all three answers -- the cost of the merge, the sources with no database yet, the file that could not be read -- are still printed, from :meth:`_summary_arrived`. """ self._plan = None if not self._sources: self._summary_token += 1 self._read_again = False self.summary.setText("") return if self._reading: self._summary_token += 1 self._read_again = True return paths = self.database_paths() self._summary_token += 1 self._reading = True self._reading_count = len(paths) self._last_read_error = "" self.summary.setText(f"reading {len(paths)} database(s)…") token = self._summary_token started = self._jobs.submit( partial(_read_the_merge, tuple(paths), self._table), partial(self._summary_arrived, token)) if not started and self._summary_token == token: self._reading = False def _read_failed(self, message: str) -> None: """Remember why a read died, so :meth:`_read_settled` can say it.""" self._last_read_error = str(message or "") def _read_settled(self, ok: bool) -> None: """Clear the in-flight flag, AND the placeholder, for a read that never delivered. :meth:`spacr.qt.job_runner.JobRunner.submit` hands the result only to a job that succeeded, so a read that dies some other way -- the handler raising, a worker killed on the way out -- reaches :meth:`_summary_arrived` never. Two things would then be left behind: ``_reading``, which makes every later change to the set coalesce into a read that is never going to run, and the "reading N database(s)…" placeholder, which tells the user the answer is still coming when it is not. Both are let go here. Only ``self._jobs`` is connected to this. The restore check has its own runner precisely so that its failure cannot be mistaken for this one's -- ``job_finished`` carries no job identity. """ if ok or not self._reading: return self._reading = False again, self._read_again = self._read_again, False try: if self._sources: detail = self._last_read_error or "the read did not finish" self.summary.setText( f"could not read {self._reading_count} database(s) as " f"'{self._table}': {detail}") except RuntimeError: return finally: self._last_read_error = "" if again: self._refresh_summary() def _summary_arrived(self, token: int, answer) -> None: """Paint one finished read. GUI thread only.""" self._reading = False self._last_read_error = "" again, self._read_again = self._read_again, False alive = True try: if answer is not None and token == self._summary_token: kind, payload = answer if kind == "plan": self._plan = payload self.summary.setText(self._summary_text(payload)) elif kind == "missing": missing, total = payload self.summary.setText( f"{len(missing)} of {total} sources have no " f"measurements database yet: " + ", ".join(os.path.dirname(p) for p in missing[:3]) + (" …" if len(missing) > 3 else "")) else: total, message = payload self.summary.setText( f"could not read {total} database(s) as " f"'{self._table}': {message}") except RuntimeError: alive = False finally: if alive and again: self._refresh_summary() def _summary_text(self, plan) -> str: """What the merge would cost, in the order a user needs it.""" lines = [ f"{len(plan.sources)} database(s) · {plan.total_rows:,} rows · " f"{len(plan.common_columns)} columns in all of them" ] dropped = plan.dropped_columns if dropped: shown = ", ".join(dropped[:6]) + (" …" if len(dropped) > 6 else "") lines.append( f"{len(dropped)} measurement(s) are in only some of them and " f"would be dropped: {shown}") if plan.colliding_plates: detail = "; ".join( f"{plate} in {', '.join(labels)}" for plate, labels in sorted(plan.colliding_plates.items())) lines.append( f"The same plate ID is in more than one database: {detail}. " "Merging would compute every per-well number over two " "experiments at once. Remove one of them, or rename the " "plates.") return "\n".join(lines) def _on_colour_toggled(self, on: bool) -> None: """Ask the owning panel to colour by source, or to stop. :param on: the switch's new state. A merged embedding whose clusters turn out to be the plates rather than biology is the most important thing a multi-database map can show, and it can only show it when the points are coloured by where they came from. """ if self._on_colour_by is None: return from ...multi_database import SOURCE_COLUMN self._on_colour_by(SOURCE_COLUMN if on else None)