From 5184705e345ceccd59b159f0b11494e6ddfdf417 Mon Sep 17 00:00:00 2001 From: Victor Morand Date: Wed, 29 Jul 2026 11:43:30 +0200 Subject: [PATCH 1/2] feat: sorting by Alphabetical order --- docs/source/interfaces.md | 2 + src/experimaestro/tests/test_tui_sorting.py | 76 ++++++++++++++++++++ src/experimaestro/tui/dialogs.py | 2 + src/experimaestro/tui/widgets/experiments.py | 30 ++++++-- src/experimaestro/tui/widgets/jobs.py | 21 ++++++ 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 src/experimaestro/tests/test_tui_sorting.py diff --git a/docs/source/interfaces.md b/docs/source/interfaces.md index e44da6b2..26de2d29 100644 --- a/docs/source/interfaces.md +++ b/docs/source/interfaces.md @@ -107,6 +107,7 @@ Press `?` in the TUI to show the help screen with all shortcuts. | `Ctrl+k` | Kill all running jobs | | `S` | Sort by status | | `D` | Sort by date | +| `O` | Sort alphabetically | **Jobs:** @@ -120,6 +121,7 @@ Press `?` in the TUI to show the help screen with all shortcuts. | `S` | Sort by status | | `T` | Sort by task | | `D` | Sort by date | +| `O` | Sort alphabetically | | `f` | Copy folder path | **Log Viewer:** diff --git a/src/experimaestro/tests/test_tui_sorting.py b/src/experimaestro/tests/test_tui_sorting.py new file mode 100644 index 00000000..7bae5ba8 --- /dev/null +++ b/src/experimaestro/tests/test_tui_sorting.py @@ -0,0 +1,76 @@ +"""Tests for TUI sorting keybindings and logic.""" + +from dataclasses import dataclass +from typing import Optional +from experimaestro.tui.widgets.experiments import ExperimentsList +from experimaestro.tui.widgets.jobs import JobsTable + + +@dataclass +class DummyExperiment: + experiment_id: str + started_at: Optional[object] = None + failed_jobs: int = 0 + total_jobs: int = 0 + finished_jobs: int = 0 + + +@dataclass +class DummyJob: + identifier: str + task_id: str = "task_a" + + +def test_experiments_alphabetical_sorting(): + exps = [ + DummyExperiment("zebra_exp"), + DummyExperiment("Alpha_exp"), + DummyExperiment("beta_exp"), + ] + + # Standard sorting by ID ascending (case-insensitive) + sorted_asc = sorted(exps, key=lambda e: (e.experiment_id or "").lower()) + assert [e.experiment_id for e in sorted_asc] == [ + "Alpha_exp", + "beta_exp", + "zebra_exp", + ] + + # Sorting by ID descending + sorted_desc = sorted( + exps, key=lambda e: (e.experiment_id or "").lower(), reverse=True + ) + assert [e.experiment_id for e in sorted_desc] == [ + "zebra_exp", + "beta_exp", + "Alpha_exp", + ] + + +def test_jobs_alphabetical_sorting(): + jobs = [ + DummyJob("job_z99"), + DummyJob("job_a01"), + DummyJob("job_M50"), + ] + + sorted_asc = sorted(jobs, key=lambda j: (j.identifier or "").lower()) + assert [j.identifier for j in sorted_asc] == ["job_a01", "job_M50", "job_z99"] + + sorted_desc = sorted( + jobs, key=lambda j: (j.identifier or "").lower(), reverse=True + ) + assert [j.identifier for j in sorted_desc] == ["job_z99", "job_M50", "job_a01"] + + +def test_sort_column_mappings_and_bindings(): + assert ExperimentsList.SORTABLE_COLUMNS.get("id") == "id" + assert JobsTable.SORTABLE_COLUMNS.get("job_id") == "job_id" + + exp_binding_keys = [b.key for b in ExperimentsList.BINDINGS] + assert "O" in exp_binding_keys + assert "S" in exp_binding_keys + + job_binding_keys = [b.key for b in JobsTable.BINDINGS] + assert "O" in job_binding_keys + assert "S" in job_binding_keys diff --git a/src/experimaestro/tui/dialogs.py b/src/experimaestro/tui/dialogs.py index 8bab00e8..2370eb66 100644 --- a/src/experimaestro/tui/dialogs.py +++ b/src/experimaestro/tui/dialogs.py @@ -133,6 +133,7 @@ def compose(self) -> ComposeResult: Ctrl+k Kill all running jobs S Sort by status D Sort by date + O Sort alphabetically [bold cyan]Jobs[/bold cyan] l View job logs @@ -143,6 +144,7 @@ def compose(self) -> ComposeResult: S Sort by status T Sort by task D Sort by date + O Sort alphabetically f Copy folder path t Toggle tree view g Cycle tree grouping (in tree view) diff --git a/src/experimaestro/tui/widgets/experiments.py b/src/experimaestro/tui/widgets/experiments.py index 531454b9..ca346fbf 100644 --- a/src/experimaestro/tui/widgets/experiments.py +++ b/src/experimaestro/tui/widgets/experiments.py @@ -28,11 +28,12 @@ class ExperimentsList(Widget): BINDINGS = [ Binding("d", "show_runs", "Runs"), - Binding("f", "copy_path", "Copy Path", show=False), - Binding("ctrl+d", "delete_experiment", "Delete", show=False), - Binding("ctrl+k", "kill_experiment", "Kill", show=False), - Binding("S", "sort_by_status", "Sort ⚑", show=False), - Binding("D", "sort_by_date", "Sort Date", show=False), + Binding("f", "copy_path", "Copy Path", show=True), + Binding("ctrl+d", "delete_experiment", "Delete", show=True), + Binding("ctrl+k", "kill_experiment", "Kill", show=True), + Binding("S", "sort_by_status", "Sort ⚑", show=True), + Binding("D", "sort_by_date", "Sort Date", show=True), + Binding("O", "sort_alphabetical", "Sort Name", show=True), ] current_experiment: reactive[Optional[str]] = reactive(None) @@ -65,6 +66,7 @@ class ExperimentsList(Widget): # Columns that support sorting (column key -> sort column name) SORTABLE_COLUMNS = { + "id": "id", "status": "status", "started": "started", } @@ -155,6 +157,18 @@ def action_sort_by_date(self) -> None: order = "newest first" if self._sort_reverse else "oldest first" self.notify(f"Sorted by date ({order})", severity="information") + def action_sort_alphabetical(self) -> None: + """Sort experiments alphabetically by ID""" + if self._sort_column == "id": + self._sort_reverse = not self._sort_reverse + else: + self._sort_column = "id" + self._sort_reverse = False + self._update_column_headers() + self.refresh_experiments() + order = "desc" if self._sort_reverse else "asc" + self.notify(f"Sorted alphabetically ({order})", severity="information") + @staticmethod def _format_experiment_status(exp) -> str: """Format experiment status for display. @@ -353,6 +367,12 @@ def _on_experiments_loaded( # noqa: C901 key=lambda e: e.started_at or datetime.min, reverse=self._sort_reverse, ) + elif self._sort_column == "id": + # Sort alphabetically by experiment ID + experiments_sorted.sort( + key=lambda e: (e.experiment_id or "").lower(), + reverse=self._sort_reverse, + ) # Default: no sorting, use order from state provider # Get existing row keys diff --git a/src/experimaestro/tui/widgets/jobs.py b/src/experimaestro/tui/widgets/jobs.py index dcb624fe..8165602a 100644 --- a/src/experimaestro/tui/widgets/jobs.py +++ b/src/experimaestro/tui/widgets/jobs.py @@ -1128,6 +1128,7 @@ class JobsTable(Vertical): Binding("S", "sort_by_status", "Sort ⚑", show=False), Binding("T", "sort_by_task", "Sort Task", show=False), Binding("D", "sort_by_submitted", "Sort Date", show=False), + Binding("O", "sort_alphabetical", "Sort Name", show=False), Binding("t", "toggle_tree_view", "Tree"), Binding("escape", "clear_search", show=False, priority=True), ] @@ -1261,6 +1262,19 @@ def action_sort_by_submitted(self) -> None: order = "newest first" if self._sort_reverse else "oldest first" self.notify(f"Sorted by date ({order})", severity="information") + def action_sort_alphabetical(self) -> None: + """Sort jobs alphabetically by ID""" + if self._sort_column == "job_id": + self._sort_reverse = not self._sort_reverse + else: + self._sort_column = "job_id" + self._sort_reverse = False + self._needs_rebuild = True + self._update_column_headers() + self.refresh_jobs() + order = "desc" if self._sort_reverse else "asc" + self.notify(f"Sorted alphabetically ({order})", severity="information") + def action_clear_search(self) -> None: """Handle escape: hide search bar if visible, or go back""" search_bar = self.query_one(SearchBar) @@ -1445,6 +1459,7 @@ def _get_status_sort_key(self, job): # Columns that support sorting (column key -> sort column name) SORTABLE_COLUMNS = { + "job_id": "job_id", "status": "status", "task": "task", "submitted": "submitted", @@ -1634,6 +1649,12 @@ def _refresh_jobs_with_data(self, jobs: list) -> None: # noqa: C901 key=lambda j: j.task_id or "", reverse=self._sort_reverse, ) + elif self._sort_column == "job_id": + # Sort alphabetically by job ID + jobs.sort( + key=lambda j: (j.identifier or "").lower(), + reverse=self._sort_reverse, + ) else: # Default: sort by submission time (oldest first by default) # Use experiment_job_info timestamp for submittime From 49e88103b8b81c6df2ddea24c5db1b586e0ab53a Mon Sep 17 00:00:00 2001 From: Victor Morand Date: Wed, 29 Jul 2026 11:56:15 +0200 Subject: [PATCH 2/2] feat: make jobs/xps sorting non blocking in TUI --- scripts/make-app-icon.py | 1 - src/experimaestro/tests/test_tui_sorting.py | 31 ++++++++++++++++++ src/experimaestro/tui/widgets/experiments.py | 22 ++++++++----- src/experimaestro/tui/widgets/jobs.py | 34 +++++++++++--------- 4 files changed, 64 insertions(+), 24 deletions(-) diff --git a/scripts/make-app-icon.py b/scripts/make-app-icon.py index d93a682c..9f632406 100644 --- a/scripts/make-app-icon.py +++ b/scripts/make-app-icon.py @@ -43,7 +43,6 @@ from __future__ import annotations import argparse -import colorsys import re import subprocess import sys diff --git a/src/experimaestro/tests/test_tui_sorting.py b/src/experimaestro/tests/test_tui_sorting.py index 7bae5ba8..71df41b9 100644 --- a/src/experimaestro/tests/test_tui_sorting.py +++ b/src/experimaestro/tests/test_tui_sorting.py @@ -74,3 +74,34 @@ def test_sort_column_mappings_and_bindings(): job_binding_keys = [b.key for b in JobsTable.BINDINGS] assert "O" in job_binding_keys assert "S" in job_binding_keys + + +def test_apply_sort_in_memory(monkeypatch): + """Verify apply_sort uses cached data when available instead of triggering refresh""" + exp_list = ExperimentsList.__new__(ExperimentsList) + exp_list.experiments = [DummyExperiment("b"), DummyExperiment("a")] + exp_list._runs_counts = {} + exp_list._sort_column = "id" + exp_list._sort_reverse = False + + refresh_called = False + + def fake_refresh(): + nonlocal refresh_called + refresh_called = True + + exp_list.refresh_experiments = fake_refresh + exp_list._update_column_headers = lambda: None + + loaded_exps = [] + + def fake_on_loaded(exps, counts): + nonlocal loaded_exps + loaded_exps = exps + + exp_list._on_experiments_loaded = fake_on_loaded + + exp_list.apply_sort() + assert not refresh_called + assert loaded_exps == exp_list.experiments + diff --git a/src/experimaestro/tui/widgets/experiments.py b/src/experimaestro/tui/widgets/experiments.py index ca346fbf..710a29b3 100644 --- a/src/experimaestro/tui/widgets/experiments.py +++ b/src/experimaestro/tui/widgets/experiments.py @@ -75,6 +75,7 @@ def __init__(self, state_provider: StateProvider) -> None: super().__init__() self.state_provider = state_provider self.experiments = [] + self._runs_counts: dict[str, str] = {} # Cache for fast in-memory re-sorting def _get_selected_experiment_id(self) -> Optional[str]: """Get the experiment ID from the currently selected row""" @@ -133,6 +134,14 @@ def action_show_runs(self) -> None: ) self.post_message(ShowRunsRequest(exp_id, current_run_id)) + def apply_sort(self) -> None: + """Apply current sort settings in-memory immediately if cached data exists""" + self._update_column_headers() + if self.experiments: + self._on_experiments_loaded(self.experiments, self._runs_counts) + else: + self.refresh_experiments() + def action_sort_by_status(self) -> None: """Sort experiments by status""" if self._sort_column == "status": @@ -140,8 +149,7 @@ def action_sort_by_status(self) -> None: else: self._sort_column = "status" self._sort_reverse = False - self._update_column_headers() - self.refresh_experiments() + self.apply_sort() order = "desc" if self._sort_reverse else "asc" self.notify(f"Sorted by status ({order})", severity="information") @@ -152,8 +160,7 @@ def action_sort_by_date(self) -> None: else: self._sort_column = "started" self._sort_reverse = True # Default to newest first for date - self._update_column_headers() - self.refresh_experiments() + self.apply_sort() order = "newest first" if self._sort_reverse else "oldest first" self.notify(f"Sorted by date ({order})", severity="information") @@ -164,8 +171,7 @@ def action_sort_alphabetical(self) -> None: else: self._sort_column = "id" self._sort_reverse = False - self._update_column_headers() - self.refresh_experiments() + self.apply_sort() order = "desc" if self._sort_reverse else "asc" self.notify(f"Sorted alphabetically ({order})", severity="information") @@ -259,8 +265,7 @@ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None self._sort_column = sort_col # Default to reverse for date (newest first) self._sort_reverse = sort_col == "started" - self._update_column_headers() - self.refresh_experiments() + self.apply_sort() def compose(self) -> ComposeResult: # Collapsed header (hidden initially) @@ -344,6 +349,7 @@ def _on_experiments_loaded( # noqa: C901 pass self.experiments = experiments + self._runs_counts = runs_counts try: table = self.query_one("#experiments-table", DataTable) diff --git a/src/experimaestro/tui/widgets/jobs.py b/src/experimaestro/tui/widgets/jobs.py index 8165602a..6dd1b226 100644 --- a/src/experimaestro/tui/widgets/jobs.py +++ b/src/experimaestro/tui/widgets/jobs.py @@ -1153,6 +1153,7 @@ def __init__(self, state_provider: StateProvider) -> None: self.task_id_map: dict[str, str] = {} # job_id -> task_id self.experiment_job_info: dict = {} # job_id -> ExperimentJobInformation self._current_experiment_obj = None # BaseExperiment object for resolved state + self._cached_jobs: Optional[list] = None # Cache for fast in-memory re-sorting def compose(self) -> ComposeResult: yield Static("", id="past-run-banner", classes="hidden") @@ -1223,6 +1224,18 @@ def action_clear_filter(self) -> None: self.refresh_jobs() self.notify("Filter cleared", severity="information") + def apply_sort(self) -> None: + """Apply current sort settings in-memory immediately if cached data exists""" + self._needs_rebuild = True + self._update_column_headers() + if self._cached_jobs is not None: + if self._tree_mode: + self._update_tree_view() + else: + self._refresh_jobs_with_data(self._cached_jobs) + else: + self.refresh_jobs() + def action_sort_by_status(self) -> None: """Sort jobs by status""" if self._sort_column == "status": @@ -1230,9 +1243,7 @@ def action_sort_by_status(self) -> None: else: self._sort_column = "status" self._sort_reverse = False - self._needs_rebuild = True - self._update_column_headers() - self.refresh_jobs() + self.apply_sort() order = "desc" if self._sort_reverse else "asc" self.notify(f"Sorted by status ({order})", severity="information") @@ -1243,9 +1254,7 @@ def action_sort_by_task(self) -> None: else: self._sort_column = "task" self._sort_reverse = False - self._needs_rebuild = True - self._update_column_headers() - self.refresh_jobs() + self.apply_sort() order = "desc" if self._sort_reverse else "asc" self.notify(f"Sorted by task ({order})", severity="information") @@ -1256,9 +1265,7 @@ def action_sort_by_submitted(self) -> None: else: self._sort_column = "submitted" self._sort_reverse = False - self._needs_rebuild = True - self._update_column_headers() - self.refresh_jobs() + self.apply_sort() order = "newest first" if self._sort_reverse else "oldest first" self.notify(f"Sorted by date ({order})", severity="information") @@ -1269,9 +1276,7 @@ def action_sort_alphabetical(self) -> None: else: self._sort_column = "job_id" self._sort_reverse = False - self._needs_rebuild = True - self._update_column_headers() - self.refresh_jobs() + self.apply_sort() order = "desc" if self._sort_reverse else "asc" self.notify(f"Sorted alphabetically ({order})", severity="information") @@ -1504,9 +1509,7 @@ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None else: self._sort_column = sort_col self._sort_reverse = False - self._needs_rebuild = True - self._update_column_headers() - self.refresh_jobs() + self.apply_sort() def set_experiment( self, @@ -1597,6 +1600,7 @@ def _on_data_loaded( if experiment_job_info is not None: self.experiment_job_info = experiment_job_info self._current_experiment_obj = experiment_obj + self._cached_jobs = jobs # Refresh display with loaded jobs if self._tree_mode: