spacr.qt.widgets.file_list

Edit ordered settings that accept one or more file paths.

FilePathListWidget appends files from a multi-select dialog or by dropping files and folders. A dropped folder contributes sorted matching files from one directory level. Duplicate paths are rejected, missing paths are marked before execution, and entries can be reordered when plate order matters.

The widget reads and writes a plain list[str] so Qt settings, settings CSVs, and command-line calls share the same value representation.

Classes

FilePathListWidget

An ordered, de-duplicated list of input paths with picker and drop.

PairedFileTableWidget

Editable one-row-per-plate score/count input and measurement link.

Functions

is_database_path(→ bool)

True when path names a measurements database by extension.

side_for_header(→ str)

'count' when the file's header names a gRNA and a count, else

suggest_file_pairs() → list[dict])

Propose visible score/count/database rows by filename tokens.

Module Contents

class spacr.qt.widgets.file_list.FilePathListWidget(value: Any = None, *, kind: str = 'table', title: str = 'Choose input files', allow_folders: bool = True, single: bool = False, parent=None)[source]

Bases: PySide6.QtWidgets.QWidget

An ordered, de-duplicated list of input paths with picker and drop.

single=True is the same control for a setting that names exactly ONE file. It keeps the file dialog and the drop target – which is the whole reason these settings stopped being text boxes – but the value it holds and returns is a plain str, choosing again REPLACES rather than appends, and the reorder buttons are gone because one path has no order.

That distinction is not cosmetic. grna_csv, row_csv and column_csv are declared str and go straight to pd.read_csv, so rendering them as a list turned a working default into ['/path/to/barcodes_row.csv'] the moment the screen was opened and saved. Every run from that file was then refused by the pre-flight – “column_csv=[…] is a list, but str is expected” – against a value the user had never typed and could not correct from the panel that wrote it.

Parameters:
  • value – what the setting already holds. A bare string stays a string – see above; that is the whole point of this widget.

  • kind – which file filter the chooser opens with, one of FILE_KIND_FILTERS. An unrecognised name falls back to "any" rather than raising, so a new setting cannot break a panel by naming a filter that does not exist yet.

  • title – the file dialog’s window title.

  • allow_folders – whether a folder may be picked. Forced off when single is set: expanding a folder into “every CSV in here” cannot mean anything for a setting that names ONE file.

  • single – whether the setting holds one path rather than a list.

  • parent – parent widget.

Build the ordered, de-duplicated path list.

Parameters:
  • value – the paths already saved.

  • kind – what sort of file it accepts.

  • title – the caption on its picker.

  • allow_folders – whether folders may be added.

  • single – whether only one path is allowed.

  • parent – parent widget.

add_paths(paths: Iterable[Any]) → int[source]

Append paths, expanding folders. Returns how many were added.

When the setting names ONE file this REPLACES what is there. A second choice is a correction, and a control that appended left the run reading a file the user believed they had swapped out.

Parameters:

paths – paths to add, as strings or path-like objects; a bare string counts as one path, and placeholders and None entries are skipped. Each is made absolute, a folder contributes its matching files one level down, and a single-file widget keeps only the last.

clear() → None[source]

Drop every path, and say so only if there was anything to drop.

dragEnterEvent(event: PySide6.QtGui.QDragEnterEvent) → None[source]

Accept a drag carrying file URLs.

Parameters:

event – the Qt drag event.

dragMoveEvent(event: PySide6.QtGui.QDragMoveEvent) → None[source]

Keep accepting while file URLs stay over the list.

Parameters:

event – the Qt drag event.

dropEvent(event: PySide6.QtGui.QDropEvent) → None[source]

Add the dropped paths, ignoring a drop that carries none.

Parameters:

event – the Qt drop event.

get_value() → Any[source]

The setting’s value: a list[str], or a str when single.

The shape is the SETTING’s, not the widget’s. A key declared str that came back as a one-element list rewrote the user’s settings file on open and reached pd.read_csv as a list.

paths() → List[str][source]

Every path currently listed, in order – always a list.

pick_files() → int[source]

Open the file dialog and take what is chosen.

Multi-select, except when the setting names one file – a dialog that lets you pick four when three of them will be discarded is a control lying about what it does.

pick_folder() → int[source]

Ask for a folder and add it.

Returns:

how many paths were added; 0 when cancelled or duplicate.

remove_selected() → None[source]

Drop the selected paths.

Removed from the BOTTOM up, so each row index is still valid when it is reached – deleting top-down shifts everything below it.

set_value(value: Any) → None[source]

Replace the contents. Accepts None, a str, or any iterable.

A single-file widget keeps only the last of whatever it is given. That is what loads a settings file written while these keys were wrongly rendered as lists: ['/x/barcodes_row.csv'] comes back as /x/barcodes_row.csv rather than carrying the wrong shape forward.

Parameters:

value – None, one path as a string or path-like object, or an iterable of paths; surrounding quotes and placeholder values are dropped.

class spacr.qt.widgets.file_list.PairedFileTableWidget(value=None, parent=None)[source]

Bases: PySide6.QtWidgets.QWidget

Editable one-row-per-plate score/count input and measurement link.

A row is ONE PLATE: its score and count source paths, optional named tables within those sources, and the separate measurements database that plate’s per-object tables live in. The source paths are filled BY ADDITION – every arrival re-proposes the whole table from filename tokens – so databases dropped in the opposite order to the files still land on the right plates. That is the rule, and the third column obeys it rather than keeping a list of its own.

A plate with NO database is legal and is not an error: the regression is fitted on scores and counts. The database is what makes that plate’s measurements available downstream, so its absence disables the plate there instead of failing the run.

Parameters:
  • value – the table already saved, one entry per plate. Everything that arrives afterwards RE-PROPOSES the whole table from filename tokens rather than appending, which is what lets databases dropped in the opposite order to the source files still land on the right plates.

  • parent – parent widget.

Build the one-row-per-plate table.

Parameters:
  • value – the rows already saved.

  • parent – parent widget.

add_paths_for_side(paths, side: str = 'score') → int[source]

Add files to one column of the table and re-propose the pairing.

The drop router calls this method after identifying a file’s role from its header, so a count table dropped anywhere on the widget still fills the count column.

'database' is the third side. It goes through this same method, and therefore through the same whole-table re-proposal, rather than through an adder of its own that would keep a private list and pair by the order things arrived.

Parameters:
  • paths – File paths to add.

  • side – Target column: 'score', 'count', or 'database'.

Returns:

Number of previously absent files added.

align_download_buttons() → bool[source]

Put the Download buttons over the columns they fill.

Returns:

True when the strip was found and is now following this table’s header; False when this table was not built into a screen that has a Download row – which is every use of it outside Regression’s Input Tables, and every test that builds it bare.

attach_database(path, row=None) → str[source]

Attach a measurements database to one plate row, and say which.

row is the row the user aimed the drop at – an EXPLICIT assignment, which is remembered and survives the re-proposal that the next dropped CSV triggers.

With no row, the database is offered to the token pairing first, so plate2/measurements/measurements.db finds plate 2 wherever that row happens to be. Only when nothing in its path names a plate does it fall back to the first row that has no database – and the returned sentence, also shown under the table, NAMES that row. A database attached to row 0 in silence is a plate’s measurements quietly credited to another plate.

Returns the sentence, so a caller with a console logs the same words the user is reading.

Parameters:

path – path to the measurements database, as a string or path-like object; an empty path raises ValueError.

dragEnterEvent(event)[source]

Accept a drag carrying files this table can take.

Parameters:

event – the Qt drag event.

dragMoveEvent(event)[source]

Keep accepting while droppable files stay over the table.

Parameters:

event – the Qt drag event.

dropEvent(event)[source]

Route each dropped file to the column, and row, it belongs in.

A drop over the score or count column goes there regardless of what the file looks like – the user aimed it. A drop anywhere else is sorted by header, so dropping the whole set at once still fills both columns correctly.

A DATABASE is decided by its extension and never by aim: a .db dropped on the score column is a mis-aim, not a request to fit the regression on a sqlite file, and a CSV dropped on the database column is likewise still a CSV. What aim adds for a database is the ROW – dropping it on a plate’s row attaches it to THAT plate, which is the one thing the token pairing cannot know when the file is called measurements.db like everybody else’s.

Parameters:

event – the drop event; its local file URLs and drop position are read, and it is ignored when it carries no usable paths.

get_value() → list[dict][source]

Every row, in the shape the settings dict wants.

Returns:

one dict per plate row.

missing_databases() → list[source]

Rows whose database is not on disk: (row number, plate, path).

Row numbers are 1-based, the way the table numbers them.

Checked as soon as the path is attached and restated in the status line, because the alternative is a run that reads its inputs, fits nothing for four minutes, and then fails on a path the panel could have flagged the moment the settings were loaded. A settings file is routinely written on one machine and run on another, which is the case that produces this.

set_value(value: Any) → None[source]

Replace every row from a settings value.

SIGNALS BLOCKED while the rows are rebuilt: this is called when a settings file is poured in, and one change signal per cell would re-validate the whole form once per cell.

Parameters:

value – the rows, as the settings dict carries them.

showEvent(event)[source]

Take the Download row above over this table’s columns.

WHY THE TABLE REACHES UP FOR THE STRIP RATHER THAN THE OTHER WAY ROUND. The strip is built by the generic AppScreen, which knows nothing about this table’s columns – it is four buttons in a QHBoxLayout, and every module’s example-data row is built the same way. This widget is the only object that knows what the columns ARE, and it is the only widget in the form that is Regression’s alone, so the knowledge and the alignment are kept in the same place.

ON SHOW rather than in __init__: the strip is added to the section AFTER the settings rows are, so at construction there is nothing to find, and the widget has no parent chain to walk up either. Once installed the layout follows the header on its own, and the call is idempotent, so repeated shows cost one dictionary lookup.

Parameters:

event – the show event; it is not inspected, only passed on to the base class first.

spacr.qt.widgets.file_list.is_database_path(path) → bool[source]

True when path names a measurements database by extension.

One rule, in one place, for the widget that files a dropped path into a column and for the drop handler that catches the same file when it lands on the screen around the widget. Two copies would disagree the first time somebody’s database was called plate1.sqlite.

Parameters:

path – file path as a string or path-like object; its extension is compared, case-insensitively, with DATABASE_EXTENSIONS.

spacr.qt.widgets.file_list.side_for_header(path) → str[source]

'count' when the file’s header names a gRNA and a count, else 'score'.

Read from the header rather than the filename: a count export carries a gRNA name and a count, a score export carries neither, and that is true whatever the file is called.

Module-level because two screens ask the same question of the same files. Regression asks it through PairedFileTableWidget; Parameter Sweep holds its two sides in separate list widgets and asks it through spacr.qt.dnd_handlers.SweepInputsDropHandler. A second copy of this rule would drift, and the direction it would drift in is silent: a count table filed as a score is not an error, it is a wrong regression.

Parameters:

path – CSV file whose first row is read as the header; a file that cannot be read counts as a score file.

spacr.qt.widgets.file_list.suggest_file_pairs(scores: Sequence[str], counts: Sequence[str], *, databases: Sequence[str] = ()) → list[dict][source]

Propose visible score/count/database rows by filename tokens.

A proposal is never authoritative: the editable table is the contract the user confirms. Unique best matches are used; ties remain unpaired.

databases is keyword-only and defaults to nothing, so the two-argument call every existing caller makes still means what it did. A database is matched against BOTH cells of a row it may join – a plate is named by its score CSV as often as by its count CSV – and one that matches nothing is listed on its own row rather than being dropped or guessed onto row 0.

Parameters:
  • scores – score CSV paths; each starts a row, in the order given.

  • counts – count CSV paths; each is paired with the score whose filename tokens it uniquely matches best, unmatched ones fill score rows still missing a count, and any left over get rows of their own.

Nested helpers

FilePathListWidget._follow_path_probes.let_go(*_args) → None

Drop the probe connection as the widget is destroyed.

Parameters:

_args – whatever destroyed sends; unused.

spacr/qt/widgets/file_list.py:1594

FilePathListWidget._follow_path_probes.redraw(_path: str, _answer: bool) → None

Refresh the hint once a probe has an answer.

Both arguments are ignored: the hint is rebuilt from every path it shows, so which one answered does not change the work.

Parameters:
  • _path – the path that was probed; unused.

  • _answer – what the probe found; unused.

spacr/qt/widgets/file_list.py:1577