botflow-gui 0.0.1__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.
- botflow/__init__.py +53 -0
- botflow/__pycache__/__init__.cpython-312.pyc +0 -0
- botflow/__pycache__/exceptions.cpython-312.pyc +0 -0
- botflow/__pycache__/i18n.cpython-312.pyc +0 -0
- botflow/__pycache__/logger.cpython-312.pyc +0 -0
- botflow/__pycache__/manager.cpython-312.pyc +0 -0
- botflow/__pycache__/pages.cpython-312.pyc +0 -0
- botflow/__pycache__/qss.cpython-312.pyc +0 -0
- botflow/__pycache__/resolver.cpython-312.pyc +0 -0
- botflow/__pycache__/runtime.cpython-312.pyc +0 -0
- botflow/__pycache__/threads.cpython-312.pyc +0 -0
- botflow/__pycache__/types.cpython-312.pyc +0 -0
- botflow/__pycache__/widgets.cpython-312.pyc +0 -0
- botflow/__pycache__/workers.cpython-312.pyc +0 -0
- botflow/__pyinstaller/__init__.py +0 -0
- botflow/__pyinstaller/__pycache__/hook-botflow.cpython-312.pyc +0 -0
- botflow/__pyinstaller/hook-botflow.py +10 -0
- botflow/exceptions.py +4 -0
- botflow/i18n.py +57 -0
- botflow/logger.py +45 -0
- botflow/manager.py +318 -0
- botflow/pages.py +117 -0
- botflow/qss.py +21 -0
- botflow/resolver.py +94 -0
- botflow/resources/assets/loading.gif +0 -0
- botflow/resources/locales/en_US/common.json +8 -0
- botflow/resources/locales/en_US/dialogs.json +15 -0
- botflow/resources/locales/en_US/initial_page.json +3 -0
- botflow/resources/locales/en_US/loading.json +3 -0
- botflow/resources/locales/en_US/messages.json +5 -0
- botflow/resources/locales/pt_BR/common.json +8 -0
- botflow/resources/locales/pt_BR/dialogs.json +15 -0
- botflow/resources/locales/pt_BR/initial_page.json +3 -0
- botflow/resources/locales/pt_BR/loading.json +3 -0
- botflow/resources/locales/pt_BR/messages.json +5 -0
- botflow/resources/styles/file_widget.qss +62 -0
- botflow/resources/styles/flow_choice_widget.qss +0 -0
- botflow/resources/styles/flow_manager.qss +51 -0
- botflow/resources/styles/form_widget.qss +40 -0
- botflow/resources/styles/initial_page.qss +30 -0
- botflow/resources/styles/loading_page.qss +37 -0
- botflow/resources/styles/text_widget.qss +33 -0
- botflow/runtime.py +42 -0
- botflow/types.py +87 -0
- botflow/widgets.py +244 -0
- botflow/workers.py +130 -0
- botflow_gui-0.0.1.dist-info/METADATA +100 -0
- botflow_gui-0.0.1.dist-info/RECORD +52 -0
- botflow_gui-0.0.1.dist-info/WHEEL +5 -0
- botflow_gui-0.0.1.dist-info/entry_points.txt +2 -0
- botflow_gui-0.0.1.dist-info/licenses/LICENSE +21 -0
- botflow_gui-0.0.1.dist-info/top_level.txt +1 -0
botflow/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from botflow.manager import FlowManager
|
|
2
|
+
from botflow.types import FlowSpec
|
|
3
|
+
from botflow.widgets import (
|
|
4
|
+
FileStepSpec,
|
|
5
|
+
FileWidget,
|
|
6
|
+
FormInput,
|
|
7
|
+
FormStepSpec,
|
|
8
|
+
FormWidget,
|
|
9
|
+
TextStepSpec,
|
|
10
|
+
TextWidget,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
'FlowManager',
|
|
15
|
+
'FileStepSpec',
|
|
16
|
+
'FileWidget',
|
|
17
|
+
'FormInput',
|
|
18
|
+
'FormStepSpec',
|
|
19
|
+
'FormWidget',
|
|
20
|
+
'TextStepSpec',
|
|
21
|
+
'TextWidget',
|
|
22
|
+
'FlowSpec',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from PySide6.QtWidgets import QApplication
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_hook_dirs():
|
|
32
|
+
return [str(Path(__file__).resolve().with_name('__pyinstaller'))]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def run_application():
|
|
36
|
+
app = QApplication.instance()
|
|
37
|
+
if not app:
|
|
38
|
+
app = QApplication(sys.argv)
|
|
39
|
+
return app
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_flow_manager(
|
|
43
|
+
flow_manager: FlowManager,
|
|
44
|
+
*,
|
|
45
|
+
width: int = 720,
|
|
46
|
+
height: int = 300,
|
|
47
|
+
window_title: str = 'Flow Manager',
|
|
48
|
+
):
|
|
49
|
+
app = run_application()
|
|
50
|
+
flow_manager.resize(width, height)
|
|
51
|
+
flow_manager.setWindowTitle(window_title)
|
|
52
|
+
flow_manager.show()
|
|
53
|
+
sys.exit(app.exec())
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from PyInstaller.utils.hooks import get_module_file_attribute
|
|
4
|
+
|
|
5
|
+
module_path = get_module_file_attribute('botflow')
|
|
6
|
+
if module_path:
|
|
7
|
+
botflow_dir = Path(module_path).parent
|
|
8
|
+
datas = [(str(botflow_dir / 'resources'), 'lib_resources')]
|
|
9
|
+
else:
|
|
10
|
+
datas = []
|
botflow/exceptions.py
ADDED
botflow/i18n.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from botflow.resolver import find_all_locales_dirs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class I18n:
|
|
9
|
+
def __init__(self, catalog: dict[str, Any], lang: str, fallback_lang: str = 'en_US'):
|
|
10
|
+
self.catalog = catalog
|
|
11
|
+
self.lang = lang
|
|
12
|
+
self.fallback_lang = fallback_lang
|
|
13
|
+
|
|
14
|
+
def t(self, key: str, **params: Any) -> str:
|
|
15
|
+
loc = self.lang or self.fallback_lang
|
|
16
|
+
|
|
17
|
+
bucket = self.catalog.get(loc, {})
|
|
18
|
+
v = bucket.get(key)
|
|
19
|
+
|
|
20
|
+
if v is None and self.fallback_lang and self.fallback_lang != loc:
|
|
21
|
+
v = self.catalog.get(self.fallback_lang, {}).get(key)
|
|
22
|
+
|
|
23
|
+
if v is None:
|
|
24
|
+
raise KeyError(f'Missing translation key: {key} (lang={self.lang})')
|
|
25
|
+
|
|
26
|
+
if not isinstance(v, str):
|
|
27
|
+
return str(v)
|
|
28
|
+
|
|
29
|
+
return v.format(**params) if params else v
|
|
30
|
+
|
|
31
|
+
def set_lang(self, lang: str) -> None:
|
|
32
|
+
self.lang = lang
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def from_locales_dirs(lang: str):
|
|
36
|
+
locales_dirs = find_all_locales_dirs()
|
|
37
|
+
catalog: dict[str, dict[str, Any]] = {}
|
|
38
|
+
|
|
39
|
+
for locales_dir in locales_dirs if isinstance(locales_dirs, list) else [locales_dirs]:
|
|
40
|
+
for json_file in Path(locales_dir).glob('**/*.json'):
|
|
41
|
+
locale_name = json_file.parent.name
|
|
42
|
+
|
|
43
|
+
with open(json_file, 'r', encoding='utf-8') as f:
|
|
44
|
+
data = json.load(f)
|
|
45
|
+
|
|
46
|
+
catalog.setdefault(locale_name, {})
|
|
47
|
+
|
|
48
|
+
if isinstance(data, dict):
|
|
49
|
+
for v in data.values():
|
|
50
|
+
if isinstance(v, dict):
|
|
51
|
+
catalog[locale_name].update(v)
|
|
52
|
+
|
|
53
|
+
for k, v in data.items():
|
|
54
|
+
if isinstance(v, str):
|
|
55
|
+
catalog[locale_name][k] = v
|
|
56
|
+
|
|
57
|
+
return I18n(catalog, lang)
|
botflow/logger.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import datetime as dt
|
|
3
|
+
from logging.handlers import TimedRotatingFileHandler
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def configure_logger(
|
|
8
|
+
name: str = 'big_views',
|
|
9
|
+
log_dir: str | Path = './logs',
|
|
10
|
+
level: int = logging.INFO,
|
|
11
|
+
console: bool = True,
|
|
12
|
+
rotating_file: bool = True,
|
|
13
|
+
) -> logging.Logger:
|
|
14
|
+
logger = logging.getLogger(name)
|
|
15
|
+
logger.setLevel(level)
|
|
16
|
+
logger.propagate = False
|
|
17
|
+
|
|
18
|
+
log_dir = Path(log_dir)
|
|
19
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
fmt = logging.Formatter('%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s')
|
|
22
|
+
|
|
23
|
+
if rotating_file:
|
|
24
|
+
log_name = dt.now().strftime('%d-%m-%Y_%Hh-%Mm-%Ss')
|
|
25
|
+
log_path = log_dir / f'{log_name}.log'
|
|
26
|
+
|
|
27
|
+
file_handler = TimedRotatingFileHandler(
|
|
28
|
+
filename=str(log_path),
|
|
29
|
+
when='midnight',
|
|
30
|
+
interval=1,
|
|
31
|
+
backupCount=7,
|
|
32
|
+
encoding='utf-8',
|
|
33
|
+
)
|
|
34
|
+
file_handler.suffix = '%Y%m%d'
|
|
35
|
+
file_handler.setLevel(level)
|
|
36
|
+
file_handler.setFormatter(fmt)
|
|
37
|
+
logger.addHandler(file_handler)
|
|
38
|
+
|
|
39
|
+
if console:
|
|
40
|
+
console_handler = logging.StreamHandler()
|
|
41
|
+
console_handler.setLevel(level)
|
|
42
|
+
console_handler.setFormatter(fmt)
|
|
43
|
+
logger.addHandler(console_handler)
|
|
44
|
+
|
|
45
|
+
return logger
|
botflow/manager.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from logging import Logger
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QThread, Slot
|
|
6
|
+
from PySide6.QtGui import QCloseEvent, QIcon, Qt
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QHBoxLayout,
|
|
9
|
+
QMessageBox,
|
|
10
|
+
QPushButton,
|
|
11
|
+
QSizePolicy,
|
|
12
|
+
QSpacerItem,
|
|
13
|
+
QStackedWidget,
|
|
14
|
+
QVBoxLayout,
|
|
15
|
+
QWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from botflow.i18n import I18n
|
|
19
|
+
from botflow.logger import configure_logger
|
|
20
|
+
from botflow.pages import InitialPage, LoadingPage
|
|
21
|
+
from botflow.qss import qss_to_string
|
|
22
|
+
from botflow.resolver import find_resource_file
|
|
23
|
+
from botflow.runtime import get_lang
|
|
24
|
+
from botflow.types import FinishFn, FlowSpec, LoadingAbstract, StepSpec, WidgetAbstract
|
|
25
|
+
from botflow.workers import AsyncLoopThreadWorker, PipelineWorker
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FlowManager(QWidget):
|
|
29
|
+
ROOT_INITIAL = 'initial'
|
|
30
|
+
ROOT_WIZARD = 'wizard'
|
|
31
|
+
ROOT_LOADING = 'loading'
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
flow: FlowSpec,
|
|
36
|
+
logger: Optional[Logger] = None,
|
|
37
|
+
icon_path: Optional[str] = None,
|
|
38
|
+
):
|
|
39
|
+
super().__init__()
|
|
40
|
+
self.flow = flow
|
|
41
|
+
self.logger = logger if logger else configure_logger()
|
|
42
|
+
|
|
43
|
+
self.lang = get_lang() or 'en_US'
|
|
44
|
+
self.i18n = I18n.from_locales_dirs(self.lang)
|
|
45
|
+
|
|
46
|
+
self.context: dict[str, Any] = {}
|
|
47
|
+
self.steps: list[StepSpec] = []
|
|
48
|
+
self.pipeline: list[FinishFn] = []
|
|
49
|
+
|
|
50
|
+
self._async_loop = AsyncLoopThreadWorker()
|
|
51
|
+
self._async_loop.start()
|
|
52
|
+
|
|
53
|
+
self._thread: Optional[QThread] = None
|
|
54
|
+
self._worker: Optional[PipelineWorker] = None
|
|
55
|
+
|
|
56
|
+
self._set_style()
|
|
57
|
+
|
|
58
|
+
self.wizard_page = QWidget()
|
|
59
|
+
self.wizard_page.setObjectName('wizard')
|
|
60
|
+
self._wizard_layout = QVBoxLayout(self.wizard_page)
|
|
61
|
+
|
|
62
|
+
self.stack = QStackedWidget()
|
|
63
|
+
self.back_btn = QPushButton(self.i18n.t('common.back'))
|
|
64
|
+
self.next_btn = QPushButton(self.i18n.t('common.next'))
|
|
65
|
+
self.back_btn.setProperty('role', 'nav')
|
|
66
|
+
self.next_btn.setProperty('role', 'primary')
|
|
67
|
+
|
|
68
|
+
self.back_btn.clicked.connect(self.back)
|
|
69
|
+
self.next_btn.clicked.connect(self.foward)
|
|
70
|
+
|
|
71
|
+
if icon_path and os.path.exists(icon_path):
|
|
72
|
+
qicon = QIcon(icon_path)
|
|
73
|
+
self.setWindowIcon(qicon)
|
|
74
|
+
|
|
75
|
+
self.initial_page = self.create_initial_page()
|
|
76
|
+
self.loading_page = self.create_loading_page()
|
|
77
|
+
|
|
78
|
+
self._build_wizard_ui()
|
|
79
|
+
self.root_stack = QStackedWidget()
|
|
80
|
+
self._root_pages: dict[str, int] = {}
|
|
81
|
+
self.register_root_pages()
|
|
82
|
+
|
|
83
|
+
layout = QVBoxLayout(self)
|
|
84
|
+
layout.addWidget(self.root_stack)
|
|
85
|
+
|
|
86
|
+
self.load_flow(self.flow)
|
|
87
|
+
self.set_root_page(self.ROOT_INITIAL)
|
|
88
|
+
|
|
89
|
+
def _set_style(self):
|
|
90
|
+
style_file = find_resource_file('styles/flow_manager.qss')
|
|
91
|
+
qss_string = qss_to_string(style_file)
|
|
92
|
+
self.setStyleSheet(qss_string)
|
|
93
|
+
|
|
94
|
+
def _build_wizard_ui(self) -> None:
|
|
95
|
+
nav_container = QWidget()
|
|
96
|
+
nav_container.setObjectName('nav_container')
|
|
97
|
+
nav = QHBoxLayout(nav_container)
|
|
98
|
+
nav.setContentsMargins(0, 0, 0, 0)
|
|
99
|
+
nav.addWidget(self.back_btn)
|
|
100
|
+
nav.addStretch()
|
|
101
|
+
nav.addWidget(self.next_btn)
|
|
102
|
+
|
|
103
|
+
nav_divider = QWidget()
|
|
104
|
+
nav_divider.setObjectName('nav_divider')
|
|
105
|
+
nav_divider.setFixedHeight(1)
|
|
106
|
+
|
|
107
|
+
center_container = QWidget()
|
|
108
|
+
center_layout = QVBoxLayout(center_container)
|
|
109
|
+
center_layout.setContentsMargins(0, 0, 0, 0)
|
|
110
|
+
center_layout.addWidget(self.stack, 1)
|
|
111
|
+
center_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
112
|
+
self._wizard_layout.addWidget(center_container, 1)
|
|
113
|
+
|
|
114
|
+
self._wizard_layout.addSpacerItem(
|
|
115
|
+
QSpacerItem(0, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
|
116
|
+
)
|
|
117
|
+
self._wizard_layout.addWidget(nav_divider, 0)
|
|
118
|
+
self._wizard_layout.addSpacerItem(
|
|
119
|
+
QSpacerItem(0, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
|
120
|
+
)
|
|
121
|
+
self._wizard_layout.addWidget(nav_container, 0)
|
|
122
|
+
|
|
123
|
+
def create_initial_page(self) -> InitialPage:
|
|
124
|
+
return InitialPage(self.flow.name, self.go_to_wizard_page, self.i18n)
|
|
125
|
+
|
|
126
|
+
def create_loading_page(self) -> LoadingAbstract:
|
|
127
|
+
return LoadingPage(self.i18n)
|
|
128
|
+
|
|
129
|
+
def current_index(self) -> int:
|
|
130
|
+
return self.stack.currentIndex()
|
|
131
|
+
|
|
132
|
+
def current_spec(self) -> StepSpec:
|
|
133
|
+
return self.steps[self.current_index()]
|
|
134
|
+
|
|
135
|
+
def page_kwargs(self, spec: StepSpec) -> dict[str, Any]:
|
|
136
|
+
return {'spec': spec, 'i18n': self.i18n}
|
|
137
|
+
|
|
138
|
+
def make_page(self, spec: StepSpec):
|
|
139
|
+
return spec.widget_cls(**self.page_kwargs(spec))
|
|
140
|
+
|
|
141
|
+
def get_page_value(self, widget: WidgetAbstract) -> Any:
|
|
142
|
+
return widget.value()
|
|
143
|
+
|
|
144
|
+
def register_root_pages(self) -> None:
|
|
145
|
+
self.add_root_page(self.ROOT_INITIAL, self.initial_page)
|
|
146
|
+
self.add_root_page(self.ROOT_WIZARD, self.wizard_page)
|
|
147
|
+
self.add_root_page(self.ROOT_LOADING, self.loading_page)
|
|
148
|
+
|
|
149
|
+
def add_root_page(self, name: str, page: QWidget) -> int:
|
|
150
|
+
idx = self.root_stack.addWidget(page)
|
|
151
|
+
self._root_pages[name] = idx
|
|
152
|
+
return idx
|
|
153
|
+
|
|
154
|
+
def set_root_page(self, name: str) -> None:
|
|
155
|
+
self.root_stack.setCurrentIndex(self._root_pages[name])
|
|
156
|
+
|
|
157
|
+
def load_flow(self, flow: FlowSpec) -> None:
|
|
158
|
+
self.context = {}
|
|
159
|
+
self.pipeline = list(flow.on_finish)
|
|
160
|
+
self.steps = list(flow.steps)
|
|
161
|
+
self.rebuild_pages(go_to=0)
|
|
162
|
+
|
|
163
|
+
def rebuild_pages(self, go_to: int = 0) -> None:
|
|
164
|
+
while self.stack.count():
|
|
165
|
+
w = self.stack.widget(0)
|
|
166
|
+
self.stack.removeWidget(w)
|
|
167
|
+
w.deleteLater()
|
|
168
|
+
|
|
169
|
+
for spec in self.steps:
|
|
170
|
+
self.stack.addWidget(self.make_page(spec))
|
|
171
|
+
|
|
172
|
+
self.stack.setCurrentIndex(max(0, min(go_to, self.stack.count() - 1)))
|
|
173
|
+
self.update_nav()
|
|
174
|
+
|
|
175
|
+
def update_nav(self) -> None:
|
|
176
|
+
i = self.current_index()
|
|
177
|
+
self.back_btn.setEnabled(i > 0)
|
|
178
|
+
last = self.stack.count() > 0 and i == self.stack.count() - 1
|
|
179
|
+
text = self.i18n.t('common.start') if last else self.i18n.t('common.next')
|
|
180
|
+
self.next_btn.setText(text)
|
|
181
|
+
|
|
182
|
+
def validate_step(self, spec: StepSpec, value: Any) -> tuple[bool, str]:
|
|
183
|
+
if spec.validator:
|
|
184
|
+
return spec.validator(value)
|
|
185
|
+
return True, ''
|
|
186
|
+
|
|
187
|
+
def confirm_run(self) -> bool:
|
|
188
|
+
reply = QMessageBox(self)
|
|
189
|
+
reply.setWindowTitle(self.i18n.t('dialogs.confirm.title'))
|
|
190
|
+
reply.setText(self.i18n.t('dialogs.confirm.run_pipeline_text'))
|
|
191
|
+
|
|
192
|
+
yes_button = reply.addButton(
|
|
193
|
+
self.i18n.t('dialogs.confirm.yes'), QMessageBox.ButtonRole.YesRole
|
|
194
|
+
)
|
|
195
|
+
no_button = reply.addButton(
|
|
196
|
+
self.i18n.t('dialogs.confirm.no'), QMessageBox.ButtonRole.NoRole
|
|
197
|
+
)
|
|
198
|
+
reply.setDefaultButton(no_button)
|
|
199
|
+
reply.setIcon(QMessageBox.Icon.Question)
|
|
200
|
+
reply.exec()
|
|
201
|
+
|
|
202
|
+
return reply.clickedButton() == yes_button
|
|
203
|
+
|
|
204
|
+
def show_warn(self, msg: str) -> None:
|
|
205
|
+
QMessageBox.warning(self, self.i18n.t('dialogs.warn_title'), msg)
|
|
206
|
+
|
|
207
|
+
def show_success(self, msg: str) -> None:
|
|
208
|
+
QMessageBox.information(self, self.i18n.t('dialogs.success_title'), msg)
|
|
209
|
+
|
|
210
|
+
def show_error(self, msg: str) -> None:
|
|
211
|
+
QMessageBox.critical(self, self.i18n.t('dialogs.error_title'), msg)
|
|
212
|
+
|
|
213
|
+
def back(self) -> None:
|
|
214
|
+
i = self.current_index()
|
|
215
|
+
if i > 0:
|
|
216
|
+
self.stack.setCurrentIndex(i - 1)
|
|
217
|
+
self.update_nav()
|
|
218
|
+
|
|
219
|
+
def foward(self) -> None:
|
|
220
|
+
spec = self.current_spec()
|
|
221
|
+
page = self.stack.currentWidget()
|
|
222
|
+
val = self.get_page_value(page) if isinstance(page, WidgetAbstract) else None
|
|
223
|
+
|
|
224
|
+
ok, err = self.validate_step(spec, val)
|
|
225
|
+
if not ok:
|
|
226
|
+
self.show_warn(err)
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
self.context[spec.key] = val
|
|
230
|
+
|
|
231
|
+
if self.current_index() == self.stack.count() - 1:
|
|
232
|
+
can_run = self.confirm_run()
|
|
233
|
+
|
|
234
|
+
if not can_run:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
self.run_pipeline_threaded()
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
self.stack.setCurrentIndex(self.current_index() + 1)
|
|
241
|
+
self.update_nav()
|
|
242
|
+
|
|
243
|
+
def run_pipeline_threaded(self) -> None:
|
|
244
|
+
if not self.pipeline:
|
|
245
|
+
self.show_success(self.i18n.t('messages.no_pipeline'))
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
self.next_btn.setEnabled(False)
|
|
249
|
+
self.back_btn.setEnabled(False)
|
|
250
|
+
|
|
251
|
+
self.set_root_page(self.ROOT_LOADING)
|
|
252
|
+
|
|
253
|
+
ctx_copy = self.context.copy()
|
|
254
|
+
|
|
255
|
+
self._thread = QThread(self)
|
|
256
|
+
self._worker = PipelineWorker(ctx_copy, self.pipeline, self.logger, self._async_loop)
|
|
257
|
+
self._worker.moveToThread(self._thread)
|
|
258
|
+
|
|
259
|
+
self._thread.started.connect(self._worker.run)
|
|
260
|
+
self._worker.progress.connect(self.loading_page.set_progress)
|
|
261
|
+
self._worker.status.connect(self.loading_page.set_status)
|
|
262
|
+
self._worker.finished.connect(self.on_finished)
|
|
263
|
+
self._worker.error.connect(self.on_error)
|
|
264
|
+
|
|
265
|
+
self._worker.finished.connect(self._thread.quit)
|
|
266
|
+
self._worker.error.connect(self._thread.quit)
|
|
267
|
+
self._thread.finished.connect(self._worker.deleteLater)
|
|
268
|
+
self._thread.finished.connect(self._thread.deleteLater)
|
|
269
|
+
|
|
270
|
+
self._thread.start()
|
|
271
|
+
|
|
272
|
+
@Slot(dict)
|
|
273
|
+
def on_finished(self, ctx: dict[str, Any]) -> None:
|
|
274
|
+
self.context.update(ctx)
|
|
275
|
+
self.next_btn.setEnabled(True)
|
|
276
|
+
self.back_btn.setEnabled(True)
|
|
277
|
+
self.show_success(self.i18n.t('messages.flow_success'))
|
|
278
|
+
self.restart_to_beginning()
|
|
279
|
+
|
|
280
|
+
@Slot(str)
|
|
281
|
+
def on_error(self, error_msg: str) -> None:
|
|
282
|
+
self.next_btn.setEnabled(True)
|
|
283
|
+
self.back_btn.setEnabled(True)
|
|
284
|
+
prefix = self.i18n.t('messages.flow_error_prefix')
|
|
285
|
+
self.show_error(prefix + error_msg)
|
|
286
|
+
self.restart_to_beginning()
|
|
287
|
+
|
|
288
|
+
def restart_to_beginning(self) -> None:
|
|
289
|
+
self.load_flow(self.flow)
|
|
290
|
+
self.set_root_page(self.ROOT_WIZARD)
|
|
291
|
+
|
|
292
|
+
def go_to_wizard_page(self) -> None:
|
|
293
|
+
self.set_root_page(self.ROOT_WIZARD)
|
|
294
|
+
|
|
295
|
+
def closeEvent(self, event: QCloseEvent): # noqa: N802
|
|
296
|
+
if not event.spontaneous():
|
|
297
|
+
event.accept()
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
reply = QMessageBox(self)
|
|
301
|
+
reply.setWindowTitle(self.i18n.t('dialogs.close.title'))
|
|
302
|
+
reply.setText(self.i18n.t('dialogs.close.exit_text'))
|
|
303
|
+
yes_button = reply.addButton(
|
|
304
|
+
self.i18n.t('dialogs.confirm.yes'), QMessageBox.ButtonRole.YesRole
|
|
305
|
+
)
|
|
306
|
+
no_button = reply.addButton(
|
|
307
|
+
self.i18n.t('dialogs.confirm.no'), QMessageBox.ButtonRole.NoRole
|
|
308
|
+
)
|
|
309
|
+
reply.setDefaultButton(no_button)
|
|
310
|
+
reply.setIcon(QMessageBox.Icon.Question)
|
|
311
|
+
|
|
312
|
+
reply.exec()
|
|
313
|
+
|
|
314
|
+
if reply.clickedButton() != yes_button:
|
|
315
|
+
event.ignore()
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
event.accept()
|
botflow/pages.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
from PySide6.QtCore import QSize, Qt, Slot
|
|
4
|
+
from PySide6.QtGui import QMovie
|
|
5
|
+
from PySide6.QtWidgets import (
|
|
6
|
+
QHBoxLayout,
|
|
7
|
+
QLabel,
|
|
8
|
+
QProgressBar,
|
|
9
|
+
QPushButton,
|
|
10
|
+
QSizePolicy,
|
|
11
|
+
QSpacerItem,
|
|
12
|
+
QVBoxLayout,
|
|
13
|
+
QWidget,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from botflow.qss import qss_to_string
|
|
17
|
+
from botflow.resolver import find_resource_file
|
|
18
|
+
from botflow.types import I18n, LoadingAbstract
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InitialPage(QWidget):
|
|
22
|
+
def __init__(self, name: str, on_start: Callable, i18n: I18n):
|
|
23
|
+
super().__init__()
|
|
24
|
+
style_file = find_resource_file('styles/initial_page.qss')
|
|
25
|
+
qss_string = qss_to_string(style_file)
|
|
26
|
+
self.setStyleSheet(qss_string)
|
|
27
|
+
|
|
28
|
+
title_label = QLabel(name)
|
|
29
|
+
title_label.setProperty('role', 'initial_title')
|
|
30
|
+
title_label.setWordWrap(True)
|
|
31
|
+
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
32
|
+
|
|
33
|
+
row = QHBoxLayout()
|
|
34
|
+
row.setContentsMargins(0, 0, 0, 0)
|
|
35
|
+
row.addWidget(title_label, alignment=Qt.AlignmentFlag.AlignVCenter)
|
|
36
|
+
|
|
37
|
+
row_container = QWidget()
|
|
38
|
+
row_container.setLayout(row)
|
|
39
|
+
|
|
40
|
+
start_text = i18n.t('common.start') if i18n else 'Start'
|
|
41
|
+
start_btn = QPushButton(start_text)
|
|
42
|
+
start_btn.setProperty('role', 'initial_button')
|
|
43
|
+
start_btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
44
|
+
if on_start:
|
|
45
|
+
start_btn.clicked.connect(on_start)
|
|
46
|
+
|
|
47
|
+
main_layout = QVBoxLayout(self)
|
|
48
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
49
|
+
main_layout.addStretch()
|
|
50
|
+
main_layout.addWidget(row_container, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
51
|
+
main_layout.addSpacerItem(
|
|
52
|
+
QSpacerItem(
|
|
53
|
+
0,
|
|
54
|
+
58,
|
|
55
|
+
QSizePolicy.Policy.Minimum,
|
|
56
|
+
QSizePolicy.Policy.Fixed,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
main_layout.addWidget(start_btn, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
60
|
+
main_layout.addStretch()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LoadingPage(LoadingAbstract):
|
|
64
|
+
def __init__(self, i18n: I18n):
|
|
65
|
+
super().__init__()
|
|
66
|
+
style_file = find_resource_file('styles/loading_page.qss')
|
|
67
|
+
qss_string = qss_to_string(style_file)
|
|
68
|
+
self.setStyleSheet(qss_string)
|
|
69
|
+
|
|
70
|
+
loading_gif = find_resource_file('assets/loading.gif')
|
|
71
|
+
gif_label = QLabel(self)
|
|
72
|
+
gif_label.setProperty('role', 'loading_icon')
|
|
73
|
+
movie = QMovie(loading_gif.as_posix())
|
|
74
|
+
movie.setScaledSize(QSize(64, 64))
|
|
75
|
+
gif_label.setMovie(movie)
|
|
76
|
+
movie.start()
|
|
77
|
+
|
|
78
|
+
title_text = i18n.t('loading.title') if i18n else 'In Progress...'
|
|
79
|
+
title_lbl = QLabel(title_text, self)
|
|
80
|
+
title_lbl.setProperty('role', 'loading_title')
|
|
81
|
+
|
|
82
|
+
header = QHBoxLayout()
|
|
83
|
+
header.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
84
|
+
header.addWidget(gif_label)
|
|
85
|
+
header.addSpacing(5)
|
|
86
|
+
header.addWidget(title_lbl)
|
|
87
|
+
|
|
88
|
+
self.status_lbl = QLabel('', self)
|
|
89
|
+
self.status_lbl.setProperty('role', 'loading_status')
|
|
90
|
+
|
|
91
|
+
self.progress_bar = QProgressBar(self)
|
|
92
|
+
self.progress_bar.setRange(0, 100)
|
|
93
|
+
self.progress_bar.setTextVisible(False)
|
|
94
|
+
self.progress_bar.setProperty('role', 'loading_progress')
|
|
95
|
+
|
|
96
|
+
progress_col = QVBoxLayout()
|
|
97
|
+
progress_col.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
98
|
+
progress_col.setSpacing(6)
|
|
99
|
+
progress_col.addWidget(self.status_lbl, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
100
|
+
progress_col.addWidget(self.progress_bar)
|
|
101
|
+
|
|
102
|
+
layout = QVBoxLayout(self)
|
|
103
|
+
layout.setContentsMargins(0, 24, 0, 0)
|
|
104
|
+
layout.addLayout(header)
|
|
105
|
+
layout.addSpacerItem(
|
|
106
|
+
QSpacerItem(0, 32, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
|
107
|
+
)
|
|
108
|
+
layout.addLayout(progress_col)
|
|
109
|
+
layout.addStretch()
|
|
110
|
+
|
|
111
|
+
@Slot(int)
|
|
112
|
+
def set_progress(self, value: int):
|
|
113
|
+
self.progress_bar.setValue(max(0, min(value, 100)))
|
|
114
|
+
|
|
115
|
+
@Slot(str)
|
|
116
|
+
def set_status(self, text: str):
|
|
117
|
+
self.status_lbl.setText(text)
|
botflow/qss.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Union
|
|
3
|
+
|
|
4
|
+
PathLike = Union[str, Path]
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def qss_to_string(*paths: PathLike) -> str:
|
|
8
|
+
parts: list[str] = []
|
|
9
|
+
|
|
10
|
+
for p in paths:
|
|
11
|
+
p = Path(p)
|
|
12
|
+
|
|
13
|
+
if p.suffix != '.qss':
|
|
14
|
+
raise ValueError('File must have a .qss extension.')
|
|
15
|
+
|
|
16
|
+
if not p.is_file():
|
|
17
|
+
raise FileNotFoundError(f'The file {p} does not exist.')
|
|
18
|
+
|
|
19
|
+
parts.append(p.read_text(encoding='utf-8'))
|
|
20
|
+
|
|
21
|
+
return '\n'.join(parts) + '\n'
|