spacr.qt.widgets.row_exclusion

Column/value editor for excluding rows from UMAP input data.

Everything this widget offers the user comes out of a measurements database, and both reads used to happen on the GUI thread:

  • discover_columns opens every database the src setting resolves to and runs sqlite_master plus a PRAGMA table_info per table. It is driven from SettingsWidgets._refresh_contextual_widgets, so it runs every time src or tables is set.

  • distinct_values runs one SELECT DISTINCT … LIMIT 501 per (database, table) pair the chosen column appears in — and the columns a user actually excludes on (plateID, columnID, rowID) hold a handful of distinct values across the whole table, so the LIMIT is never reached and SQLite scans every row. Measured on a 200 000-row × 8-table measurements.db with a warm page cache: 196 ms per column, and the column combo is editable, so currentTextChanged fires on every keystroke. Eight quick edits froze the window for 894 ms in one unbroken block; set_source itself cost 183 ms and a single deliberate column choice 220 ms. Threaded, the same three are 29 ms, 5 ms and 2 ms.

So both go through a JobRunner owned by the editor, and the value reads are additionally debounced. Three rules hold this together:

  • _value_cache and _column_sources are GUI-thread state. The worker returns plain data and the completion handler — which Qt runs on the GUI thread — is the only thing that writes them.

  • Schema discovery and value reads get separate runners. JobRunner.cancel() works by generation, so one runner cannot supersede a value read without also dropping the schema read that the value read depends on.

  • A debounce timer coalesces keystrokes, and JobRunner.cancel() drops whatever an earlier keystroke started, so the last edit wins rather than the slowest read.

Classes

RowExclusionEditor

Add one or more UMAP row exclusions by choosing columns and values.

Functions

discover_columns(→ dict[str, list[tuple[pathlib.Path, ...)

Map every column name to the (database, table) pairs holding it.

distinct_values(→ list[Any])

Return the distinct values of column across sources, sorted.

source_paths(→ list[pathlib.Path])

Return the measurements.db files source resolves to.

Module Contents

class spacr.qt.widgets.row_exclusion.RowExclusionEditor(value=None, parent=None, *, threaded: bool = True, debounce_ms: int = DEBOUNCE_MS)[source]

Bases: PySide6.QtWidgets.QWidget

Add one or more UMAP row exclusions by choosing columns and values.

The database reads behind the two dropdowns run on worker threads; see the module docstring for the measured reason. Nothing about the widget’s public contract changed — set_source() still takes a source and returns, it just no longer waits for sqlite before it does.

Parameters:
  • value – initial rules, in any form spacr.row_exclusions.normalize_row_exclusions() accepts.

  • parent – Qt parent.

  • threaded – False runs both reads inline, in the same order, emitting the same signals — a caller that must have the values the instant set_source() returns can ask for it. Keyword only, and defaulted, because the shipped call site (SettingsWidgets._widget_for) passes value and parent and nothing else.

  • debounce_ms – how long a column edit waits before it is read. 0 reads on the next event-loop turn without coalescing.

Build the row-exclusion editor.

Two runners, not one: cancel abandons everything a runner has in flight, and superseding a keystroke’s value read must not also abandon the schema read that says which databases the column even lives in.

Parameters:
  • value – the exclusions to start with.

  • parent – parent widget, or None.

  • threaded – read on worker threads. Set False in tests so a read finishes before it returns.

  • debounce_ms – how long typing settles before a value read is run.

active_jobs() → int[source]

How many worker threads are still winding down.

closeEvent(event)[source]

Closing mid-read must not leave a thread behind.

Qt aborts the process when a running QThread is destroyed, and a worker that delivers into a widget on its way out is a use-after-free. JobRunner.shutdown handles both.

Not the only line of defence, deliberately, because it is not always reached: this editor is a child inside a settings panel, and navigating away from that panel destroys it without any close event. What covers that case is the runner itself — 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.

Parameters:

event – the close event; passed on unchanged to the base class after the workers are shut down.

get_value() → dict[str, list[Any]] | None[source]

The exclusions, in the shape the settings dict wants.

Returns:

{column: [values]}, or None when nothing is excluded.

is_busy() → bool[source]

True while a read is queued, running, or undelivered.

set_source(source, tables=None) → None[source]

Populate column/value choices from a dropped measurements DB.

Returns as soon as the schema read is dispatched. The dropdowns keep whatever they are showing until the worker delivers — a list that is half a second stale beats a frozen window, and on the first call they are showing nothing anyway.

Parameters:
  • source – anything source_paths() accepts: a run folder, its measurements folder, a .db file, or a list of them.

  • tables – restrict the columns to these table names; None reads every table.

set_value(value) → None[source]

Replace the exclusions from a settings value.

Parameters:

value – {column: [values]}, or None to clear.

shutdown() → None[source]

Stop reading and let no worker outlive the widget.

Public because a host that owns this editor inside a larger screen can call it directly — Qt delivers a close event to the window, not to every widget inside it, so a screen that wants the reads stopped the moment the user navigates away has to say so. Idempotent.

spacr.qt.widgets.row_exclusion.discover_columns(source, tables=None) → dict[str, list[tuple[pathlib.Path, str]]][source]

Map every column name to the (database, table) pairs holding it.

Pure and Qt-free so it can run on a worker thread; the result is plain data the GUI thread installs.

Parameters:
  • source – anything source_paths() accepts.

  • tables – restrict to these table names; falsy means all of them.

spacr.qt.widgets.row_exclusion.distinct_values(sources: Iterable[tuple[pathlib.Path, str]], column: str, limit: int = VALUE_LIMIT) → list[Any][source]

Return the distinct values of column across sources, sorted.

The slow half of this widget, and the reason it has a worker thread. Pure and Qt-free — hand it the (database, table) pairs discover_columns() found and it touches nothing else.

Every connection is closed in a finally. The version this replaces used with sqlite3.connect(...) as connection, which commits but does not close, so a session of typing in the column box leaked one file descriptor onto a multi-hundred-megabyte database per keystroke.

Parameters:
  • sources – (database path, table name) pairs to read; a database or table that cannot be read is skipped.

  • column – the column name; empty returns []. NULL values are left out.

  • limit – stop after this many distinct values.

spacr.qt.widgets.row_exclusion.source_paths(source) → list[pathlib.Path][source]

Return the measurements.db files source resolves to.

Accepts a path, a list of paths, or the repr of a list (which is what a settings text field holds after a multi-folder drop), and tolerates a run folder, its measurements subfolder, or the database itself. Paths that do not exist are dropped rather than reported: this feeds a dropdown, and a half-typed src is not an error.

Parameters:

source – a path, a list or tuple of paths, or the text of such a list; empty entries are skipped and ~ is expanded.