Source code for spacr.qt.widgets.sweep_runs

"""Display regression runs and parameter-sweep trials in one Runs tab.

The panel shares the main regression screen's results and figure surfaces.
Selecting a row loads that run's results and replaces the figures beside the
tab. Ordinary runs, refits, and sweep trials use the same columns so their
settings, status, output folder, and figures can be compared directly.

Runs are recorded when they start and updated when they finish. Saved sweep
tables can be loaded alongside in-session records; switching rows announces a
single active run to every dependent view.

NOTHING ON THIS TAB TOUCHES A RUN FOLDER FROM THE GUI THREAD. Measured on one
workstation: one ``os.path.exists`` on a path under
``/nas_mnt`` -- an ``autofs`` mount whose share was asleep -- had NOT
RETURNED AFTER TWENTY SECONDS. Every row here carries a folder the user
chose, and this panel used to walk, stat, read and delete those folders in
the click that asked for it: the chooser's start directory, the sweep table
read on a tab change, ``describe_folder``'s ``os.walk`` inside the delete
confirmation, ``shutil.rmtree`` after it, and ``workspace.has_workspace``
while the row menu was being built. Each of those is a frozen application
with no traceback for as long as the mount takes to wake -- reported as
"opening map barcodes crashes spacr", as hover flicker, and as glimpses of
other screens. They run on a :class:`spacr.qt.job_runner.JobRunner` now, and
the one bare existence question left -- which folder to open the chooser in
-- is answered from :mod:`spacr.qt.path_probe`'s cache.
"""

from __future__ import annotations

import os
from typing import Optional

import logging

from PySide6.QtCore import QEvent, Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget

from .. import path_probe

LOG = logging.getLogger("spacr.qt.sweep_runs")

#: What the sweep writes its table to, inside the destination folder.
RESULTS_FILENAME = "sweep_results.csv"

#: Column marking the run currently shown by the other views on the screen.
#:
#: A text marker keeps the value sortable, filterable, and copyable in the
#: shared :class:`ResultsTable`.
LOADED_COLUMN = "loaded"

#: What that column holds for the loaded run. Blank for every other row: a
#: column of "no" against one "yes" is noise, and the eye is looking for the
#: single mark.
LOADED_MARK = "loaded"

#: The columns worth reading first, in this order, when the table has them.
#: Settings, then WHAT WENT IN, then what came out: a hit count means little
#: without the size of the design behind it, and two trials differing only by
#: a filtration cutoff can be fitted on completely different data.
#:
#: ``run`` and ``source`` lead, ahead of the sweep's ``trial_id``, because the
#: first question over a mixed table is WHICH RUN THIS IS -- a trial number
#: names nothing when half the rows are not trials.
PREFERRED_COLUMNS = (
    LOADED_COLUMN,
    "run", "source",
    "trial_id", "status", "dependent_variable",
    "regression_type", "regression_backend", "inference",
    "guide_permutations", "analysis_unit",
    "agg_type", "transform", "multiple_testing_method", "fdr_alpha",
    "fraction_threshold", "min_cell_count",
    "n_wells", "n_guides", "n_cells", "n_rows_fitted",
    "n_results", "n_below_alpha", "positive_rank", "positive_percentile",
    "r_squared", "genomic_inflation", "seconds", "error_type",
)

#: What a run of the module contributes about ITSELF, taken off the settings
#: it was started with. Every name here is also in :data:`PREFERRED_COLUMNS`,
#: and a test holds that: a setting recorded under a name the ordering does
#: not know lands past the last sweep column, which is the far right of a
#: twenty-column table -- recorded, and never seen.
RUN_SETTING_COLUMNS = (
    "dependent_variable",
    "regression_type", "regression_backend", "inference",
    "guide_permutations", "analysis_unit", "agg_type", "transform",
    "multiple_testing_method", "fdr_alpha", "fraction_threshold",
    "min_cell_count",
)

#: What the ``source`` column says. The sweep's trials are read off its CSV
#: and get :data:`SOURCE_SWEEP`; the other two are recorded live.
SOURCE_RUN = "run"
SOURCE_REFIT = "re-fit"
SOURCE_SWEEP = "sweep trial"
#: Source label for a run opened from disk. Disk-loaded runs retain settings
#: and may become the active run like runs produced in the current process.
SOURCE_DISK = "on disk"
#: Source label for one fit from the Measurements tab's column queue. It
#: distinguishes queued fits over one merged frame from ordinary runs.
SOURCE_MEASUREMENT = "measurement column"

#: A run that has been started and has not come back yet. Not "ok": a run
#: still going has produced nothing to look at, and a row that claims
#: otherwise is a click that opens last month's folder.
STATUS_RUNNING = "running"


_DELETION_STARTED = -1


def _should_thread() -> bool:
    """Whether this panel's filesystem work has to leave the calling thread.

    THE ANSWER IS "IS ANYONE WATCHING". In the application the calling thread
    is the one painting, and a stat on a sleeping mount freezes the whole
    interface (see the module docstring); under pytest there is no event loop
    to starve, and every caller in this file's test suite reads what the
    method RETURNED -- a count of deleted folders, whether a run was opened --
    which an answer arriving later cannot supply. `somebody_is_there` is this
    repository's existing answer to "is a person waiting on this thread", so
    it is asked rather than re-derived.

    Unanswerable means threaded: never block a thread that might be the one
    the user is looking at.
    """
    try:
        from ..ask_for_the_path import somebody_is_there

        return bool(somebody_is_there())
    except Exception:                                        # noqa: BLE001
        return True


def _read_results_table(path: str):
    """The sweep's table at ``path``. WORKER ONLY -- never from a slot.

    Both halves block: the ``isfile`` is what wakes an ``autofs`` mount, and
    the read that follows it is a whole file over the same wire. Returns
    ``(frame, error)``; ``(None, "")`` means there is no table there yet,
    which is a different sentence from one that could not be read.

    IT NEVER RAISES, and that is load-bearing rather than defensive:
    `JobRunner._on_settled` calls ``on_done`` only for a job that came back
    cleanly, so a worker that throws leaves the "Reading …" placeholder on
    the status line with nothing behind it that would ever replace it. Every
    failure comes back as the second half of the pair instead -- the
    ``isfile`` included, which raises on a path with a null byte in it, and
    that is a path the user typed.
    """
    try:
        import pandas as pd

        if not os.path.isfile(path):
            return None, ""
        return pd.read_csv(path), ""
    except Exception as error:                               # noqa: BLE001
        return None, str(error) or type(error).__name__


def _find_the_run(folder: str):
    """The results table under ``folder`` and its settings. WORKER ONLY.

    `find_results_table` walks the tree and `settings_of_run` opens a file
    beside it; on a folder the user picked from a chooser, either can be the
    twenty seconds the module docstring is about. Returns ``(table,
    settings)`` and raises nothing, so the GUI half always gets its turn --
    the "Load run…" button is disabled until it does.
    """
    try:
        from .regression_results import find_results_table

        table = str(find_results_table(folder) or "")
    except Exception:                                        # noqa: BLE001
        LOG.debug("could not search %s for a run", folder, exc_info=True)
        return "", {}
    if not table:
        return "", {}
    try:
        from ...refit import settings_of_run

        settings = settings_of_run(table) or {}
    except Exception:                                        # noqa: BLE001
        settings = {}
    return table, settings


def _what_would_be_deleted(folders) -> list:
    """``(folder, what is in it)`` for each folder still on disk. WORKER ONLY.

    The confirmation cannot be composed without this and this cannot be done
    without walking every run folder, which is why the modal is now shown
    from a callback rather than from the click.

    RAISES NOTHING, for the reason :func:`_read_results_table` gives: the
    callback that puts the confirmation up is the only thing that can clear
    "Working out what these runs hold…", and it does not run for a job that
    threw. A folder that cannot even be asked about is dropped from the list
    rather than taking the other folders' answers down with it.
    """
    described = []
    for folder in folders:
        try:
            if not os.path.isdir(folder):
                continue
            described.append((folder, SweepRunsPanel.describe_folder(folder)))
        except Exception:                                    # noqa: BLE001
            LOG.debug("could not describe %s", folder, exc_info=True)
    return described


def _delete_folders(folders) -> tuple:
    """Remove each folder and its contents. WORKER ONLY.

    `shutil.rmtree` walks and unlinks every file under the folder, so on a
    sleeping mount it blocks at least as long as the walk that described it
    did. Returns ``(deleted, failed)``.

    EVERY failure is caught, not only ``OSError``: this runs on a worker now,
    and a worker that throws never reaches `_deletion_finished` -- so the
    rows would stay on the table under a "Deleting…" that never resolves,
    with no way to tell whether the folders went.

    A FAILURE IS A ``(folder, why)`` PAIR, NOT A SENTENCE. `_deletion_finished`
    has to match these folders against the rows it was given to decide which
    rows survive; a pre-formatted ``"<folder> (<why>)"`` matches no path, so
    every row came off the table whether or not its folder actually went.
    The sentence is composed where it is shown, by `_format_failures`.
    """
    import shutil

    deleted, failed = [], []
    for folder in folders:
        try:
            shutil.rmtree(folder)
        except Exception as error:                           # noqa: BLE001
            why = getattr(error, "strerror", None) or error
            failed.append((folder, str(why)))
        else:
            deleted.append(folder)
    return deleted, failed


def _format_failures(failed) -> str:
    """Render ``(folder, why)`` pairs as the half-sentence the user reads.

    Tolerates a bare string as well, so a record written by an older build --
    or a caller handing back the pre-formatted form -- still reads.
    """
    parts = []
    for item in failed or []:
        if isinstance(item, (tuple, list)) and len(item) == 2:
            folder, why = item
            parts.append(f"{folder} ({why})")
        else:
            parts.append(str(item))
    return "; ".join(parts)


def _failed_folder(item) -> str:
    """The folder out of a failure entry, whichever shape it arrived in."""
    if isinstance(item, (tuple, list)) and len(item) == 2:
        return str(item[0])
    return str(item)


def _readable_size(total: int) -> str:
    """Bytes as the unit a person decides in. 31 MB, not 32,505,856."""
    size = float(max(0, int(total)))
    for unit in ("B", "KB", "MB", "GB"):
        if size < 1024 or unit == "GB":
            return f"{size:.0f} {unit}" if unit == "B" or size >= 10 \
                else f"{size:.1f} {unit}"
        size /= 1024
    raise AssertionError(                                # pragma: no cover
        "the unit table no longer ends at GB")


[docs] def save_run_states(folders, app_key: str = "") -> tuple: """Write a workspace bundle for each of ``folders``. WORKER ONLY -- :meth:`SweepRunsPanel._apply_run_menu` submits it. The ``isdir`` below and the write after it are both on a folder the user chose, and doing either in the menu's own click is the freeze the module docstring describes. ASKED FOR, SO IT IS WRITTEN. `workspace.save_for_run` returns None when the `runs/save_workspace` preference is off, which is right for the automatic save at the end of a run and wrong here: a user who chose "Save the state" from a menu has asked, and a menu item that silently does nothing is worse than one that is absent. The mode is forced on for this call only, and the preference is not written. :param folders: run folders to save. :returns: ``(saved, failures)`` -- the folders that got a bundle, and ``(folder, reason)`` for those that did not. One folder failing does not stop the others, and every failure is NAMED: a count of "3 of 5" with no names is a report the user cannot act on. """ saved = [] failures = [] try: from ...workspace import save_for_run except Exception as error: # noqa: BLE001 return [], [(str(folder), f"workspace unavailable: {error}") for folder in folders] for folder in folders: path = str(folder or "").strip() if not path: continue try: on_disk = os.path.isdir(path) except Exception as error: # noqa: BLE001 failures.append((path, f"{type(error).__name__}: {error}")) continue if not on_disk: failures.append((path, "the run folder is not on disk any more")) continue try: written = save_for_run(path, {"save_workspace": "reference"}) except Exception as error: # noqa: BLE001 failures.append((path, f"{type(error).__name__}: {error}")) continue if written is None: failures.append( (path, "nothing to save -- no panel offered any state")) else: saved.append(path) return saved, failures
[docs] def describe_saved_states(saved, failures) -> str: """One sentence about what a save did, for the panel's own note. :param saved: the run folders whose state was saved. :param failures: ``(path, reason)`` pairs for runs that did not save; the first three are named. """ if not saved and not failures: return "No run was selected, so nothing was saved." parts = [] if saved: plural = "" if len(saved) == 1 else "s" parts.append(f"Saved the state of {len(saved)} run{plural}.") for path, why in failures[:3]: parts.append(f"{os.path.basename(path) or path}: {why}") if len(failures) > 3: parts.append(f"...and {len(failures) - 3} more that did not save.") return " ".join(parts)
def _permuted(settings) -> bool: """Whether this run actually ran the permutation test. `guide_permutations` has a default, so every settings dict carries a number whether or not a shuffle ever happened. Reading it off a least-squares fit would put "200000" in the column for a run that permuted nothing. """ mode = str((settings or {}).get("analysis_mode") or "").strip().lower() if mode == "guide_permutation": return True inference = str((settings or {}).get("inference") or "").strip().lower() return inference in ("nonparametric", "permutation") def _run_settings_row(settings) -> dict: """The settings columns for one run, as a dict to update a row with. A value of None is left out rather than written, so a column stays empty for a run that has nothing to say there -- and 0 permutations stays distinguishable from "this was not a permutation test". """ out = {} for name in RUN_SETTING_COLUMNS: if name == "guide_permutations" and not _permuted(settings): continue value = (settings or {}).get(name) if value is not None: out[name] = value return out def _has_workspace(folder: str) -> bool: """Whether a run folder carries a workspace bundle to restore. Asked of the FOLDER and not of a column, because the bundle is written when the run closes and the row was built when it started -- a run that finished this session would otherwise be offered no restore until the list was reloaded. WORKER ONLY. `workspace.has_workspace` is a bare stat on a folder the user chose; the menu asks :meth:`SweepRunsPanel._workspace_answer`, which answers from a cache this fills in the background. """ if not folder: return False try: from ...workspace import has_workspace return bool(has_workspace(folder)) except Exception: # noqa: BLE001 return False def _is_ok(row) -> bool: """Whether a run row has results behind it, so it can be the loaded run. A MISSING STATUS COUNTS AS OK, which is the same reading `_show_trial` takes: a sweep's CSV need not carry the column, and treating its absence as a failure would make every trial in an older table unloadable. NaN is handled explicitly -- concatenating a session run into a sweep frame fills the column with it, and ``str(nan)`` is ``'nan'``, which is not a status. """ if not isinstance(row, dict): return False status = row.get("status", "ok") if status is None: return True text = str(status).strip() if not text or text.lower() == "nan": return True return text == "ok"
[docs] def ordered_columns(frame) -> list: """``PREFERRED_COLUMNS`` that this frame has, then everything else. Ordering, not filtering. A column nobody thought to list is still worth seeing -- it is in the CSV, and hiding it means the user has to leave the application to read their own results. :param frame: the frame whose columns are ordered; ``None`` returns an empty list. """ if frame is None: return [] have = list(frame.columns) first = [name for name in PREFERRED_COLUMNS if name in have] return first + [name for name in have if name not in first]
[docs] class SweepRunsPanel(QWidget): """One row per RUN -- the sweep's trials and this session's own. :ivar trial_activated: emitted with a run's row, as a dict, whenever a view should be showing that run. The older of the two names for that one event -- see :attr:`loaded_run_changed`. :ivar loaded: emitted with the number of rows shown. :param parent: parent widget. :param threaded: whether the folder reads, walks and deletes go to a worker. ``None`` decides by asking whether a person is waiting on the calling thread (:func:`_should_thread`), which is what the application wants and what a test does not: unthreaded, every job runs inline and the methods below still return what they found. """ trial_activated = Signal(dict) loaded = Signal(int) #: Emitted with the row of the run that is now the LOADED one -- the run #: every view on this screen is describing. Emitted for the automatic #: cases too (a run finishing, a folder holding exactly one run), because #: a view that only learns about deliberate choices shows the wrong run #: after the common one. #: #: THE SAME EVENT AS :attr:`trial_activated`, from the same funnel #: (:meth:`_announce_the_loaded_run`), because "a run was activated" and #: "the loaded run changed" turned out to be one fact -- and while they #: were two, only one of them was connected to anything (157). loaded_run_changed = Signal(dict) #: Emitted with the rows that have just left the table, as dicts. #: #: Other views use this event to discard per-run plot and figure state. The #: results panel keeps a plot state per run and the figure grid keys its #: sections by run label; the screen coordinates cleanup so this widget #: remains usable on its own. runs_removed = Signal(list) #: Emitted with one run's row when the user asks to see it beside the #: loaded one. Opening a second run is a deliberate #: act: two runs is what a comparison needs and twelve is what makes the #: screen unusable, so it has its own gesture rather than happening on a #: click. The screen decides whether the bound allows it. compare_requested = Signal(dict) #: Emitted with one run's row when the user requests its saved workspace. #: Restoration is separate from loading results because #: a restore reattaches databases, re-points the montage and rewrites the #: settings on screen, which is a great deal more than showing a table, #: and a user who wanted to glance at a run's volcano would not expect #: their current screen replaced. workspace_restore_requested = Signal(dict) def __init__(self, parent=None, *, threaded: Optional[bool] = None): """Build the panel listing a sweep's previous runs. Two runners, because one of them is cancelled: a tab change can ask for the sweep table again while the last read is still out, and cancelling that read must not also abandon whatever else is in flight. :param parent: parent widget, or ``None``. :param threaded: read on a worker thread; ``None`` follows the process default. """ super().__init__(parent) from ..job_runner import JobRunner from .fast_plots import ResultsTable self._threaded = _should_thread() if threaded is None else bool( threaded) self._jobs = JobRunner(self, threaded=self._threaded, app_key="sweep runs", user_visible=False) self._load_jobs = JobRunner(self, threaded=self._threaded, app_key="sweep runs table", user_visible=False) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(4) header = QHBoxLayout() self._status = QLabel("Nothing run yet.") self._status.setWordWrap(True) header.addWidget(self._status, 1) self._open = QPushButton("Load run…") self._open.setToolTip( "Open a run's results folder from disk — including one from an " "earlier session. It joins the table as a run like any other and " "becomes the loaded run.") self._open.clicked.connect(lambda: self.load_run_from_disk()) header.addWidget(self._open) self._reload = QPushButton("Reload") self._reload.setToolTip( "Re-read the results table. A sweep writes each trial as it " "finishes, so a running sweep can be watched.") self._reload.clicked.connect(self.reload) header.addWidget(self._reload) layout.addLayout(header) self.table = ResultsTable() self.table.configure( placeholder="Filter runs — a model, a cutoff, anything in the row", significance_filter=False) self.table.table.itemSelectionChanged.connect(self._on_selection) from PySide6.QtWidgets import QAbstractItemView self.table.table.setSelectionMode( QAbstractItemView.ExtendedSelection) self.table.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.table.customContextMenuRequested.connect(self._run_menu) self.table.table.doubleClicked.connect(self._on_double_click) self.table.table.installEventFilter(self) layout.addWidget(self.table, 1) self._photo = QLabel() self._photo.setAlignment(Qt.AlignCenter) self._photo.setObjectName("RunPhotograph") self._photo.setToolTip( "This run's volcano as it last looked. A still, not a live plot: " "open the run to hover, click and filter it.") self._photo.hide() layout.addWidget(self._photo) #: Callable that returns the latest still for a run folder. Using a #: provider ensures photographs created after panel construction are #: still found. self._photo_provider = None self._frame = None self._folder = "" #: The sweep's own table, as read off disk. Kept SEPARATE from the #: composed frame the table shows, because a reload re-reads this one #: and must not take the session's own runs down with it. self._sweep_frame = None #: What this session has started, oldest first, keyed by the handle #: :meth:`record_run` hands back. An ordinary run and a re-fit are #: the same kind of thing as a trial and belong in the same table. self._recorded: "dict[int, dict]" = {} self._next_handle = 0 #: WHICH RUN IS LOADED, as the key :meth:`_row_key` gives a row -- #: its folder when it has one, its name when it does not. A key #: rather than an index, because the table is rebuilt from scratch #: every time a run is recorded and an index would name a different #: run afterwards. self._loaded_key = "" #: Previous loaded-run key, retained so a failed load can restore the #: marker to the run that remains on screen. self._previous_loaded_key = "" #: Which announcement `_previous_loaded_key` answers. self._undo_answers = None #: The sentence the last load or refusal added to the status line, #: kept so moving the mark can rewrite the line without losing it. self._source_note = "" #: True while :meth:`_rebuild` is refilling the table. The re-select #: it does at the end would otherwise read as a user picking a row and #: re-emit `trial_activated`, which re-loads the results panel on #: every recorded run. self._rebuilding = False #: What the last job of each kind found, so the method that started #: it can still answer its caller when it ran inline. Threaded, the #: answer is not knowable at return time and these are not read. self._load_answer = False self._open_answer = False self._saved_state = False self._deleted_count = 0 #: Which run folders carry a workspace bundle, as far as this panel #: knows. Filled in off the GUI thread -- see #: :meth:`_workspace_answer`. self._workspace_answers: "dict[str, bool]" = {} #: The probe outstanding for each folder, as ``folder -> stamp``. A #: DICT RATHER THAN A SET, because membership alone cannot tell two #: probes for the same folder apart: `update_run`, a save and a #: delete each drop the folder because what they did changed the #: answer, the next right-click asks again, and with a bare set the #: OLDER reply -- the one describing the run before it finished -- #: would land first, be accepted as pending, and take the newer #: probe's place with it. The stamp is what discards it. self._workspace_pending: "dict[str, int]" = {} self._workspace_probes = 0 #: The "Reading …" / "Deleting…" placeholder currently on the status #: line, and the sentence it is covering. A placeholder is written #: before a job and cleared by its answer; when there is no answer -- #: the worker raised, or the user said No -- these are what put the #: line back. See :meth:`_start_waiting`. self._waiting_note = "" self._note_before_waiting = "" for runner in (self._jobs, self._load_jobs): runner.job_failed.connect(self._on_job_failed)
[docs] def closeEvent(self, event): # noqa: N802 """Stop background work and unlink before going away. :param event: the Qt close event. """ for runner in (getattr(self, "_load_jobs", None), getattr(self, "_jobs", None)): if runner is None: continue try: runner.shutdown() except Exception: # noqa: BLE001 LOG.debug("could not stop a runs-tab job", exc_info=True) super().closeEvent(event)
[docs] def load(self, folder) -> bool: """Read ``sweep_results.csv`` from a sweep's destination folder. THE READ IS NOT DONE HERE. This is reached from a tab change (`AppScreen._on_results_tab_changed`) with the sweep destination the user typed, and both the ``isfile`` and the ``read_csv`` block for as long as that folder takes to answer -- which on one ``/nas_mnt`` was twenty seconds of frozen application. The path work below is pure string manipulation; everything that touches the disk goes to a worker and comes back to :meth:`_table_arrived`. :param folder: the sweep destination folder, or a path ending in ``.csv`` naming the results file itself; ``~`` is expanded and an empty value returns ``False``. :returns: whether the tab now shows a row -- or, when the read went to a worker, whether it was started. The row count arrives with the `loaded` signal either way. """ if not folder: return False folder = os.path.abspath(os.path.expanduser(os.fspath(folder))) path = folder if folder.lower().endswith(".csv") else os.path.join( folder, RESULTS_FILENAME) self._load_answer = False self._load_jobs.cancel() self._start_waiting(f"Reading {path}…") started = self._load_jobs.submit( lambda target=path: _read_results_table(target), lambda outcome, target=path, home=folder: self._table_arrived(outcome, target, home)) return bool(started) if self._threaded else self._load_answer
def _table_arrived(self, outcome, path: str, folder: str) -> None: """Put the sweep's table on screen. On the GUI thread, from `load`. THE PLACEHOLDER IS RETIRED LAST, after the sentence that replaces it has actually been written. `JobRunner._on_settled` routes an exception raised HERE to `job_failed`, and `_on_job_failed` takes a line down only while it can see a placeholder outstanding -- so clearing first and then throwing in the rebuild leaves "Reading …" on the status line with nothing left that would ever replace it. """ frame, error = outcome if outcome else (None, "") if frame is None: if error: self._rebuild(f"Could not read {path}: {error}") else: self._rebuild(f"No results table at {path} yet.") self._load_answer = False self._stop_waiting() return self._folder = folder path_probe.isdir(folder) self._load_answer = self.set_frame(frame, source=path) self._stop_waiting()
[docs] def reload(self) -> bool: """Re-read the sweep's runs from disk.""" return self.load(self._folder) if self._folder else False
[docs] def set_frame(self, frame, source: str = "") -> bool: """Point the panel at a table of runs. :param frame: the runs, or None to clear. """ self._sweep_frame = frame if frame is not None and len(frame) else None if self._sweep_frame is None and not source: source = "The sweep has recorded no trials yet." return self._rebuild(source)
[docs] def record_run(self, label: str, source: str = SOURCE_RUN, settings=None, folder: str = "") -> int: """Put a run of the module on the table, and return its handle. Recorded when the run STARTS, for the reason the figure grid marks its section then: a run that fails, or that is still going, is a fact worth seeing rather than a gap. Its row says ``running`` until :meth:`update_run` is told otherwise -- not ``ok``, because a row claiming ok is a row a click will try to open results from. :param label: user-visible run name stored in the new row. :param settings: the dict the run was started with. Only :data:`RUN_SETTING_COLUMNS` are copied out of it, and they are the sweep's own setting columns -- which is what makes a run and a trial two rows of one table rather than two tables. """ self._next_handle += 1 handle = self._next_handle row = { "run": str(label), "source": str(source), "status": STATUS_RUNNING, "folder": str(folder or ""), } for name in RUN_SETTING_COLUMNS: value = (settings or {}).get(name) if value is not None: row[name] = value self._recorded[handle] = row self._rebuild() return handle
[docs] def update_run(self, handle: int, **fields) -> bool: """Change a recorded run's row -- its status, folder, seconds. Returns False for a handle this panel never issued, rather than inventing a row: a run whose panel was rebuilt underneath it is a stale handle, and a phantom row is worse than a missing one. :param handle: the handle returned when the run was recorded; an unknown handle returns ``False``. """ row = self._recorded.get(int(handle)) if row is None: return False row.update({name: value for name, value in fields.items() if value is not None}) folder = str(row.get("folder") or "") if folder: self._workspace_answers.pop(folder, None) self._workspace_pending.pop(folder, None) before = self._loaded_key if _is_ok(row): self._loaded_key = self._row_key(row) self._rebuild(since=before) return True
[docs] def load_run_from_disk(self, folder: str = "") -> bool: """Open a results folder and make that run active. Parameters ---------- folder : str, optional Run directory. An empty value opens a directory chooser. Returns ------- bool ``True`` when a valid run was opened. Notes ----- Saved settings are restored beside the results so imported and current-session runs use the same table columns. The search under the chosen folder runs on a worker: it is an ``os.walk`` of somewhere the user just pointed at, which is the one place a sleeping network mount is guaranteed to be reached. Threaded, ``True`` means the search was started and the row appears when it answers. """ if not folder: from PySide6.QtWidgets import QFileDialog start = self._folder if path_probe.isdir(self._folder) else "" folder = QFileDialog.getExistingDirectory( self, "Choose a run's results folder", start) if not folder: return False path_probe.prime(folder, True) try: folder = os.path.abspath(os.path.expanduser(os.fspath(folder))) except TypeError: return False self._open_answer = False self._open.setEnabled(False) self._start_waiting(f"Looking for a run in {folder}…") started = self._jobs.submit( lambda target=folder: _find_the_run(target), lambda found, target=folder: self._run_arrived(found, target)) return bool(started) if self._threaded else self._open_answer
def _run_arrived(self, found, folder: str) -> None: """Put the run that was found on the table. On the GUI thread. The button is re-enabled FIRST and the placeholder retired LAST: the button is the thing a user cannot work around, and the placeholder is what `_on_job_failed` needs to still see if the rebuild below throws. """ self._open.setEnabled(True) table, settings = found if found else ("", {}) if not table: from .regression_results import RESULT_FILENAMES self._rebuild(f"No run in {folder}: none of " f"{', '.join(RESULT_FILENAMES)} is in it or under " f"it.") self._open_answer = False self._stop_waiting() return run_folder = os.path.dirname(table) handle = self._handle_for_folder(run_folder) if handle is None: self._next_handle += 1 handle = self._next_handle row = { "run": self._name_for_folder(run_folder), "source": SOURCE_DISK, "status": "ok", "folder": run_folder, } row.update(_run_settings_row(settings)) self._recorded[handle] = row before = self._loaded_key self._loaded_key = self._row_key(self._recorded[handle]) self._rebuild(f"Loaded the run in {run_folder}.", since=before) self._open_answer = True self._stop_waiting() def _handle_for_folder(self, folder: str): """The handle of the recorded row for ``folder``, or ``None``. Opening the same run twice is one row, not two: the second would be indistinguishable from a re-run, which is a different event. """ for handle, row in self._recorded.items(): existing = row.get("folder") if isinstance(existing, str) and existing and os.path.abspath( os.path.expanduser(existing)) == folder: return handle return None def _name_for_folder(self, folder: str) -> str: """A row name for a run opened off disk, unique in this table. A run folder is ``results/<kind>_<n>``, so the basename alone is ``ols_3`` -- readable, and ambiguous across two screens. The parent is added only when the basename is already taken, because a name that is always a path is a name nobody can scan. """ base = os.path.basename(folder.rstrip(os.sep)) or folder taken = {str(row.get("run")) for row in self._recorded.values()} if base not in taken: return base parent = os.path.basename(os.path.dirname(folder.rstrip(os.sep))) longer = os.path.join(parent, base) if parent else folder if longer not in taken: return longer return folder def _recorded_rows(self) -> list: """This session's runs, oldest first.""" return [dict(row) for _handle, row in sorted(self._recorded.items())] @staticmethod def _row_key(row) -> str: """What names a run across a rebuild: its folder, else its name. The folder first because it is what a view actually needs -- the results table, the summary and ``regression_data.csv`` are all in it -- and because two runs of the same screen carry the same label and different folders. """ if not isinstance(row, dict): return "" folder = row.get("folder") if isinstance(folder, str) and folder.strip(): return os.path.abspath(os.path.expanduser(folder.strip())) name = row.get("run") return str(name).strip() if isinstance(name, str) else ""
[docs] def loaded_run(self) -> Optional[dict]: """The run every view on this screen is describing, or ``None``. Read off the composed frame rather than off the recorded dict, so a sweep trial and a session run answer the same way -- which is the whole reason they share one table. """ if self._frame is None: return None for _index, row in self._frame.iterrows(): record = row.to_dict() if self._row_key(record) and self._row_key( record) == self._loaded_key: return record return None
[docs] def loaded_run_folder(self) -> str: """The loaded run's folder, or ``""``. What a view actually needs.""" record = self.loaded_run() folder = (record or {}).get("folder") return str(folder) if isinstance(folder, str) and folder.strip() else ""
[docs] def set_loaded_run(self, key) -> bool: """Make ``key`` the loaded run. ``key`` is a folder or a run name. :param key: the run's folder (``~`` is expanded and made absolute for matching) or its name; ``None`` or an empty string returns ``False``. :returns: True when a row matched. False rather than a blank mark for a run this table does not hold -- a tick against nothing is worse than none, because it reads as an answer. """ if key is None: return False wanted = str(key).strip() if not wanted: return False expanded = os.path.abspath(os.path.expanduser(wanted)) before = self._loaded_key for record in self._all_rows(): key = self._row_key(record) if key and key in (wanted, expanded): changed = self._loaded_key != key self._loaded_key = key if changed: self._source_note = "" self._paint_the_loaded_mark() if changed: self._announce_the_loaded_run(before) return True return False
def _announce_the_loaded_run(self, before: str) -> bool: """Notify dependent views when the loaded-run key changes. All load paths use this helper so row selection, folder loading, and a run finishing emit the same update. Return whether a change was announced relative to ``before``. """ if not self._loaded_key or self._loaded_key == before: return False record = self.loaded_run() if record is None: return False self._previous_loaded_key = before self._undo_answers = self._loaded_key self.loaded_run_changed.emit(dict(record)) self.trial_activated.emit(dict(record)) return True
[docs] def the_load_succeeded(self) -> None: """Finalize the selection after an asynchronous run load succeeds. Records the loaded run as the current stable selection. A later failure callback for an older request therefore cannot move the selection back. """ self._previous_loaded_key = self._loaded_key self._undo_answers = self._loaded_key
[docs] def the_load_failed(self, why: str = "") -> bool: """The run that was just announced could not be shown: undo the mark. A mark on a run whose results are not visible leaves the run list and results view out of sync. Restore the previous mark so the list names the run that remains on screen. :param why: Reason added to the status line. :returns: Whether the mark moved back. """ previous = self._previous_loaded_key answers = getattr(self, "_undo_answers", None) if answers is not None and answers != self._loaded_key: return False if previous == self._loaded_key: return False if not previous: return False self._loaded_key = previous self._undo_answers = previous self._source_note = str(why or "") self._paint_the_loaded_mark() return True
def _all_rows(self) -> list: """Every run the table is SHOWING, in the order it shows them. Read off the composed frame rather than off the two halves it was built from: a sweep trial's name is derived during the rebuild (``trial 2`` from ``trial_id``), so a search over the raw sweep frame cannot match the name the user is looking at. """ if self._frame is None: return [] return [row.to_dict() for _index, row in self._frame.iterrows()] def _paint_the_loaded_mark(self) -> None: """Move the mark to the loaded run WITHOUT rebuilding the table. A rebuild from inside a selection handler destroys the item Qt is still holding -- ``select_key`` came back to a deleted C++ object and raised -- and it drops the selection a moment after the user made it. The mark is one cell per row; moving it is not a reason to refill a table of sixty trials either. """ frame = self._frame if frame is None or LOADED_COLUMN not in frame.columns: return column = list(frame.columns).index(LOADED_COLUMN) marks = [LOADED_MARK if self._row_key(row.to_dict()) == self._loaded_key and self._loaded_key else "" for _index, row in frame.iterrows()] frame[LOADED_COLUMN] = marks table = self.table.table for row in range(table.rowCount()): item = table.item(row, column) if item is None: continue index = item.data(0x0100) if index is None or not 0 <= int(index) < len(marks): continue item.setText(marks[int(index)]) self._status.setText(self._describe(frame, self._source_note)) def _settle_the_loaded_run(self, frame) -> None: """Keep the mark on a run that still exists, or place it. ONE RUN IN THE FOLDER IS THE LOADED RUN -- there is nothing to choose between (154 G). Several means the choice is the user's and the mark stays where they put it, or where the last finished run put it. """ import pandas as pd keys = [self._row_key(row.to_dict()) for _i, row in frame.iterrows()] ok = [self._row_key(row.to_dict()) for _i, row in frame.iterrows() if _is_ok(row.to_dict())] if self._loaded_key in keys: pass elif len(ok) == 1: self._loaded_key = ok[0] else: self._loaded_key = "" frame[LOADED_COLUMN] = pd.Series( [LOADED_MARK if key and key == self._loaded_key else "" for key in keys], index=frame.index, dtype=object) def _rebuild(self, source: str = "", since: Optional[str] = None) -> bool: """Compose the session's runs and the sweep's trials into one table. THE SESSION'S OWN RUNS COME FIRST. A sweep is sixty rows and the run the user just made is the one they are looking for; sorting is one click away for every other question. :param since: the key the mark was on before the caller touched it. Every rebuild can move the mark -- a run finishing sets it, and :meth:`_settle_the_loaded_run` places it when a folder turns out to hold exactly one run -- so the announcement belongs HERE rather than at each of those call sites, which is how the finishing one came to be missing (157). ``None`` means "compare against wherever the mark is now", so a rebuild that does not move it says nothing. """ import pandas as pd before = self._loaded_key if since is None else str(since) frames = [] recorded = self._recorded_rows() if recorded: frames.append(pd.DataFrame(recorded)) if self._sweep_frame is not None and len(self._sweep_frame): trials = self._sweep_frame.copy() if "source" not in trials.columns: trials["source"] = SOURCE_SWEEP if "run" not in trials.columns and "trial_id" in trials.columns: trials["run"] = ["trial " + str(value) for value in trials["trial_id"]] frames.append(trials) if not frames: self._frame = None self._source_note = source self.table.set_frame(None) self._status.setText(source or "Nothing run yet.") self.loaded.emit(0) self._announce_the_loaded_run(before) return False if len(frames) == 1: frame = frames[0] else: whole = {name for part in frames for name in part.columns if part[name].dtype.kind in "iu"} frame = pd.concat(frames, ignore_index=True, sort=False) frame = self._keep_whole_numbers_whole(frame, whole) self._settle_the_loaded_run(frame) self._frame = frame[ordered_columns(frame)].reset_index(drop=True) self._rebuilding = True try: self.table.set_frame(self._frame, key_column=( "run" if "run" in self._frame.columns else None)) record = self.loaded_run() if record is not None and isinstance(record.get("run"), str): self.table.select_key(record["run"]) finally: self._rebuilding = False self._source_note = source self._status.setText(self._describe(self._frame, source)) self.loaded.emit(len(self._frame)) self._announce_the_loaded_run(before) return True @staticmethod def _keep_whole_numbers_whole(frame, columns): """Put ``columns`` back to integers after a concat introduced NaN. Nullable ``Int64``, not ``int``: the missing values are real -- a run of the module has no trial number -- and casting them away would put a zero where "this row is not a trial" belongs. """ for name in columns: if name not in frame.columns: continue series = frame[name] if getattr(series.dtype, "kind", "") != "f": continue present = series.dropna() if not len(present) or not bool((present % 1 == 0).all()): continue try: frame[name] = series.astype("Int64") except (TypeError, ValueError): continue return frame def _describe(self, frame, source: str = "") -> str: """The one-line summary over the table. Counts what did not work OUT LOUD, rather than leaving it to be noticed: a sweep whose trials mostly failed still writes a full-looking table, and so does a session of runs that all crashed. AND IT NAMES THE LOADED RUN. The mark is in a column that may be scrolled past or sorted away; the sentence over the table is where a user looks to find out which run everything else on the screen is describing. """ note = f"{len(frame)} runs" if "source" in frame.columns: mine = int((frame["source"].astype(str) != SOURCE_SWEEP).sum()) if mine and mine != len(frame): note += f" — {mine} from this session, " \ f"{len(frame) - mine} from the sweep" if "status" in frame.columns: status = frame["status"].astype(str) failed = int(((status != "ok") & (status != STATUS_RUNNING)).sum()) running = int((status == STATUS_RUNNING).sum()) if failed: note += f", {failed} of which did not produce a regression" if running: note += f", {running} still going" record = self.loaded_run() if record is not None: name = record.get("run") note += (f". Loaded: {name}" if isinstance(name, str) and name else ". One run is loaded") elif len(frame) > 1: note += ". No run is loaded — pick one to show it everywhere else" return f"{note}. {source}" if source else note
[docs] def selected_trial(self) -> Optional[dict]: """The selected row as a dict, or ``None``. Read back through the FRAME, not off the table's cells: the table holds display strings, and a trial re-run from `"0.05"` instead of 0.05 is not the trial that was recorded. """ if self._frame is None: return None items = self.table.table.selectedItems() if not items: return None index = items[0].data(0x0100) if index is None or not 0 <= int(index) < len(self._frame): return None return self._frame.iloc[int(index)].to_dict()
[docs] def selected_runs(self) -> list: """Every selected row as a dict, in the order the table shows them. Read back through the FRAME for the same reason :meth:`selected_trial` is: the table holds display strings, and a folder path is not something to reconstruct from one. """ if self._frame is None: return [] rows, seen = [], set() for item in self.table.table.selectedItems(): index = item.data(0x0100) if index is None: continue index = int(index) if index in seen or not 0 <= index < len(self._frame): continue seen.add(index) rows.append(self._frame.iloc[index].to_dict()) return rows
@staticmethod def _is_running(record) -> bool: """Whether this row is a run that has not come back yet.""" return str((record or {}).get("status", "")).strip() == STATUS_RUNNING @staticmethod
[docs] def describe_folder(folder: str) -> str: """What is in a run folder, in the words a decision needs. "12 figures, 4 CSVs, 31 MB". A user deciding whether to destroy an overnight fit needs to see WHAT they are destroying, and a folder path alone is not that. WORKER ONLY -- it walks the whole run folder and stats every file in it. Never call it from menu-build or paint code. :param folder: path of the run folder to walk; an empty or missing folder gives ``"nothing on disk"``. """ figures = tables = other = 0 total = 0 if not folder or not os.path.isdir(folder): return "nothing on disk" for root, _dirs, names in os.walk(folder): for name in names: path = os.path.join(root, name) try: total += os.path.getsize(path) except OSError: continue suffix = os.path.splitext(name)[1].lower() if suffix in (".png", ".pdf", ".svg", ".jpg", ".jpeg"): figures += 1 elif suffix in (".csv", ".tsv"): tables += 1 else: other += 1 parts = [] if figures: parts.append(f"{figures} figure" + ("s" if figures != 1 else "")) if tables: parts.append(f"{tables} CSV" + ("s" if tables != 1 else "")) if other: parts.append(f"{other} other file" + ("s" if other != 1 else "")) if not parts: return "an empty folder" return ", ".join(parts) + f", {_readable_size(total)}"
[docs] def remove_runs(self, records) -> int: """Take rows off the table. THE FOLDERS ON DISK ARE UNTOUCHED. The safe half, and the default gesture. It needs no confirmation because it is recoverable -- :meth:`reload` reads the folder again -- and the status line says exactly that rather than leaving the user to discover it. :param records: run row dicts to take off the table, matched by folder or else by name (and by ``trial_id`` in the sweep table); ``None`` removes nothing. :returns: how many rows left the table. """ wanted = {self._row_key(record) for record in (records or []) if self._row_key(record)} if not wanted: return 0 gone = [record for record in (records or []) if self._row_key(record) in wanted] for handle, row in list(self._recorded.items()): if self._row_key(row) in wanted: del self._recorded[handle] if self._sweep_frame is not None and len(self._sweep_frame): trials = {str(record.get("trial_id")) for record in (records or []) if record.get("trial_id") is not None} keep = [] for _index, row in self._sweep_frame.iterrows(): record = row.to_dict() keep.append(self._row_key(record) not in wanted and str(record.get("trial_id")) not in trials) self._sweep_frame = self._sweep_frame.loc[keep].copy() if not len(self._sweep_frame): self._sweep_frame = None before = self._loaded_key if self._loaded_key in wanted: self._loaded_key = "" count = len(wanted) self._rebuild(f"Removed {count} run" + ("s" if count != 1 else "") + " from the list; Reload brings " + ("them" if count != 1 else "it") + " back.", since=before) self.runs_removed.emit([dict(record) for record in gone]) return count
[docs] def delete_runs_from_disk(self, records, confirm=None) -> int: """Delete the run FOLDERS, then take the rows off the table. NOT RECOVERABLE, so it takes a confirmation naming the folder and saying what is in it, and there is no undo offered -- an undo that cannot honour itself is worse than none. :param records: run-row mappings whose distinct, existing ``folder`` paths are candidates for deletion. A running record refuses the whole operation. :param confirm: called with the message and the list of folders; returns True to go ahead. Defaults to a modal question. Injected rather than assumed so a headless test can drive the real method instead of a copy of it. :returns: how many folders were deleted, or :data:`_DELETION_STARTED` when the work went to a worker and the count is not knowable yet. Nothing the user sees changes -- the same question is asked and the same sentence written, a moment later. """ records = [record for record in (records or []) if record] refused = [record for record in records if self._is_running(record)] if refused: self._say(self._why_it_cannot_be_deleted(refused)) return 0 folders = [] for record in records: folder = record.get("folder") if isinstance(folder, str) and folder.strip(): folders.append(os.path.abspath(os.path.expanduser( folder.strip()))) folders = list(dict.fromkeys(folders)) if not folders: self._say("Nothing to delete: these runs have no folder on disk.") return 0 self._deleted_count = 0 self._start_waiting("Working out what these runs hold…") started = self._jobs.submit( lambda targets=list(folders): _what_would_be_deleted(targets), lambda described, rows=list(records): self._ask_then_delete(described, rows, confirm)) if self._threaded: return _DELETION_STARTED if started else 0 return self._deleted_count
def _ask_then_delete(self, described, records, confirm=None) -> bool: """Show the confirmation, then delete on a worker. On the GUI thread. The only half of a delete that touches a widget: it composes the message out of what the worker found and puts the modal up. """ folders = [folder for folder, _what in described or []] if not folders: self._say("Nothing to delete: these runs have no folder on disk.") self._stop_waiting() return False lines = [f"{folder} — {what}" for folder, what in described] message = ("Delete " + ("this run" if len(folders) == 1 else f"these {len(folders)} runs") + " from disk? This cannot be undone.\n\n" + "\n".join(lines)) ask = confirm if callable(confirm) else self._confirm_deletion if not ask(message, list(folders)): self._abandon_waiting() return False self._start_waiting("Deleting…") return bool(self._jobs.submit( lambda targets=list(folders): _delete_folders(targets), lambda outcome, rows=records: self._deletion_finished(outcome, rows))) def _deletion_finished(self, outcome, records) -> None: """Take the rows off the table and say what went. On the GUI thread. THE WAIT IS OVER, and saying so is not optional -- but it is said at the END rather than here. A `_waiting_note` left standing tells the next `_on_job_failed` that "Deleted 3 run folders from disk." is a placeholder it may overwrite, and the next `_abandon_waiting` that "Deleting…" is a sentence worth restoring; retiring it BEFORE the real sentence is written costs the other half, because an exception raised in `remove_runs` reaches `job_failed` and finds no placeholder to take down -- leaving "Deleting…" up for the rest of the session. """ deleted, failed = outcome if outcome else ([], []) keep = {os.path.abspath(os.path.expanduser(_failed_folder(item))) for item in failed} gone = [record for record in records if os.path.abspath(os.path.expanduser( str(record.get("folder") or ""))) not in keep] self.remove_runs(gone) for folder in deleted: path_probe.forget(folder) self._workspace_answers.pop(folder, None) self._workspace_pending.pop(folder, None) note = (f"Deleted {len(deleted)} run folder" + ("s" if len(deleted) != 1 else "") + " from disk.") if failed: note += " Could not delete " + _format_failures(failed) + "." self._say(note) self._deleted_count = len(deleted) self._stop_waiting() def _confirm_deletion(self, message: str, folders) -> bool: """The modal question. Defaults to No: this one cannot be undone.""" from PySide6.QtWidgets import QMessageBox answer = QMessageBox.question( self, "Delete runs from disk", message, QMessageBox.Yes | QMessageBox.No, QMessageBox.No) return answer == QMessageBox.Yes @staticmethod def _why_it_cannot_be_deleted(running) -> str: """The reason a running run is refused, naming it.""" names = ", ".join(str(record.get("run") or "a run") for record in running) return (f"{names} is still going and cannot be deleted. Stop it " f"first — a folder deleted underneath a run that is still " f"writing leaves half a result and no way to tell.") def _say(self, note: str) -> None: """Put a sentence on the status line without refilling the table.""" self._source_note = str(note or "") if self._frame is None: self._status.setText(self._source_note or "Nothing run yet.") else: self._status.setText(self._describe(self._frame, self._source_note)) def _start_waiting(self, note: str) -> None: """Say what is being waited for, and remember what that replaced. Only threaded: unthreaded the answer is already here by the time this would be read, and a placeholder that is overwritten in the same call is a line the user never sees. THE FIRST PLACEHOLDER IS THE ONE THAT REMEMBERS. A delete writes two in a row -- "Working out what these runs hold…" and then "Deleting…" -- and `_source_note` is the first of them by the time the second is written, so capturing again would make the sentence to go back to a placeholder. What is being restored is what the line said before any of this started. """ if not self._threaded: return if not self._waiting_note: self._note_before_waiting = self._source_note self._waiting_note = str(note or "") self._say(self._waiting_note) def _stop_waiting(self) -> None: """The wait has been answered; the answer writes its own sentence.""" self._waiting_note = "" self._note_before_waiting = "" def _abandon_waiting(self) -> None: """Nothing came of it -- put back the sentence the placeholder hid. SAYING NOTHING IS THE RIGHT ANSWER HERE, and it is not the same as leaving the placeholder up. Declining the delete confirmation used to leave the status line exactly as it was; without this it would leave "Working out what these runs hold…" on screen for good. """ if not self._waiting_note: return previous = self._note_before_waiting self._stop_waiting() self._say(previous) def _on_job_failed(self, message) -> None: """A worker raised. On the GUI thread, from either runner. `JobRunner._on_settled` calls ``on_done`` only for a job that came back cleanly, so every arrival handler in this file is one that may never run. This is what is left holding the placeholder it would have replaced and the button its click disabled. NOT GENERATION-GUARDED, deliberately: `job_failed` carries no job id, so it cannot be, and everything done here is safe to do twice or for a job whose result nobody wanted any more. Enabling an enabled button changes nothing -- `load_run_from_disk` is the ONLY caller that disables it, so the button is disabled exactly while a search is out, and a failure from one of the other jobs on this runner finds it enabled already. The line is only rewritten while a placeholder is actually outstanding -- a stale failure landing after a newer placeholder went up replaces that one, which is the correct order. Guarded for ``RuntimeError`` because a worker parked by `bridge.drain_thread` outlives the widget it belonged to, and by the time it fails this panel's C++ half may be gone. """ try: self._open.setEnabled(True) if self._waiting_note: self._stop_waiting() self._say("That did not finish: " + (str(message).strip() or "unknown error")) except RuntimeError: LOG.debug("the runs tab has gone; a job failure has nowhere to " "go", exc_info=True) def _workspace_answer(self, folder: str) -> bool: """Whether this run has a bundle, without stat-ing on this thread. THE MENU CANNOT WAIT. `_build_run_menu` runs inside the right-click that opens the menu, and `workspace.has_workspace` is a stat on a folder the user chose -- twenty seconds of nothing, on the mount this module's docstring is about, before the menu appears. Answered from what a worker has already found out, and OPTIMISTICALLY while nothing is known: for the same reason :mod:`spacr.qt.path_probe` gives, an entry offered and then quietly correct is better than one withdrawn on the strength of a slow mount. The restore handler has to cope with a bundle it cannot read in any case. """ key = str(folder or "") if not key: return False if key in self._workspace_answers: return self._workspace_answers[key] if key not in self._workspace_pending: self._workspace_probes += 1 stamp = self._workspace_probes self._workspace_pending[key] = stamp self._jobs.submit( lambda target=key: _has_workspace(target), lambda answer, target=key, mark=stamp: self._remember_workspace(target, answer, mark)) return self._workspace_answers.get(key, True) def _remember_workspace(self, folder: str, answer, stamp=None) -> None: """File what the worker found out about a run's bundle. THE STAMP IS THE GENERATION GUARD. `update_run`, a save and a delete each drop a folder from `_workspace_pending` precisely because what they did changes the answer, and a reply describing the folder as it was BEFORE that must not be written on top. Membership alone is not enough: the next right-click starts a SECOND probe for the same folder, and on a mount that has since woken it can answer first -- so the older reply would arrive to find the folder pending again, write its stale "no", and discard the newer probe along with it. Only the reply whose stamp is the one still outstanding is filed; anything else is dropped, and the next question asks again. :param stamp: the counter this probe was submitted under. ``None`` never matches an outstanding probe, so a caller that has no stamp cannot overwrite one. """ if self._workspace_pending.get(folder) != stamp or stamp is None: return self._workspace_pending.pop(folder, None) self._workspace_answers[folder] = bool(answer) def _build_run_menu(self, records): """The context menu for these rows: remove first, delete second. THE FIRST ENTRY IS THE SAFE ONE. A user reaching for a menu takes what is at the top, and what is at the top must not be the irreversible one. BUILT APART FROM BEING SHOWN so a test can read the entries without entering a modal loop. `QMenu.exec` is a C++ event loop and it cannot be monkeypatched off a PySide type -- tried, and the test hung inside it -- so the seam has to be here rather than in the test. Each action carries its verb in `setData`, so dispatch does not depend on identity or on the order the entries were added. """ from PySide6.QtWidgets import QMenu count = len(records) plural = "s" if count != 1 else "" menu = QMenu(self) load = None if count == 1: load = menu.addAction("Load this run") load.setData("load") load.setToolTip("Show this run's results, figures and summary.") menu.addSeparator() keep = menu.addAction( f"Save the state of {count} run{plural}" if count != 1 else "Save this run's state") keep.setData("save_state") keep.setEnabled(any(str(record.get("folder") or "").strip() for record in records)) if count == 1: folder = str(records[0].get("folder") or "") restore = menu.addAction("Restore what this run had open…") restore.setData("restore") if self._workspace_answer(folder): restore.setToolTip( "Re-attach the databases, put the montage back on its " "coefficient, and restore the view built on every figure. " "Says what it put back and what it could not.") else: restore.setEnabled(False) restore.setToolTip( "This run saved no workspace — it was run with 'Saved " "runs carry: Nothing', or before spaCR recorded one. " "Preferences ▸ Performance ▸ Saved runs carry.") menu.addSeparator() remove = menu.addAction(f"Remove {count} run{plural} from the list") remove.setData("remove") remove.setToolTip("The folders on disk are untouched. Reload brings " "them back.") if count == 1 and _is_ok(records[0]): beside = menu.addAction("Open beside the loaded run") beside.setData("beside") beside.setToolTip( "Show this run's own volcano next to the loaded run's, each " "answering its own hover and click. Two runs can be live at " "once.") menu.addSeparator() delete = menu.addAction(f"Delete {count} run{plural} from disk…") delete.setData("delete") delete.setToolTip("Deletes the run folder and everything in it. This " "cannot be undone; you are shown the path first.") running = [record for record in records if self._is_running(record)] if running: why = self._why_it_cannot_be_deleted(running) if load is not None: load.setEnabled(False) load.setToolTip( "This run is still going, so it has no results to show " "yet. It becomes the loaded run when it finishes.") for action in (remove, delete): action.setEnabled(False) action.setToolTip(why) menu.addSeparator() menu.addAction(why).setEnabled(False) return menu def _on_double_click(self, index=None) -> None: """Load the double-clicked run, whatever state the mark is in.""" record = self.selected_trial() if record is None: return self.load_this_run(record)
[docs] def load_this_run(self, record) -> bool: """Load a run and announce it even when it is already selected. Parameters ---------- record : mapping Run record containing a resolvable row key. Returns ------- bool ``True`` when the record was accepted and announced. Notes ----- Unlike :meth:`set_loaded_run`, this method deliberately reloads the current selection. This lets an explicit user action resynchronize the run list and results view. """ if not isinstance(record, dict): return False key = self._row_key(record) if not key: return False before = self._loaded_key self._loaded_key = key self._source_note = "" self._paint_the_loaded_mark() if not self._announce_the_loaded_run(before): self.trial_activated.emit(dict(record)) return True
def _run_menu(self, position) -> None: """Show the row menu where the user right-clicked, and act on it.""" records = self.selected_runs() if not records: item = self.table.table.itemAt(position) if item is None: return self.table.table.selectRow(item.row()) records = self.selected_runs() if not records: return menu = self._build_run_menu(records) chosen = menu.exec(self.table.table.viewport().mapToGlobal(position)) self._apply_run_menu(chosen.data() if chosen is not None else "", records) def _apply_run_menu(self, verb, records) -> bool: """Do what one menu entry says. The seam a test can drive. Apart from :meth:`_run_menu` because that method ends in `QMenu.exec`, a C++ event loop -- so a test that wants to know what an entry DOES either enters it and hangs, or re-implements the dispatch and tests its own copy. """ if verb == "save_state" and records: folders = [str(record.get("folder") or "").strip() for record in records] folders = [folder for folder in folders if folder] self._saved_state = False if not folders: self._on_states_saved(([], [])) return False plural = "" if len(folders) == 1 else "s" self._start_waiting( f"Saving the state of {len(folders)} run{plural}…") started = self._jobs.submit( lambda targets=list(folders): save_run_states(targets), self._on_states_saved) return bool(started) if self._threaded else self._saved_state if verb == "load" and records: if self._is_running(records[0]): return False return self.load_this_run(records[0]) if verb == "remove": return bool(self.remove_runs(records)) if verb == "beside" and records: self.compare_requested.emit(dict(records[0])) return True if verb == "restore" and records: if not self._workspace_answer(str(records[0].get("folder") or "")): return False self.workspace_restore_requested.emit(dict(records[0])) return True if verb == "delete": return bool(self.delete_runs_from_disk(records)) return False def _on_states_saved(self, result) -> None: """Say what the save did. On the GUI thread, from `_apply_run_menu`. SAVING IS NOT CHOOSING. The loaded mark stays where it was: keeping a run for later is not the same as switching to it, and moving the mark would drag every view with it. THE PLACEHOLDER IS RETIRED LAST, once the sentence that replaces it is on the line. `_rebuild` is the throw the placeholder exists for -- it is what `_apply_run_menu` names -- and `_on_job_failed` can only take down a line it can still see is outstanding. """ saved, failures = result if result else ([], []) self._saved_state = bool(saved) for folder in saved: self._workspace_answers.pop(str(folder), None) self._workspace_pending.pop(str(folder), None) self._source_note = describe_saved_states(saved, failures) self._rebuild(self._source_note) self._stop_waiting()
[docs] def eventFilter(self, watched, event): # noqa: N802 """Delete on the selection removes it FROM THE LIST. The safe half on the bare key, per the design. Deleting from disk is a separate, explicitly-worded choice and is not something a keystroke can reach. :param watched: the object the event was sent to; only the run table is handled here. :param event: the filtered event; a Delete or Backspace ``KeyPress`` on the run table removes the selected rows, and anything else goes to the base class. """ if (watched is self.table.table and event.type() == QEvent.KeyPress and event.key() in (Qt.Key_Delete, Qt.Key_Backspace)): records = self.selected_runs() running = [record for record in records if self._is_running(record)] if running: self._say(self._why_it_cannot_be_deleted(running)) return True if records: self.remove_runs(records) return True return super().eventFilter(watched, event)
[docs] def set_photo_provider(self, provider) -> None: """Tell this panel where a run's still comes from. :param provider: ``folder -> QPixmap or None``. """ self._photo_provider = provider self._show_photograph(self.selected_trial())
[docs] def photograph_shown(self): """The still currently painted under the table, or ``None``. Public because "is the still on screen" is what a caller and a test both want, and reading `isVisible()` off a widget that has never been shown answers a different question. """ return self._photo.pixmap() if not self._photo.isHidden() else None
def _show_photograph(self, record) -> bool: """Paint the selected run's still, or take the frame away.""" photo = None folder = str((record or {}).get("folder") or "") if isinstance( record, dict) else "" if folder and callable(self._photo_provider): try: photo = self._photo_provider(folder) except Exception: # noqa: BLE001 LOG.debug("could not reach the run photograph", exc_info=True) if photo is None or photo.isNull(): self._photo.clear() self._photo.hide() return False width = max(160, min(self.width() - 12, photo.width())) self._photo.setPixmap(photo.scaledToWidth(width, Qt.SmoothTransformation)) self._photo.show() return True def _on_selection(self) -> None: """Show the selected run's photograph, without loading it. Picking a run is not loading it: this used to load on selection, so arrowing down a list of five runs did five multi-second reads nobody asked for. Loading is a double click. The re-select at the end of a rebuild is this panel putting the highlight back rather than the user choosing, and is ignored. """ self._show_photograph(self.selected_trial()) if self._rebuilding: return record = self.selected_trial() if record is None: return if not _is_ok(record): self.trial_activated.emit(record)