diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index 586b5a1..2867e1e 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -155,7 +155,7 @@ def main( ) gui.setup() - gui.generate_screens() + gui.create_screens() logger_.info(f"Screens generated for {gui.conf.beamline.short_dom}.") diff --git a/src/techui_builder/autofill.py b/src/techui_builder/autofill.py index 6299748..7789e63 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -1,5 +1,6 @@ import logging import os +from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path @@ -8,6 +9,7 @@ from techui_builder.builder import Builder, _get_action_group from techui_builder.models import Component +from techui_builder.utils import read_bob logger_ = logging.getLogger(__name__) @@ -16,41 +18,32 @@ class Autofiller: path: Path macros: list[str] = field(default_factory=lambda: ["prefix", "desc", "file"]) + widgets: dict[str, ObjectifiedElement] = field( + default_factory=defaultdict, init=False, repr=False + ) def read_bob(self) -> None: - # Read the bob file - self.tree = objectify.parse(self.path) - - # Find the root tag (in this case: ) - self.root = self.tree.getroot() + self.tree, self.widgets = read_bob(self.path) def autofill_bob(self, gui: "Builder"): # Get names from component list - # Loop over objects in the xml - # i.e. every tag below - # but not any nested tags below them - for child in self.root.iterchildren(): - # If widget is a symbol (i.e. a component) - if child.tag == "widget" and child.get("type", default=None) == "symbol": - # Extract it's name - symbol_name = child.name - - # If the name exists in the component list - if symbol_name in gui.conf.components.keys(): - # Get first copy of component (should only be one) - comp = next( - (comp for comp in gui.conf.components if comp == symbol_name), - ) - - self.replace_content( - widget=child, - component_name=comp, - component=gui.conf.components[comp], - ) - - # Add option to allow left mouse click to run action - child["run_actions_on_mouse_click"] = "true" + for symbol_name, child in self.widgets.items(): + # If the name exists in the component list + if symbol_name in gui.conf.components.keys(): + # Get first copy of component (should only be one) + comp = next( + (comp for comp in gui.conf.components if comp == symbol_name), + ) + + self.replace_content( + widget=child, + component_name=comp, + component=gui.conf.components[comp], + ) + + # Add option to allow left mouse click to run action + child["run_actions_on_mouse_click"] = "true" def write_bob(self, filename: Path): # Check if data/ dir exists and if not, make it diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index de16cba..e02585b 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -1,5 +1,6 @@ import json import logging +import os from collections import defaultdict from dataclasses import _MISSING_TYPE, dataclass, field from pathlib import Path @@ -11,6 +12,7 @@ from techui_builder.generate import Generator from techui_builder.models import Entity, TechUi +from techui_builder.validator import Validator logger_ = logging.getLogger(__name__) @@ -57,8 +59,34 @@ def setup(self): """Run intial setup, e.g. extracting entries from service ioc.yaml.""" self._extract_services() synoptic_dir = self._write_directory + + self.clean_bobs() + self.generator = Generator(synoptic_dir) + def clean_bobs(self): + exclude = {"index.bob"} + bobs = [ + bob + for bob in self._write_directory.glob("*.bob") + if bob.name not in exclude + ] + + self.validator = Validator(bobs) + self.validator.check_bobs() + + # Get bobs that are only present in the bobs list (i.e. generated) + self.generated_bobs = list(set(bobs) ^ set(self.validator.validate.values())) + + logger_.info("Preserving edited screens for validation.") + logger_.debug(f"Screens to validate: {list(self.validator.validate.keys())}") + + logger_.info("Cleaning synoptic/ of generated screens.") + # Remove any generated bobs that exist + for bob in self.generated_bobs: + logger_.debug(f"Removing generated screen: {bob.name}") + os.remove(bob) + def _extract_services(self): """ Finds the services folders in the services directory @@ -95,13 +123,20 @@ def _extract_entities(self, ioc_yaml: Path): ) self.entities[new_entity.P].append(new_entity) - def _generate_screen(self, screen_name: str, screen_components: list[Entity]): - self.generator.load_screen(screen_name, screen_components) - self.generator.build_groups() - self.generator.write_screen(self._write_directory) + def _generate_screen(self, screen_name: str): + self.generator.build_screen(screen_name) + self.generator.write_screen(screen_name, self._write_directory) + + def _validate_screen(self, screen_name: str): + # Get the generated widgets to validate against + widgets = self.generator.widgets + widget_group = self.generator.group + assert widget_group is not None + widget_group_name = widget_group.get_element_value("name") + self.validator.validate_bob(screen_name, widget_group_name, widgets) - def generate_screens(self): - """Generate the screens for each component in techui.yaml""" + def create_screens(self): + """Create the screens for each component in techui.yaml""" if len(self.entities) == 0: logger_.critical("No ioc entities found, has setup() been run?") exit() @@ -124,7 +159,18 @@ def generate_screens(self): continue screen_entities.extend(self.entities[extra_p]) - self._generate_screen(component_name, screen_entities) + # This is used by both generate and validate, + # so called beforehand for tidyness + self.generator.build_widgets(component_name, screen_entities) + self.generator.build_groups(component_name) + + screens_to_validate = list(self.validator.validate.keys()) + + if component_name in screens_to_validate: + self._validate_screen(component_name) + else: + self._generate_screen(component_name) + else: logger_.warning( f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold]\ diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index bd35396..c812828 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -20,9 +20,6 @@ class Generator: synoptic_dir: Path = field(repr=False) - screen_name: str = field(init=False) - screen_components: list[Entity] = field(init=False) - # These are global params for the class (not accessible by user) support_path: Path = field(init=False, repr=False) techui_support: dict = field(init=False, repr=False) @@ -33,6 +30,7 @@ class Generator: widgets: list[ActionButton | EmbeddedDisplay] = field( default_factory=list[ActionButton | EmbeddedDisplay], init=False, repr=False ) + group: Group | None = field(default=None, init=False, repr=False) # Add group padding, and self.widget_x for placing widget in x direction relative to # other widgets, with a widget count to reset the self.widget_x dimension when the @@ -55,10 +53,6 @@ def _read_map(self): with open(support_yaml) as map: self.techui_support = yaml.safe_load(map) - def load_screen(self, screen_name: str, screen_components: list[Entity]): - self.screen_name = screen_name - self.screen_components = screen_components - def _get_screen_dimensions(self, file: str) -> tuple[int, int]: """ Parses the bob files for information on the height @@ -248,7 +242,7 @@ def _allocate_widget( return new_widget def _create_widget( - self, component: Entity + self, name: str, component: Entity ) -> EmbeddedDisplay | ActionButton | None | list[EmbeddedDisplay | ActionButton]: # if statement below is check if the suffix is # missing from the component description. If @@ -261,7 +255,7 @@ def _create_widget( except KeyError: logger_.warning( f"No available widget for {component.type} in screen \ -{self.screen_name}. Skipping..." +{name}. Skipping..." ) return None @@ -337,21 +331,14 @@ def layout_widgets(self, widgets: list[EmbeddedDisplay | ActionButton]): return sorted_widgets - def build_groups(self): - """ - Create a group to fill with widgets - """ - # Create screen - self.screen_ = pscreen.Screen(self.screen_name) + def build_widgets(self, screen_name: str, screen_components: list[Entity]): # Empty widget buffer self.widgets = [] - # create widget and group objects - # order is an enumeration of the components, used to list them, # and serves as functionality in the math for formatting. - for component in self.screen_components: - new_widget = self._create_widget(component=component) + for component in screen_components: + new_widget = self._create_widget(name=screen_name, component=component) if new_widget is None: continue if isinstance(new_widget, list): @@ -359,6 +346,11 @@ def build_groups(self): continue self.widgets.append(new_widget) + def build_groups(self, screen_name: str): + """ + Create a group to fill with widgets + """ + if self.widgets == []: # No widgets found, so just back out return @@ -369,28 +361,43 @@ def build_groups(self): height, width = self._get_group_dimensions(self.widgets) self.group = Group( - self.screen_name, + screen_name, 0, 0, width, height, ) + # TODO: we shouldn't need this assert; fix + assert self.group is not None self.group.version("2.0.0") self.group.add_widget(self.widgets) + + def build_screen(self, screen_name): + """ + Build the screen with the widget groups. + """ + # Create screen + self.screen_ = pscreen.Screen(screen_name) + + # TODO: I don't like this + if self.group is None: + # No group found, so just back out + return + self.screen_.add_widget(self.group) - def write_screen(self, directory: Path): + def write_screen(self, screen_name: str, directory: Path): """Write the screen to file""" if self.widgets == []: logger_.warning( - f"Could not write screen: {self.screen_name} \ + f"Could not write screen: {screen_name} \ as no widgets were available" ) return if not directory.exists(): os.mkdir(directory) - self.screen_.write_screen(f"{directory}/{self.screen_name}.bob") - logger_.info(f"{self.screen_name}.bob has been created successfully") + self.screen_.write_screen(f"{directory}/{screen_name}.bob") + logger_.info(f"{screen_name}.bob has been created successfully") diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py new file mode 100644 index 0000000..11b1304 --- /dev/null +++ b/src/techui_builder/utils.py @@ -0,0 +1,32 @@ +from lxml import objectify +from lxml.objectify import ObjectifiedElement + + +def read_bob(path): + # Read the bob file + tree = objectify.parse(path) + + # Find the root tag (in this case: ) + root = tree.getroot() + + widgets = get_widgets(root) + + return tree, widgets + + +def get_widgets(root: ObjectifiedElement): + widgets: dict[str, ObjectifiedElement] = {} + # Loop over objects in the xml + # i.e. every tag below + # but not any nested tags below them + for child in root.iterchildren(): + # If widget is a symbol (i.e. a component) + if child.tag == "widget" and child.get("type", default=None) in [ + "symbol", + "group", + ]: + name = child.name.text + assert name is not None + widgets[name] = child + + return widgets diff --git a/src/techui_builder/validator.py b/src/techui_builder/validator.py new file mode 100644 index 0000000..5261326 --- /dev/null +++ b/src/techui_builder/validator.py @@ -0,0 +1,77 @@ +import logging +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +from lxml import etree +from phoebusgen.widget.widgets import ActionButton, EmbeddedDisplay + +from techui_builder.utils import read_bob + +LOGGER = logging.getLogger(__name__) + + +@dataclass +class Validator: + bobs: list[Path] + validate: dict[str, Path] = field( + default_factory=defaultdict, init=False, repr=False + ) + + def check_bobs(self): + for bob in self.bobs: + self._check_bob(bob) + + def _check_bob(self, bob_path: Path): + # etree has to used as objectify ignore comments + xml = etree.parse(bob_path) + # fetch all the comments at the base of the tree + comments = list(xml.getroot().itersiblings(tag=etree.Comment, preceding=True)) + if len(comments) > 0: + # Check if any comments found are the manually saved tag + if any(str(comment).startswith(" + + motor + + motor + 0 + 0 + 255 + 470 + + X + 205 + 120 + example/t01-services/synoptic/techui-support/bob/pmac/motor_embed.bob + +

BL01T-MO-MOTOR-01

+ X +
+ 0 + 0 +
+
+
diff --git a/tests/test_files/motor.bob b/tests/test_files/motor.bob index 529674f..70d68b7 100644 --- a/tests/test_files/motor.bob +++ b/tests/test_files/motor.bob @@ -11,7 +11,7 @@ X 205 120 - ../../../../src/techui-support/bob/pmac/motor_embed.bob + example/t01-services/synoptic/techui-support/bob/pmac/motor_embed.bob

BL01T-MO-MOTOR-01

X @@ -23,7 +23,7 @@ A 205 120 - ../../../../src/techui-support/bob/pmac/motor_embed.bob + example/t01-services/synoptic/techui-support/bob/pmac/motor_embed.bob

BL01T-MO-MOTOR-01

A @@ -43,7 +43,7 @@

BL01T-MO-BRICK-01

- ../../../../src/techui-support/bob/pmac/pmacController.bob + example/t01-services/synoptic/techui-support/bob/pmac/pmacController.bob tab diff --git a/tests/test_files/motor_bad.bob b/tests/test_files/motor_bad.bob index 34d32fc..145fc6d 100644 --- a/tests/test_files/motor_bad.bob +++ b/tests/test_files/motor_bad.bob @@ -9,7 +9,7 @@ X 205 120 - ../../../../src/techui-support/bob/pmac/motor_embed.bob + example/t01-services/synoptic/techui-support/bob/pmac/motor_embed.bob

BL01T-MO-MOTOR-01

X @@ -21,7 +21,7 @@ A 205 120 - ../../../../src/techui-support/bob/pmac/motor_embed.bob + example/t01-services/synoptic/techui-support/bob/pmac/motor_embed.bob

BL01T-MO-MOTOR-01

A @@ -41,7 +41,7 @@

BL01T-MO-BRICK-01

- ../../../../src/techui-support/bob/pmac/pmacController.bob + example/t01-services/synoptic/techui-support/bob/pmac/pmacController.bob tab diff --git a/tests/test_generate.py b/tests/test_generate.py index 2adae48..9e708c2 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -24,14 +24,6 @@ def y(self, val: int): self._y = val -def test_generator_load_screen(generator): - entity = Entity(type="test", P="TEST", desc=None, M=None, R=None) - generator.load_screen("test", [entity]) - - assert generator.screen_name == "test" - assert generator.screen_components == [entity] - - def test_generator_get_screen_dimensions_good(generator): test_embedded_screen = "tests/test_files/motor_embed.bob" x, y = generator._get_screen_dimensions(test_embedded_screen) @@ -116,12 +108,12 @@ def test_generator_get_group_dimensions(generator): def test_generator_create_widget_keyerror(generator, caplog): generator._get_screen_dimensions = Mock(return_value=(800, 1280)) - generator.screen_name = "test" + screen_name = "test" component = Entity( type="key.notavailable", P="BL23B-DI-MOD-02", desc=None, M=None, R="CAM:" ) - result = generator._create_widget(component=component) + result = generator._create_widget(name=screen_name, component=component) assert result is None assert ( @@ -138,11 +130,11 @@ def test_generator_create_widget_is_list_of_dicts(generator): name="X", file="", x=0, y=0, width=205, height=120 ) ) - generator.screen_name = "test" + screen_name = "test" component = Entity( type="ADAravis.aravisCamera", P="BL23B-DI-MOD-02", desc=None, M=None, R="CAM:" ) - widget = generator._create_widget(component=component) + widget = generator._create_widget(name=screen_name, component=component) for value in widget: assert str(value) == str( pwidget.EmbeddedDisplay(name="X", file="", x=0, y=0, width=205, height=120) @@ -151,11 +143,13 @@ def test_generator_create_widget_is_list_of_dicts(generator): def test_generator_create_widget_embedded(generator): generator._get_screen_dimensions = Mock(return_value=(570, 990)) + screen_name = "test" component = Entity( type="ADAravis.aravisCamera", P="BL23B-DI-MOD-02", desc=None, M=None, R="CAM:" ) widget = generator._create_widget( + name=screen_name, component=component, ) control_widget = Path("tests/test_files/widget.xml") @@ -229,11 +223,13 @@ def test_generator_allocate_widget(generator): def test_generator_create_widget_related(generator): generator._get_screen_dimensions = Mock(return_value=(800, 1280)) + screen_name = "test" component = Entity( type="pmac.GeoBrick", P="BL23B-MO-BRICK-01", desc=None, M=":M", R=None ) widget = generator._create_widget( + name=screen_name, component=component, ) @@ -245,11 +241,13 @@ def test_generator_create_widget_related(generator): def test_generator_create_widget_related_no_suffix(generator): generator._get_screen_dimensions = Mock(return_value=(800, 1280)) + screen_name = "test" component = Entity( type="pmac.GeoBrick", P="BL23B-MO-BRICK-01", desc=None, M=None, R=None ) widget = generator._create_widget( + name=screen_name, component=component, ) @@ -298,7 +296,8 @@ def test_generator_layout_widgets(generator, index, x, y): assert arranged_widgets[index]._y == y -def test_generator_build_groups(generator): +# TODO: Split up test +def test_generator_build_screen(generator): generator._create_widget = Mock(return_value=Mock()) generator.layout_widgets = Mock( return_value=[ @@ -309,25 +308,27 @@ def test_generator_build_groups(generator): ] ) generator._get_group_dimensions = Mock(return_value=(600, 400)) - generator.screen_name = "test" - generator.screen_components = [Mock(), Mock(), Mock()] + screen_name = "test" + screen_components = [Mock(), Mock(), Mock()] - generator.build_groups() + generator.build_widgets(screen_name, screen_components) + generator.build_groups(screen_name) + generator.build_screen(screen_name) assert objectify.fromstring(str(generator.screen_)).xpath("//widget[@type='group']") def test_generator_write_screen(generator): - generator.screen_name = "test" + screen_name = "test" generator.screen_ = pscreen.Screen("test") generator.widgets = [Mock(), Mock()] - generator.write_screen(Path("tests/test_files/")) + generator.write_screen(screen_name, Path("tests/test_files/")) assert Path("tests/test_files/test.bob").exists() Path("tests/test_files/test.bob").unlink() def test_generator_write_screen_no_widgets(generator, caplog): - generator.screen_name = "test" + screen_name = "test" generator.screen_ = pscreen.Screen("test") generator.widgets = [] - generator.write_screen(Path("tests/test_files/")) + generator.write_screen(screen_name, Path("tests/test_files/")) assert "Could not write screen: test as no widgets were available" in caplog.text diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 0000000..0ee4da1 --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,69 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +from lxml.etree import Element, SubElement, _ElementTree, tostring +from lxml.objectify import fromstring +from phoebusgen.widget import EmbeddedDisplay + + +def test_validator_check_bobs(validator): + validator._check_bob = Mock() + + validator.check_bobs() + + validator._check_bob.assert_called() + + +def test_validator_check_bob(validator): + validator._check_bob(validator.bobs[0]) + + assert len(validator.validate.keys()) > 0 + assert list(validator.validate.keys())[0] == "motor-edited" + + +def test_validator_read_bob(validator): + with patch("techui_builder.validator.read_bob") as mock_read_bob: + # We need to set the spec of the first Mock so it knows + # it has a getroot() function + mock_read_bob.return_value = (Mock(spec=_ElementTree), Mock()) + + validator._read_bob(validator.bobs[0]) + + +# TODO: Clean up this test... (make fixture for mock xml?) +def test_validator_validate_bob(validator): + # You cannot set a text tag of an ObjectifiedElement, + # so we need to make an etree.Element and convert it ... + mock_root_element = Element("root") + mock_widget_element = SubElement(mock_root_element, "widget") + mock_name_element = SubElement(mock_widget_element, "name") + mock_name_element.text = "motor" + mock_width_element = SubElement(mock_widget_element, "width") + mock_width_element.text = "205" + mock_height_element = SubElement(mock_widget_element, "height") + mock_height_element.text = "120" + mock_file_element = SubElement(mock_widget_element, "file") + mock_file_element.text = ( + "example/t01-services/synoptic/techui_supportbob/pmac/motor_embed.bob" + ) + # ... which requires this horror + mock_element = fromstring(tostring(mock_root_element)) + # mock_element = ObjectifiedElement(mock_widget_element) + # mock_name_element.text = "motor" + validator._read_bob = Mock( + return_value=( + Mock(), + {"motor": (mock_element)}, + ) + ) + validator.validate = {"motor-edited": Path("tests/test_files/motor-edited.bob")} + test_pwidget = EmbeddedDisplay( + "motor", + "example/t01-services/synoptic/techui_supportbob/pmac/motor_embed.bob", + 0, + 0, + 205, + 120, + ) + + validator.validate_bob("motor-edited", "motor", [test_pwidget])