diff --git a/CHANGELOG.md b/CHANGELOG.md index 2424c6eb..a0fef05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- **Navigator: DAQ system browser, cloud dataset id, Assign to workspace, + and faster DAQ system loading** (mirroring VH-Lab/NDI-matlab#1058). + "Session > DAQ Systems..." opens the new `ndi.gui.nav.DaqSystemBrowser`, a + read-only list of a session's DAQ systems with, for the selected one, its + reader, file navigator and file patterns, epoch probe map class and + patterns, metadata readers, and -- on "Load epochs" -- an epoch's actual + files, the pattern each matches and which is the epoch probe map. "Check + Cloud status" now opens `ndi.gui.nav.CloudStatusDialog`, which shows the + NDI Cloud dataset id with a Copy button (disabled when the dataset is not + in the cloud). Dataset and session nodes gain "Assign to workspace...", + which binds the object to a variable in `__main__` and relabels the tree in + place. `session.daqsystem_load(name='(.*)')` (and any match-everything + name) no longer sends a regexp to the database -- on a ~100,000-document + database that halved the load time -- via the new + `ndi_session.isMatchAllRegexp`; probe epoch tables, `getprobes` and the sync + graph, which all load DAQ systems that way, benefit too. + - **Bulk document upload, and waiting for it to be ingested** (mirroring VH-Lab/NDI-matlab#1047). `ndi.cloud.upload.uploadDocumentCollection` now uploads documents in bulk, as NDI-matlab does: each chunk @@ -118,6 +135,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Fixed +- **`session.daqsystem_add` raised AttributeError for every DAQ system**: it + called `dev.setsession`, but `ndi_daq_system`'s method is `set_session`. + +- **Deleting a document left its dependents behind with current DID-python.** + `session.database_rm`'s cascade asked for `depends_on("", id)`, relying on + an empty name meaning "any"; DID's SQL `depends_on` search (DID-python#101) + treats only `'*'` as any, so nothing was found and, e.g., deleting neurons + in the spike sorter importer left them in the database. It now asks for + `'*'`, as MATLAB's `findalldependencies` does. + - **Lightsheet viewer: slow tiles from the local cache.** Each pyramid level was built as one dask graph layer per chunk (`from_delayed` + `da.block`), and dask walks every layer each time napari computes a slice, so the cost grew diff --git a/src/ndi/daq/ndi_matlab_python_bridge.yaml b/src/ndi/daq/ndi_matlab_python_bridge.yaml index cfe19a78..f57146f7 100644 --- a/src/ndi/daq/ndi_matlab_python_bridge.yaml +++ b/src/ndi/daq/ndi_matlab_python_bridge.yaml @@ -756,10 +756,14 @@ classes: - name: system type: class matlab_path: "+ndi/+daq/system.m" - matlab_last_sync_hash: "fe64a9f5" + matlab_last_sync_hash: "e066e8d" python_path: "ndi/daq/system.py" python_class: "ndi_daq_system" inherits: "ndi.ido" + decision_log: > + 2026-10-24: NDI-matlab 989a620 added an optional prefetched-documents + input to the constructor and e066e8d reverted it (VH-Lab/NDI-matlab#1058); + the file is unchanged from fe64a9f5 in net, so nothing to follow. properties: - name: name diff --git a/src/ndi/gui/nav/__init__.py b/src/ndi/gui/nav/__init__.py index 36918fad..bf4326df 100644 --- a/src/ndi/gui/nav/__init__.py +++ b/src/ndi/gui/nav/__init__.py @@ -23,6 +23,8 @@ from . import datasets_cloud, datasets_model, datasets_text from .cloud_pane import CloudPane +from .cloud_status_dialog import CloudStatusDialog +from .daq_system_browser import DaqSystemBrowser from .datasets_pane import DatasetsPane from .ndi_pane import NdiPane from .pane import NavPane @@ -35,6 +37,8 @@ "datasets_model", "datasets_text", "CloudPane", + "CloudStatusDialog", + "DaqSystemBrowser", "DatasetsPane", "NavPane", "NdiPane", diff --git a/src/ndi/gui/nav/cloud_status_dialog.py b/src/ndi/gui/nav/cloud_status_dialog.py new file mode 100644 index 00000000..25eb313c --- /dev/null +++ b/src/ndi/gui/nav/cloud_status_dialog.py @@ -0,0 +1,151 @@ +"""ndi.gui.nav.cloud_status_dialog - the result window of "Check Cloud status". + +MATLAB counterpart: ``src/ndi/+ndi/+gui/+nav/cloudStatusDialog.m`` + +Reports whether a dataset is linked to NDI Cloud. It shows the status +sentence, the NDI Cloud dataset id in a read-only (selectable) field, and a +"Copy" button that puts the id on the clipboard. When the dataset is not in +the cloud there is no id: the field reads "(not in NDI Cloud)" and Copy is +disabled. + +What the window SAYS -- the sentence, the id text, whether Copy is enabled -- +is decided by the static methods, which need no display and are tested on +their own; :meth:`CloudStatusDialog.build` only lays the widgets out. +""" + +from __future__ import annotations + +from typing import Any + +from ..cloud_colors import cloud_colors, rgb_to_hex + +__all__ = ["CloudStatusDialog"] + +#: Shown in the id field when there is no id to show. +NOT_IN_CLOUD_TEXT = "(not in NDI Cloud)" + + +class CloudStatusDialog: + """The "Check Cloud status" result window for one dataset.""" + + def __init__( + self, + in_cloud: bool, + cloud_dataset_id: str = "", + *, + parent: Any = None, + build: bool = True, + ): + self.in_cloud = bool(in_cloud) + self.cloud_dataset_id = str(cloud_dataset_id or "") + self.figure: Any = None + self.id_field: Any = None + self.copy_button: Any = None + if build: + self.build(parent) + + # ------------------------------------------------------------------ + # what the window says -- no Qt + # ------------------------------------------------------------------ + @staticmethod + def status_message(in_cloud: bool) -> str: + """The status sentence shown at the top of the window.""" + if in_cloud: + return "This dataset is linked to NDI Cloud." + return 'This dataset is not in NDI Cloud. Use "Upload to Cloud" to add it.' + + @staticmethod + def can_copy(in_cloud: bool, cloud_dataset_id: str) -> bool: + """True when there is a cloud dataset id to copy.""" + return bool(in_cloud) and bool(cloud_dataset_id) + + @staticmethod + def copy_enable(in_cloud: bool, cloud_dataset_id: str) -> bool: + """Whether the Copy button is enabled.""" + return CloudStatusDialog.can_copy(in_cloud, cloud_dataset_id) + + @staticmethod + def id_text(in_cloud: bool, cloud_dataset_id: str) -> str: + """Text shown in the id field.""" + if CloudStatusDialog.can_copy(in_cloud, cloud_dataset_id): + return str(cloud_dataset_id) + return NOT_IN_CLOUD_TEXT + + # ------------------------------------------------------------------ + # Qt + # ------------------------------------------------------------------ + def build(self, parent: Any = None) -> Any: + """Create the window: message, id row with Copy, and OK.""" + from .._qt_helpers import get_or_create_app, require_qt + + require_qt() + get_or_create_app() + from PySide6 import QtWidgets + + c = cloud_colors() + navy = rgb_to_hex(c.dark_blue) + white = rgb_to_hex(c.white) + light = rgb_to_hex(c.light_blue) + + self.figure = QtWidgets.QDialog(parent) + self.figure.setWindowTitle("Check Cloud Status") + self.figure.setObjectName("ndiNavigatorCloudStatus") + self.figure.setStyleSheet(f"QDialog {{ background-color: {navy}; }}") + self.figure.resize(460, 170) + + layout = QtWidgets.QGridLayout(self.figure) + layout.setContentsMargins(12, 12, 12, 12) + layout.setVerticalSpacing(10) + + message = QtWidgets.QLabel(self.status_message(self.in_cloud)) + message.setWordWrap(True) + message.setStyleSheet(f"color: {white};") + layout.addWidget(message, 0, 0, 1, 3) + + label = QtWidgets.QLabel("Cloud dataset ID:") + label.setStyleSheet(f"color: {white}; font-weight: bold;") + layout.addWidget(label, 1, 0) + + # A read-only line edit rather than a label, so the id can also be + # selected and copied by hand. + self.id_field = QtWidgets.QLineEdit(self.id_text(self.in_cloud, self.cloud_dataset_id)) + self.id_field.setReadOnly(True) + self.id_field.setObjectName("ndiCloudDatasetId") + self.id_field.setStyleSheet(f"background-color: {white}; color: {navy};") + layout.addWidget(self.id_field, 1, 1) + + self.copy_button = QtWidgets.QPushButton("Copy") + self.copy_button.setObjectName("ndiCloudDatasetIdCopy") + self.copy_button.setToolTip("Copy the cloud dataset ID to the clipboard") + self.copy_button.setEnabled(self.copy_enable(self.in_cloud, self.cloud_dataset_id)) + self.copy_button.setStyleSheet(f"background-color: {light}; color: {navy};") + self.copy_button.clicked.connect(self.copy_id) + layout.addWidget(self.copy_button, 1, 2) + + ok = QtWidgets.QPushButton("OK") + ok.setStyleSheet(f"background-color: {white}; color: {navy};") + ok.clicked.connect(self.figure.accept) + layout.addWidget(ok, 2, 2) + layout.setRowStretch(0, 1) + return self.figure + + def copy_id(self) -> bool: + """Copy the cloud dataset id to the clipboard. False when there is none.""" + if not self.can_copy(self.in_cloud, self.cloud_dataset_id): + return False + from PySide6 import QtGui + + QtGui.QGuiApplication.clipboard().setText(self.cloud_dataset_id) + if self.copy_button is not None: + self.copy_button.setText("Copied") + return True + + def show(self) -> None: + if self.figure is not None: + self.figure.show() + + def __repr__(self) -> str: + return ( + f"CloudStatusDialog(in_cloud={self.in_cloud!r}, " + f"cloud_dataset_id={self.cloud_dataset_id!r})" + ) diff --git a/src/ndi/gui/nav/daq_system_browser.py b/src/ndi/gui/nav/daq_system_browser.py new file mode 100644 index 00000000..aa14a680 --- /dev/null +++ b/src/ndi/gui/nav/daq_system_browser.py @@ -0,0 +1,597 @@ +"""ndi.gui.nav.daq_system_browser - a read-only browser of a session's DAQ systems. + +MATLAB counterpart: ``src/ndi/+ndi/+gui/+nav/daqSystemBrowser.m`` + +Shows every DAQ system of a session so the user can see how each one finds +its data files and its epoch probe maps. A viewer only; nothing is edited. +The target of the navigator's per-session "Session > DAQ Systems..." item. + +Layout (master-detail): + +* Left -- a list of the session's DAQ systems by name. +* Right -- a scrollable panel describing the selected DAQ system, in + sections: *Acquisition* (DAQ system class, DAQ reader, metadata readers), + *Files in each epoch* (the file navigator and its ``fileparameters`` + patterns, one per line), *Epoch probe map* (the ``epochprobemap_class`` + and ``epochprobemap_fileparameters`` patterns, or the default location + when none are set), and *Example epoch*: on request ("Load epochs") a + chooser of the DAQ system's epochs and, for the chosen epoch, a table of + the actual files on disk, the pattern each matches, and which file is the + epoch probe map. Reading epochs touches the disk, so it runs only when + asked. + +Class names are MATLAB-style (``ndi.daq.reader.mfdaq.intan``, via +:func:`ndi.util.classname.ndi_matlab_classname`), so the two languages show +the same names; the panel shows the last component and the full name on +hover. + +As in :mod:`ndi.gui.nav.session_info`, what the window will SAY is computed +by plain functions -- :meth:`DaqSystemBrowser.describe`, +:meth:`DaqSystemBrowser.epoch_file_rows` and friends -- that are testable +without a display; only the widgets need Qt. +""" + +from __future__ import annotations + +import fnmatch +import os +import re +from typing import Any + +from ...util.classname import ndi_matlab_classname +from ...util.matlab_regex import matlab_to_python_regex +from ..cloud_colors import cloud_colors, rgb_to_hex + +__all__ = ["DaqSystemBrowser"] + +NONE_TEXT = "(none)" +NO_DAQ = "(no DAQ systems)" +UNNAMED_SESSION = "(unnamed session)" +EPOCH_COLUMN_NAMES = ["File", "Matches pattern", "Role"] +EPOCH_PROMPT_ROW = ['(choose "Load epochs" to see the files of an epoch)', "", ""] + + +class DaqSystemBrowser: + """A window browsing one session's DAQ systems. + + Constructing it reads the session's DAQ systems once (with + ``daqsystem_load()``); :meth:`build` creates the widgets. They are + separate so the reading can be tested on its own. + """ + + def __init__(self, session: Any, *, build: bool = True): + self.session = session + self.daqs: list[Any] = self.daq_systems() + self.info: list[dict[str, Any]] = self.describe_all(self.daqs) + #: Per DAQ system: None until loaded, else a list of + #: ``{"number": int, "id": str}`` (see :meth:`load_epochs`). + self.epochs: list[list[dict[str, Any]] | None] = [None] * len(self.daqs) + self.selected: int = 0 + + self.figure: Any = None + self.list: Any = None + self.detail_area: Any = None + self.detail_panel: Any = None + self.epoch_dropdown: Any = None + self.epoch_button: Any = None + self.epoch_table: Any = None + #: The rows currently shown in the epoch table (what the user sees). + self.epoch_rows: list[list[str]] = [list(EPOCH_PROMPT_ROW)] + #: The text of every label in the detail panel, top to bottom. + self.detail_texts: list[str] = [] + if build: + self.build() + + # ------------------------------------------------------------------ + # reading the session -- no Qt in this section + # ------------------------------------------------------------------ + def session_ref(self) -> str: + """A human-readable session reference, best effort.""" + try: + ref = str(self.session.reference or "") + except Exception: # noqa: BLE001 + ref = "" + return ref or UNNAMED_SESSION + + def daq_systems(self) -> list[Any]: + """The session's DAQ system objects. + + No name filter: a name regexp makes the database search much slower + on large databases, and every DAQ system is wanted anyway. + """ + try: + daqs = self.session.daqsystem_load() + except Exception: # noqa: BLE001 + return [] + if daqs is None: + return [] + return list(daqs) if isinstance(daqs, (list, tuple)) else [daqs] + + @staticmethod + def describe_all(daqs: list[Any]) -> list[dict[str, Any]]: + """:meth:`describe` for each DAQ system in DAQS.""" + return [DaqSystemBrowser.describe(d) for d in daqs or []] + + @staticmethod + def describe(d: Any) -> dict[str, Any]: + """A display description of one DAQ system D. + + Keys ``name``, ``daq_class``, ``reader_class``, ``navigator_class`` + and ``epoch_probe_map_class`` (str), and ``file_parameters``, + ``epoch_probe_map_file_parameters`` and ``metadata_readers`` (lists of + str). D may be an ``ndi_daq_system`` or anything with the same + attributes; a part that cannot be read is left empty. ``describe(None)`` + is the empty template. + """ + s: dict[str, Any] = { + "name": "", + "daq_class": "", + "reader_class": "", + "navigator_class": "", + "file_parameters": [], + "epoch_probe_map_class": "", + "epoch_probe_map_file_parameters": [], + "metadata_readers": [], + } + if d is None: + return s + s["daq_class"] = _classname(d) + s["name"] = _text(_get(d, "name")) + + reader = _get(d, "daqreader") + if reader is not None: + s["reader_class"] = _classname(reader) + + nav = _get(d, "filenavigator") + if nav is not None: + s["navigator_class"] = _classname(nav) + s["file_parameters"] = DaqSystemBrowser.filematch_list(_get(nav, "fileparameters")) + s["epoch_probe_map_class"] = _text(_get(nav, "epochprobemap_class")) + s["epoch_probe_map_file_parameters"] = DaqSystemBrowser.filematch_list( + _get(nav, "epochprobemap_fileparameters") + ) + + readers = _get(d, "daqmetadatareaders") + if readers is None: + readers = _get(d, "daqmetadatareader") + if readers is not None and not isinstance(readers, (list, tuple)): + readers = [readers] + for m in readers or []: + entry = _classname(m) + tsv = _text(_get(m, "tab_separated_file_parameter")) + if tsv: + entry = f"{entry} ({tsv})" + s["metadata_readers"].append(entry) + return s + + @staticmethod + def filematch_list(fp: Any) -> list[str]: + """The filematch patterns of file parameters FP, as a list of str. + + FP may be empty, a pattern, a list of patterns, or a dict with a + ``filematch`` key holding either (the form ``ndi_file_navigator`` + stores). + """ + if not fp: + return [] + if isinstance(fp, dict): + fp = fp.get("filematch") + if not fp: + return [] + if isinstance(fp, str): + return [fp] + if isinstance(fp, (list, tuple)): + return [str(x) for x in fp] + return [] + + @staticmethod + def short_class_name(full_name: str) -> str: + """Last component of a dotted class name: ``'a.b.intan'`` -> ``'intan'``.""" + return str(full_name or "").rsplit(".", 1)[-1] + + @staticmethod + def epoch_labels(numbers: list[int], ids: list[str]) -> list[str]: + """Epoch chooser text, e.g. ``'Epoch 1 (t00001)'``.""" + labels = [] + for i, n in enumerate(numbers): + label = f"Epoch {n}" + if i < len(ids) and ids[i]: + label += f" ({ids[i]})" + labels.append(label) + return labels + + @staticmethod + def matching_pattern(patterns: list[str], name: str) -> str: + """The first of PATTERNS that file NAME matches, or ``""``. + + Matching follows :func:`ndi.file.navigator.find_file_groups`: ``#`` is + a wildcard (as a glob), and failing that the pattern is tried as a + MATLAB regular expression. Each pattern is tried on its own. + """ + for pattern in patterns or []: + if fnmatch.fnmatch(name, pattern.replace("#", "*")): + return pattern + try: + if re.search(matlab_to_python_regex(pattern), name): + return pattern + except re.error: + pass + return "" + + @staticmethod + def epoch_file_rows( + files: list[str], patterns: list[str], probe_map_file: str | None + ) -> list[list[str]]: + """Rows of [file name, matching pattern, role] for one epoch. + + FILES are the full paths of the epoch's files, PATTERNS the + navigator's filematch patterns and PROBE_MAP_FILE the epoch's epoch + probe map file (full path, or empty). Each file is listed by name + with the first pattern it matches; the epoch probe map file is marked + in the Role column. When the probe map lives outside the epoch files + (the default hidden location), it gets its own row. + """ + rows: list[list[str]] = [] + probe_map_file = probe_map_file or "" + for f in files or []: + name = os.path.basename(f) + role = "epoch probe map" if probe_map_file and f == probe_map_file else "" + rows.append([name, DaqSystemBrowser.matching_pattern(patterns, name), role]) + if probe_map_file and probe_map_file not in (files or []): + role = "epoch probe map (default location)" + if not os.path.isfile(probe_map_file): + role += ", not found" + rows.append([os.path.basename(probe_map_file), "", role]) + return rows or [["(this epoch has no files)", "", ""]] + + @staticmethod + def default_epoch_probe_map_text() -> str: + """Shown when no epoch probe map file parameters are set.""" + return "(none set; default: hidden ..epochprobemap.ndi file)" + + @staticmethod + def detail_items(s: dict[str, Any]) -> list[tuple[str, str, str, str]]: + """The detail panel's rows for description S, top to bottom. + + Each is ``(kind, label, value, tooltip)`` with kind one of + ``title``, ``section``, ``field``, ``pattern``, ``note``, + ``epochControls`` and ``epochTable``. + """ + short = DaqSystemBrowser.short_class_name + items: list[tuple[str, str, str, str]] = [("title", s["name"], "", "")] + items.append(("section", "Acquisition", "", "")) + items.append(("field", "DAQ system class", short(s["daq_class"]), s["daq_class"])) + items.append(("field", "DAQ reader", short(s["reader_class"]), s["reader_class"])) + items += _list_items("Metadata readers", s["metadata_readers"], NONE_TEXT) + items.append(("section", "Files in each epoch", "", "")) + items.append(("field", "File navigator", short(s["navigator_class"]), s["navigator_class"])) + items += _list_items("File patterns", s["file_parameters"], NONE_TEXT) + items.append(("section", "Epoch probe map", "", "")) + items.append( + ( + "field", + "Class", + short(s["epoch_probe_map_class"]), + s["epoch_probe_map_class"], + ) + ) + items += _list_items( + "File patterns", + s["epoch_probe_map_file_parameters"], + DaqSystemBrowser.default_epoch_probe_map_text(), + ) + items.append(("section", "Example epoch", "", "")) + items.append(("epochControls", "Epoch", "", "")) + items.append(("epochTable", "", "", "")) + return items + + # ------------------------------------------------------------------ + # epochs -- read on request + # ------------------------------------------------------------------ + def load_epochs(self, k: int | None = None) -> list[dict[str, Any]] | None: + """Read DAQ system K's epochs and list them ("Load epochs"). + + K is the 0-based position in the list, defaulting to the selected DAQ + system. Reads the navigator's epoch table (which reads the disk), + fills the epoch chooser and shows the files of the first epoch. + Returns the epochs, or None when they could not be read (the reason + is shown in the epoch table). + """ + k = self.selected if k is None else k + nav = _get(self.daqs[k], "filenavigator") + try: + et = nav.epochtable() + if isinstance(et, tuple): + et = et[0] + except Exception as exc: # noqa: BLE001 + self._set_epoch_rows([[f"(could not read the epochs: {exc})", "", ""]]) + return None + self.epochs[k] = [ + {"number": i, "id": str((e or {}).get("epoch_id", "") or "")} + for i, e in enumerate(et or [], start=1) + ] + if k == self.selected: + self._fill_epoch_dropdown() + return self.epochs[k] + + def choose_epoch(self, n: int) -> list[list[str]] | None: + """Show the files of epoch N (1-based) of the selected DAQ system. + + The same as picking epoch N in the chooser; the epochs must have been + loaded first. Returns the rows shown, or None when N is not an epoch. + """ + ep = self.epochs[self.selected] if self.daqs else None + if not ep or not any(e["number"] == n for e in ep): + return None + if self.epoch_dropdown is not None: + self.epoch_dropdown.blockSignals(True) + self.epoch_dropdown.setCurrentIndex(n - 1) + self.epoch_dropdown.blockSignals(False) + return self._show_epoch_files(self.selected, n) + + def epoch_rows_for(self, k: int, n: int) -> list[list[str]]: + """The epoch-table rows for epoch N (1-based) of DAQ system K. No Qt.""" + nav = _get(self.daqs[k], "filenavigator") + try: + files = list(nav.getepochfiles_number(n) or []) + except Exception as exc: # noqa: BLE001 + return [[f"(could not read the epoch files: {exc})", "", ""]] + if files and str(files[0]).startswith("epochid://"): + return [["(this epoch is ingested; its raw files are stored in the database)", "", ""]] + probe_map_file = "" + try: + probe_map_file = nav.epochprobemapfilename(n) or "" + except Exception: # noqa: BLE001 + pass + return self.epoch_file_rows(files, self.info[k]["file_parameters"], probe_map_file) + + def _show_epoch_files(self, k: int, n: int) -> list[list[str]]: + rows = self.epoch_rows_for(k, n) + self._set_epoch_rows(rows) + return rows + + def _set_epoch_rows(self, rows: list[list[str]]) -> None: + self.epoch_rows = [list(r) for r in rows] + if self.epoch_table is None: + return + from PySide6 import QtWidgets + + self.epoch_table.setRowCount(len(rows)) + for r, row in enumerate(rows): + for col, value in enumerate(row): + self.epoch_table.setItem(r, col, QtWidgets.QTableWidgetItem(str(value))) + + def _fill_epoch_dropdown(self) -> None: + """Populate the chooser from the selected DAQ system's loaded epochs.""" + ep = self.epochs[self.selected] + if self.epoch_button is not None: + self.epoch_button.setEnabled(False) + if not ep: + if self.epoch_dropdown is not None: + self.epoch_dropdown.clear() + self.epoch_dropdown.addItem("(no epochs)") + self._set_epoch_rows([["(this DAQ system has no epochs)", "", ""]]) + return + if self.epoch_dropdown is not None: + self.epoch_dropdown.blockSignals(True) + self.epoch_dropdown.clear() + self.epoch_dropdown.addItems( + self.epoch_labels([e["number"] for e in ep], [e["id"] for e in ep]) + ) + self.epoch_dropdown.setEnabled(True) + self.epoch_dropdown.setCurrentIndex(0) + self.epoch_dropdown.blockSignals(False) + self._show_epoch_files(self.selected, ep[0]["number"]) + + # ------------------------------------------------------------------ + # Qt + # ------------------------------------------------------------------ + def build(self) -> Any: + """Create the window: DAQ system list and detail panel.""" + from .._qt_helpers import get_or_create_app, require_qt + + require_qt() + get_or_create_app() + from PySide6 import QtWidgets + + c = cloud_colors() + navy = rgb_to_hex(c.dark_blue) + white = rgb_to_hex(c.white) + + self.figure = QtWidgets.QWidget() + self.figure.setWindowTitle(f"DAQ systems: {self.session_ref()}") + self.figure.setObjectName("ndiNavigatorDaqSystemBrowser") + self.figure.resize(900, 620) + self.figure.setStyleSheet(f"background-color: {navy};") + + outer = QtWidgets.QHBoxLayout(self.figure) + outer.setContentsMargins(10, 10, 10, 10) + outer.setSpacing(10) + + left = QtWidgets.QVBoxLayout() + heading = QtWidgets.QLabel("DAQ systems") + heading.setStyleSheet(f"color: {white}; font-weight: bold;") + left.addWidget(heading) + self.list = QtWidgets.QListWidget() + self.list.setObjectName("ndiDaqSystemList") + self.list.setFixedWidth(200) + self.list.setStyleSheet(f"background-color: {white}; color: {navy};") + left.addWidget(self.list, 1) + outer.addLayout(left) + + self.detail_area = QtWidgets.QScrollArea() + self.detail_area.setWidgetResizable(True) + self.detail_area.setStyleSheet(f"background-color: {white};") + outer.addWidget(self.detail_area, 1) + + if not self.info: + self.list.addItem(NO_DAQ) + self.list.setEnabled(False) + panel = QtWidgets.QWidget() + QtWidgets.QVBoxLayout(panel).addWidget( + self._label("This session has no DAQ systems.", navy) + ) + self.detail_area.setWidget(panel) + self.detail_texts = ["This session has no DAQ systems."] + return self.figure + + self.list.addItems([s["name"] for s in self.info]) + self.list.currentRowChanged.connect(self.select) + self.select(0) + return self.figure + + def select(self, k: int) -> None: + """Show the details of DAQ system K (0-based position in the list).""" + if k < 0 or k >= len(self.info): + return + self.selected = k + if self.list is not None and self.list.currentRow() != k: + self.list.blockSignals(True) + self.list.setCurrentRow(k) + self.list.blockSignals(False) + self._show_details(k) + + def _show_details(self, k: int) -> None: + """Rebuild the detail panel for DAQ system K.""" + self.epoch_rows = [list(EPOCH_PROMPT_ROW)] + if self.detail_area is None: + return + from PySide6 import QtWidgets + + c = cloud_colors() + navy = rgb_to_hex(c.dark_blue) + light = rgb_to_hex(c.light_blue) + + panel = QtWidgets.QWidget() + grid = QtWidgets.QGridLayout(panel) + grid.setContentsMargins(14, 12, 14, 12) + grid.setVerticalSpacing(4) + grid.setColumnMinimumWidth(0, 150) + grid.setColumnStretch(1, 1) + self.detail_texts = [] + + items = self.detail_items(self.info[k]) + for r, (kind, label, value, tooltip) in enumerate(items): + if kind == "title": + w = self._label(label, navy, "font-size: 18pt; font-weight: bold;") + grid.addWidget(w, r, 0, 1, 2) + elif kind == "section": + w = self._label(label, light, "font-size: 14pt; font-weight: bold;") + grid.addWidget(w, r, 0, 1, 2) + elif kind in ("field", "pattern", "note"): + if label: + grid.addWidget(self._label(f"{label}:", navy), r, 0) + if kind == "field" and not value: + value, extra = NONE_TEXT, "font-style: italic;" + elif kind == "pattern": + extra = "font-family: monospace;" + elif kind == "note": + extra = "font-style: italic;" + else: + extra = "" + w = self._label(value, navy, extra) + if tooltip: + w.setToolTip(tooltip) + grid.addWidget(w, r, 1) + elif kind == "epochControls": + grid.addWidget(self._label(f"{label}:", navy), r, 0) + grid.addWidget(self._epoch_controls(k), r, 1) + elif kind == "epochTable": + self.epoch_table = QtWidgets.QTableWidget(1, len(EPOCH_COLUMN_NAMES)) + self.epoch_table.setObjectName("ndiDaqSystemEpochFiles") + self.epoch_table.setHorizontalHeaderLabels(EPOCH_COLUMN_NAMES) + self.epoch_table.verticalHeader().setVisible(False) + self.epoch_table.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.epoch_table.horizontalHeader().setStretchLastSection(True) + self.epoch_table.setMinimumHeight(200) + self.epoch_table.setStyleSheet(f"color: {navy};") + grid.addWidget(self.epoch_table, r, 0, 1, 2) + grid.setRowStretch(len(items), 1) + self.detail_panel = panel + self.detail_area.setWidget(panel) + + self._set_epoch_rows([list(EPOCH_PROMPT_ROW)]) + # Epochs already loaded for this DAQ system are shown at once. + if self.epochs[k] is not None: + self._fill_epoch_dropdown() + + def _epoch_controls(self, k: int) -> Any: + """The epoch chooser and the "Load epochs" button.""" + from PySide6 import QtWidgets + + c = cloud_colors() + box = QtWidgets.QWidget() + row = QtWidgets.QHBoxLayout(box) + row.setContentsMargins(0, 0, 0, 0) + self.epoch_dropdown = QtWidgets.QComboBox() + self.epoch_dropdown.setObjectName("ndiDaqSystemEpochChooser") + self.epoch_dropdown.addItem("(not loaded)") + self.epoch_dropdown.setEnabled(False) + self.epoch_dropdown.currentIndexChanged.connect(lambda i: self._on_epoch_index(k, i)) + row.addWidget(self.epoch_dropdown, 1) + self.epoch_button = QtWidgets.QPushButton("Load epochs") + self.epoch_button.setToolTip("Read this DAQ system's epochs from disk") + self.epoch_button.setStyleSheet( + f"background-color: {rgb_to_hex(c.light_blue)}; color: {rgb_to_hex(c.dark_blue)};" + ) + self.epoch_button.clicked.connect(lambda: self.load_epochs(k)) + row.addWidget(self.epoch_button) + return box + + def _on_epoch_index(self, k: int, index: int) -> None: + ep = self.epochs[k] + if ep and 0 <= index < len(ep): + self._show_epoch_files(k, ep[index]["number"]) + + def _label(self, text: str, color: str, extra: str = "") -> Any: + from PySide6 import QtWidgets + + w = QtWidgets.QLabel(str(text)) + w.setWordWrap(True) + w.setStyleSheet(f"color: {color}; {extra}") + # Selectable, so a pattern or class name can be copied by hand. + from PySide6 import QtCore + + w.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) + self.detail_texts.append(str(text)) + return w + + def show(self) -> None: + if self.figure is not None: + self.figure.show() + + def __repr__(self) -> str: + return f"DaqSystemBrowser(session={self.session_ref()!r}, daqs={len(self.daqs)})" + + +def _list_items(label: str, values: list[str], empty_text: str) -> list[tuple[str, str, str, str]]: + """One ``pattern`` row per value, labelling only the first; or one ``note``.""" + if not values: + return [("note", label, empty_text, "")] + return [("pattern", label if i == 0 else "", v, "") for i, v in enumerate(values)] + + +def _get(obj: Any, name: str) -> Any: + """``obj.name`` or ``obj[name]``, or None when it cannot be read.""" + if obj is None: + return None + if isinstance(obj, dict): + return obj.get(name) + try: + return getattr(obj, name) + except Exception: # noqa: BLE001 + return None + + +def _text(value: Any) -> str: + return "" if value is None else str(value) + + +def _classname(obj: Any) -> str: + """The MATLAB-style class name of OBJ, e.g. ``ndi.daq.system.mfdaq``.""" + try: + return ndi_matlab_classname(obj) + except Exception: # noqa: BLE001 + return type(obj).__name__ diff --git a/src/ndi/gui/nav/datasets_cloud.py b/src/ndi/gui/nav/datasets_cloud.py index cd6abbaa..408149ef 100644 --- a/src/ndi/gui/nav/datasets_cloud.py +++ b/src/ndi/gui/nav/datasets_cloud.py @@ -70,6 +70,9 @@ class CloudActionResult(NamedTuple): message: str icon: str = "info" state: str | None = None + #: The NDI Cloud dataset id, when the action learned it ("" otherwise). + #: Set by "Check Cloud status" so its dialog can show and copy the id. + cloud_dataset_id: str = "" #: The additive sync modes, mapping the pane's command to the sync function @@ -158,12 +161,13 @@ def check_cloud_status(dataset: Any) -> CloudActionResult: try: answer = dataset.is_in_cloud() in_cloud = answer[0] if isinstance(answer, tuple) else answer + cloud_id = str(answer[1] or "") if isinstance(answer, tuple) and len(answer) > 1 else "" except Exception as exc: # noqa: BLE001 return CloudActionResult(False, title, str(exc), "error", None) if in_cloud: return CloudActionResult( - True, title, "This dataset is linked to NDI Cloud.", "success", "incloud" + True, title, "This dataset is linked to NDI Cloud.", "success", "incloud", cloud_id ) return CloudActionResult( True, diff --git a/src/ndi/gui/nav/datasets_model.py b/src/ndi/gui/nav/datasets_model.py index d25a014c..f7e2efee 100644 --- a/src/ndi/gui/nav/datasets_model.py +++ b/src/ndi/gui/nav/datasets_model.py @@ -55,6 +55,9 @@ "dataset_rows", "unaffiliated_rows", "check_all_cloud_status", + "unique_by_id", + "assign_workspace_var", + "workspace_has_var", "UNKNOWN_STATUS", ] @@ -355,6 +358,8 @@ def dataset_session_rows( { "label": decorate_with_workspace_vars(label, str(session_id or ""), index), "node_data": session_node_data(None, dataset, session_id), + "base_label": label, + "id": str(session_id or ""), } ) return rows @@ -374,29 +379,79 @@ def dataset_rows( datasets = list(user_datasets or []) datasets += search_path_datasets() datasets += scan_workspace(dataset_class, namespace) + # A dataset can come from more than one source (added with "+" and also + # assigned to a variable), so each id is listed once. + datasets = unique_by_id(datasets) rows: list[dict[str, Any]] = [] for ds in datasets: + base = dataset_label(ds) rows.append( { - "label": decorate_with_workspace_vars(dataset_label(ds), obj_id(ds), index), + "label": decorate_with_workspace_vars(base, obj_id(ds), index), "node_data": dataset_node_data(ds), "sessions": dataset_session_rows(ds, index), + "base_label": base, + "id": obj_id(ds), } ) return rows +def unique_by_id(objs: Sequence[Any]) -> list[Any]: + """OBJS with later objects dropped when their id was already seen. + + MATLAB counterpart: ``ndi.gui.nav.datasetsPane/uniqueById``. Objects + whose id cannot be read are always kept: two objects that cannot say + who they are are not thereby the same object. + """ + seen: set[str] = set() + out: list[Any] = [] + for obj in objs: + key = obj_id(obj) + if key and key in seen: + continue + if key: + seen.add(key) + out.append(obj) + return out + + +def assign_workspace_var(name: str, value: Any, namespace: Any = None) -> None: + """Bind NAME to VALUE in the user's workspace. + + MATLAB counterpart: ``assignin('base', name, value)`` in + ``ndi.gui.nav.datasetsPane/assignToWorkspace``. The workspace is + :data:`WORKSPACE_MODULE`'s globals unless NAMESPACE (a mutable mapping) + is given, as everywhere in this module. + """ + target = namespace + if target is None: + module = sys.modules.get(WORKSPACE_MODULE) + if module is None: + raise RuntimeError(f"There is no {WORKSPACE_MODULE} module to assign into.") + target = module.__dict__ + target[name] = value + + +def workspace_has_var(name: str, namespace: Mapping[str, Any] | None = None) -> bool: + """Whether the user's workspace already has a variable NAME.""" + return name in workspace_namespace(namespace) + + def unaffiliated_rows( sessions: Sequence[Any] | None, index: Mapping[str, Sequence[str]] | None = None ) -> list[dict[str, Any]]: """One row per unaffiliated session: its label and its node payload.""" rows: list[dict[str, Any]] = [] for s in sessions or []: + base = session_label(s) rows.append( { - "label": decorate_with_workspace_vars(session_label(s), obj_id(s), index), + "label": decorate_with_workspace_vars(base, obj_id(s), index), "node_data": session_node_data(s, None, ""), + "base_label": base, + "id": obj_id(s), } ) return rows diff --git a/src/ndi/gui/nav/datasets_pane.py b/src/ndi/gui/nav/datasets_pane.py index 1e6b0e64..82736276 100644 --- a/src/ndi/gui/nav/datasets_pane.py +++ b/src/ndi/gui/nav/datasets_pane.py @@ -48,6 +48,7 @@ dataset_menu_enable, normalize_cloud_list, occupied_folder_message, + workspace_name_error, ) from .pane import NavPane from .status_icon import status_icon @@ -195,6 +196,10 @@ def __init__(self, navigator: Any = None): #: :meth:`launch_app`. self.launched_apps: list[Any] = [] + #: Windows opened from this pane's menus (DAQ system browser, cloud + #: status dialog), held for the same reason as ``launched_apps``. + self.open_windows: list[Any] = [] + def has_body(self) -> bool: return True @@ -434,7 +439,7 @@ def add_user_session(self, session: Any) -> Any: def _parent_widget(self) -> Any: return getattr(self.navigator, "figure", None) - def _ask_text(self, prompt: str, title: str) -> str | None: + def _ask_text(self, prompt: str, title: str, default: str = "") -> str | None: """A one-line text prompt. None when cancelled, "" when left blank. The two are different and both callers act on the difference: @@ -443,7 +448,9 @@ def _ask_text(self, prompt: str, title: str) -> str | None: """ from PySide6 import QtWidgets - text, ok = QtWidgets.QInputDialog.getText(self._parent_widget(), title, prompt) + text, ok = QtWidgets.QInputDialog.getText( + self._parent_widget(), title, prompt, QtWidgets.QLineEdit.EchoMode.Normal, default + ) if not ok: return None return text.strip() @@ -494,9 +501,11 @@ def populate_tree(self) -> list[dict[str, Any]]: for row in rows: item = QtWidgets.QTreeWidgetItem([row["label"]]) item.setData(0, _NODE_ROLE, row["node_data"]) + _set_label_data(item, row) for child in row.get("children", ()): sub = QtWidgets.QTreeWidgetItem([child["label"]]) sub.setData(0, _NODE_ROLE, child["node_data"]) + _set_label_data(sub, child) item.addChild(sub) self.tree.addTopLevelItem(item) item.setExpanded(True) @@ -528,6 +537,8 @@ def tree_rows(self) -> list[dict[str, Any]]: "label": row["label"], "node_data": row["node_data"], "children": row["sessions"], + "base_label": row["base_label"], + "id": row["id"], } ) return rows @@ -632,7 +643,8 @@ def _session_menu(self, node: Any) -> Any: menu = QtWidgets.QMenu(self.tree) - # "Apps" then "Session", alphabetical, as MATLAB creates them. + # "Apps", "Assign to workspace..." then "Session", alphabetical, as + # MATLAB creates them. apps_root = menu.addMenu("Apps") for entry in _order_app_menu(session_apps()): if entry["kind"] == "app": @@ -646,8 +658,11 @@ def _session_menu(self, node: Any) -> Any: lambda a=app, n=node: self.launch_app(a, n), ) + menu.addAction("Assign to workspace...", lambda: self.assign_session_to_workspace(node)) + session_root = menu.addMenu("Session") session_root.addAction("Clear Cache", lambda: self.clear_session_cache(node)) + session_root.addAction("DAQ Systems...", lambda: self.show_daq_systems(node)) session_root.addAction("Info...", lambda: self.show_session_info(node)) session_root.addAction("Ingest", lambda: self.ingest_session_node(node)) session_root.addAction("Ingestion Status", lambda: self.update_session_status(node)) @@ -666,6 +681,9 @@ def _dataset_menu(self, node: Any, dataset: Any) -> Any: from PySide6 import QtWidgets menu = QtWidgets.QMenu(self.tree) + menu.addAction( + "Assign to workspace...", lambda: self.assign_to_workspace(dataset, "D", node) + ) cloud = menu.addMenu("Cloud") cloud.addAction( @@ -730,8 +748,8 @@ def update_dataset_menu_enable( # ------------------------------------------------------------------ def _run_cloud(self, node: Any, action: str, dataset: Any) -> Any: if action == "check_status": - result = datasets_cloud.check_cloud_status(dataset) - elif action == "upload": + return self._report_cloud_status(node, datasets_cloud.check_cloud_status(dataset)) + if action == "upload": result = datasets_cloud.upload_dataset( dataset, organization_chooser=lambda labels, prompt: self._ask_choice( @@ -744,6 +762,32 @@ def _run_cloud(self, node: Any, action: str, dataset: Any) -> Any: result = datasets_cloud.check_for_new(dataset, "local") return self._report(node, result) + def _report_cloud_status(self, node: Any, result: Any) -> Any: + """Show "Check Cloud status" in its own window, with the cloud id. + + The window shows the NDI Cloud dataset id with a Copy button (disabled + when the dataset is not in the cloud). A failed check is reported as + an ordinary alert, since there is no status to show. + """ + if not result.ok: + return self._report(node, result) + if result.state is not None and node is not None: + self.set_dataset_cloud_state(node, result.state) + from .cloud_status_dialog import CloudStatusDialog + + try: + dialog = CloudStatusDialog( + result.state == "incloud", + result.cloud_dataset_id, + parent=self._parent_widget(), + ) + except Exception as exc: # noqa: BLE001 + self._alert(str(exc), result.title, success=False) + return result + self.open_windows.append(dialog) + dialog.show() + return result + def _run_sync(self, node: Any, dataset: Any, mode: str) -> Any: return self._report(node, datasets_cloud.sync_dataset(dataset, mode)) @@ -810,6 +854,95 @@ def clear_session_cache(self, node: Any) -> bool: self._alert("The session cache was cleared.", title, success=True) return True + def show_daq_systems(self, node: Any) -> Any: + """Open the DAQ system browser for NODE's session.""" + title = "DAQ Systems" + session = self._resolve_or_report(node, title) + if session is None: + return None + from .daq_system_browser import DaqSystemBrowser + + try: + browser = DaqSystemBrowser(session) + except Exception as exc: # noqa: BLE001 + self._alert(str(exc), title, success=False) + return None + self.open_windows.append(browser) + browser.show() + return browser + + def assign_session_to_workspace(self, node: Any) -> str | None: + """Put NODE's session in a workspace variable (default ``S``). + + A session inside a dataset is opened first. + """ + session = self._resolve_or_report(node, "Assign to workspace") + if session is None: + return None + return self.assign_to_workspace(session, "S", node) + + def assign_to_workspace( + self, value: Any, default_name: str, node: Any = None, name: str | None = None + ) -> str | None: + """Assign VALUE to a variable in the user's workspace. + + MATLAB counterpart: ``assignToWorkspace``, which uses ``assignin('base', + ...)``; here the workspace is ``__main__``'s globals, the same + namespace the tree scans for its labels (see + :data:`ndi.gui.nav.datasets_model.WORKSPACE_MODULE`). + + Asks for a name (DEFAULT_NAME, e.g. ``D`` or ``S``) unless NAME is + given, checks it is a valid variable name, confirms before replacing + an existing variable, and assigns. The tree labels are then updated + in place (:meth:`relabel_nodes`), which keeps expansion and badges. + Returns the name assigned, or None when nothing was. + """ + title = "Assign to workspace" + if name is None: + name = self._ask_text("Workspace variable name:", title, default_name) + if name is None: + return None # cancelled + message = workspace_name_error(name) + if message: + self._alert(message, title, success=False) + return None + if datasets_model.workspace_has_var(name) and not self._confirm( + title, + f'The workspace already has a variable "{name}". Replace it?', + accept="Replace", + ): + return None + try: + datasets_model.assign_workspace_var(name, value) + except Exception as exc: # noqa: BLE001 + self._alert(str(exc), title, success=False) + return None + self.relabel_nodes() + return name + + def relabel_nodes(self) -> None: + """Refresh the workspace-variable names on every node's label. + + Re-indexes the workspace and recomputes each label from the base + label and id stored on the node, without rebuilding the tree. + """ + session_cls, dataset_cls = _model_classes() + self.ws_var_index = datasets_model.build_workspace_var_index((session_cls, dataset_cls)) + if self.tree is None: + return + for i in range(self.tree.topLevelItemCount()): + top = self.tree.topLevelItem(i) + for item in [top] + [top.child(j) for j in range(top.childCount())]: + stored = item.data(0, _LABEL_ROLE) + if isinstance(stored, (list, tuple)) and len(stored) == 2: + base, identifier = stored + item.setText( + 0, + datasets_model.decorate_with_workspace_vars( + base, identifier, self.ws_var_index + ), + ) + def show_session_info(self, node: Any) -> Any: """Open the vital-statistics window for NODE's session.""" title = "Session Info" @@ -940,3 +1073,13 @@ def _model_classes() -> tuple[type, type]: #: literal avoids importing Qt just to name a constant in a module that is #: otherwise importable without a display. _NODE_ROLE = 32 + +#: Qt item-data role holding a node's ``(base label, id)``, from which +#: :meth:`DatasetsPane.relabel_nodes` recomputes the label in place. +_LABEL_ROLE = 33 + + +def _set_label_data(item: Any, row: dict[str, Any]) -> None: + """Store ROW's base label and id on ITEM, when the row carries them.""" + if "base_label" in row: + item.setData(0, _LABEL_ROLE, [str(row["base_label"]), str(row.get("id", "") or "")]) diff --git a/src/ndi/gui/nav/datasets_text.py b/src/ndi/gui/nav/datasets_text.py index 03442244..dc0fe237 100644 --- a/src/ndi/gui/nav/datasets_text.py +++ b/src/ndi/gui/nav/datasets_text.py @@ -23,6 +23,7 @@ from __future__ import annotations +import keyword from collections.abc import Iterable, Mapping, Sequence from typing import Any @@ -33,6 +34,7 @@ "cloud_dataset_id_label", "first_field", "append_workspace_var_names", + "workspace_name_error", "dataset_menu_enable", "cloud_summary_message", "cloud_check_message", @@ -195,6 +197,24 @@ def append_workspace_var_names(label: str, names: Sequence[str] | None) -> str: return f"{label} {quoted}" +def workspace_name_error(name: str) -> str: + """Why NAME cannot be a workspace variable, or ``""`` when it can. + + MATLAB counterpart: ``ndi.gui.nav.datasetsPane.workspaceNameError``, + which uses ``isvarname``; the Python rule is an identifier that is not a + keyword. + """ + name = str(name or "") + if not name: + return "A variable name is required." + if not name.isidentifier() or keyword.iskeyword(name): + return ( + f'"{name}" is not a valid Python variable name. Use letters, digits ' + "and underscores, not starting with a digit." + ) + return "" + + def dataset_menu_enable(state: Any) -> tuple[bool, bool]: """Enable flags for the dataset Cloud menu items. diff --git a/src/ndi/gui/nav/session_info.py b/src/ndi/gui/nav/session_info.py index cd41cd81..14452047 100644 --- a/src/ndi/gui/nav/session_info.py +++ b/src/ndi/gui/nav/session_info.py @@ -58,7 +58,9 @@ def session_ref(self) -> str: def daq_systems(self) -> list[Any]: """The session's DAQ system objects.""" try: - daqs = self.session.daqsystem_load(name="(.*)") + # No name filter: a name regexp makes the database search much + # slower on large databases, and every DAQ system is wanted. + daqs = self.session.daqsystem_load() except Exception: # noqa: BLE001 return [] if daqs is None: diff --git a/src/ndi/gui/ndi_matlab_python_bridge.yaml b/src/ndi/gui/ndi_matlab_python_bridge.yaml index fba83cbf..428cc947 100644 --- a/src/ndi/gui/ndi_matlab_python_bridge.yaml +++ b/src/ndi/gui/ndi_matlab_python_bridge.yaml @@ -770,7 +770,7 @@ ported_late: - name: datasetsPane matlab_path: "+ndi/+gui/+nav/datasetsPane.m" - matlab_last_sync_hash: "293ec411" + matlab_last_sync_hash: "d2b5170" python_path: "ndi/gui/nav/datasets_pane.py" decision_log: > The navigator's elastic pane, ported across #103, #109, #110 and @@ -782,6 +782,16 @@ ported_late: "[Org Name]" when there is more than one. Python's "Upload to Cloud" also passes its list dialog to uploadDataset, so a user in several organizations is asked which one, as MATLAB's navigator asks. + 2026-10-24: synchronized with NDI-matlab d2b5170 (VH-Lab/NDI-matlab#1058): + "Check Cloud status" opens cloudStatusDialog (cloud dataset id + Copy) + instead of an alert; a "Session > DAQ Systems..." item opens + daqSystemBrowser; "Assign to workspace..." on dataset and session nodes + assigns the object to a workspace variable (Python: __main__'s globals, + the namespace the tree already scans) and relabels nodes in place; + datasets are listed once per id (uniqueById -> unique_by_id). As for the + rest of this pane, Python's names are snake_case (workspaceNameError -> + datasets_text.workspace_name_error, assignToWorkspace -> + assign_to_workspace, relabelNodes -> relabel_nodes). - name: cloudPane matlab_path: "+ndi/+gui/+nav/cloudPane.m" @@ -801,9 +811,52 @@ ported_late: - name: sessionInfo matlab_path: "+ndi/+gui/+nav/sessionInfo.m" - matlab_last_sync_hash: "46672187" + matlab_last_sync_hash: "b1da61b" python_path: "ndi/gui/nav/session_info.py" - decision_log: "Ported in #103." + decision_log: > + Ported in #103. 2026-10-24: synchronized with NDI-matlab b1da61b + (VH-Lab/NDI-matlab#1058): DAQ systems are loaded with daqsystem_load() + and no name regexp, which is slow on large databases. + + - name: daqSystemBrowser + matlab_path: "+ndi/+gui/+nav/daqSystemBrowser.m" + matlab_last_sync_hash: "663ccff" + python_path: "ndi/gui/nav/daq_system_browser.py" + decision_log: > + Read-only master-detail browser of a session's DAQ systems, with an + on-request example epoch. Ported with VH-Lab/NDI-matlab#1058. Named like + the rest of ndi.gui.nav (snake_case module and methods: loadEpochs -> + load_epochs, chooseEpoch -> choose_epoch, epochFileRows -> + epoch_file_rows, ...). select() takes a 0-based list position where + MATLAB's takes a 1-based one; epoch numbers stay 1-based. Class names + are MATLAB-style via ndi_matlab_classname so both languages show the + same names. File patterns are matched the way ndi.file.navigator's + find_file_groups matches them in Python. Class DaqSystemBrowser; like + the other ndi.gui.nav entries it names no python_class, because its + attributes are snake_case: Session/Figure/List/Daqs/Info/Epochs -> + session/figure/list/daqs/info/epochs, DetailPanel -> detail_panel + (DetailGrid's role is played by detail_area, a QScrollArea), + EpochDropdown/EpochButton/EpochTable -> epoch_dropdown/epoch_button/ + epoch_table, EpochColumnNames -> module constant EPOCH_COLUMN_NAMES. + NDI-matlab 663ccff only preallocates describe's metadata-reader cell + array (a Code Analyzer performance warning); the result is unchanged, + so nothing to follow. + + - name: cloudStatusDialog + matlab_path: "+ndi/+gui/+nav/cloudStatusDialog.m" + matlab_last_sync_hash: "a5b3690" + python_path: "ndi/gui/nav/cloud_status_dialog.py" + decision_log: > + The "Check Cloud status" result window: status, read-only cloud dataset + id, Copy (disabled when not in the cloud). Ported with + VH-Lab/NDI-matlab#1058; snake_case methods as in the rest of + ndi.gui.nav (copyEnable -> copy_enable, idText -> id_text, ...). Copy + uses the Qt clipboard. The cloud id reaches it through + datasets_cloud.CloudActionResult.cloud_dataset_id. Class + CloudStatusDialog; like the other ndi.gui.nav entries it names no + python_class, because its attributes are snake_case: InCloud/ + CloudDatasetId/Figure/IdField/CopyButton -> in_cloud/cloud_dataset_id/ + figure/id_field/copy_button. - name: statusIcon matlab_path: "+ndi/+gui/+nav/statusIcon.m" diff --git a/src/ndi/session/ndi_matlab_python_bridge.yaml b/src/ndi/session/ndi_matlab_python_bridge.yaml index 723acb31..25267ea4 100644 --- a/src/ndi/session/ndi_matlab_python_bridge.yaml +++ b/src/ndi/session/ndi_matlab_python_bridge.yaml @@ -33,7 +33,7 @@ classes: - name: session type: class matlab_path: "+ndi/session.m" - matlab_last_sync_hash: "629787865" + matlab_last_sync_hash: "10bad94" python_path: "ndi/session/session_base.py" python_class: "ndi_session" @@ -106,7 +106,10 @@ classes: output_arguments: - name: ndi_session_obj type_python: "ndi_session" - decision_log: "Exact match." + decision_log: > + Exact match. 2026-10-24: fixed the Python body calling + dev.setsession, which ndi_daq_system does not have (its method is + set_session), so adding any DAQ system raised AttributeError. - name: daqsystem_rm input_arguments: @@ -129,7 +132,22 @@ classes: output_arguments: - name: dev type_python: "list[Any] | Any | None" - decision_log: "MATLAB uses varargin name-value pairs; Python uses keyword arguments." + decision_log: > + MATLAB uses varargin name-value pairs; Python uses keyword arguments. + 2026-10-24: synchronized with NDI-matlab 10bad94 (VH-Lab/NDI-matlab#1058): + a name that matches every name ('(.*)', '.*', optionally anchored) is + dropped from the query instead of being sent as a regexp, which is slow + on large databases; see isMatchAllRegexp. + + - name: isMatchAllRegexp + input_arguments: + - name: value + type_matlab: "any" + type_python: "Any" + output_arguments: + - name: b + type_python: "bool" + decision_log: "Static method. Exact match." - name: daqsystem_clear input_arguments: [] diff --git a/src/ndi/session/session_base.py b/src/ndi/session/session_base.py index ef4f01fc..5ba4ca76 100644 --- a/src/ndi/session/session_base.py +++ b/src/ndi/session/session_base.py @@ -225,7 +225,7 @@ def daqsystem_add(self, dev: Any) -> ndi_session: raise TypeError("dev must be an ndi.daq.ndi_daq_system") # Set the session on the DAQ system - dev = dev.setsession(self) + dev = dev.set_session(self) # Check if already exists sq = dev.searchquery() @@ -287,6 +287,30 @@ def daqsystem_rm(self, dev: Any) -> ndi_session: return self + @staticmethod + def isMatchAllRegexp(value: Any) -> bool: + """Does the regular expression VALUE match every string? + + MATLAB equivalent: ``ndi.session.isMatchAllRegexp`` + + True for the patterns commonly used to mean "any value": ``'(.*)'`` + and ``'.*'``, optionally anchored with ``'^'`` and/or ``'$'``. False + for anything else, including non-text values. Used by + :meth:`daqsystem_load` to drop a ``name`` search that would match + every DAQ system. + """ + if not isinstance(value, str): + return False + v = value + if v.startswith("^"): + v = v[1:] + if v.endswith("$"): + v = v[:-1] + return v in ("(.*)", ".*") + + #: snake_case alias, as for every MATLAB-named method on this class. + is_match_all_regexp = isMatchAllRegexp + def daqsystem_load(self, name: str | None = None, **kwargs) -> list[Any] | Any | None: """ Load DAQ systems from the session. @@ -299,6 +323,13 @@ def daqsystem_load(self, name: str | None = None, **kwargs) -> list[Any] | Any | List of ndi_daq_system objects, single ndi_daq_system if only one, or None if none found. + A ``name`` that matches every name, such as ``'(.*)'`` or ``'.*'``, + is dropped from the search rather than sent to the database as a + regular expression: every DAQ system has a name, so the result is the + same, and a regular-expression search is slow on large databases. + ``daqsystem_load(name='(.*)')`` is therefore the same search as + ``daqsystem_load()``. See :meth:`isMatchAllRegexp`. + Example: >>> all_daqs = session.daqsystem_load(name='(.*)') >>> intan_daq = session.daqsystem_load(name='Intan') @@ -308,8 +339,10 @@ def daqsystem_load(self, name: str | None = None, **kwargs) -> list[Any] | Any | q = ndi_query("").isa("daqsystem") q = q & (ndi_query("base.session_id") == self.id()) - # Add name filter (using regex match for compatibility) - if name is not None: + # Add name filter (using regex match for compatibility). A pattern + # that matches every name is skipped: it cannot narrow the result and + # a regexp search costs as much as the rest of the query again. + if name is not None and not self.isMatchAllRegexp(name): q = q & (ndi_query("base.name").match(name)) # Add other filters @@ -1102,7 +1135,9 @@ def _find_all_dependencies(self, document: ndi_document) -> list[ndi_document]: if self._database is None: return dependents - q = ndi_query("").depends_on("", document.id) + # '*' is "any dependency name", as MATLAB's findalldependencies asks. + # An empty name is not a wildcard in DID's depends_on search. + q = ndi_query("").depends_on("*", document.id) results = self.database_search(q) for doc in results: diff --git a/tests/test_gui_nav_cloud_status_dialog.py b/tests/test_gui_nav_cloud_status_dialog.py new file mode 100644 index 00000000..80d6dd71 --- /dev/null +++ b/tests/test_gui_nav_cloud_status_dialog.py @@ -0,0 +1,99 @@ +"""ndi.gui.nav.cloud_status_dialog against MATLAB's cloudStatusDialog. + +MATLAB counterpart: ``src/ndi/+ndi/+gui/+nav/cloudStatusDialog.m`` (tests: +``tests/+ndi/+unittest/+gui/+nav/cloudStatusDialog_test.m``) + +What the window says is decided by static methods and tested everywhere; +the window itself is built under the offscreen platform and skips without +PySide6. +""" + +from __future__ import annotations + +import os + +import pytest + +from ndi.gui.nav import datasets_cloud as dc +from ndi.gui.nav.cloud_status_dialog import CloudStatusDialog + +D = CloudStatusDialog + + +class TestWhatItSays: + def test_copy_enabled_in_cloud(self): + assert D.copy_enable(True, "abc123") is True + assert D.id_text(True, "abc123") == "abc123" + + def test_copy_disabled_when_not_in_cloud(self): + assert D.copy_enable(False, "") is False + assert "not in NDI Cloud" in D.id_text(False, "") + + def test_copy_disabled_when_the_id_is_missing(self): + # a linked dataset with no id has nothing to copy + assert D.copy_enable(True, "") is False + + def test_status_message(self): + assert "linked to NDI Cloud" in D.status_message(True) + assert "not in NDI Cloud" in D.status_message(False) + + +class FakeDataset: + def __init__(self, in_cloud, cloud_id=""): + self._answer = (in_cloud, cloud_id) + + def is_in_cloud(self): + return self._answer + + +class TestCheckCloudStatusCarriesTheId: + def test_in_cloud(self): + r = dc.check_cloud_status(FakeDataset(True, "668b0539f13096e04f1feccd")) + assert r.state == "incloud" + assert r.cloud_dataset_id == "668b0539f13096e04f1feccd" + + def test_not_in_cloud(self): + r = dc.check_cloud_status(FakeDataset(False)) + assert r.state == "notincloud" + assert r.cloud_dataset_id == "" + + +def _qt_or_skip(): + pytest.importorskip("PySide6") + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + try: + from ndi.gui._qt_helpers import get_or_create_app + + return get_or_create_app() + except Exception as exc: # noqa: BLE001 + pytest.skip(f"no usable Qt platform plugin: {exc}") + + +class TestWindow: + def test_in_cloud(self): + _qt_or_skip() + d = CloudStatusDialog(True, "abc123") + assert d.id_field.text() == "abc123" + assert d.id_field.isReadOnly() + assert d.copy_button.isEnabled() + + def test_not_in_cloud(self): + _qt_or_skip() + d = CloudStatusDialog(False, "") + assert not d.copy_button.isEnabled() + assert "not in NDI Cloud" in d.id_field.text() + + def test_copy_puts_the_id_on_the_clipboard(self): + app = _qt_or_skip() + d = CloudStatusDialog(True, "abc123") + assert d.copy_id() is True + assert app.clipboard().text() == "abc123" + assert d.copy_button.text() == "Copied" + + def test_copy_does_nothing_without_an_id(self): + _qt_or_skip() + assert CloudStatusDialog(False, "").copy_id() is False + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_gui_nav_daq_system_browser.py b/tests/test_gui_nav_daq_system_browser.py new file mode 100644 index 00000000..7af023e6 --- /dev/null +++ b/tests/test_gui_nav_daq_system_browser.py @@ -0,0 +1,247 @@ +"""ndi.gui.nav.daq_system_browser against MATLAB's daqSystemBrowser. + +MATLAB counterpart: ``src/ndi/+ndi/+gui/+nav/daqSystemBrowser.m`` (tests: +``tests/+ndi/+unittest/+gui/+nav/daqSystemBrowser_test.m`` and +``TestDaqSystemBrowserWindow.m``) + +The description and epoch-table logic is plain Python and runs everywhere; +the window tests build it under the offscreen platform and skip without +PySide6, as elsewhere in this package. The window tests use a real session +with one DAQ system, 'intan1', whose epochs are '#.rhd' + '#.epochprobemap.ndi' +file pairs and whose epoch probe map is the '(.*)epochprobemap.ndi' file -- +the session MATLAB's buildSession makes. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace + +import pytest + +from ndi.gui.nav.daq_system_browser import DaqSystemBrowser + +B = DaqSystemBrowser + + +def fake_daq(): + nav = SimpleNamespace( + fileparameters={"filematch": ["#.rhd", "#.epochprobemap.txt"]}, + epochprobemap_class="ndi.epoch.epochprobemap_daqsystem", + epochprobemap_fileparameters={"filematch": ["#.epochprobemap.txt"]}, + ) + return SimpleNamespace(name="intan1", filenavigator=nav, daqreader=None, daqmetadatareaders=[]) + + +class TestDescribe: + def test_filematch_list_forms(self): + assert B.filematch_list(None) == [] + assert B.filematch_list(".*\\.rhd\\>") == [".*\\.rhd\\>"] + assert B.filematch_list(["#.rhd", "#.epochprobemap.txt"]) == [ + "#.rhd", + "#.epochprobemap.txt", + ] + assert B.filematch_list({"filematch": ["a.ext", "b.ext"]}) == ["a.ext", "b.ext"] + assert B.filematch_list({"filematch": []}) == [] + assert B.filematch_list({"other": 1}) == [] + + def test_describe_reads_navigator_parameters(self): + s = B.describe(fake_daq()) + assert s["name"] == "intan1" + assert s["file_parameters"] == ["#.rhd", "#.epochprobemap.txt"] + assert s["epoch_probe_map_class"] == "ndi.epoch.epochprobemap_daqsystem" + assert s["epoch_probe_map_file_parameters"] == ["#.epochprobemap.txt"] + + def test_describe_tolerates_missing_parts(self): + s = B.describe(SimpleNamespace(name="bare")) + assert s["name"] == "bare" + assert s["file_parameters"] == [] + assert s["navigator_class"] == "" + assert s["metadata_readers"] == [] + + def test_describe_none_is_the_template(self): + assert B.describe(None)["name"] == "" + + def test_short_class_name(self): + assert B.short_class_name("ndi.daq.reader.mfdaq.intan") == "intan" + assert B.short_class_name("struct") == "struct" + assert B.short_class_name("") == "" + + def test_epoch_labels(self): + assert B.epoch_labels([1, 2], ["t00001", ""]) == ["Epoch 1 (t00001)", "Epoch 2"] + + def test_detail_items_one_pattern_per_line(self): + items = B.detail_items(B.describe(fake_daq())) + patterns = [v for kind, _, v, _ in items if kind == "pattern"] + assert "#.rhd" in patterns + assert "#.epochprobemap.txt" in patterns + # the default text when no probe map patterns are set + d = fake_daq() + d.filenavigator.epochprobemap_fileparameters = {"filematch": []} + notes = [v for kind, _, v, _ in B.detail_items(B.describe(d)) if kind == "note"] + assert any("default" in n for n in notes) + + +class TestEpochFileRows: + def test_marks_the_probe_map_and_matches_patterns(self, tmp_path): + d = tmp_path / "t00001" + files = [str(d / "reference.txt"), str(d / "data.rhd"), str(d / "grouping.txt")] + patterns = ["reference.txt", ".*\\.rhd\\>", "grouping.txt"] + rows = B.epoch_file_rows(files, patterns, files[2]) + assert [r[0] for r in rows] == ["reference.txt", "data.rhd", "grouping.txt"] + assert rows[0][1] == "reference.txt" + assert rows[1][1] == ".*\\.rhd\\>" + assert rows[2][2] == "epoch probe map" + assert rows[0][2] == "" + + def test_hash_is_a_wildcard(self): + assert B.matching_pattern(["#.rhd"], "rec1.rhd") == "#.rhd" + + def test_default_probe_map_gets_its_own_row(self, tmp_path): + d = tmp_path / "no_such_epoch_dir" + files = [str(d / "data.rhd")] + pm = str(d / ".data.rhd.epochprobemap.ndi") + rows = B.epoch_file_rows(files, [], pm) + assert len(rows) == 2 + assert rows[1][0] == ".data.rhd.epochprobemap.ndi" + assert "default location" in rows[1][2] + assert "not found" in rows[1][2] + + def test_empty(self): + assert "no files" in B.epoch_file_rows([], [], "")[0][0] + + def test_no_match(self): + assert B.matching_pattern(["a.txt"], "b.dat") == "" + assert B.matching_pattern([], "b.dat") == "" + + +# ---------------------------------------------------------------------- +# a real session +# ---------------------------------------------------------------------- +@pytest.fixture +def intan_session(tmp_path): + from ndi.daq.reader.mfdaq import ndi_daq_reader_mfdaq_intan + from ndi.daq.system_mfdaq import ndi_daq_system_mfdaq + from ndi.file.navigator import ndi_file_navigator + from ndi.session import ndi_session_dir + + (tmp_path / "rec1.rhd").write_text("x") + (tmp_path / "rec1.epochprobemap.ndi").write_text( + "name\treference\ttype\tdevicestring\tsubjectstring\n" + ) + session = ndi_session_dir("exp1", str(tmp_path)) + nav = ndi_file_navigator( + session, + ["#.rhd", "#.epochprobemap.ndi"], + "ndi.epoch.epochprobemap_daqsystem", + ["(.*)epochprobemap.ndi"], + ) + session.daqsystem_add(ndi_daq_system_mfdaq("intan1", nav, ndi_daq_reader_mfdaq_intan())) + return session + + +class TestOnARealSession: + """No widgets: what the browser reads and would show.""" + + def test_lists_and_describes_the_daq_system(self, intan_session): + b = DaqSystemBrowser(intan_session, build=False) + assert [s["name"] for s in b.info] == ["intan1"] + s = b.info[0] + assert s["daq_class"] == "ndi.daq.system.mfdaq" + assert s["reader_class"] == "ndi.daq.reader.mfdaq.intan" + assert s["navigator_class"] == "ndi.file.navigator" + assert s["file_parameters"] == ["#.rhd", "#.epochprobemap.ndi"] + assert s["epoch_probe_map_file_parameters"] == ["(.*)epochprobemap.ndi"] + + def test_epochs_are_not_read_until_asked(self, intan_session): + b = DaqSystemBrowser(intan_session, build=False) + assert b.epochs == [None] + + def test_load_epochs_shows_the_files(self, intan_session): + b = DaqSystemBrowser(intan_session, build=False) + epochs = b.load_epochs(0) + assert [e["number"] for e in epochs] == [1] + rows = b.epoch_rows + by_name = {r[0]: r for r in rows} + assert by_name["rec1.rhd"][1] == "#.rhd" + assert by_name["rec1.epochprobemap.ndi"][2] == "epoch probe map" + + def test_choose_epoch(self, intan_session): + b = DaqSystemBrowser(intan_session, build=False) + assert b.choose_epoch(1) is None # not loaded yet + b.load_epochs(0) + assert b.choose_epoch(1) == b.epoch_rows + before = list(b.epoch_rows) + assert b.choose_epoch(99) is None + assert b.epoch_rows == before + + def test_a_session_with_no_daq_systems(self, tmp_path): + from ndi.session import ndi_session_dir + + b = DaqSystemBrowser(ndi_session_dir("empty", str(tmp_path)), build=False) + assert b.info == [] + + +def _qt_or_skip(): + pytest.importorskip("PySide6") + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + try: + from ndi.gui._qt_helpers import get_or_create_app + + return get_or_create_app() + except Exception as exc: # noqa: BLE001 + pytest.skip(f"no usable Qt platform plugin: {exc}") + + +class TestWindow: + def test_the_window_lists_and_describes(self, intan_session): + _qt_or_skip() + b = DaqSystemBrowser(intan_session) + assert b.list.count() == 1 + assert b.list.item(0).text() == "intan1" + assert b.list.currentRow() == 0 + texts = b.detail_texts + assert texts[0] == "intan1" + assert "Files in each epoch" in texts + assert "#.rhd" in texts + assert "(.*)epochprobemap.ndi" in texts + # short class name shown, full name on hover + from PySide6 import QtWidgets + + labels = b.detail_panel.findChildren(QtWidgets.QLabel) + intan = [w for w in labels if w.text() == "intan"] + assert len(intan) == 1 + assert intan[0].toolTip() == "ndi.daq.reader.mfdaq.intan" + assert not b.epoch_dropdown.isEnabled() + + def test_load_epochs_fills_the_chooser_and_table(self, intan_session): + _qt_or_skip() + b = DaqSystemBrowser(intan_session) + b.epoch_button.click() + assert b.epoch_dropdown.isEnabled() + assert b.epoch_dropdown.count() == 1 + assert b.epoch_dropdown.currentText().startswith("Epoch 1") + assert not b.epoch_button.isEnabled() + names = [b.epoch_table.item(r, 0).text() for r in range(b.epoch_table.rowCount())] + assert sorted(names) == ["rec1.epochprobemap.ndi", "rec1.rhd"] + + def test_reselecting_keeps_loaded_epochs(self, intan_session): + _qt_or_skip() + b = DaqSystemBrowser(intan_session) + b.load_epochs() + before = list(b.epoch_rows) + b.select(0) + assert b.epoch_dropdown.isEnabled() + assert b.epoch_rows == before + + def test_no_daq_systems(self, tmp_path): + _qt_or_skip() + from ndi.session import ndi_session_dir + + b = DaqSystemBrowser(ndi_session_dir("empty", str(tmp_path))) + assert not b.list.isEnabled() + assert b.detail_texts == ["This session has no DAQ systems."] + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_gui_nav_datasets_model.py b/tests/test_gui_nav_datasets_model.py index 396d8f1b..d77deadf 100644 --- a/tests/test_gui_nav_datasets_model.py +++ b/tests/test_gui_nav_datasets_model.py @@ -553,5 +553,34 @@ def test_a_dataset_with_no_session_is_not_in_cloud(self): assert _DatasetBase().is_in_cloud() == (False, "") +class TestUniqueByIdAndWorkspaceAssignment: + def test_unique_by_id_keeps_the_first_of_each_id(self): + from types import SimpleNamespace + + from ndi.gui.nav.datasets_model import unique_by_id + + a, b, a2 = (SimpleNamespace(id=i) for i in ("a", "b", "a")) + nameless = SimpleNamespace(id="") + assert unique_by_id([a, b, a2, nameless, nameless]) == [a, b, nameless, nameless] + + def test_assign_into_a_namespace(self): + from ndi.gui.nav.datasets_model import assign_workspace_var, workspace_has_var + + ns: dict = {} + assign_workspace_var("D", 5, ns) + assert ns == {"D": 5} + assert workspace_has_var("D", ns) + assert not workspace_has_var("S", ns) + + def test_workspace_name_error(self): + from ndi.gui.nav.datasets_text import workspace_name_error + + for good in ("D", "S", "my_dataset2"): + assert workspace_name_error(good) == "" + assert "required" in workspace_name_error("") + for bad in ("2D", "my dataset", "for"): + assert "not a valid Python variable name" in workspace_name_error(bad) + + if __name__ == "__main__": pytest.main([__file__]) diff --git a/tests/test_gui_nav_datasets_pane.py b/tests/test_gui_nav_datasets_pane.py index fde57575..a28765f4 100644 --- a/tests/test_gui_nav_datasets_pane.py +++ b/tests/test_gui_nav_datasets_pane.py @@ -526,7 +526,11 @@ def test_a_session_node_gets_apps_and_session_submenus(self): pane.populate_tree() node = pane.tree.topLevelItem(0).child(0) menu = pane.build_node_menu(node) - assert [a.text() for a in menu.actions()] == ["Apps", "Session"] + assert [a.text() for a in menu.actions()] == [ + "Apps", + "Assign to workspace...", + "Session", + ] def test_the_apps_menu_is_present_even_with_no_apps_to_offer(self, monkeypatch): """Empty says 'no apps found'. Omitting it would say 'sessions have @@ -626,15 +630,22 @@ def test_the_session_menu_items_are_alphabetical(self): pane.user_sessions = [FakeSession("a", path="/s/a")] pane.populate_tree() menu = pane.build_node_menu(pane.tree.topLevelItem(0).child(0)) - items = [a.text() for a in menu.actions()[1].menu().actions()] - assert items == ["Clear Cache", "Info...", "Ingest", "Ingestion Status"] + items = [a.text() for a in menu.actions()[2].menu().actions()] + assert items == [ + "Clear Cache", + "DAQ Systems...", + "Info...", + "Ingest", + "Ingestion Status", + ] def test_a_dataset_node_gets_the_cloud_menu_in_matlabs_order(self): _qt_or_skip() nav, pane = _built_pane(user_datasets=[FakeDataset("d")]) pane.populate_tree() menu = pane.build_node_menu(pane.dataset_nodes()[0]) - cloud = menu.actions()[0].menu() + assert [a.text() for a in menu.actions()] == ["Assign to workspace...", "Cloud"] + cloud = menu.actions()[1].menu() labels = [a.text() for a in cloud.actions() if a.text()] assert labels == [ "Check Cloud status", @@ -1010,6 +1021,103 @@ def test_an_empty_catalogue_is_two_empty_lists(self, monkeypatch): assert fetch_cloud_datasets(True) == ([], []) +class TestAssignToWorkspace: + """ "Assign to workspace..." on dataset and session nodes. + + The workspace is a throwaway module standing in for ``__main__``, and the + classes the workspace scan looks for are the fakes, so the labels can be + checked without a real session. + """ + + @pytest.fixture + def workspace(self, monkeypatch): + import sys + import types + + from ndi.gui.nav import datasets_model + + module = types.ModuleType("_ndi_test_workspace") + monkeypatch.setitem(sys.modules, module.__name__, module) + monkeypatch.setattr(datasets_model, "WORKSPACE_MODULE", module.__name__) + monkeypatch.setattr( + "ndi.gui.nav.datasets_pane._model_classes", lambda: (FakeSession, FakeDataset) + ) + return module.__dict__ + + def test_assigns_a_dataset_and_relabels_in_place(self, workspace): + _qt_or_skip() + ds = FakeDataset("d", sessions=[("refA", "idA")]) + nav, pane = _built_pane(user_datasets=[ds]) + pane.populate_tree() + node = pane.dataset_nodes()[0] + assert pane.assign_to_workspace(ds, "D", node, name="D") == "D" + assert workspace["D"] is ds + assert node.text(0) == 'ds-d "D"' + + def test_assigns_a_session_opened_from_its_dataset(self, workspace, monkeypatch): + _qt_or_skip() + ds = FakeDataset("d", sessions=[("refA", "idA")]) + nav, pane = _built_pane(user_datasets=[ds]) + pane.populate_tree() + child = pane.dataset_nodes()[0].child(0) + monkeypatch.setattr(pane, "_ask_text", lambda p, t, default="": default) + assert pane.assign_session_to_workspace(child) == "S" + assert workspace["S"].id() == "idA" + assert child.text(0) == 'refA "S"' + + def test_a_bad_name_is_refused(self, workspace): + _qt_or_skip() + ds = FakeDataset("d") + nav, pane = _built_pane(user_datasets=[ds]) + assert pane.assign_to_workspace(ds, "D", None, name="2D") is None + assert "2D" not in workspace + assert any("not a valid Python variable name" in m for m, _, _ in nav.alerts) + + def test_replacing_needs_confirmation(self, workspace, monkeypatch): + _qt_or_skip() + ds = FakeDataset("d") + nav, pane = _built_pane(user_datasets=[ds]) + workspace["D"] = "keep me" + monkeypatch.setattr(pane, "_confirm", lambda *a, **k: False) + assert pane.assign_to_workspace(ds, "D", None, name="D") is None + assert workspace["D"] == "keep me" + monkeypatch.setattr(pane, "_confirm", lambda *a, **k: True) + assert pane.assign_to_workspace(ds, "D", None, name="D") == "D" + assert workspace["D"] is ds + + def test_cancel_assigns_nothing(self, workspace, monkeypatch): + _qt_or_skip() + ds = FakeDataset("d") + nav, pane = _built_pane(user_datasets=[ds]) + monkeypatch.setattr(pane, "_ask_text", lambda p, t, default="": None) + assert pane.assign_to_workspace(ds, "D") is None + assert "D" not in workspace + + +class TestCheckCloudStatusWindow: + def test_the_status_window_shows_the_cloud_id(self, monkeypatch): + _qt_or_skip() + ds = FakeDataset("d", in_cloud=True) + nav, pane = _built_pane(user_datasets=[ds]) + pane.populate_tree() + node = pane.dataset_nodes()[0] + result = pane._run_cloud(node, "check_status", ds) + assert result.cloud_dataset_id == "cid" + dialog = pane.open_windows[-1] + assert dialog.id_field.text() == "cid" + assert dialog.copy_button.isEnabled() + assert node.data(0, 32)["cloud"] == "incloud" + assert nav.alerts == [] # the window replaces the alert + + def test_not_in_cloud_greys_out_copy(self): + _qt_or_skip() + ds = FakeDataset("d", in_cloud=False) + nav, pane = _built_pane(user_datasets=[ds]) + pane.populate_tree() + pane._run_cloud(pane.dataset_nodes()[0], "check_status", ds) + assert not pane.open_windows[-1].copy_button.isEnabled() + + def _raise(message): def boom(*args, **kwargs): raise RuntimeError(message) diff --git a/tests/test_session_daqsystem_load_name.py b/tests/test_session_daqsystem_load_name.py new file mode 100644 index 00000000..588fa02d --- /dev/null +++ b/tests/test_session_daqsystem_load_name.py @@ -0,0 +1,74 @@ +"""daqsystem_load name searches, against MATLAB's ndi.session/daqsystem_load. + +MATLAB counterpart: ``src/ndi/+ndi/session.m`` (``daqsystem_load``, +``isMatchAllRegexp``; tests: ``tests/+ndi/+unittest/+session/TestDaqsystemLoadName.m``) + +A ``name`` that matches every name (``'(.*)'``, ``'.*'``) is dropped from the +database search -- a regexp search is slow on large databases and cannot +narrow the result -- so it must return the same DAQ systems as no name at +all, while a specific name still filters. + +Building the session also exercises ``daqsystem_add``, which called +``dev.setsession`` -- a method the Python DAQ system does not have (it is +``set_session``) -- so adding any DAQ system raised AttributeError. +""" + +from __future__ import annotations + +import pytest + +from ndi.session.session_base import ndi_session + + +class TestIsMatchAllRegexp: + @pytest.mark.parametrize("value", ["(.*)", ".*", "^(.*)$", "^.*", ".*$"]) + def test_match_all(self, value): + assert ndi_session.isMatchAllRegexp(value) is True + + @pytest.mark.parametrize("value", ["intan1", "(.+)", "a.*", "", 5, None, ["(.*)"]]) + def test_not_match_all(self, value): + assert ndi_session.isMatchAllRegexp(value) is False + + +@pytest.fixture +def session(tmp_path): + from ndi.daq.reader.mfdaq import ndi_daq_reader_mfdaq_intan + from ndi.daq.system_mfdaq import ndi_daq_system_mfdaq + from ndi.file.navigator import ndi_file_navigator + from ndi.session import ndi_session_dir + + (tmp_path / "rec1.rhd").write_text("x") + s = ndi_session_dir("exp1", str(tmp_path)) + for name in ("intan1", "intan2"): + nav = ndi_file_navigator(s, ["#.rhd"]) + s.daqsystem_add(ndi_daq_system_mfdaq(name, nav, ndi_daq_reader_mfdaq_intan())) + return s + + +def _ids(daqs): + if daqs is None: + return [] + daqs = daqs if isinstance(daqs, list) else [daqs] + return sorted(d.id for d in daqs) + + +class TestDaqsystemLoadName: + def test_match_all_name_equals_no_name(self, session): + everything = session.daqsystem_load() + assert len(_ids(everything)) == 2 + assert _ids(session.daqsystem_load(name="(.*)")) == _ids(everything) + assert _ids(session.daqsystem_load(name=".*")) == _ids(everything) + + def test_a_specific_name_still_filters(self, session): + d = session.daqsystem_load(name="intan1") + assert d.name == "intan1" + assert session.daqsystem_load(name="no_such_daqsystem") is None + + def test_a_match_all_name_sends_no_regexp(self, session, monkeypatch): + seen = [] + real = session.database_search + monkeypatch.setattr( + session, "database_search", lambda q: seen.append(q.to_search_structure()) or real(q) + ) + session.daqsystem_load(name="(.*)") + assert "regexp" not in repr(seen[0])