spacr.qt.widgets.column_picker¶
SQL column picker — the “SQL” button that sits beside every column field.
Anywhere spaCR asks for the name of a database column — the annotation
column, the measurement used to prefilter crops, the heatmap feature, the
regression’s dependent variable — the field has historically been a bare
text box. Typing into it is blind: nothing tells you what the database
already contains, so annotate and annotaet are equally acceptable
and equally silent. The second one starts a brand new annotation pass, and
two passes that should have been one then look like two annotators who
agree on nothing.
This module adds the missing half of that interaction:
ColumnPickerButtona small
SQLbutton that can be dropped beside an existing field.ColumnPickerDialogthe panel it opens — the tables in the database, the columns of the selected table with their declared type, and a name box that says, in words, what will happen to the name you typed: used, created, or refused.
attach_column_pickera one-liner that wires the two onto a
QLineEdit/QComboBoxa screen has already laid out. It re-uses the field’s own slot in its parent layout, so a host adopts the picker without restructuring anything.
Three properties this file is built around:
Read-only. Opening the picker reads column names and nothing else. The connection is
file:…?mode=roplusPRAGMA query_only = ON— the same pairspacr.qt.screens.db_browserandspacr.agreementuse — so SQLite itself refuses a write. The dialog never creates a column: it returns a name, and the write path that owns creation (annotate_engine.ensure_annotation_column, which does it lazily withALTER TABLE) stays where it is.Cheap on open.
PRAGMA table_infois free;SELECT COUNT(*)over a 400 k-row measurement table is not. Opening the dialog runs no count at all. The per-table row figure comes frommax(_rowid_)and is labelled an estimate; a per-column non-null count happens only when the user asks for it by name. (_rowid_, notrowid— seeSchemaReader.estimate_rows(), where the difference was a full table scan followed by aValueError.)Off the GUI thread. Cheap is not free, and opening the picker is four sequential sqlite round trips — open, list tables, read one table’s columns, estimate its rows — every one of which used to happen inside
__init__, before the modal appeared. Measured cold on a 383 MB measurements.db that is 45 ms, and on a 1 500-table schema 87 ms, entirely between the click and any window. The button now builds the dialog withthreaded=Trueand the reads arrive from aJobRunner. The default is still the synchronous mode, deliberately;ColumnPickerDialogsays why.No modal errors. A missing database, a file that isn’t SQLite, a database with no tables, a name SQLite would refuse — every one of them is reported in a banner inside the dialog. The dialog itself is the one deliberate modal, and it is injectable (
ColumnPickerButton. set_dialog_runner()) so a headless test drives it without ever entering an event loop.
Classes¶
The small |
|
Browse a database's columns and settle on one name. |
|
Read-only access to a database's schema — names and types only. |
Functions¶
|
Put a |
|
Return |
|
Whether |
|
Return the text of a line edit / combo box / chip strip. |
|
Return the column names |
|
Return the column matching |
|
Return the existing column |
|
Return |
|
Double-quote a SQL identifier, escaping embedded quotes. |
|
Everything opening the picker needs, in one worker-thread call. |
|
Read one table's columns and row estimate. Touches no widget. |
|
Turn whatever a host screen holds into a path to a database file. |
|
Write |
|
Write every name in |
|
Return why |
Module Contents¶
- class spacr.qt.widgets.column_picker.ColumnPickerButton(db_path_getter: Any = '', table: str | None = None, field: PySide6.QtWidgets.QWidget | None = None, parent: PySide6.QtWidgets.QWidget | None = None, text: str = 'SQL', allow_new: bool = True, multi: bool | None = None)[source]¶
Bases:
PySide6.QtWidgets.QToolButtonThe small
SQLbutton that opens aColumnPickerDialog.- Parameters:
db_path_getter – callable returning the current database path or run folder — a callable, not a string, because the path usually depends on a
srcfield the user is still editing. A plain string is accepted and wrapped.table – table to preselect in the dialog.
field – the
QLineEdit/QComboBox/chip-strip editor a picked name is written into; may beNonefor a button that only emitspicked.multi – this field holds a list of columns — names are appended rather than replacing what is there, and the dialog lets the user select any number of columns in one visit instead of one per press.
Noneauto-detects (field_is_list()).parent – parent widget; ownership only.
text – the button’s face.
SQLeverywhere today; a parameter because the button is small enough that its label is the only thing distinguishing two of them side by side.allow_new – let the dialog accept a name that is not in the table.
Falserestricts the user to columns that exist, which is right for a field that must match the schema and wrong for one naming a column a later step will create.
Build a button that opens a column picker for a database.
- Parameters:
db_path_getter – the database path, or a callable returning one – the path is usually a setting the user is still editing, so it is resolved at click time rather than at construction.
table – table to preselect in the picker.
field – the widget the chosen column is written back into.
parent – parent widget, or
None.text – the button’s label.
allow_new – let the picker name a column that does not exist yet.
multi – pick several columns;
Noneleaves the choice to the picker’s own default.
- is_multi() bool[source]¶
Whether this field holds a list of columns rather than one.
Explicit
multiwins. Otherwise the field decides: a chip-strip editor is list-valued whether or not it currently holds anything, which a text inspection cannot tell (an empty one reads"", and an empty list field is exactly when the user most wants to add several at once). Text fields keep the old shape test.
- make_dialog() ColumnPickerDialog[source]¶
Build the dialog, wired to the current path — but do not run it.
threaded=True: this is the user-facing path, and the dialog it builds is run modally byopen_picker(), so every millisecond the constructor spends in sqlite is a millisecond between the click and any window at all. SeeColumnPickerDialogfor the two modes and why the default is the other one.
- open_picker() str[source]¶
Open the picker; on OK, write every chosen name into the field.
- Returns:
the first chosen column name, or
""when cancelled.picked_manycarries the whole selection.
- set_dialog_runner(runner: Callable[[ColumnPickerDialog], int]) None[source]¶
Replace how the dialog is run.
The default enters a modal event loop. Tests inject a runner that inspects the dialog and returns
QDialog.Accepted/Rejectedwithout ever blocking — which is also how a host could swap in a non-modal presentation later.- Parameters:
runner – callable that takes the
ColumnPickerDialog, presents it and returns its result code (QDialog.AcceptedorQDialog.Rejected).
- class spacr.qt.widgets.column_picker.ColumnPickerDialog(db_path: Any = '', table: str | None = None, current: str = '', parent: PySide6.QtWidgets.QWidget | None = None, allow_new: bool = True, reader: SchemaReader | None = None, threaded: bool = False, multi: bool = False)[source]¶
Bases:
PySide6.QtWidgets.QDialogBrowse a database’s columns and settle on one name.
The dialog answers one question — “what should this field say?” — and is explicit about the consequence of the answer: an existing column will be used, a new one will be created by whoever owns the write path. It creates nothing itself.
Two modes, and the default is the synchronous one.
threaded=False(the default) reads the schema inside__init__: when the constructor returns, the tables are listed, a table is selected, its columns are in the tree and the name box has been judged. That is what every programmatic caller and the ~60 tests intests/qt/test_column_picker.pyare written against — they construct a dialog and assert on its contents on the next line, with no event loop anywhere (the suite’s autouse fixture makesQDialog.execraise). Defaulting to the asynchronous mode would turn every one of those into a race, so it is opt-in.threaded=Trueis what theSQLbutton uses (ColumnPickerButton.make_dialog()), and it is the real user-facing path. The window appears immediately saying it is reading the schema, and the tables, columns and row estimate arrive from aJobRunner. That matters because opening the picker is four sequential sqlite round trips against a file that is usually cold and often on a network mount — measured at 45 ms on a 383 MB measurements.db with nothing cached and 87 ms on a 1 500-table schema, all of it dead time between the click and the window.Both modes perform the same
read_schema()calls and preserve the same result order. The synchronous runner executes its job inline.- Parameters:
db_path – database file or run folder; may be empty.
table – table to preselect (e.g.
png_listfor annotations).current – the field’s current value, prefilled into the name box.
allow_new – when False, only existing columns are accepted.
reader – an already-built
SchemaReader(or a stand-in); injecting one is how tests exercise schema edge cases. Its reads are still threaded whenthreadedis set.threaded – read the schema on a worker thread. See above.
multi – let the user select several columns at once and return every one of them (
chosen_columns()). For fields that hold a list of columns —exclude,annotation_columns— where one name per press meant reopening the dialog, and re-reading the schema, once per column the user wanted.parent – parent widget; ownership only.
Build the dialog and read the database’s schema into it.
- Parameters:
db_path – database or run folder to read.
table – table to select on opening, if it exists.
current – column name to start with in the name box.
parent – parent widget, or
None.allow_new – let the user name a column that does not exist yet.
reader – an already-open
SchemaReaderto use instead of openingdb_path.threaded – read the schema on a worker thread. Left
False, the read runs inline and the dialog is fully populated by the time the constructor returns, which is the default mode’s contract.multi – pick several columns rather than one; the tree’s selection becomes the answer instead of the name box.
- chosen_columns() List[str][source]¶
Every column OK would hand back, in order and without repeats.
Always non-empty when
chosen_column()is — a single-select dialog is the one-element case of this, so a caller can use this alone. In multi mode the highlighted rows are the answer, with the typed name added when it is not one of them (that is how a column that does not exist yet is named).
- confirm_box() spacr.qt.widgets.toggle.Toggle[source]¶
Return the “create it anyway” checkbox (visible only on a near-miss).
- done(result: int) None[source]¶
Close, and let no reader thread outlive the dialog.
donerather thancloseEventbecause it is the one funnel:accept,rejectand the window’s close button all arrive here, and a dialog dismissed halfway through its first read is the ordinary case — the user clickedSQLon the wrong field.JobRunner.shutdowndrops the results and waits briefly so nothing destroys a running QThread.- Parameters:
result – the dialog result code, passed unchanged to
QDialog.done.
- executed_sql() List[str][source]¶
Return every statement the dialog’s reader has run.
The assertion hook for “what did opening this cost”. Threaded, the statements are appended on a worker thread, so the snapshot is taken under the reader’s lock — see
SchemaReader.executed_sql(). It is still only complete once the load is (is_busy()says when).
- name_edit() PySide6.QtWidgets.QLineEdit[source]¶
Return the name box, for hosts that want to prefill or focus it.
- select_column(name: str) bool[source]¶
Select
namein the column list (and fill the name box).- Parameters:
name – column name, converted with
str()and matched exactly against the first column of the list.
- select_columns(names: Sequence[str]) List[str][source]¶
Highlight several columns at once. Returns the ones that existed.
- Parameters:
names – column names to select, converted with
str()and matched exactly; the previous selection is cleared first.
- select_table(name: str) bool[source]¶
Select
namein the table list. Returns False if absent.- Parameters:
name – table name, converted with
str()and matched exactly against the table list.
- class spacr.qt.widgets.column_picker.SchemaReader(path: str)[source]¶
Read-only access to a database’s schema — names and types only.
Every method opens and closes its own connection, which keeps the object cheap to hold and impossible to leave a transaction open on.
mode=romakes SQLite refuse writes;PRAGMA query_onlyrefuses them a second time, including schema changes smuggled in through a temp attachment.Every method may be called from a worker thread — the dialog reads its schema off the GUI thread — and each one opens its own connection, so nothing is shared across threads except
executed, which is guarded.- Parameters:
path – database file or run folder (see
resolve_db_path()).- Variables:
executed – every statement this reader has run, in order — the hook a test uses to prove that opening the picker costs no
COUNT(*).
Open a read-only schema reader over a measurements database.
- Parameters:
path – database or run folder; resolved through spaCR’s project layout.
- column_info(table: str) List[Tuple[str, str]][source]¶
Return
[(column name, declared type), …]fortable.PRAGMA table_inforeads the stored schema text; it never touches a data page, so this stays free on a huge table.- Parameters:
table – name of the table or view; it is quoted as an identifier.
- Raises:
sqlite3.Error – for a view whose base table has been dropped — SQLite resolves the view here and says so.
- count_non_null(table: str, column: str) int[source]¶
Return how many rows have a value in
column.This one is a real scan — it exists only behind an explicit button, never on the path that opens the dialog.
- Parameters:
table – name of the table or view to scan; quoted as an identifier.
column – name of the column whose non-NULL values are counted; quoted as an identifier.
- estimate_rows(table: str) int | None[source]¶
Return an O(1) estimate of the row count, or
None.max(_rowid_)is answered from the right-hand edge of the b-tree without a scan. Deleted rows leave gaps, so it is an estimate and every caller must label it one.Nonefor a view, a WITHOUT ROWID table, or an empty table.It is spelt
_rowid_, and that is load-bearing. Every spaCR object table declares a column calledrowID— the plate row,'r1','r2'— and SQLite identifiers are case-insensitive, so a barerowidresolves to that column rather than to the row id. The version this replaces asked formax(rowid)and got two things wrong at once on every real measurements database: it scanned the whole table to take the maximum of a text column (measured: 21 ms warm, 41 ms cold, on a 200 000-row table — precisely theCOUNT(*)cost this method exists to avoid), and thenint('r16')raisedValueError, which is not asqlite3.Errorand so escaped the caller’s handler into the Qt event loop.spacr.predictions,spacr.foreignandspacr.data_managerall carry a comment about this shadowing; this method had not got the memo.The remaining spellings are tried in turn for the pathological table that declares
_rowid_as well, and a value that will not convert is treated as a shadowed column rather than as an answer.- Parameters:
table – name of the table or view; it is quoted as an identifier.
- probe() None[source]¶
Open the file once so “that isn’t a database” surfaces early.
- Raises:
sqlite3.Error – when the file is not a SQLite database or cannot be opened.
- spacr.qt.widgets.column_picker.attach_column_picker(field: PySide6.QtWidgets.QWidget, db_path_getter: Any, table: str | None = None, *, text: str = 'SQL', allow_new: bool = True, multi: bool | None = None, tooltip: str | None = None, layout: PySide6.QtWidgets.QLayout | None = None, on_pick: Callable[[str], None] | None = None) ColumnPickerButton[source]¶
Put a
SQLbutton besidefieldand wire it to the picker.Designed to be a single line a host screen adds after it has already laid the field out:
attach_column_picker(self._ann_col, lambda: self._src_edit.text(), "png_list")
The field keeps its place in its parent layout: the button and the field are wrapped in a small horizontal box that takes over the field’s original slot, so a
QFormLayoutrow keeps its label and a caller that stored a reference to the field keeps using it unchanged. When the field is not in a layout yet, the button is returned unplaced, and the caller positions it.- Parameters:
field – the
QLineEdit/QComboBoxthe user types into.db_path_getter – callable (or string) giving the database path or run folder. Called each time the button is pressed, so a path the user edits later is picked up.
table – table to preselect —
"png_list"for annotation columns,"cell"/"object"for measurements.text – button label.
allow_new – allow naming a column that does not exist yet.
multi – append rather than replace (
Noneauto-detects a list-valued field).tooltip – override the button’s tooltip.
layout – the layout holding
field, for the case where the host builds aQFormLayoutbefore installing it on a widget (sofield.parentWidget()is stillNone). Normally unnecessary — the field’s own parent is searched.on_pick – extra callback invoked with the chosen name.
- Returns:
the created
ColumnPickerButton.
- spacr.qt.widgets.column_picker.chip_editor(field: PySide6.QtWidgets.QWidget | None) PySide6.QtWidgets.QWidget | None[source]¶
Return
fieldwhen it is a chip-strip list editor, elseNone.Duck-typed on purpose: the editor lives in
spacr.qt.screens.settings_modeland importing a screen from a widget would invert the dependency (and, in practice, cycle). The test is “not a text field, but speaksget_value/set_value” — the settings screen’s_ScalarEditand_ListEditspeak those too, but they areQLineEditsubclasses and are caught by the branch above.- Parameters:
field – the widget to test, or None; line edits and combo boxes always yield None.
- spacr.qt.widgets.column_picker.field_is_list(field: PySide6.QtWidgets.QWidget | None) bool[source]¶
Whether
fieldholds a list of names rather than a single one.- Parameters:
field – the settings field: a
QLineEdit, aQComboBox, a chip-strip list editor, or None.
- spacr.qt.widgets.column_picker.field_text(field: PySide6.QtWidgets.QWidget | None) str[source]¶
Return the text of a line edit / combo box / chip strip.
- Parameters:
field – the settings field: a
QLineEdit, aQComboBox, a chip-strip list editor, or None. Any other widget reads as an empty string.
- spacr.qt.widgets.column_picker.field_values(field: PySide6.QtWidgets.QWidget | None) List[str][source]¶
Return the column names
fieldcurrently holds, in order.- Parameters:
field – the settings field: a
QLineEdit, aQComboBox, a chip-strip list editor, or None. Text is split on commas, and a bracketed[...]list has its quotes stripped.
- spacr.qt.widgets.column_picker.find_existing(name: str, columns: Sequence[str]) str[source]¶
Return the column matching
name, or"".Case-insensitively: SQLite resolves identifiers without regard to case, so
Annotateisannotateand adding it would fail with “duplicate column name” rather than create a second column.
- spacr.qt.widgets.column_picker.near_miss(name: str, columns: Sequence[str], cutoff: float = NEAR_MISS_CUTOFF) str[source]¶
Return the existing column
namemost resembles, or"".A new name one keystroke away from an existing one is almost never a new column; it is the old one, misspelt. Uses
difflib.get_close_matches()with the same cutoffspacr.cliapplies to mistyped setting names.- Parameters:
name – the candidate new column.
columns – the columns the table already has.
- Returns:
the closest existing column, or
""whennameis either already a column or genuinely unlike all of them.
- spacr.qt.widgets.column_picker.open_reader(path: Any) Tuple[SchemaReader | None, str][source]¶
Return
(reader, message)— exactly one of the two is set.Turning every failure into a sentence here is what lets the dialog render problems inline instead of raising into a modal.
- spacr.qt.widgets.column_picker.quote_ident(name: str) str[source]¶
Double-quote a SQL identifier, escaping embedded quotes.
Only ever called with a name taken from the live schema; the quoting keeps legal-but-awkward names (
cell_channel_1 (raw)) working.
- spacr.qt.widgets.column_picker.read_schema(db_path: Any, reader: SchemaReader | None = None, preferred: str = '') Dict[str, Any][source]¶
Everything opening the picker needs, in one worker-thread call.
Opens the database, lists its tables, picks the one to show and reads that table — the four synchronous sqlite round trips that used to sit between the click on
SQLand the dialog appearing.- Parameters:
db_path – file, run folder, or measurements folder.
reader – use this reader instead of opening
db_path.preferred – table to select if the database has it.
- Returns:
{"reader", "error", "tables", "schema_error", "table"}plus the keysread_table()returns for the chosen table.
- spacr.qt.widgets.column_picker.read_table(reader: SchemaReader | None, table: str) Dict[str, Any][source]¶
Read one table’s columns and row estimate. Touches no widget.
Returns plain data so the same result can come back from a worker thread or from an inline call, and the painting code does not have to know which it was.
- Parameters:
reader – open schema reader for the database; None returns the empty payload.
table – table name to read; None or an empty name returns the empty payload.
- Returns:
{"table", "columns", "rows", "error"}.erroris the sentence to put in the banner; an emptycolumnslist with noerrormeans the table honestly has none.
- spacr.qt.widgets.column_picker.resolve_db_path(path: str) str[source]¶
Turn whatever a host screen holds into a path to a database file.
Hosts variously hold
src(a run folder),src/measurementsor the database itself, so all three resolve here rather than in each caller.- Parameters:
path – file, run folder, or measurements folder.
- Returns:
absolute path to a
.dbfile (existence not guaranteed — a file path is returned as given so the caller can report it).- Raises:
ValueError – when
pathis empty.
- spacr.qt.widgets.column_picker.set_field_text(field: PySide6.QtWidgets.QWidget | None, name: str, append: bool = False) bool[source]¶
Write
nameintofield. Returns False when it could not.- Parameters:
field –
QLineEdit(including the settings screen’s_ScalarEdit/_ListEditsubclasses) orQComboBox.name – the column name to write.
append – add to the existing value instead of replacing it, keeping the field’s own list style (
['a', 'b']ora, b).
- spacr.qt.widgets.column_picker.set_field_values(field: PySide6.QtWidgets.QWidget | None, names: Sequence[str], append: bool = False) bool[source]¶
Write every name in
namesintofield. False when it could not.The multi-column half of
set_field_text(). A chip strip is set from a real list — no punctuation round trip — and anything else keeps its own list style through_appended().- Parameters:
field – the settings field: a
QLineEdit, aQComboBox, a chip-strip list editor, or None. None returns False.names – column names to write; blank entries are dropped, and an empty result returns False.
- spacr.qt.widgets.column_picker.validate_column_name(name: str) str[source]¶
Return why
namecannot be a new column, or""if it can.The point is to fail here, with a sentence, rather than three screens later inside an
ALTER TABLEthe user never sees.- Parameters:
name – candidate column name as typed.
- Returns:
an explanation, or the empty string when the name is fine.