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.
- ansys/solutions/dash_super_components/__init__.py +44 -0
- ansys/solutions/dash_super_components/assets/__init__.py +20 -0
- ansys/solutions/dash_super_components/assets/dashAgGridComponentFunctions.js +71 -0
- ansys/solutions/dash_super_components/authenticator.py +277 -0
- ansys/solutions/dash_super_components/dual_input_range_slider.py +397 -0
- ansys/solutions/dash_super_components/folder_selector.py +830 -0
- ansys/solutions/dash_super_components/input_form.py +908 -0
- ansys/solutions/dash_super_components/input_row_array.py +368 -0
- ansys/solutions/dash_super_components/logs_supervisor.py +887 -0
- ansys/solutions/dash_super_components/transaction_method_status_badge.py +366 -0
- ansys/solutions/dash_super_components/transaction_supervisor.py +587 -0
- ansys/solutions/dash_super_components/tree.py +396 -0
- ansys/solutions/dash_super_components/utils/__init__.py +18 -0
- ansys/solutions/dash_super_components/utils/aio_ids.py +83 -0
- ansys/solutions/dash_super_components/utils/api_requests.py +69 -0
- ansys/solutions/dash_super_components/utils/assets_server.py +52 -0
- ansys/solutions/dash_super_components/utils/config.py +77 -0
- ansys/solutions/dash_super_components/utils/logs_parser.py +151 -0
- ansys/solutions/dash_super_components/utils/path_validator.py +163 -0
- ansys/solutions/dash_super_components/utils/status_badge_properties.py +60 -0
- ansys/solutions/dash_super_components/utils/svg_icons.py +395 -0
- ansys_solutions_dash_super_components-0.2.dev7.dist-info/METADATA +169 -0
- ansys_solutions_dash_super_components-0.2.dev7.dist-info/RECORD +26 -0
- ansys_solutions_dash_super_components-0.2.dev7.dist-info/WHEEL +4 -0
- ansys_solutions_dash_super_components-0.2.dev7.dist-info/licenses/AUTHORS +12 -0
- 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()
|