Skip to content

Commit 3828536

Browse files
AdityaGupta716pre-commit-ci[bot]JoeZiminski
authored
Add waiting screen to validate and centralise CentralWaitingScreen (#693)
* Fix: Centralise waiting screen into CentralWaitingScreen, add validate waiting screen (Fixes #604) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix: mypy type errors in validate_content.py * Fix: save asyncio task reference and fix Worker return type * Refactor: replace setattr call_from_thread with _update_logs_label method * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Small updates. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix css. * Fix linting. * Fix waiting. * Remove unused log label. * Store asyncio to prevent (unlikely? impossible?) garbage collection. * Fix tests. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: JoeZiminski <joseph.j.ziminski@gmail.com>
1 parent 5ec53c4 commit 3828536

5 files changed

Lines changed: 116 additions & 38 deletions

File tree

datashuttle/tui/css/tui_menu.tcss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ ConfirmAndAwaitTransferPopup {
551551

552552
/* Suggest next subject / session loading pop up --------------------------------------------------- */
553553

554-
SearchingCentralForNextSubSesPopup {
554+
CentralWaitingScreen {
555555
align: center middle;
556556
}
557557

datashuttle/tui/screens/modal_dialogs.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from textual.worker import Worker
1212

1313
from datashuttle.tui.app import TuiApp
14-
from datashuttle.utils.custom_types import InterfaceOutput, Prefix
14+
from datashuttle.utils.custom_types import InterfaceOutput
1515

1616
import platform
1717
from pathlib import Path
@@ -281,24 +281,27 @@ async def handle_transfer_and_update_ui_when_complete(self) -> None:
281281
self.app.show_modal_error_dialog(str(e))
282282

283283

284-
class SearchingCentralForNextSubSesPopup(ModalScreen):
285-
"""Show message and a loading indicator for suggesting the next subject or session.
284+
class CentralWaitingScreen(ModalScreen):
285+
"""Show a message and loading indicator while awaiting a slow central connection.
286286
287-
Used to await searching next sub/ses across including folders
288-
present on the central machine. This search happens in a separate
289-
thread to allow TUI to display the loading indicate without freezing.
290-
291-
Only displayed when the `include_central` flag is checked and the
292-
connection method is "ssh".
287+
Used when operations require network access (ssh, aws, gdrive), such as
288+
suggesting the next sub/ses or validating the central project.
293289
"""
294290

295-
def __init__(self, sub_or_ses: Prefix) -> None:
296-
"""Initialise SearchingCentralForNextSubSesPopup."""
291+
def __init__(self, message: str) -> None:
292+
"""Initialise CentralWaitingScreen.
293+
294+
Parameters
295+
----------
296+
message
297+
Message to display while waiting.
298+
299+
"""
297300
super().__init__()
298-
self.message = f"Searching central for next {sub_or_ses}"
301+
self.message = message
299302

300303
def compose(self) -> ComposeResult:
301-
"""Add widgets to SearchingCentralForNextSubSesPopup."""
304+
"""Add widgets to CentralWaitingScreen."""
302305
yield Container(
303306
Label(self.message, id="searching_message_label"),
304307
LoadingIndicator(id="searching_animated_indicator"),

datashuttle/tui/shared/validate_content.py

Lines changed: 81 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
from __future__ import annotations
22

33
import platform
4-
from typing import TYPE_CHECKING, Optional, Union, cast
4+
from typing import TYPE_CHECKING, Optional, Union
55

66
if TYPE_CHECKING:
77
from textual.app import ComposeResult
8+
from textual.worker import Worker
89

910
from datashuttle.tui.interface import Interface
1011
from datashuttle.tui.screens.project_manager import ProjectManagerScreen
1112

1213
from pathlib import Path
1314

15+
from textual import work
1416
from textual.containers import Container, Horizontal
1517
from textual.widgets import (
1618
Button,
@@ -64,6 +66,9 @@ def __init__(
6466

6567
self.parent_class = parent_class
6668
self.interface = interface
69+
self.validating_central_popup: (
70+
modal_dialogs.CentralWaitingScreen | None
71+
) = None
6772

6873
def compose(self) -> ComposeResult:
6974
"""Set up the widgets for the container."""
@@ -108,7 +113,6 @@ def compose(self) -> ComposeResult:
108113
id="validate_arguments_horizontal",
109114
),
110115
RichLog(highlight=True, markup=True, id="validate_richlog"),
111-
Label("", id="validate_logs_label"),
112116
Button("Validate", id="validate_validate_button"),
113117
]
114118

@@ -150,6 +154,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
150154
)
151155

152156
elif event.button.id == "validate_validate_button":
157+
# Get settings from widgets
153158
select_value = self.query_one(
154159
"#validate_top_level_folder_select"
155160
).value
@@ -165,28 +170,33 @@ def on_button_pressed(self, event: Button.Pressed) -> None:
165170
).value
166171

167172
if self.interface:
173+
# If we are in a project, and it has a central storage we
174+
# will connect and, if it's a slow connection, show a waiting screen
168175
if self.interface.project.is_local_project():
169176
include_central = False
170177
else:
171178
include_central = self.query_one(
172179
"#validate_include_central_checkbox"
173180
).value
174181

175-
success, output = self.interface.validate_project(
182+
if include_central and self.interface.project.cfg[
183+
"connection_method"
184+
] in ["aws", "gdrive", "ssh"]:
185+
self.validating_central_popup = (
186+
modal_dialogs.CentralWaitingScreen(
187+
"Validating central project..."
188+
)
189+
)
190+
self.parent_class.mainwindow.push_screen(
191+
self.validating_central_popup
192+
)
193+
194+
self.run_validate_and_dismiss_popup(
176195
top_level_folder=top_level_folder,
177196
include_central=include_central,
178197
strict_mode=strict_mode,
179198
allow_letters_in_sub_ses_values=allow_letters_in_sub_ses_values,
180199
)
181-
if not success:
182-
self.parent_class.mainwindow.show_modal_error_dialog(
183-
cast("str", output)
184-
)
185-
else:
186-
self.write_results_to_richlog(output)
187-
self.query_one(
188-
"#validate_logs_label"
189-
).value = f"Logs output to: {self.interface.project.get_logging_path()}"
190200
else:
191201
path_ = self.query_one("#validate_path_input").value
192202

@@ -215,6 +225,65 @@ def set_select_path(self, path_):
215225
if path_:
216226
self.query_one("#validate_path_input").value = path_.as_posix()
217227

228+
@work(group="validate_async", exclusive=True)
229+
async def run_validate_and_dismiss_popup(
230+
self,
231+
top_level_folder,
232+
include_central,
233+
strict_mode,
234+
allow_letters_in_sub_ses_values,
235+
) -> None:
236+
"""Run validation in a worker thread and dismiss the waiting popup when done.
237+
238+
Decorated with ``@work`` so Textual owns the task lifecycle (no need to
239+
hold an ``asyncio.Task`` reference to prevent premature GC), and so a
240+
repeat button-press cancels the previous in-flight invocation via
241+
``exclusive=True``.
242+
"""
243+
worker = self.validate_project_worker(
244+
top_level_folder=top_level_folder,
245+
include_central=include_central,
246+
strict_mode=strict_mode,
247+
allow_letters_in_sub_ses_values=allow_letters_in_sub_ses_values,
248+
)
249+
await worker.wait()
250+
if self.validating_central_popup:
251+
self._hide_validating_central_popup()
252+
253+
@work(exclusive=True, thread=True)
254+
def validate_project_worker(
255+
self,
256+
top_level_folder,
257+
include_central,
258+
strict_mode,
259+
allow_letters_in_sub_ses_values,
260+
) -> Worker[None]:
261+
"""Run validation in a separate thread to avoid freezing the TUI."""
262+
assert self.interface is not None
263+
264+
success, output = self.interface.validate_project(
265+
top_level_folder=top_level_folder,
266+
include_central=include_central,
267+
strict_mode=strict_mode,
268+
allow_letters_in_sub_ses_values=allow_letters_in_sub_ses_values,
269+
)
270+
271+
if not success:
272+
if self.validating_central_popup:
273+
self.app.call_from_thread(
274+
self._hide_validating_central_popup,
275+
)
276+
self.app.call_from_thread(
277+
self.parent_class.mainwindow.show_modal_error_dialog,
278+
output,
279+
)
280+
else:
281+
self.app.call_from_thread(self.write_results_to_richlog, output)
282+
283+
def _hide_validating_central_popup(self):
284+
self.validating_central_popup.dismiss()
285+
self.validating_central_popup = None
286+
218287
def write_results_to_richlog(self, results):
219288
"""Display the validation results on the Rich Log widget."""
220289
text_log = self.query_one("#validate_richlog")

datashuttle/tui/tabs/create_folders.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import asyncio
43
from typing import TYPE_CHECKING, List, Optional
54

65
if TYPE_CHECKING:
@@ -26,16 +25,14 @@
2625
CustomDirectoryTree,
2726
TreeAndInputTab,
2827
)
28+
from datashuttle.tui.screens import modal_dialogs
2929
from datashuttle.tui.screens.create_folder_settings import (
3030
CreateFoldersSettingsScreen,
3131
)
3232
from datashuttle.tui.screens.datatypes import (
3333
CreateDatatypeCheckboxes,
3434
DisplayedDatatypesScreen,
3535
)
36-
from datashuttle.tui.screens.modal_dialogs import (
37-
SearchingCentralForNextSubSesPopup,
38-
)
3936
from datashuttle.tui.tooltips import get_tooltip
4037
from datashuttle.tui.utils.tui_decorators import (
4138
ClickInfo,
@@ -65,7 +62,7 @@ def __init__(self, mainwindow: TuiApp, interface: Interface) -> None:
6562
self.mainwindow = mainwindow
6663
self.interface = interface
6764
self.searching_central_popup_widget: (
68-
SearchingCentralForNextSubSesPopup | None
65+
modal_dialogs.CentralWaitingScreen | None
6966
) = None
7067

7168
self.click_info = ClickInfo()
@@ -318,7 +315,7 @@ def suggest_next_sub_ses(
318315
Shows a pop up screen in cases when searching for next sub/ses takes
319316
time such as searching central in SSH connection method.
320317
321-
Creates an asyncio task which handles the suggestion logic and
318+
Spawns a Textual worker which handles the suggestion logic and
322319
dismissing the pop up.
323320
324321
Parameters
@@ -342,17 +339,17 @@ def suggest_next_sub_ses(
342339
"connection_method"
343340
] in ["aws", "gdrive", "ssh"]:
344341
self.searching_central_popup_widget = (
345-
SearchingCentralForNextSubSesPopup(prefix)
342+
modal_dialogs.CentralWaitingScreen(
343+
f"Searching central for next {prefix}"
344+
)
346345
)
347346
self.mainwindow.push_screen(self.searching_central_popup_widget)
348347

349-
asyncio.create_task(
350-
self.fill_suggestion_and_dismiss_popup(
351-
prefix, input_id, include_central
352-
),
353-
name=f"suggest_next_{prefix}_async_task",
348+
self.fill_suggestion_and_dismiss_popup(
349+
prefix, input_id, include_central
354350
)
355351

352+
@work(group="suggest_next_async", exclusive=True)
356353
async def fill_suggestion_and_dismiss_popup(
357354
self, prefix, input_id, include_central
358355
) -> None:
@@ -364,6 +361,11 @@ async def fill_suggestion_and_dismiss_popup(
364361
Else, if the worker successfully exits, this function handles dismissal
365362
of the popup.
366363
364+
Decorated with ``@work`` so Textual owns the task lifecycle (no need to
365+
hold an ``asyncio.Task`` reference to prevent premature GC), and so a
366+
repeat invocation cancels the previous in-flight one via
367+
``exclusive=True``.
368+
367369
see `suggest_next_sub_ses()` for parameters.
368370
"""
369371
worker = self.fill_input_with_next_sub_or_ses_template(

tests/tests_tui/test_tui_validate.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async def test_validate_on_project_manager_output(
5454
await self.scroll_to_click_pause(
5555
pilot, "#validate_validate_button"
5656
)
57+
await pilot.pause(10)
5758

5859
written_lines = [
5960
ele.text
@@ -91,6 +92,7 @@ async def test_validate_on_project_manager_kwargs(
9192
await self.scroll_to_click_pause(
9293
pilot, "#validate_validate_button"
9394
)
95+
await pilot.pause(10)
9496

9597
args_, kwargs_ = spy_validate.call_args_list[0]
9698

@@ -138,6 +140,7 @@ async def test_validate_on_project_manager_kwargs(
138140
await self.scroll_to_click_pause(
139141
pilot, "#validate_validate_button"
140142
)
143+
await pilot.pause(10)
141144

142145
args_, kwargs_ = spy_validate.call_args_list[1]
143146

@@ -196,6 +199,7 @@ async def test_validate_at_path_kwargs(self, setup_project_paths, mocker):
196199
await self.scroll_to_click_pause(
197200
pilot, "#validate_validate_button"
198201
)
202+
await pilot.pause(10)
199203
warnings.filterwarnings("default")
200204

201205
# Check args are passed through to function as expected

0 commit comments

Comments
 (0)