ansys-solutions-dash-super-components 0.2.dev7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. ansys/solutions/dash_super_components/__init__.py +44 -0
  2. ansys/solutions/dash_super_components/assets/__init__.py +20 -0
  3. ansys/solutions/dash_super_components/assets/dashAgGridComponentFunctions.js +71 -0
  4. ansys/solutions/dash_super_components/authenticator.py +277 -0
  5. ansys/solutions/dash_super_components/dual_input_range_slider.py +397 -0
  6. ansys/solutions/dash_super_components/folder_selector.py +830 -0
  7. ansys/solutions/dash_super_components/input_form.py +908 -0
  8. ansys/solutions/dash_super_components/input_row_array.py +368 -0
  9. ansys/solutions/dash_super_components/logs_supervisor.py +887 -0
  10. ansys/solutions/dash_super_components/transaction_method_status_badge.py +366 -0
  11. ansys/solutions/dash_super_components/transaction_supervisor.py +587 -0
  12. ansys/solutions/dash_super_components/tree.py +396 -0
  13. ansys/solutions/dash_super_components/utils/__init__.py +18 -0
  14. ansys/solutions/dash_super_components/utils/aio_ids.py +83 -0
  15. ansys/solutions/dash_super_components/utils/api_requests.py +69 -0
  16. ansys/solutions/dash_super_components/utils/assets_server.py +52 -0
  17. ansys/solutions/dash_super_components/utils/config.py +77 -0
  18. ansys/solutions/dash_super_components/utils/logs_parser.py +151 -0
  19. ansys/solutions/dash_super_components/utils/path_validator.py +163 -0
  20. ansys/solutions/dash_super_components/utils/status_badge_properties.py +60 -0
  21. ansys/solutions/dash_super_components/utils/svg_icons.py +395 -0
  22. ansys_solutions_dash_super_components-0.2.dev7.dist-info/METADATA +169 -0
  23. ansys_solutions_dash_super_components-0.2.dev7.dist-info/RECORD +26 -0
  24. ansys_solutions_dash_super_components-0.2.dev7.dist-info/WHEEL +4 -0
  25. ansys_solutions_dash_super_components-0.2.dev7.dist-info/licenses/AUTHORS +12 -0
  26. ansys_solutions_dash_super_components-0.2.dev7.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,830 @@
1
+ # Copyright (C) 2026 ANSYS, Inc. and/or its affiliates.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+
18
+ """Provides a Dash component for browsing and selecting a folder."""
19
+
20
+ from enum import Enum
21
+ import json
22
+ import os
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from ansys.solutions.dash_super_components.utils.svg_icons import IconNames, create_base64_svg_src
27
+
28
+ try:
29
+ import tkinter
30
+ from tkinter import filedialog
31
+
32
+ TKINTER_AVAILABLE = True
33
+ except Exception: # ImportError or other import-related exception
34
+ tkinter = None # type: ignore[assignment]
35
+ filedialog = None # type: ignore[assignment]
36
+ TKINTER_AVAILABLE = False
37
+
38
+ from typing import cast
39
+ import uuid
40
+
41
+ try:
42
+ # dash >=3.2.0
43
+ from dash import NoUpdate
44
+ except ImportError:
45
+ # dash >=2.18.2, <3.2.0
46
+ from dash._callback import NoUpdate # pyright: ignore[reportPrivateImportUsage]
47
+ from dash.exceptions import PreventUpdate
48
+
49
+ try:
50
+ # dash-extensions < 2.0.5
51
+ from dash_extensions.enrich import _Wildcard # pyright: ignore[reportAttributeAccessIssue]
52
+ except ImportError:
53
+ # dash-extensions >= 2.0.5
54
+ from dash_extensions.enrich import (
55
+ Wildcard as _Wildcard, # pyright: ignore[reportAttributeAccessIssue]
56
+ )
57
+ try:
58
+ from dash_extensions.enrich import set_props
59
+ except ImportError:
60
+ from dash import set_props
61
+ from dash_extensions.enrich import (
62
+ MATCH,
63
+ Input,
64
+ Output,
65
+ State,
66
+ callback,
67
+ dcc,
68
+ html,
69
+ no_update,
70
+ )
71
+ import dash_mantine_components as dmc
72
+
73
+ from ansys.solutions.dash_super_components.tree import Tree
74
+ from ansys.solutions.dash_super_components.utils import config
75
+ from ansys.solutions.dash_super_components.utils.aio_ids import AIOIds
76
+ from ansys.solutions.dash_super_components.utils.path_validator import PathValidator
77
+
78
+
79
+ class FolderSelectorMode(str, Enum):
80
+ """Enumeration representing the desired technology for generating the folder selector."""
81
+
82
+ TKINTER = "tkinter"
83
+ BOOTSTRAP = "bootstrap"
84
+
85
+
86
+ class FolderSelectorStatus:
87
+ """Represent runtime information about the folder selector."""
88
+
89
+ def __init__(
90
+ self,
91
+ mode: FolderSelectorMode,
92
+ browse_from: str,
93
+ open_folder_selector: bool = False,
94
+ ):
95
+ self.mode = mode
96
+ self.open_folder_selector = open_folder_selector
97
+ self.browse_from = browse_from
98
+
99
+
100
+ def custom_serializer(obj: object) -> Any:
101
+ """Serialize an object to be dumped."""
102
+ if isinstance(obj, Enum):
103
+ return obj.value
104
+ if isinstance(obj, Path):
105
+ return str(obj)
106
+ if isinstance(obj, (FolderSelectorStatus, FolderSelectorMode)):
107
+ return obj.__dict__
108
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
109
+
110
+
111
+ class FolderSelector(html.Div):
112
+ """
113
+ A Dash component for browsing and selecting a directory.
114
+
115
+ FolderSelector is based on the Dash All-in-One (AIO) component pattern. It
116
+ supports two operating modes:
117
+
118
+ - **Tkinter** (``FolderSelectorMode.TKINTER``): native OS dialog; requires the
119
+ ``tkinter`` package.
120
+ - **Bootstrap** (``FolderSelectorMode.BOOTSTRAP``): browser-based modal with a
121
+ tree view of the file system; works in headless or remote environments.
122
+
123
+ When ``mode`` is not specified, the mode is selected automatically: bootstrap is
124
+ used when ``tkinter`` is unavailable or when the environment variable
125
+ ``FOLDER_SELECTOR_REMOTE_DEPLOYMENT`` is set to ``true``, ``1``, or ``yes``;
126
+ otherwise tkinter is used.
127
+
128
+ .. note::
129
+
130
+ The component must be placed inside a ``MantineProvider``. For error
131
+ notifications to work correctly, a ``dmc.NotificationContainer`` must also be
132
+ present in the application layout. By default the container ID is
133
+ ``"notification-container"``; use
134
+ :func:`~ansys.solutions.dash_super_components.configure` to change it.
135
+
136
+ Parameters
137
+ ----------
138
+ mode : FolderSelectorMode, optional
139
+ Operating mode. Use ``FolderSelectorMode.TKINTER`` for a native OS dialog or
140
+ ``FolderSelectorMode.BOOTSTRAP`` for a browser-based modal. When omitted,
141
+ the mode is selected automatically (see above).
142
+ aio_id : str, optional
143
+ Unique identifier for this component instance. A UUID is generated when not
144
+ provided.
145
+ browse_button_props : dict, optional
146
+ Properties forwarded to the browse :class:`dmc.Button`.
147
+ clear_button_props : dict, optional
148
+ Properties forwarded to the clear :class:`dmc.ActionIcon`.
149
+ style : dict, optional
150
+ CSS style properties applied to the component container.
151
+ options : dict, optional
152
+ Additional configuration options:
153
+
154
+ - ``default_path`` (str, optional): Path used as the default selected folder
155
+ when nothing has been selected or the selection is cleared.
156
+ - ``browse_from`` (str, optional): Root path for the file tree in Bootstrap
157
+ mode. Falls back to the ``FOLDER_SELECTOR_BROWSE_FROM`` environment
158
+ variable, then the current working directory.
159
+ - ``topmost`` (bool, optional): If ``True``, the tkinter dialog appears in
160
+ the foreground. Default is ``True``.
161
+ - ``max_path_length`` (int, optional): Maximum allowed path length.
162
+ Defaults to the system limit.
163
+ - ``display_field`` (dict, optional): Dict with an ``enabled`` key (``bool``)
164
+ controlling whether the selected path is shown below the buttons.
165
+ Default is ``{"enabled": True}``.
166
+ value : str, optional
167
+ Preselected folder path. Populates the selected folder store upon component
168
+ instantiation.
169
+ """
170
+
171
+ class FolderSelectorIds(AIOIds):
172
+ """Provides IDs for the subcomponents of :class:`FolderSelector`."""
173
+
174
+ @classmethod
175
+ def _settings(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
176
+ """Return the ID for the settings store.
177
+
178
+ Parameters
179
+ ----------
180
+ aio_id : str or _Wildcard
181
+ The component's unique identifier.
182
+
183
+ Returns
184
+ -------
185
+ dict[str, Any]
186
+ The unique ID dictionary for the settings store subcomponent.
187
+ """
188
+ return cls.make_id_dict("folder-selector", "settings", aio_id)
189
+
190
+ @classmethod
191
+ def _folder_selector_status(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
192
+ """Return the ID for the folder selector status store.
193
+
194
+ Parameters
195
+ ----------
196
+ aio_id : str or _Wildcard
197
+ The component's unique identifier.
198
+
199
+ Returns
200
+ -------
201
+ dict[str, Any]
202
+ The unique ID dictionary for the folder selector status store subcomponent.
203
+ """
204
+ return cls.make_id_dict("folder-selector", "folder-selector-status", aio_id)
205
+
206
+ @classmethod
207
+ def _folder_selector_modal(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
208
+ """Return the ID for the folder selector modal.
209
+
210
+ Parameters
211
+ ----------
212
+ aio_id : str or _Wildcard
213
+ The component's unique identifier.
214
+
215
+ Returns
216
+ -------
217
+ dict[str, Any]
218
+ The unique ID dictionary for the folder selector modal subcomponent.
219
+ """
220
+ return cls.make_id_dict("folder-selector", "folder-selector-modal", aio_id)
221
+
222
+ @classmethod
223
+ def _folder_selector_modal_done_button(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
224
+ """Return the ID for the modal done button.
225
+
226
+ Parameters
227
+ ----------
228
+ aio_id : str or _Wildcard
229
+ The component's unique identifier.
230
+
231
+ Returns
232
+ -------
233
+ dict[str, Any]
234
+ The unique ID dictionary for the modal done button subcomponent.
235
+ """
236
+ return cls.make_id_dict("folder-selector", "folder-selector-modal-done-button", aio_id)
237
+
238
+ @classmethod
239
+ def browse_button(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
240
+ """Return the ID for the browse button.
241
+
242
+ Parameters
243
+ ----------
244
+ aio_id : str or _Wildcard
245
+ The component's unique identifier.
246
+
247
+ Returns
248
+ -------
249
+ dict[str, Any]
250
+ The unique ID dictionary for the browse button subcomponent.
251
+ """
252
+ return cls.make_id_dict("folder-selector", "browse-button", aio_id)
253
+
254
+ @classmethod
255
+ def clear_button(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
256
+ """Return the ID for the clear button.
257
+
258
+ Parameters
259
+ ----------
260
+ aio_id : str or _Wildcard
261
+ The component's unique identifier.
262
+
263
+ Returns
264
+ -------
265
+ dict[str, Any]
266
+ The unique ID dictionary for the clear button subcomponent.
267
+ """
268
+ return cls.make_id_dict("folder-selector", "clear-button", aio_id)
269
+
270
+ @classmethod
271
+ def display_field(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
272
+ """Return the ID for the display field component.
273
+
274
+ Parameters
275
+ ----------
276
+ aio_id : str or _Wildcard
277
+ The component's unique identifier.
278
+
279
+ Returns
280
+ -------
281
+ dict[str, Any]
282
+ The unique ID dictionary for the display field subcomponent.
283
+ """
284
+ return cls.make_id_dict("folder-selector", "display-field", aio_id)
285
+
286
+ @classmethod
287
+ def selected_folder(cls, aio_id: str | _Wildcard) -> dict[str, Any]:
288
+ """Return the ID for the selected folder store.
289
+
290
+ Parameters
291
+ ----------
292
+ aio_id : str or _Wildcard
293
+ The component's unique identifier.
294
+
295
+ Returns
296
+ -------
297
+ dict[str, Any]
298
+ The unique ID dictionary for the selected folder store subcomponent.
299
+ """
300
+ return cls.make_id_dict("folder-selector", "selected-folder", aio_id)
301
+
302
+ ids = FolderSelectorIds
303
+
304
+ def __init__(
305
+ self,
306
+ mode: FolderSelectorMode | None = None,
307
+ aio_id: str | None = None,
308
+ browse_button_props: dict[str, Any] | None = None,
309
+ clear_button_props: dict[str, Any] | None = None,
310
+ style: dict[str, Any] | None = None,
311
+ options: dict[str, Any] | None = None,
312
+ value: str | None = None,
313
+ ):
314
+ # set basic defaults
315
+ if aio_id is None:
316
+ aio_id = str(uuid.uuid4())
317
+ if browse_button_props is None:
318
+ browse_button_props = {}
319
+ if clear_button_props is None:
320
+ clear_button_props = {}
321
+ if style is None:
322
+ style = {}
323
+ if options is None:
324
+ options = {}
325
+
326
+ # check inputs
327
+ if mode and not isinstance(mode, FolderSelectorMode): # type: ignore - user input check
328
+ raise TypeError(f"mode must be an instance of FolderSelectorMode, got {type(mode)}")
329
+
330
+ # extract and populate settings
331
+ self.aio_id = aio_id
332
+ self._set_mode(mode)
333
+
334
+ display_field_enabled = options.get("display_field", {}).get("enabled", True)
335
+ internal_settings = {
336
+ "topmost": options.get("topmost", True),
337
+ "max_path_length": options.get("max_path_length", PathValidator.max_path_length()),
338
+ "default_path": options.get("default_path"),
339
+ }
340
+
341
+ folder_selector_status = FolderSelectorStatus(
342
+ mode=self.mode, browse_from=self._get_browse_from(options)
343
+ )
344
+
345
+ folders_structure = (
346
+ self.get_folders_structure(folder_selector_status.browse_from)
347
+ if self.mode == FolderSelectorMode.BOOTSTRAP
348
+ else None
349
+ )
350
+
351
+ browse_button_default_props = {
352
+ "children": "Browse",
353
+ "leftSection": html.Img(
354
+ src=create_base64_svg_src(IconNames.MDI_FOLDER_SEARCH, color="#4c6ef5"),
355
+ ),
356
+ "radius": "xl",
357
+ "className": "mantine-button",
358
+ "variant": "outline",
359
+ "style": {"width": "80%"},
360
+ "color": "indigo",
361
+ }
362
+ self._populate_with_defaults(browse_button_props, browse_button_default_props)
363
+
364
+ clear_button_default_props = {
365
+ "children": html.Img(
366
+ src=create_base64_svg_src(IconNames.MATERIAL_DELETE, color="#4c6ef5"),
367
+ width=20,
368
+ ),
369
+ "size": "md",
370
+ "radius": "sm",
371
+ "variant": "outline",
372
+ "color": "indigo",
373
+ }
374
+ self._populate_with_defaults(clear_button_props, clear_button_default_props)
375
+
376
+ super().__init__(
377
+ [
378
+ html.Div(
379
+ [
380
+ dmc.Group(
381
+ [
382
+ dmc.Button(
383
+ id=self.ids.browse_button(self.aio_id),
384
+ **browse_button_props,
385
+ ),
386
+ dmc.ActionIcon(
387
+ id=self.ids.clear_button(self.aio_id),
388
+ **clear_button_props,
389
+ ),
390
+ ],
391
+ ),
392
+ dmc.Text(
393
+ id=self.ids.display_field(self.aio_id),
394
+ style={
395
+ "display": ("none" if not display_field_enabled else "inline"),
396
+ },
397
+ ),
398
+ dcc.Store(
399
+ id=self.ids.selected_folder(self.aio_id),
400
+ storage_type="memory",
401
+ data=value,
402
+ ),
403
+ dcc.Store(
404
+ id=self.ids._settings(self.aio_id),
405
+ storage_type="memory",
406
+ data=json.dumps(internal_settings),
407
+ ),
408
+ dcc.Store(
409
+ id=self.ids._folder_selector_status(self.aio_id),
410
+ data=json.dumps(
411
+ folder_selector_status,
412
+ default=custom_serializer,
413
+ ),
414
+ storage_type="memory",
415
+ ),
416
+ dmc.Modal(
417
+ title="Pick a folder from the server",
418
+ id=self.ids._folder_selector_modal(self.aio_id),
419
+ zIndex=10000,
420
+ children=[
421
+ self.generate_tree(self.aio_id, folders_structure),
422
+ dmc.Group(
423
+ children=[
424
+ dmc.Button(
425
+ "Done",
426
+ id=self.ids._folder_selector_modal_done_button(
427
+ self.aio_id,
428
+ ),
429
+ ),
430
+ ],
431
+ justify="center",
432
+ ),
433
+ ],
434
+ closeOnClickOutside=False,
435
+ closeOnEscape=False,
436
+ withCloseButton=False,
437
+ size="xl",
438
+ ),
439
+ ],
440
+ style=style,
441
+ ),
442
+ ],
443
+ )
444
+
445
+ def _get_browse_from(self, options: dict[str, Any]) -> str:
446
+ browse_from = (
447
+ os.environ.get(
448
+ "FOLDER_SELECTOR_BROWSE_FROM",
449
+ Path.cwd(),
450
+ )
451
+ if "browse_from" not in options
452
+ else options["browse_from"]
453
+ ) # this option restricts the FS visibility when mode == BOOTSTRAP
454
+ # normalize the path - strip trailing slashes and resolve .. and .
455
+ browse_from = os.path.normpath(browse_from)
456
+ return browse_from
457
+
458
+ def _set_mode(self, mode: FolderSelectorMode | None) -> None:
459
+ if not TKINTER_AVAILABLE:
460
+ self.mode = FolderSelectorMode.BOOTSTRAP
461
+ elif mode:
462
+ # user provided an enum
463
+ self.mode = mode
464
+ else: # mode is not specified
465
+ folder_selector_remote_deployment = str(
466
+ os.environ.get("FOLDER_SELECTOR_REMOTE_DEPLOYMENT", "false"),
467
+ ).lower() in {"true", "1", "yes"}
468
+ self.mode = (
469
+ FolderSelectorMode.TKINTER
470
+ if not folder_selector_remote_deployment
471
+ else FolderSelectorMode.BOOTSTRAP
472
+ )
473
+
474
+ def _populate_with_defaults(
475
+ self, properties: dict[str, Any], default_properties: dict[str, Any]
476
+ ) -> None:
477
+ for key, val in default_properties.items():
478
+ if key not in properties:
479
+ properties[key] = val
480
+
481
+ @staticmethod
482
+ @callback(
483
+ Output(ids.selected_folder(MATCH), "data"),
484
+ Input(ids.selected_folder(MATCH), "data"),
485
+ State(ids._settings(MATCH), "data"),
486
+ )
487
+ def populate_selected_folder_with_default_path(
488
+ selected_folder: str | None,
489
+ internal_settings: str,
490
+ ) -> str | NoUpdate:
491
+ """Populate the selected_folder store with default_path upon component instantiation."""
492
+ settings: dict[str, str | int | None] = json.loads(internal_settings)
493
+ default_path = settings["default_path"]
494
+ if default_path and not selected_folder:
495
+ return cast(str, default_path)
496
+ else:
497
+ return no_update
498
+
499
+ @staticmethod
500
+ @callback(
501
+ Output(ids.browse_button(MATCH), "disabled", allow_duplicate=True),
502
+ Input(ids.browse_button(MATCH), "n_clicks"),
503
+ prevent_initial_call=True,
504
+ )
505
+ def disable_browse_button(n_clicks: int) -> bool | NoUpdate:
506
+ """Disable browse button."""
507
+ return True if n_clicks else no_update
508
+
509
+ @staticmethod
510
+ @callback(
511
+ Output(ids.browse_button(MATCH), "disabled", allow_duplicate=True),
512
+ Input(ids._folder_selector_status(MATCH), "data"),
513
+ prevent_initial_call=True,
514
+ )
515
+ def enable_browse_button(_data: str) -> bool | NoUpdate:
516
+ """Enable browse button."""
517
+ data: FolderSelectorStatus = json.loads(
518
+ _data,
519
+ object_hook=lambda o: FolderSelectorStatus(**o),
520
+ )
521
+ if not data.open_folder_selector:
522
+ return False
523
+ else:
524
+ return no_update
525
+
526
+ @staticmethod
527
+ @callback(
528
+ Output(ids._folder_selector_status(MATCH), "data", allow_duplicate=True),
529
+ Input(ids.browse_button(MATCH), "n_clicks"),
530
+ State(ids._folder_selector_status(MATCH), "data"),
531
+ prevent_initial_call=True,
532
+ )
533
+ def browse(
534
+ n_clicks: int,
535
+ _folder_selector_status: str,
536
+ ) -> str | NoUpdate:
537
+ """Browse and enable folder selection."""
538
+ if n_clicks:
539
+ folder_selector_status: FolderSelectorStatus = json.loads(
540
+ _folder_selector_status,
541
+ object_hook=lambda o: FolderSelectorStatus(**o),
542
+ )
543
+ folder_selector_status.open_folder_selector = True
544
+ return json.dumps(folder_selector_status, default=custom_serializer)
545
+ else:
546
+ return no_update
547
+
548
+ @staticmethod
549
+ @callback(
550
+ Output(ids._folder_selector_status(MATCH), "data", allow_duplicate=True),
551
+ Output(ids.selected_folder(MATCH), "data", allow_duplicate=True),
552
+ Output(ids._folder_selector_modal(MATCH), "opened", allow_duplicate=True),
553
+ Input(ids._folder_selector_status(MATCH), "data"),
554
+ State(ids._settings(MATCH), "data"),
555
+ State(ids.browse_button(MATCH), "n_clicks"),
556
+ prevent_initial_call=True,
557
+ )
558
+ def open_folder_selector(
559
+ _folder_selector_status: str,
560
+ internal_settings: str,
561
+ n_clicks: int,
562
+ ) -> tuple[str | NoUpdate, str | NoUpdate | None, bool | NoUpdate]:
563
+ """
564
+ Open a OS-provided or a browser-generated popup.
565
+
566
+ The choice is ruled by `FolderSelectorMode`.
567
+ The final goal is to select a folder from the server's file-system.
568
+ """
569
+ settings: dict[str, str | int | None] = json.loads(internal_settings)
570
+ topmost = cast(bool, settings["topmost"])
571
+ max_path_length = cast(int, settings["max_path_length"])
572
+ notification = no_update
573
+ selected_directory = None
574
+ folder_selector_status: FolderSelectorStatus = json.loads(
575
+ _folder_selector_status,
576
+ object_hook=lambda d: FolderSelectorStatus(**d),
577
+ )
578
+ if folder_selector_status.open_folder_selector:
579
+ if folder_selector_status.mode == FolderSelectorMode.TKINTER:
580
+ try:
581
+ selected_directory = FolderSelector.ask_directory_with_tkinter(
582
+ topmost,
583
+ )
584
+ validation = PathValidator(selected_directory).validate(
585
+ max_path_length,
586
+ )
587
+ if isinstance(validation, Exception):
588
+ selected_directory = None
589
+ notification = {
590
+ "title": "Error during folder selection",
591
+ "id": "folder-selector-notify-failure" + "-" + str(n_clicks),
592
+ "action": "show",
593
+ "color": "red",
594
+ "message": f"{str(validation)}.",
595
+ "autoClose": False,
596
+ }
597
+ set_props(
598
+ config._config.notification_container_id,
599
+ {"sendNotifications": [notification]},
600
+ )
601
+
602
+ folder_selector_status.open_folder_selector = False
603
+ return (
604
+ json.dumps(folder_selector_status, default=custom_serializer),
605
+ selected_directory,
606
+ no_update,
607
+ )
608
+ except tkinter.TclError: # type: ignore reportOptionalMemberAccess - tkinter availability is checked before
609
+ # Any error related to tkinter availability/operation will be handled here
610
+ folder_selector_status.open_folder_selector = False
611
+
612
+ message = "The server either does not support tkinter "
613
+ message += "or the tkinter package is not installed "
614
+ message += "or it is running in a headless mode. "
615
+ message += "Please switch to BOOTSTRAP mode."
616
+ notification = {
617
+ "title": "Error during folder selection",
618
+ "id": "folder-selector-notify-failure" + "-" + str(n_clicks),
619
+ "action": "show",
620
+ "color": "red",
621
+ "message": message,
622
+ "autoClose": False,
623
+ }
624
+ set_props(
625
+ config._config.notification_container_id,
626
+ {"sendNotifications": [notification]},
627
+ )
628
+ return (
629
+ json.dumps(folder_selector_status, default=custom_serializer),
630
+ None,
631
+ False,
632
+ )
633
+ else:
634
+ # open the modal
635
+ return no_update, no_update, True
636
+ else:
637
+ raise PreventUpdate
638
+
639
+ @staticmethod
640
+ @callback(
641
+ Output(ids._folder_selector_status(MATCH), "data", allow_duplicate=True),
642
+ Output(ids._folder_selector_modal(MATCH), "opened", allow_duplicate=True),
643
+ Output(ids.selected_folder(MATCH), "data", allow_duplicate=True),
644
+ Input(ids._folder_selector_modal_done_button(MATCH), "n_clicks"),
645
+ State(Tree.ids.selected_item(MATCH), "data"),
646
+ State(Tree.ids.tree_structure(MATCH), "data"),
647
+ State(ids._folder_selector_status(MATCH), "data"),
648
+ prevent_initial_call=True,
649
+ )
650
+ def dismiss_modal_and_update_selected_folder(
651
+ n_clicks: int | None,
652
+ selected_item_id: dict[str, str] | None,
653
+ tree_structure: str,
654
+ _folder_selector_status: str,
655
+ ) -> tuple[str, bool, str | None | NoUpdate]:
656
+ """Close the modal and update data storage."""
657
+ if n_clicks:
658
+ folder_selector_status: FolderSelectorStatus = json.loads(
659
+ _folder_selector_status,
660
+ object_hook=lambda o: FolderSelectorStatus(**o),
661
+ )
662
+ folder_selector_status.open_folder_selector = False
663
+
664
+ parsed_tree = json.loads(tree_structure)
665
+ if isinstance(parsed_tree, list):
666
+ if len(parsed_tree) == 0: # The tree is empty, no folder can be selected
667
+ return (
668
+ json.dumps(folder_selector_status, default=custom_serializer),
669
+ False,
670
+ no_update,
671
+ )
672
+ parsed_tree = parsed_tree[
673
+ 0
674
+ ] # The graph is a tree, so there is only one element in the first level
675
+
676
+ selected_folder = no_update
677
+ notification = no_update
678
+ if selected_item_id:
679
+ # translate the navlink id to its corresponding path
680
+ try:
681
+ folder_id = Tree.ids.get_index_from_navlink_item_id(selected_item_id)
682
+ selected_folder = FolderSelector.resolve_folder(
683
+ folder_id,
684
+ parsed_tree,
685
+ )
686
+ except ValueError:
687
+ # In case the selected nav item id is not found in the tree structure
688
+ # we consider it as an invalid selection:
689
+ message = (
690
+ "The selected folder path could not be resolved. Please try again or "
691
+ "select another folder."
692
+ )
693
+ notification = {
694
+ "title": "Error during folder selection",
695
+ "id": "folder-selector-notify-failure" + "-" + str(n_clicks),
696
+ "action": "show",
697
+ "color": "red",
698
+ "message": message,
699
+ "autoClose": True,
700
+ }
701
+ set_props(
702
+ config._config.notification_container_id,
703
+ {"sendNotifications": [notification]},
704
+ )
705
+
706
+ return (
707
+ json.dumps(folder_selector_status, default=custom_serializer),
708
+ False,
709
+ selected_folder,
710
+ )
711
+ else:
712
+ raise PreventUpdate
713
+
714
+ @staticmethod
715
+ @callback(
716
+ Output(ids.display_field(MATCH), "children"),
717
+ Input(ids.selected_folder(MATCH), "data"),
718
+ )
719
+ def update_display_field(
720
+ selected_folder: str | None,
721
+ ) -> str | None:
722
+ """Update the display field when a new folder is selected."""
723
+ return f"Selected folder: {selected_folder}" if selected_folder else "No folder selected"
724
+
725
+ @staticmethod
726
+ @callback(
727
+ Output(ids.selected_folder(MATCH), "clear_data", allow_duplicate=True),
728
+ Input(ids.clear_button(MATCH), "n_clicks"),
729
+ prevent_initial_call=True,
730
+ )
731
+ def clear_selected_folder(n_clicks: int) -> bool:
732
+ """Clear previously selected folder."""
733
+ if n_clicks > 0:
734
+ return True
735
+ raise PreventUpdate
736
+
737
+ @classmethod
738
+ def generate_tree(cls, aio_id: str, folders_structure: dict[str, Any] | None) -> Tree:
739
+ """Create a tree to browse and select a folder from the server file system."""
740
+ return Tree(
741
+ aio_id=aio_id,
742
+ items=[folders_structure] if isinstance(folders_structure, dict) else [],
743
+ default_icon="material-symbols:folder",
744
+ )
745
+
746
+ @classmethod
747
+ def get_folders_structure(
748
+ cls,
749
+ browse_from: str,
750
+ _as_absolute_path: bool = True,
751
+ ) -> dict[str, Any]:
752
+ """
753
+ Recursively generate the file-system tree starting from `browse_from`.
754
+
755
+ Parameters
756
+ ----------
757
+ browse_from : str
758
+ The path from which to start browsing.
759
+ _as_absolute_path : bool, optional
760
+ Internal parameter used for recursion. Do not use externally.
761
+
762
+ Returns
763
+ -------
764
+ dict[str, Any]
765
+ A dictionary representing the folder structure.
766
+ """
767
+ children = sorted(
768
+ [
769
+ os.path.normpath(Path(browse_from) / folder.name)
770
+ for folder in Path(browse_from).glob("*")
771
+ if folder.is_dir() and not folder.name.startswith(".")
772
+ ],
773
+ )
774
+ return {
775
+ "id": str(
776
+ uuid.uuid3(uuid.NAMESPACE_URL, browse_from),
777
+ ), # idempotent hash value
778
+ "text": (browse_from if _as_absolute_path else Path(browse_from).name), # folder name
779
+ "children": ([cls.get_folders_structure(child, False) for child in children]),
780
+ }
781
+
782
+ @classmethod
783
+ def resolve_folder(
784
+ cls,
785
+ folder_id: str,
786
+ tree_structure: dict[str, Any],
787
+ _prefix: str = "",
788
+ ) -> str | None:
789
+ """
790
+ Traverse the tree to find a path corresponding to the `folder_id`.
791
+
792
+ Parameters
793
+ ----------
794
+ folder_id : str
795
+ The unique identifier of the folder to find in the tree.
796
+ tree_structure : dict[str, Any]
797
+ The tree structure dictionary to search through.
798
+ _prefix : str, optional
799
+ Internal parameter used for recursion. Do not use externally.
800
+
801
+ Returns
802
+ -------
803
+ str or None
804
+ The full path to the folder if found, ``None`` otherwise.
805
+ """
806
+ if len(_prefix) > 0 and not _prefix.endswith(os.path.sep):
807
+ _prefix += os.path.sep
808
+ if tree_structure["id"] == folder_id:
809
+ return _prefix + tree_structure["text"]
810
+ for child in tree_structure.get("children", []):
811
+ found = cls.resolve_folder(folder_id, child, _prefix + tree_structure["text"])
812
+ if found:
813
+ return found
814
+ return None
815
+
816
+ @classmethod
817
+ def ask_directory_with_tkinter(cls, topmost: bool) -> str:
818
+ """Open a tkinter popup and return user choice on dismiss."""
819
+ if not TKINTER_AVAILABLE:
820
+ raise RuntimeError("tkinter is not available in this environment")
821
+
822
+ root = tkinter.Tk() # type: ignore reportOptionalMemberAccess - tkinter availability is checked before
823
+ try:
824
+ root.withdraw()
825
+ if topmost:
826
+ root.wm_attributes("-topmost", 1)
827
+ selected_directory = filedialog.askdirectory() # type: ignore reportOptionalMemberAccess - tkinter availability is checked before
828
+ return selected_directory
829
+ finally:
830
+ root.destroy()