From d84068afd92ff8a474c3858f21fcfd4b785588af Mon Sep 17 00:00:00 2001 From: tangkong Date: Fri, 31 May 2024 17:09:04 -0700 Subject: [PATCH 01/18] ENH: Add expand_path to undo simplify_path, add origin tracking to FindReplaceAction --- atef/find_replace.py | 46 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/atef/find_replace.py b/atef/find_replace.py index 2c9ef671..1d32ae88 100644 --- a/atef/find_replace.py +++ b/atef/find_replace.py @@ -138,6 +138,38 @@ def simplify_path(path: List[Tuple[Any, Any]]) -> List[Tuple[str, Any]]: return simplified_path +def expand_path(path: List[Tuple[str, Any]], target: Any) -> List[Tuple[Any, Any]]: + """ + Expands ``path`` using ``target`` as the object to traverse. + Replaces all string type references with the object in question. + + The inverse of ``simplify_path`` + + Parameters + ---------- + path : List[Tuple[str, Any]] + the simplified path to expand + target : Any + the object that ``path`` is referring to + + Returns + ------- + List[Tuple[Any, Any]] + the expanded path + """ + new_path = [] + new_path.append((target, path[0][1])) + # Look at all path segments after index 0, replace the next object in line. + for idx in range(len(path) - 1): + if path[idx+1][0].startswith("__") and path[idx+1][0].endswith("__"): + seg_object = path[idx+1][0] + else: + seg_object = get_item_from_path(path[:idx+1], item=target) + new_path.append((seg_object, path[idx + 1][1])) + + return new_path + + def get_item_from_path( path: List[Tuple[Any, Any]], item: Optional[Any] = None @@ -323,15 +355,21 @@ class RegexFindReplace: def to_action(self, target: Optional[Any] = None) -> FindReplaceAction: """Create FindReplaceAction from a SerializableFindReplaceAction""" + flags = re.IGNORECASE if not self.case_sensitive else 0 try: - re.compile(self.search_regex) + search_regex = re.compile(self.search_regex, flags=flags) except re.error: raise ValueError(f'regex is not valid: {self.search_regex}, ' 'could not construct FindReplaceAction') replace_fn = get_default_replace_fn( - self.replace_text, re.compile(self.search_regex) + self.replace_text, re.compile(search_regex) ) - return FindReplaceAction(target=target, path=self.path, replace_fn=replace_fn) + if target: + path = expand_path(self.path, target=target) + else: + path = self.path + + return FindReplaceAction(target=target, path=path, replace_fn=replace_fn) @dataclass @@ -341,6 +379,8 @@ class FindReplaceAction: # Union[ConfigurationFile, ProcedureFile], but circular imports target: Optional[Any] = None + origin: Optional[RegexFindReplace] = None + def apply( self, target: Optional[Union[ConfigurationFile, ProcedureFile]] = None, From de2a6d333cc03c3869f8f4c9dac1ad714a4e0d7f Mon Sep 17 00:00:00 2001 From: tangkong Date: Fri, 31 May 2024 17:09:16 -0700 Subject: [PATCH 02/18] ENH: add signal to FindReplacePage to notify if data has been updated, store simplified path RegexFindReplace with actions for serialization --- atef/widgets/config/find_replace.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/atef/widgets/config/find_replace.py b/atef/widgets/config/find_replace.py index 021c6fd0..1d0caa03 100644 --- a/atef/widgets/config/find_replace.py +++ b/atef/widgets/config/find_replace.py @@ -16,14 +16,16 @@ from apischema import ValidationError, serialize from pcdsutils.qt.callbacks import WeakPartialMethodSlot from qtpy import QtCore, QtWidgets +from qtpy.QtCore import Signal as QSignal from atef.cache import get_signal_cache from atef.config import ConfigurationFile, PreparedFile from atef.find_replace import (FindReplaceAction, MatchFunction, - ReplaceFunction, get_deepest_dataclass_in_path, + RegexFindReplace, ReplaceFunction, + get_deepest_dataclass_in_path, get_default_match_fn, get_default_replace_fn, get_item_from_path, patch_client_cache, - walk_find_match) + simplify_path, walk_find_match) from atef.procedure import PreparedProcedureFile, ProcedureFile from atef.util import get_happi_client from atef.widgets.config.run_base import create_tree_from_file @@ -392,6 +394,8 @@ class FillTemplatePage(DesignerDisplay, QtWidgets.QWidget): busy_thread: Optional[BusyCursorThread] + data_updated: ClassVar[QtCore.Signal] = QSignal() + filename = 'fill_template_page.ui' def __init__( @@ -471,11 +475,14 @@ def open_file(self, *args, filename: Optional[str] = None, **kwargs) -> None: def finish_setup(): self.details_list.clear() + self.staged_list.clear() + self.staged_actions.clear() self.setup_edits_table() self.setup_tree_view() self.setup_devices_list() self.update_title() self.vert_splitter.setSizes([200, 200, 200, 200,]) + self.data_updated.emit() self.busy_thread = BusyCursorThread( func=partial(self.load_file, filepath=filename) @@ -803,6 +810,7 @@ def refresh_staged_table(self) -> None: row_widget.button_box.removeButton(ok_button) self.update_title() + self.data_updated.emit() class TemplateEditRowWidget(DesignerDisplay, QtWidgets.QWidget): @@ -911,8 +919,15 @@ def refresh_paths(self) -> None: # generator can be unstable if dataclass changes during walk # this is only ok because we consume generator entirely for path in self.match_paths: + origin_action = RegexFindReplace( + path=simplify_path(path), + search_regex=self._search_regex.pattern, + replace_text=self.replace_edit.text(), + case_sensitive=self.case_button.isChecked(), + ) action = FindReplaceAction(target=self.orig_file, path=path, - replace_fn=self._replace_fn) + replace_fn=self._replace_fn, + origin=origin_action) self.actions.append(action) From 9f34ba54a65d01b4952e3b97b04f54a79973163d Mon Sep 17 00:00:00 2001 From: tangkong Date: Fri, 31 May 2024 17:10:09 -0700 Subject: [PATCH 03/18] ENH/WIP: first pass add passive TemplateConfigurationPage --- atef/widgets/config/page.py | 66 ++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/atef/widgets/config/page.py b/atef/widgets/config/page.py index b17bcb18..a73a237c 100644 --- a/atef/widgets/config/page.py +++ b/atef/widgets/config/page.py @@ -35,7 +35,7 @@ NotEquals, Range, ValueSet) from atef.config import (Configuration, ConfigurationGroup, DeviceConfiguration, PVConfiguration, - ToolConfiguration) + TemplateConfiguration, ToolConfiguration) from atef.procedure import (ComparisonToTarget, DescriptionStep, PassiveStep, PreparedDescriptionStep, PreparedPassiveStep, PreparedProcedureStep, PreparedSetValueStep, @@ -46,6 +46,7 @@ GeneralProcedureWidget, PassiveEditWidget, SetValueEditWidget) +from atef.widgets.config.find_replace import FillTemplatePage from atef.widgets.config.paged_table import SETUP_SLOT_ROLE, PagedTableWidget from atef.widgets.config.run_active import (DescriptionRunWidget, PassiveRunWidget, @@ -661,6 +662,7 @@ class ConfigurationGroupPage(DesignerDisplay, PageWidget): DeviceConfiguration, PVConfiguration, ToolConfiguration, + TemplateConfiguration, ) } @@ -1401,6 +1403,67 @@ def new_tool_selected(self, tool_name: str) -> None: self.new_tool_widget(new_tool) +class TemplateConfigurationPage(DesignerDisplay, PageWidget): + """Widget for configuring Templated checkouts within other checkouts""" + filename = "template_group_page.ui" + + template_page_widget: FillTemplatePage + template_page_placeholder: QWidget + + data: TemplateConfiguration + + def __init__(self, data: TemplateConfiguration, **kwargs): + super().__init__(data=data, **kwargs) + self.setup_name_desc_tags_init() + self.setup_template_widget_init() + + def setup_template_widget_init(self) -> None: + self.template_page_widget = FillTemplatePage() + + def finish_widget_setup(*args, **kwargs): + # only run this once, when we're loading an existing template checkout + # subsequent opening of files do not populate staged list + self.template_page_widget.data_updated.disconnect(finish_widget_setup) + + target = getattr(self.template_page_widget, 'orig_file', None) + if target is not None: + for regexFR in self.data.edits: + action = regexFR.to_action(target=target) + self.template_page_widget.stage_edit(action) + self.template_page_widget.refresh_staged_table() + + self.template_page_widget.data_updated.connect(finish_widget_setup) + self.template_page_widget.open_file(filename=self.data.filename) + + # remove save as button + self.template_page_widget.save_button.hide() + + # setup update data with each change to staged, new file + self.template_page_widget.data_updated.connect(self.update_data) + self.template_page_widget.data_updated.connect(self.update_data) + + self.insert_widget(self.template_page_widget, self.template_page_placeholder) + + def update_data(self) -> None: + """Update the dataclass with information from the FillTemplatePage widget""" + # FillTemplatePage is not a normal datawidget, and does not have a bridge. + # Luckily there isn't much to track, via children, so we can do it manually + self.data.filename = self.template_page_widget.fp + staged_list = self.template_page_widget.staged_list + edits = [] + for idx in range(staged_list.count()): + row_data = staged_list.itemWidget(staged_list.item(idx)).data + edits.append(row_data.origin) + + self.data.edits = edits + print(f'update_data: {self.data.filename}, {len(self.data.edits)}') + + def post_tree_setup(self) -> None: + super().post_tree_setup() + + self.setup_name_desc_tags_link() + + class ProcedureGroupPage(DesignerDisplay, PageWidget): """ Top level page for Procedures (active checkout) @@ -2362,6 +2425,7 @@ def clean_up_any_comparison(self) -> None: DeviceConfiguration: DeviceConfigurationPage, PVConfiguration: PVConfigurationPage, ToolConfiguration: ToolConfigurationPage, + TemplateConfiguration: TemplateConfigurationPage, # Active Pages ProcedureGroup: ProcedureGroupPage, DescriptionStep: StepPage, From e6285883272376132cc82ae510fbea7ffdb3d6dc Mon Sep 17 00:00:00 2001 From: tangkong Date: Sun, 2 Jun 2024 12:02:12 -0700 Subject: [PATCH 04/18] MNT: remove print, wire up parent button --- atef/widgets/config/page.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atef/widgets/config/page.py b/atef/widgets/config/page.py index a73a237c..3fae46b4 100644 --- a/atef/widgets/config/page.py +++ b/atef/widgets/config/page.py @@ -1416,6 +1416,7 @@ def __init__(self, data: TemplateConfiguration, **kwargs): super().__init__(data=data, **kwargs) self.setup_name_desc_tags_init() self.setup_template_widget_init() + self.post_tree_setup() def setup_template_widget_init(self) -> None: self.template_page_widget = FillTemplatePage() @@ -1456,7 +1457,6 @@ def update_data(self) -> None: edits.append(row_data.origin) self.data.edits = edits - print(f'update_data: {self.data.filename}, {len(self.data.edits)}') def post_tree_setup(self) -> None: super().post_tree_setup() From 1c838f528ac5536265d10561545a3a62873dceb1 Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 3 Jun 2024 08:22:14 -0700 Subject: [PATCH 05/18] GUI: Template group page ui file --- atef/ui/template_group_page.ui | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 atef/ui/template_group_page.ui diff --git a/atef/ui/template_group_page.ui b/atef/ui/template_group_page.ui new file mode 100644 index 00000000..d0d3ae92 --- /dev/null +++ b/atef/ui/template_group_page.ui @@ -0,0 +1,41 @@ + + + Form + + + + 0 + 0 + 400 + 380 + + + + Form + + + + + + + 0 + 0 + + + + + + + + Qt::Horizontal + + + + + + + + + + + From 026c0b04c77d2c97b6a378fbf53bc1242921c9ae Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 3 Jun 2024 14:43:17 -0700 Subject: [PATCH 06/18] MNT: add TemplateStep branch to PreparedProcedureStep.from_origin factory classmethod --- atef/procedure.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/atef/procedure.py b/atef/procedure.py index c75efbd6..038636c7 100644 --- a/atef/procedure.py +++ b/atef/procedure.py @@ -616,6 +616,10 @@ def from_origin( return PreparedPlanStep.from_origin( origin=step, parent=parent ) + if isinstance(step, TemplateStep): + return PreparedTemplateStep.from_origin( + step=step, parent=parent, + ) raise NotImplementedError(f"Step type unsupported: {type(step)}") except Exception as ex: From d3489893f5b43e619cc9df8e871037b6f687c87c Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 3 Jun 2024 14:45:42 -0700 Subject: [PATCH 07/18] MNT: also update data when item removed from staged list --- atef/widgets/config/find_replace.py | 1 + 1 file changed, 1 insertion(+) diff --git a/atef/widgets/config/find_replace.py b/atef/widgets/config/find_replace.py index 1d0caa03..d60a443f 100644 --- a/atef/widgets/config/find_replace.py +++ b/atef/widgets/config/find_replace.py @@ -748,6 +748,7 @@ def remove_item_from_staged(self, item: QtWidgets.QListWidgetItem) -> None: self.staged_actions.remove(data) self.staged_list.takeItem(self.staged_list.row(item)) self.update_title() + self.data_updated.emit() def stage_item_from_details( self, From 953ef222fe1daaee1d3475a0ef0cc0c99f2b8dfb Mon Sep 17 00:00:00 2001 From: tangkong Date: Mon, 3 Jun 2024 14:57:29 -0700 Subject: [PATCH 08/18] ENH: add passive and active checkout TemplatePage, use TempateRunWidget for both --- atef/ui/template_run_widget.ui | 41 +++++++++ atef/widgets/config/page.py | 65 +++++++++++--- atef/widgets/config/run_active.py | 137 +++++++++++++++++++++++++++++- atef/widgets/config/window.py | 9 +- 4 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 atef/ui/template_run_widget.ui diff --git a/atef/ui/template_run_widget.ui b/atef/ui/template_run_widget.ui new file mode 100644 index 00000000..ac851ff8 --- /dev/null +++ b/atef/ui/template_run_widget.ui @@ -0,0 +1,41 @@ + + + Form + + + + 0 + 0 + 342 + 297 + + + + Form + + + + + + + + + Qt::Horizontal + + + + + + + + + + Refresh Status Icons + + + + + + + + diff --git a/atef/widgets/config/page.py b/atef/widgets/config/page.py index 3fae46b4..df28b9c9 100644 --- a/atef/widgets/config/page.py +++ b/atef/widgets/config/page.py @@ -34,12 +34,15 @@ Equals, Greater, GreaterOrEqual, Less, LessOrEqual, NotEquals, Range, ValueSet) from atef.config import (Configuration, ConfigurationGroup, - DeviceConfiguration, PVConfiguration, - TemplateConfiguration, ToolConfiguration) + DeviceConfiguration, PreparedConfiguration, + PreparedGroup, PreparedTemplateConfiguration, + PVConfiguration, TemplateConfiguration, + ToolConfiguration) from atef.procedure import (ComparisonToTarget, DescriptionStep, PassiveStep, PreparedDescriptionStep, PreparedPassiveStep, PreparedProcedureStep, PreparedSetValueStep, - ProcedureGroup, ProcedureStep, SetValueStep) + PreparedTemplateStep, ProcedureGroup, + ProcedureStep, SetValueStep, TemplateStep) from atef.tools import Ping, PingResult, Tool, ToolResult from atef.type_hints import AnyDataclass from atef.widgets.config.data_active import (CheckRowWidget, @@ -50,7 +53,8 @@ from atef.widgets.config.paged_table import SETUP_SLOT_ROLE, PagedTableWidget from atef.widgets.config.run_active import (DescriptionRunWidget, PassiveRunWidget, - SetValueRunWidget) + SetValueRunWidget, + TemplateRunWidget) from atef.widgets.config.run_base import RunCheck from atef.widgets.utils import ExpandableFrame, insert_widget @@ -1433,16 +1437,15 @@ def finish_widget_setup(*args, **kwargs): self.template_page_widget.stage_edit(action) self.template_page_widget.refresh_staged_table() + # setup update data with each change to staged, new file + self.template_page_widget.data_updated.connect(self.update_data) + self.template_page_widget.data_updated.connect(finish_widget_setup) self.template_page_widget.open_file(filename=self.data.filename) # remove save as button self.template_page_widget.save_button.hide() - # setup update data with each change to staged, new file - self.template_page_widget.data_updated.connect(self.update_data) - self.template_page_widget.data_updated.connect(self.update_data) - self.insert_widget(self.template_page_widget, self.template_page_placeholder) def update_data(self) -> None: @@ -1484,7 +1487,8 @@ class ProcedureGroupPage(DesignerDisplay, PageWidget): ProcedureGroup, DescriptionStep, PassiveStep, - SetValueStep + SetValueStep, + TemplateStep, ) } @@ -1682,7 +1686,7 @@ class StepPage(DesignerDisplay, PageWidget): step_map: ClassVar[Dict[ProcedureStep, DataWidget]] = { DescriptionStep: None, PassiveStep: PassiveEditWidget, - SetValueStep: SetValueEditWidget + SetValueStep: SetValueEditWidget, } step_types: Dict[str, ProcedureStep] @@ -2001,6 +2005,43 @@ def remove_table_data(self, data: Any): self.data.success_criteria.remove(data) +class RunConfigPage(DesignerDisplay, PageWidget): + """ + Base Widget for running active checkout steps and displaying their + results + + Will always have a RunCheck widget, which should be connected after + instantiation via ``RunCheck.setup_buttons()`` + + Contains a placeholder for a DataWidget + """ + filename = 'run_step_page.ui' + + run_widget_placeholder: QWidget + run_widget: DataWidget + run_check_placeholder: QWidget + run_check: RunCheck + + run_widget_map: ClassVar[Dict[Union[PreparedConfiguration, PreparedGroup], DataWidget]] = { + PreparedTemplateConfiguration: TemplateRunWidget, + } + + def __init__(self, *args, data, **kwargs): + super().__init__(*args, data, **kwargs) + self.run_check = RunCheck(data=[data]) + self.insert_widget(self.run_check, self.run_check_placeholder) + # gather run_widget + run_widget_cls = self.run_widget_map[type(data)] + self.run_widget = run_widget_cls(data=data) + + self.insert_widget(self.run_widget, self.run_widget_placeholder) + + if isinstance(data, PreparedTemplateConfiguration): + self.run_check.run_button.clicked.connect(self.run_widget.run_config) + + self.post_tree_setup() + + class RunStepPage(DesignerDisplay, PageWidget): """ Base Widget for running active checkout steps and displaying their @@ -2022,6 +2063,7 @@ class RunStepPage(DesignerDisplay, PageWidget): PreparedDescriptionStep: DescriptionRunWidget, PreparedPassiveStep: PassiveRunWidget, PreparedSetValueStep: SetValueRunWidget, + PreparedTemplateStep: TemplateRunWidget, } def __init__(self, *args, data, **kwargs): @@ -2034,7 +2076,7 @@ def __init__(self, *args, data, **kwargs): self.insert_widget(self.run_widget, self.run_widget_placeholder) - if isinstance(data, PreparedPassiveStep): + if isinstance(data, (PreparedPassiveStep, PreparedTemplateStep)): self.run_check.run_button.clicked.connect(self.run_widget.run_config) elif isinstance(data, PreparedSetValueStep): self.run_check.busy_thread.task_finished.connect( @@ -2431,6 +2473,7 @@ def clean_up_any_comparison(self) -> None: DescriptionStep: StepPage, PassiveStep: StepPage, SetValueStep: StepPage, + TemplateStep: TemplateConfigurationPage, } # add comparison pages diff --git a/atef/widgets/config/run_active.py b/atef/widgets/config/run_active.py index d00f32f4..0d3431cb 100644 --- a/atef/widgets/config/run_active.py +++ b/atef/widgets/config/run_active.py @@ -7,17 +7,24 @@ import asyncio import logging import pathlib +from typing import Optional, Union import qtawesome -from qtpy import QtWidgets +from pcdsutils.qt.callbacks import WeakPartialMethodSlot +from qtpy import QtCore, QtWidgets from atef.config import (ConfigurationFile, PreparedFile, - PreparedSignalComparison, run_passive_step) + PreparedSignalComparison, + PreparedTemplateConfiguration, run_passive_step) +from atef.find_replace import FindReplaceAction from atef.procedure import (PreparedDescriptionStep, PreparedPassiveStep, - PreparedSetValueStep, PreparedValueToSignal) + PreparedProcedureFile, PreparedSetValueStep, + PreparedTemplateStep, PreparedValueToSignal, + ProcedureFile) from atef.widgets.config.data_base import DataWidget +from atef.widgets.config.find_replace import FindReplaceRow from atef.widgets.config.run_base import ResultStatus, create_tree_from_file -from atef.widgets.config.utils import ConfigTreeModel +from atef.widgets.config.utils import ConfigTreeModel, walk_tree_items from atef.widgets.core import DesignerDisplay from atef.widgets.utils import insert_widget @@ -182,3 +189,125 @@ def __init__(self, *args, data: PreparedSignalComparison, **kwargs): self.name_label.setText(data.name) self.target_label.setText(data.signal.name) self.check_summary_label.setText(data.comparison.describe()) + + +class TemplateRunWidget(DesignerDisplay, DataWidget): + """Widget for viewing TemplateConfigurations, either passive or active""" + filename = 'template_run_widget.ui' + + refresh_button: QtWidgets.QPushButton + tree_view: QtWidgets.QTreeView + edits_list: QtWidgets.QListWidget + + def __init__( + self, + *args, + data: Union[PreparedTemplateConfiguration, PreparedTemplateStep], + **kwargs + ): + super().__init__(*args, data=data, **kwargs) + self._partial_slots: list[WeakPartialMethodSlot] = [] + self.orig_step = getattr(data, 'config', None) or getattr(data, 'origin', None) + if not self.orig_step.filename: + logger.warning('no passive step to run') + return + + self.prepared_file = self.bridge.file.get() + self.orig_file = self.prepared_file.file + + fp = pathlib.Path(self.orig_step.filename) + if not fp.is_file(): + return + + if isinstance(self.orig_file, ConfigurationFile): + self.unedited_file = ConfigurationFile.from_filename(fp) + elif isinstance(self.orig_file, ProcedureFile): + self.unedited_file = ProcedureFile.from_filename(fp) + + self.setup_tree() + self.setup_edits_list() + + self.refresh_button.setIcon(qtawesome.icon('fa.refresh')) + self.refresh_button.clicked.connect(self.run_config) + + def setup_tree(self): + """Sets up ConfigTreeModel with the data from the ConfigurationFile""" + + root_item = create_tree_from_file( + data=self.orig_file, + prepared_file=self.prepared_file + ) + + model = ConfigTreeModel(data=root_item) + self.tree_view.setModel(model) + + self.tree_view.header().swapSections(0, 1) + self.tree_view.expandAll() + + def setup_edits_list(self): + """Populate edit_list with edits for display. Links """ + target = self.unedited_file + if target is not None: + for regexFR in self.orig_step.edits: + action = regexFR.to_action(target=target) + l_item = QtWidgets.QListWidgetItem() + row_widget = FindReplaceRow(data=action) + row_widget.button_box.hide() + l_item.setSizeHint(QtCore.QSize(row_widget.width(), row_widget.height())) + self.edits_list.addItem(l_item) + self.edits_list.setItemWidget(l_item, row_widget) + + # reveal tree when details selected + reveal_slot = WeakPartialMethodSlot( + row_widget, row_widget.details_button.pressed, + self.reveal_tree_item, self.edits_list, action=row_widget.data + ) + self._partial_slots.append(reveal_slot) + + reveal_staged_slot = WeakPartialMethodSlot( + self.edits_list, self.edits_list.itemSelectionChanged, + self.reveal_tree_item, self.edits_list, + ) + self._partial_slots.append(reveal_staged_slot) + + def reveal_tree_item( + self, + this_list: QtWidgets.QListWidget, + action: Optional[FindReplaceAction] = None + ) -> None: + """Reveal and highlight the tree-item referenced by ``action``""" + if not action: + curr_widget = this_list.itemWidget(this_list.currentItem()) + if curr_widget is None: # selection has likely been removed + return + + action: FindReplaceAction = curr_widget.data + + model: ConfigTreeModel = self.tree_view.model() + + closest_index = None + # Gather objects in path, ignoring steps that jump into lists etc + path_objs = [part[0] for part in action.path if not isinstance(part[0], str)] + for tree_item in walk_tree_items(model.root_item): + if tree_item.orig_data in path_objs: + closest_index = model.index_from_item(tree_item) + + if closest_index: + self.tree_view.setCurrentIndex(closest_index) + self.tree_view.scrollTo(closest_index) + + def run_config(self, *args, **kwargs) -> None: + """slot to be connected to RunCheck Button""" + try: + self.tree_view.model().layoutAboutToBeChanged.emit() + except AttributeError: + # no model has been set, this method should be no-op + return + + if isinstance(self.prepared_file, PreparedFile): + asyncio.run(run_passive_step(self.prepared_file)) + elif isinstance(self.prepared_file, PreparedProcedureFile): + # ensure + asyncio.run(self.prepared_file.run()) + + self.tree_view.model().layoutChanged.emit() diff --git a/atef/widgets/config/window.py b/atef/widgets/config/window.py index 55b0cf97..27f0728b 100644 --- a/atef/widgets/config/window.py +++ b/atef/widgets/config/window.py @@ -26,9 +26,10 @@ QTabWidget, QWidget) from atef.cache import DataCache -from atef.config import ConfigurationFile, PreparedFile +from atef.config import ConfigurationFile, PreparedFile, TemplateConfiguration from atef.procedure import (DescriptionStep, PassiveStep, - PreparedProcedureFile, ProcedureFile, SetValueStep) + PreparedProcedureFile, ProcedureFile, SetValueStep, + TemplateStep) from atef.report import ActiveAtefReport, PassiveAtefReport from atef.type_hints import AnyDataclass from atef.walk import get_prepared_step, get_relevant_configs_comps @@ -38,7 +39,7 @@ from ..archive_viewer import get_archive_viewer from ..core import DesignerDisplay -from .page import PAGE_MAP, FailPage, PageWidget, RunStepPage +from .page import PAGE_MAP, FailPage, PageWidget, RunConfigPage, RunStepPage from .result_summary import ResultsSummaryWidget from .run_base import create_tree_from_file, make_run_page from .utils import (ConfigTreeModel, MultiInputDialog, Toggle, TreeItem, @@ -457,9 +458,11 @@ def setup_ui(self): EDIT_TO_RUN_PAGE: Dict[type, PageWidget] = { + TemplateConfiguration: RunConfigPage, DescriptionStep: RunStepPage, PassiveStep: RunStepPage, SetValueStep: RunStepPage, + TemplateStep: RunStepPage, } From 547b306a8c69d11d8885b02e490dff96ec2bba31 Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 4 Jun 2024 12:27:37 -0700 Subject: [PATCH 09/18] MNT: add PreparationError, also fail validation if edits cannot be applied --- atef/config.py | 21 +++++++++++++++++---- atef/exceptions.py | 5 +++++ atef/procedure.py | 21 +++++++++++++++++---- atef/widgets/config/find_replace.py | 19 ++++++++++++++----- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/atef/config.py b/atef/config.py index 2386c5fc..da3a3067 100644 --- a/atef/config.py +++ b/atef/config.py @@ -27,7 +27,7 @@ from .cache import DataCache from .check import Comparison from .enums import GroupResultMode, Severity -from .exceptions import PreparedComparisonException +from .exceptions import PreparationError, PreparedComparisonException from .result import Result, incomplete_result from .type_hints import AnyPath from .yaml_support import init_yaml_support @@ -1307,8 +1307,21 @@ def from_config( # convert and apply edits edits = [e.to_action() for e in config.edits] - for edit in edits: - edit.apply(target=config_file) + edit_results = [edit.apply(target=config_file) for edit in edits] + + if not all(edit_results): + return FailedConfiguration( + config=config, + parent=parent, + exception=PreparationError, + result=Result( + severity=Severity.internal_error, + reason=( + f'Failed to prepare templated config: ({config.name}) ' + f'Could not apply all edits.' + ) + ) + ) # verify edited file success, msg = config_file.validate() @@ -1316,7 +1329,7 @@ def from_config( return FailedConfiguration( config=config, parent=parent, - exception=ValueError, # TODO: get a better exception + exception=PreparationError, result=Result( severity=Severity.internal_error, reason=( diff --git a/atef/exceptions.py b/atef/exceptions.py index 8d6c84bc..ed66f5d1 100644 --- a/atef/exceptions.py +++ b/atef/exceptions.py @@ -61,6 +61,11 @@ class UnpreparedComparisonException(ComparisonException): ... +class PreparationError(Exception): + """Raise this when failing to prepare a configuration or procedure""" + ... + + class PreparedComparisonException(Exception): """Exception caught during preparation of comparisons.""" #: The exception instance itself. diff --git a/atef/procedure.py b/atef/procedure.py index 038636c7..a60aa2b6 100644 --- a/atef/procedure.py +++ b/atef/procedure.py @@ -37,7 +37,7 @@ from atef.config import (ConfigurationFile, PreparedComparison, PreparedFile, PreparedSignalComparison, run_passive_step) from atef.enums import GroupResultMode, PlanDestination, Severity -from atef.exceptions import PreparedComparisonException +from atef.exceptions import PreparationError, PreparedComparisonException from atef.find_replace import RegexFindReplace from atef.plan_utils import (BlueskyState, GlobalRunEngine, get_default_namespace, register_run_identifier, @@ -944,8 +944,21 @@ def from_origin( # convert and apply edits edits = [e.to_action() for e in step.edits] - for edit in edits: - edit.apply(target=orig_file) + edit_results = [edit.apply(target=orig_file) for edit in edits] + + if not all(edit_results): + return FailedStep( + origin=step, + parent=parent, + exception=PreparationError, + result=Result( + severity=Severity.internal_error, + reason=( + f'Failed to prepare templated config: ({step.name}) ' + f'Could not apply all edits.' + ) + ) + ) # verify edited file success, msg = orig_file.validate() @@ -953,7 +966,7 @@ def from_origin( return FailedStep( origin=step, parent=parent, - exception=ValueError, # TODO: get a better exception + exception=PreparationError, combined_result=Result( severity=Severity.internal_error, reason=( diff --git a/atef/widgets/config/find_replace.py b/atef/widgets/config/find_replace.py index d60a443f..689a238d 100644 --- a/atef/widgets/config/find_replace.py +++ b/atef/widgets/config/find_replace.py @@ -577,12 +577,21 @@ def reveal_tree_item( def verify_changes(self) -> None: """Apply staged changes and validate copy of file""" - if self.orig_file is not None: - temp_file = copy.deepcopy(self.orig_file) - for action in self.staged_actions: - action.apply(target=temp_file) + if self.orig_file is None: + return + temp_file = copy.deepcopy(self.orig_file) + + edit_results = [e.apply(target=temp_file) for e in self.staged_actions] + if not all(edit_results): + fail_idx = [i for i, result in enumerate(edit_results) if not result] + QtWidgets.QMessageBox.warning( + self, + 'Verification FAIL', + f'Some staged edits at {fail_idx} could not be applied:' + ) + return - verify_file_and_notify(temp_file, self) + verify_file_and_notify(temp_file, self) def save_file(self) -> None: if self.orig_file is None: From 07605df9b0ef9a49e2dca0f09f7e66426d9a7bd5 Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 4 Jun 2024 13:49:52 -0700 Subject: [PATCH 10/18] MNT: add allowed types to FillTemplatePage to prevent active template in passive checkouts --- atef/widgets/config/find_replace.py | 21 ++++++++++++++++++++- atef/widgets/config/page.py | 19 +++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/atef/widgets/config/find_replace.py b/atef/widgets/config/find_replace.py index 689a238d..3f73e2fc 100644 --- a/atef/widgets/config/find_replace.py +++ b/atef/widgets/config/find_replace.py @@ -9,7 +9,7 @@ from functools import partial from pathlib import Path from typing import (TYPE_CHECKING, Any, ClassVar, Iterable, List, Optional, - Union) + Tuple, Union) import happi import qtawesome as qta @@ -403,11 +403,14 @@ def __init__( *args, filepath: Optional[str] = None, window: Optional[Window] = None, + allowed_types: Optional[Tuple[Any]] = None, **kwargs ) -> None: super().__init__(*args, **kwargs) self._window = window self.fp = filepath + self.orig_file = None + self.allowed_types = allowed_types self.staged_actions: List[FindReplaceAction] = [] self._signals: List[str] = [] self._devices: List[str] = [] @@ -501,6 +504,18 @@ def load_file(self, filepath: str) -> None: logger.error('failed to open file as either active ' 'or passive checkout') + if self.allowed_types and not isinstance(data, self.allowed_types): + logger.error("loaded checkout is of a disallowed type: " + f"({type(data)})") + QtWidgets.QMessageBox.warning( + self, + 'Template Checkout type error', + f'Loaded checkout is one of the allowed types: {self.allowed_types}' + ) + self.fp = None + self.orig_file = None + return + self.fp = filepath self.orig_file = data @@ -540,6 +555,10 @@ def _fill_devices_list(self) -> None: def setup_tree_view(self) -> None: """Populate tree view with preview of loaded file""" + if self.orig_file is None: + # clear tree + self.tree_view.setModel(None) + return root_item = create_tree_from_file(data=self.orig_file) model = ConfigTreeModel(data=root_item) diff --git a/atef/widgets/config/page.py b/atef/widgets/config/page.py index df28b9c9..c0369aec 100644 --- a/atef/widgets/config/page.py +++ b/atef/widgets/config/page.py @@ -33,7 +33,7 @@ from atef.check import (ALL_COMPARISONS, AnyComparison, AnyValue, Comparison, Equals, Greater, GreaterOrEqual, Less, LessOrEqual, NotEquals, Range, ValueSet) -from atef.config import (Configuration, ConfigurationGroup, +from atef.config import (Configuration, ConfigurationFile, ConfigurationGroup, DeviceConfiguration, PreparedConfiguration, PreparedGroup, PreparedTemplateConfiguration, PVConfiguration, TemplateConfiguration, @@ -41,8 +41,9 @@ from atef.procedure import (ComparisonToTarget, DescriptionStep, PassiveStep, PreparedDescriptionStep, PreparedPassiveStep, PreparedProcedureStep, PreparedSetValueStep, - PreparedTemplateStep, ProcedureGroup, - ProcedureStep, SetValueStep, TemplateStep) + PreparedTemplateStep, ProcedureFile, + ProcedureGroup, ProcedureStep, SetValueStep, + TemplateStep) from atef.tools import Ping, PingResult, Tool, ToolResult from atef.type_hints import AnyDataclass from atef.widgets.config.data_active import (CheckRowWidget, @@ -1414,16 +1415,22 @@ class TemplateConfigurationPage(DesignerDisplay, PageWidget): template_page_widget: FillTemplatePage template_page_placeholder: QWidget - data: TemplateConfiguration + data: Union[TemplateConfiguration, TemplateStep] + ALLOWED_TYPE_MAP: ClassVar[Dict[Any, Tuple[Any]]] = { + TemplateConfiguration: (ConfigurationFile), + TemplateStep: (ConfigurationFile, ProcedureFile) + } - def __init__(self, data: TemplateConfiguration, **kwargs): + def __init__(self, data: Union[TemplateConfiguration, TemplateStep], **kwargs): super().__init__(data=data, **kwargs) self.setup_name_desc_tags_init() self.setup_template_widget_init() self.post_tree_setup() def setup_template_widget_init(self) -> None: - self.template_page_widget = FillTemplatePage() + self.template_page_widget = FillTemplatePage( + allowed_types=self.ALLOWED_TYPE_MAP[type(self.data)] + ) def finish_widget_setup(*args, **kwargs): # only run this once, when we're loading an existing template checkout From a42dac52827260c09919d6fa8b18fbdb111bc17b Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 4 Jun 2024 16:17:09 -0700 Subject: [PATCH 11/18] DOC: pre-release notes --- .../241-enh_template_gui.rst | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/source/upcoming_release_notes/241-enh_template_gui.rst diff --git a/docs/source/upcoming_release_notes/241-enh_template_gui.rst b/docs/source/upcoming_release_notes/241-enh_template_gui.rst new file mode 100644 index 00000000..00d92cfd --- /dev/null +++ b/docs/source/upcoming_release_notes/241-enh_template_gui.rst @@ -0,0 +1,22 @@ +241 enh_template_gui +#################### + +API Breaks +---------- +- N/A + +Features +-------- +- Adds atef config GUI pages for passive and active templated checkouts, refactoring some pieces of atef.find_replace as necessary + +Bugfixes +-------- +- N/A + +Maintenance +----------- +- N/A + +Contributors +------------ +- tangkong From a7eeee3338cb23c29645f120c4cef7dcd4563f8c Mon Sep 17 00:00:00 2001 From: tangkong Date: Tue, 4 Jun 2024 18:28:01 -0700 Subject: [PATCH 12/18] TST: one quick dumb TemplateConfigurationPage test --- atef/tests/test_page.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/atef/tests/test_page.py b/atef/tests/test_page.py index 29b2767e..77f30f16 100644 --- a/atef/tests/test_page.py +++ b/atef/tests/test_page.py @@ -4,6 +4,7 @@ from pytestqt.qtbot import QtBot from qtpy import QtCore, QtWidgets +from atef.config import TemplateConfiguration from atef.type_hints import AnyDataclass from atef.widgets.config.page import ComparisonPage, ConfigurationGroupPage @@ -178,3 +179,26 @@ def condition(): full_tree.select_by_data(group_data) full_tree.select_by_data(new_data) comp_page = full_tree.current_widget + + +def test_template_page( + qtbot: QtBot, + template_configuration: TemplateConfiguration, + make_page: Callable, +): + group_page = make_page(template_configuration) + + # Does the configuration initialize properly? + qtbot.wait_until( + lambda: group_page.template_page_widget.staged_list.count() == 1 + ) + + # test preparation + group_page.full_tree.mode = 'run' + group_page.full_tree.switch_mode('run') + + qtbot.wait_signal(group_page.full_tree.mode_switch_finished) + qtbot.wait_until( + lambda: group_page.template_page_widget.staged_list.count() == 1 + ) + qtbot.addWidget(group_page) From 614e3450a2f4a01ccac5e482d247bfb5d3274a7f Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 5 Jun 2024 10:44:59 -0700 Subject: [PATCH 13/18] BUG: properly track origin in loaded template configs, raise more helpful error when mode switch serialization fails --- atef/find_replace.py | 7 ++++++- atef/widgets/config/window.py | 10 +++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/atef/find_replace.py b/atef/find_replace.py index 1d32ae88..a349f429 100644 --- a/atef/find_replace.py +++ b/atef/find_replace.py @@ -369,7 +369,12 @@ def to_action(self, target: Optional[Any] = None) -> FindReplaceAction: else: path = self.path - return FindReplaceAction(target=target, path=path, replace_fn=replace_fn) + return FindReplaceAction( + path=path, + replace_fn=replace_fn, + target=target, + origin=self, + ) @dataclass diff --git a/atef/widgets/config/window.py b/atef/widgets/config/window.py index 27f0728b..0ed95c2d 100644 --- a/atef/widgets/config/window.py +++ b/atef/widgets/config/window.py @@ -27,6 +27,7 @@ from atef.cache import DataCache from atef.config import ConfigurationFile, PreparedFile, TemplateConfiguration +from atef.exceptions import PreparationError from atef.procedure import (DescriptionStep, PassiveStep, PreparedProcedureFile, ProcedureFile, SetValueStep, TemplateStep) @@ -797,9 +798,12 @@ def show_mode_widgets(self) -> None: update_run = False if self.mode == 'run': # store a copy of the edit tree to detect diffs - current_edit_config = deepcopy( - serialize(type(self.orig_file), self.orig_file) - ) + try: + ser = serialize(type(self.orig_file), self.orig_file) + except Exception: + logger.debug(f'Unable to serialize file as defined: {self.orig_file}') + raise PreparationError('Unable to serialize file with current settings') + current_edit_config = deepcopy(ser) if self.prepared_file is None: update_run = True From d80b39a1d65b57df2ffa33a9626e4a1f5b4f2415 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 5 Jun 2024 11:17:15 -0700 Subject: [PATCH 14/18] BUG: cannot reference widgets from different threads --- atef/widgets/config/find_replace.py | 12 +++++++----- atef/widgets/config/page.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/atef/widgets/config/find_replace.py b/atef/widgets/config/find_replace.py index 3f73e2fc..20e7cc47 100644 --- a/atef/widgets/config/find_replace.py +++ b/atef/widgets/config/find_replace.py @@ -477,6 +477,13 @@ def open_file(self, *args, filename: Optional[str] = None, **kwargs) -> None: return def finish_setup(): + if self.fp is None: + QtWidgets.QMessageBox.warning( + self, + 'Template Checkout type error', + 'Loaded checkout is NOT one of the allowed types: ' + f'{[t.__name__ for t in self.allowed_types]}' + ) self.details_list.clear() self.staged_list.clear() self.staged_actions.clear() @@ -507,11 +514,6 @@ def load_file(self, filepath: str) -> None: if self.allowed_types and not isinstance(data, self.allowed_types): logger.error("loaded checkout is of a disallowed type: " f"({type(data)})") - QtWidgets.QMessageBox.warning( - self, - 'Template Checkout type error', - f'Loaded checkout is one of the allowed types: {self.allowed_types}' - ) self.fp = None self.orig_file = None return diff --git a/atef/widgets/config/page.py b/atef/widgets/config/page.py index c0369aec..ce13943e 100644 --- a/atef/widgets/config/page.py +++ b/atef/widgets/config/page.py @@ -1417,7 +1417,7 @@ class TemplateConfigurationPage(DesignerDisplay, PageWidget): data: Union[TemplateConfiguration, TemplateStep] ALLOWED_TYPE_MAP: ClassVar[Dict[Any, Tuple[Any]]] = { - TemplateConfiguration: (ConfigurationFile), + TemplateConfiguration: (ConfigurationFile,), TemplateStep: (ConfigurationFile, ProcedureFile) } From c0428caafe68ee34d50ed9d1169414f035a04085 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 5 Jun 2024 11:47:41 -0700 Subject: [PATCH 15/18] MNT: adjust patch_client_cache to prevent CA context unset warnings --- atef/find_replace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/atef/find_replace.py b/atef/find_replace.py index a349f429..7a62281d 100644 --- a/atef/find_replace.py +++ b/atef/find_replace.py @@ -31,11 +31,11 @@ def patch_client_cache(): try: happi.loader.cache = {} dcache = DataCache() + # Clear the global signal cache to prevent previous signals from leaking dcache.signals.clear() yield finally: happi.loader.cache = old_happi_cache - dcache.signals.clear() def walk_find_match( From 4d2141154014a0b0c1d7e0cca16316c427677c02 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 5 Jun 2024 13:40:35 -0700 Subject: [PATCH 16/18] BUG: properly populate results for PreparedTemplateConfiguration --- atef/config.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/atef/config.py b/atef/config.py index da3a3067..962bb21d 100644 --- a/atef/config.py +++ b/atef/config.py @@ -1352,11 +1352,20 @@ def from_config( return prepared async def compare(self) -> Result: - """Run the edited checkoutand return the combined result""" + """Run the edited checkout and return the combined result""" result = await self.file.compare() self.combined_result = result return result + @property + def result(self) -> Result: + """ + Re-compute combined result and return it. Override standard since this + configuration has no comparisons + """ + self.combined_result = self.file.root.result + return self.combined_result + @dataclass class PreparedComparison: From 10a2970adcbc342b38e887013dd0ac675e5db2d3 Mon Sep 17 00:00:00 2001 From: tangkong Date: Wed, 5 Jun 2024 13:41:47 -0700 Subject: [PATCH 17/18] BUG: use old QTableWidget handling for ProcedureGroupPage, was previously using PagedTableWidget handling from PageWidget parent class --- atef/widgets/config/page.py | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/atef/widgets/config/page.py b/atef/widgets/config/page.py index ce13943e..81b42dc7 100644 --- a/atef/widgets/config/page.py +++ b/atef/widgets/config/page.py @@ -1669,6 +1669,45 @@ def replace_step( ) self.procedure_table.setCellWidget(found_row, 0, step_row) + def delete_table_row( + self, + *args, + table: QTableWidget, + item: TreeItem, + row: DataWidget, + **kwargs + ) -> None: + # Use old QTableWidget handling + # Confirmation dialog + reply = QMessageBox.question( + self, + 'Confirm deletion', + ( + 'Are you sure you want to delete the ' + f'{item.data(2)} named "{item.data(0)}"? ' + 'Note that this will delete any child nodes in the tree.' + ), + ) + if reply != QMessageBox.Yes: + return + # Get the identity of the data + data = row.bridge.data + # Remove item from the tree + with self.full_tree.modifies_tree(): + try: + self.tree_item.removeChild(item) + except ValueError: + pass + + # Remove row from the table + for row_index in range(table.rowCount()): + widget = table.cellWidget(row_index, 0) + if widget is row: + table.removeRow(row_index) + break + # Remove configuration from the data structure + self.remove_table_data(data) + class StepPage(DesignerDisplay, PageWidget): """ From b8ff61e4b5625585900eb228c1b809cdb02946aa Mon Sep 17 00:00:00 2001 From: tangkong Date: Thu, 6 Jun 2024 10:04:46 -0700 Subject: [PATCH 18/18] MNT: type hint and redundant call cleaning --- atef/find_replace.py | 2 +- atef/widgets/config/page.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/atef/find_replace.py b/atef/find_replace.py index 7a62281d..2cafb0ac 100644 --- a/atef/find_replace.py +++ b/atef/find_replace.py @@ -362,7 +362,7 @@ def to_action(self, target: Optional[Any] = None) -> FindReplaceAction: raise ValueError(f'regex is not valid: {self.search_regex}, ' 'could not construct FindReplaceAction') replace_fn = get_default_replace_fn( - self.replace_text, re.compile(search_regex) + self.replace_text, search_regex ) if target: path = expand_path(self.path, target=target) diff --git a/atef/widgets/config/page.py b/atef/widgets/config/page.py index 81b42dc7..cacea9cf 100644 --- a/atef/widgets/config/page.py +++ b/atef/widgets/config/page.py @@ -1416,7 +1416,7 @@ class TemplateConfigurationPage(DesignerDisplay, PageWidget): template_page_placeholder: QWidget data: Union[TemplateConfiguration, TemplateStep] - ALLOWED_TYPE_MAP: ClassVar[Dict[Any, Tuple[Any]]] = { + ALLOWED_TYPE_MAP: ClassVar[Dict[Any, Tuple[Any, ...]]] = { TemplateConfiguration: (ConfigurationFile,), TemplateStep: (ConfigurationFile, ProcedureFile) }