diff --git a/example/t01-services/synoptic/techui.yaml b/example/t01-services/synoptic/techui.yaml index be069098..341bfaf0 100644 --- a/example/t01-services/synoptic/techui.yaml +++ b/example/t01-services/synoptic/techui.yaml @@ -7,16 +7,20 @@ beamline: components: fshtr: - desc: Fast Shutter + label: Fast Shutter prefix: BL01T-EA-FSHTR-01 d1: - desc: Diode 1 + label: Diode 1 prefix: BL01T-DI-PHDGN-01 file: test.bob motor: - desc: Motor Stage + label: Motor Stage prefix: BL01T-MO-MOTOR-01 extras: - BL01T-MO-BRICK-01 + child_labels: + X: X1 + Y: Y1 + Z: Z1 diff --git a/src/techui_builder/autofill.py b/src/techui_builder/autofill.py index 63b7e68d..04433429 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -91,7 +91,11 @@ def replace_content( tag_name = "description" if component_attr is None: - component_attr = component_name + component_attr = ( + component_name + if component.label is None + else component.label + ) case "file": tag_name = "file" diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 1336afb6..c26050e5 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -13,7 +13,7 @@ from softioc.builder import records from techui_builder.generate import Generator -from techui_builder.models import Entity, TechUi +from techui_builder.models import Component, Entity, TechUi from techui_builder.validator import Validator logger_ = logging.getLogger(__name__) @@ -215,7 +215,13 @@ def create_screens(self): # ONLY IF there is a matching component and entity, generate a screen if component.prefix in self.entities.keys(): + # Populate child labels for any entities + # with the same prefix as the component + for entity in self.entities[component.prefix]: + entity.child_labels = component.child_labels + screen_entities.extend(self.entities[component.prefix]) + if component.extras is not None: # If component has any extras, add them to the entries to generate for extra_p in component.extras: @@ -230,7 +236,7 @@ def create_screens(self): # 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) + self.generator.build_groups(component_name, self.conf.components) screens_to_validate = list(self.validator.validate.keys()) @@ -246,7 +252,14 @@ def create_screens(self): " any P field in the ioc.yaml files in services" ) - def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: + def _generate_json_map( + self, + screen_path: Path, + dest_path: Path, + component: dict[str, Component], + current_component_name: str | None = None, + name_elem: str | None = None, + ) -> JsonMap: """Recursively generate JSON map from .bob file tree""" # Create initial node at top of .bob file @@ -255,6 +268,10 @@ def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: display_name=None, ) + # Get Current Component + if current_component_name is None and screen_path.stem in component: + current_component_name = screen_path.stem + abs_path = screen_path.absolute() try: @@ -266,7 +283,12 @@ def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: current_node.display_name = self._parse_display_name( root.name.text, screen_path ) - + current_node.display_name = _get_labels( + name_elem, + component, + current_component_name, + current_node.display_name, + ) # Find all elements widgets = [ w @@ -302,6 +324,15 @@ def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: case _: continue + # Validated screen names don't get renegerated + display_name = name_elem + display_name = _get_labels( + name_elem, + component, + current_component_name, + display_name, + ) + # Extract file path from file_elem file_path = Path(file_elem.text.strip() if file_elem.text else "") @@ -310,7 +341,7 @@ def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: continue # Create valid displayName - display_name = self._parse_display_name(name_elem, file_path) + display_name = self._parse_display_name(display_name, file_path) # TODO: misleading var name? next_file_path = dest_path.joinpath(file_path) @@ -318,7 +349,13 @@ def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: # Crawl the next file if next_file_path.is_file(): # TODO: investigate non-recursive approaches? - child_node = self._generate_json_map(next_file_path, dest_path) + child_node = self._generate_json_map( + next_file_path, + dest_path, + component, + current_component_name=current_component_name, + name_elem=name_elem, + ) else: child_node = JsonMap( str(file_path), display_name, exists=("IOC" in macro_dict) @@ -335,7 +372,7 @@ def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: except Exception as e: current_node.error = str(e) - self._fix_duplicate_names(current_node) + self._fix_names_json_map(current_node, component) return current_node @@ -393,7 +430,9 @@ def _parse_display_name(self, name: str | None, file_path: Path) -> str | None: # Populate displayName with null return None - def _fix_duplicate_names(self, node: JsonMap) -> None: + def _fix_names_json_map( + self, node: JsonMap, components: dict[str, Component] + ) -> None: """Recursively fix duplicate display names in children""" if not node.children: return @@ -407,6 +446,7 @@ def _fix_duplicate_names(self, node: JsonMap) -> None: for name, children in name_groups.items(): if name and len(children) > 1: # append pv names when present + for child in children: if "P" in child.macros: child.display_name = f"{name} ({child.macros['P']})" @@ -418,7 +458,7 @@ def _fix_duplicate_names(self, node: JsonMap) -> None: # recursively fix children for child in node.children: - self._fix_duplicate_names(child) + self._fix_names_json_map(child, components) def write_json_map( self, @@ -434,7 +474,7 @@ def write_json_map( f"Cannot generate json map for {synoptic}. Has it been generated?" ) - map = self._generate_json_map(synoptic, dest) + map = self._generate_json_map(synoptic, dest, self.conf.components) with open(dest.joinpath("JsonMap.json"), "w") as f: f.write( json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o)) @@ -499,3 +539,36 @@ def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None: f"Actions group not found in component [bold]{name}[/bold] on " f"[bold]{parent_name}[/bold]" ) + + +def _get_labels( + name_elem: str | None, + component: dict[str, Component], + current_component_name: str | None, + display_name: str | None, +) -> str | None: + """ + Get display name from child labels if they exist, otherwise return name_elem + or existing display_name if name_elem is None. + """ + if name_elem is not None: + if name_elem in component.keys() and component[name_elem].label is not None: + display_name = component[name_elem].label + elif ( + current_component_name is not None + and (current_component_name in component.keys()) + and (component[current_component_name].child_labels is not None) + ): + child_labels = component[current_component_name].child_labels + if child_labels is not None: + # Because name_elem is initially + # grabbed from the .bob file, the generated .bob + # file might have already propagated the child label from techui.yaml + if name_elem in child_labels.values(): + display_name = name_elem + # In the case of screens not regenerated, such as validated screens, + # the name text will not be updated to the childlabel,so we check the + # keys solely for generating the json_map from the top level .bob file + elif name_elem in child_labels: + display_name = child_labels[name_elem] + return display_name diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index 2795ab29..1a297e67 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -12,7 +12,7 @@ from phoebusgen import widget as pwidget from phoebusgen.widget.widgets import ActionButton, EmbeddedDisplay, Group -from techui_builder.models import Entity +from techui_builder.models import Component, Entity logger_ = logging.getLogger(__name__) @@ -40,6 +40,7 @@ class Generator: widget_x: int = field(default=0, init=False, repr=False) widget_count: int = field(default=0, init=False, repr=False) group_padding: int = field(default=50, init=False, repr=False) + label_flag: bool = field(default=False, init=False, repr=False) def __post_init__(self): # This needs to be before _read_map() @@ -173,6 +174,14 @@ def _initialise_name_suffix(self, component: Entity) -> tuple[str, str, str | No suffix = "" suffix_label = "" + name = name.removeprefix(":").removesuffix(":") + # Try to get name from child labels if they exist, + # if not, just use the name as it is. + if component.child_labels is not None: + if name in component.child_labels.keys(): + name = component.child_labels[name] + self.label_flag = True + return (name, suffix, suffix_label) def _is_list_of_dicts(self, scrn_mapping: Mapping) -> bool: @@ -201,7 +210,8 @@ def _allocate_widget( ) if match: suffix_label: str | None = match.group(2) - name: str = suffix + if self.label_flag is False: + name = suffix except KeyError: pass @@ -221,6 +231,7 @@ def _allocate_widget( new_widget.macro( f"{suffix_label}", suffix.removeprefix(":").removesuffix(":") ) + new_widget.macro("label", name.removeprefix(":").removesuffix(":")) # TODO: Change this to pvi_button if True: new_widget.macro("IOC", f"{self.beamline_url}/{component.P.lower()}") @@ -260,6 +271,7 @@ def _allocate_widget( # For some reason the version of action buttons is 3.0.0? new_widget.version("2.0.0") + self.label_flag = False return new_widget def _create_widget( @@ -367,7 +379,7 @@ def build_widgets(self, screen_name: str, screen_components: list[Entity]): continue self.widgets.append(new_widget) - def build_groups(self, screen_name: str): + def build_groups(self, screen_name: str, builder_components: dict[str, Component]): """ Create a group to fill with widgets """ @@ -381,8 +393,16 @@ def build_groups(self, screen_name: str): # that will be created. height, width = self._get_group_dimensions(self.widgets) + if ( + screen_name in builder_components.keys() + and builder_components[screen_name].label is not None + ): + label = builder_components[screen_name].label or screen_name + else: + label = screen_name + self.group = Group( - screen_name, + label, 0, 0, width, diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 4ee1a20d..f0ce63e8 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -106,7 +106,10 @@ class Component(BaseModel): """One UI Component from techui.yaml `components:` dictionary""" prefix: Annotated[str, Field(description="Component PV Prefix")] - desc: Annotated[str | None, Field(description="Component description")] = None + label: Annotated[str | None, Field(description="Component label")] = None + child_labels: Annotated[ + dict[str, str] | None, Field(description="Component Children Label") + ] = None extras: Annotated[ list[str] | None, Field( @@ -272,6 +275,10 @@ class Entity(BaseModel): desc: Annotated[ str | None, Field(description="Optional description of module entity") ] = None + child_labels: Annotated[ + dict[str, str] | None, + Field(description="Optional child labels for module entity"), + ] = None M: Annotated[str | None, Field(description="Optional PV suffix for a motor")] R: Annotated[ str | None, Field(description="Optional PV suffix for an ADAravis plugin") diff --git a/tests/conftest.py b/tests/conftest.py index 1c05c40b..60934c7f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,6 +39,11 @@ def builder_with_test_files(builder: Builder): return builder +@pytest.fixture +def components(builder_with_test_files: Builder): + return builder_with_test_files.conf.components + + @pytest.fixture def test_files(): screen_path = Path("tests/test_files/test_bob.bob").absolute() diff --git a/tests/test_autofiller.py b/tests/test_autofiller.py index aeae4bc8..81af83fd 100644 --- a/tests/test_autofiller.py +++ b/tests/test_autofiller.py @@ -87,7 +87,7 @@ def test_autofiller_replace_content( # Cannot use a Mock object as need P to be computed fake_component = Component( prefix=prefix, - desc=description, + label=description, file=filename, macros=macros, ) diff --git a/tests/test_builder.py b/tests/test_builder.py index b0f0ec05..6f4a0144 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -12,6 +12,7 @@ from techui_builder.builder import ( JsonMap, _get_action_group, # type: ignore + _get_labels, # type: ignore _serialise_json_map, # type: ignore ) @@ -29,10 +30,10 @@ def test_beamline_attributes(builder, attr, expected): @pytest.mark.parametrize( - "index, name, desc, P, R, attribute, file, extras", + "index, name, label, P, R, attribute, file, extras, child_labels", [ - (0, "fshtr", "Fast Shutter", "BL01T-EA-FSHTR-01", None, None, None, None), - (1, "d1", "Diode 1", "BL01T-DI-PHDGN-01", None, None, "test.bob", None), + (0, "fshtr", "Fast Shutter", "BL01T-EA-FSHTR-01", None, None, None, None, None), + (1, "d1", "Diode 1", "BL01T-DI-PHDGN-01", None, None, "test.bob", None, None), ( 2, "motor", @@ -42,6 +43,7 @@ def test_beamline_attributes(builder, attr, expected): None, None, None, + {"X": "X1", "Y": "Y1", "Z": "Z1"}, ), ], ) @@ -49,20 +51,22 @@ def test_component_attributes( builder, index, name, - desc, + label, P, # noqa: N803 R, # noqa: N803 attribute, file, extras, + child_labels, ): components = list(builder.conf.components.keys()) component = builder.conf.components[components[index]] assert components[index] == name - assert component.desc == desc + assert component.label == label assert component.P == P assert component.R == R assert component.attribute == attribute + assert component.child_labels == child_labels if file is not None: assert component.file == file if extras is not None: @@ -290,34 +294,50 @@ def test_write_json_map(builder): # We don't want to access the _get_action_group function in this test @patch("techui_builder.builder._get_action_group") +@patch("techui_builder.builder._get_labels") def test_generate_json_map( - mock_get_action_group, builder_with_test_files, example_json_map, test_files + mock_get_labels, + mock_get_action_group, + builder_with_test_files, + example_json_map, + test_files, + components, ): screen_path, dest_path = test_files mock_xml = objectify.Element("action") mock_xml["file"] = "test_child_bob.bob" mock_get_action_group.return_value = mock_xml + mock_get_labels.side_effect = ["Display", "Detector"] test_json_map = builder_with_test_files._generate_json_map( - screen_path.absolute(), dest_path + screen_path.absolute(), dest_path, components ) assert test_json_map == example_json_map # TODO: write this test -def test_generate_json_map_embedded_screen(builder_with_test_files, example_json_map): +@patch("techui_builder.builder._get_labels") +def test_generate_json_map_embedded_screen( + mock_get_labels, builder_with_test_files, example_json_map, components +): + mock_get_labels.side_effect = ["Display", "Detector", "Embedded Display"] + screen_path = Path("tests/test_files/test_bob_embedded.bob").absolute() dest_path = Path("tests/test_files/") - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) example_json_map.file = "test_bob_embedded.bob" example_json_map.children.append( JsonMap( "$(IOC)/pmacAxis.pvi.bob", display_name="Embedded Display", exists=False ) ) + + test_json_map = builder_with_test_files._generate_json_map( + screen_path, dest_path, components + ) + assert test_json_map == example_json_map @@ -342,7 +362,7 @@ def test_parse_display_name_returns_none(builder): assert display_name is None -def test_fix_duplicate_names_recursive(builder, example_display_names_json): +def test_fix_names_json_map_recursive(builder, example_display_names_json): """Test duplicate names are enumerated correctly for all children""" test_display_names_json = JsonMap( @@ -376,15 +396,21 @@ def test_fix_duplicate_names_recursive(builder, example_display_names_json): test_display_names_json.children.append(test_display_names_json_dev1) test_display_names_json.children.append(test_display_names_json_dev2) - builder._fix_duplicate_names(test_display_names_json) + builder._fix_names_json_map(test_display_names_json, builder.conf.components) assert test_display_names_json == example_display_names_json # We don't want to access the _get_action_group function in this test @patch("techui_builder.builder._get_action_group") +@patch("techui_builder.builder._get_labels") def test_generate_json_map_get_macros( - mock_get_action_group, builder_with_test_files, example_json_map, test_files + mock_get_labels, + mock_get_action_group, + builder_with_test_files, + example_json_map, + test_files, + components, ): screen_path, dest_path = test_files @@ -396,31 +422,45 @@ def test_generate_json_map_get_macros( macros = objectify.SubElement(mock_xml, "macros") # Set a macro to test macros["macro"] = "value" + mock_get_labels.side_effect = ["Display", "Detector"] mock_get_action_group.return_value = mock_xml - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) - + test_json_map = builder_with_test_files._generate_json_map( + screen_path, dest_path, components + ) assert test_json_map == example_json_map -def test_generate_json_map_xml_parse_error(builder_with_test_files, test_files): +def test_generate_json_map_xml_parse_error( + builder_with_test_files, test_files, components +): screen_path = Path("tests/test_files/test_bob_bad.bob").absolute() _, dest_path = test_files - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) + test_json_map = builder_with_test_files._generate_json_map( + screen_path, dest_path, components + ) assert test_json_map.error.startswith("XML parse error:") @patch("techui_builder.builder._get_action_group") +@patch("techui_builder.builder._get_labels") def test_generate_json_map_other_exception( - mock_get_action_group, builder_with_test_files, test_files + mock_get_labels, + mock_get_action_group, + builder_with_test_files, + test_files, + components, ): screen_path, dest_path = test_files mock_get_action_group.side_effect = Exception("Some exception") + mock_get_labels.side_effect = ["Display", "Detector"] - test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) + test_json_map = builder_with_test_files._generate_json_map( + screen_path, dest_path, components + ) assert test_json_map.error != "" @@ -471,3 +511,59 @@ def test_get_action_group_no_actions_group(caplog): for log_output in caplog.records: assert "Actions group not found" in log_output.message + + +def test_get_labels(components): + display_name = _get_labels( + "motor", + components, + None, + None, + ) + assert display_name == "Motor Stage" + + +def test_get_labels_child_labels(components): + display_name = _get_labels( + "X", + components, + current_component_name="motor", + display_name="X", + ) + assert display_name == "X1" + + +def test_get_labels_child_labels_with_name_already_pregenerated( + components, +): + display_name = _get_labels( + "X1", + components, + current_component_name="motor", + display_name="X", + ) + assert display_name == "X1" + + +def test_get_labels_with_name_elem_invalid( + components, +): + display_name = _get_labels( + "invalid_name", + components, + current_component_name=None, + display_name="new_name", + ) + assert display_name == "new_name" + + +def test_get_labels_with_current_component_name_invalid( + components, +): + display_name = _get_labels( + "invalid_name", + components, + current_component_name="invalid_name", + display_name="new_name", + ) + assert display_name == "new_name" diff --git a/tests/test_files/widget.xml b/tests/test_files/widget.xml index ddda1618..85122be3 100644 --- a/tests/test_files/widget.xml +++ b/tests/test_files/widget.xml @@ -9,6 +9,7 @@

BL23B-DI-MOD-02

CAM + test_url/bl23b-di-mod-02
diff --git a/tests/test_generate.py b/tests/test_generate.py index 74b097bf..e6097689 100644 --- a/tests/test_generate.py +++ b/tests/test_generate.py @@ -191,6 +191,23 @@ def test_generator_initialise_name_suffix_none(generator): assert suffix_label == "" +def test_generator_initialise_name_suffix_with_child_labels(generator): + component = Entity( + type="test", + P="TEST", + desc=None, + M=None, + R="T1", + child_labels={"T1": "Test 1"}, + ) + + name, suffix, suffix_label = generator._initialise_name_suffix(component) + + assert name == "Test 1" + assert suffix == "T1" + assert suffix_label == "R" + + def test_generator_is_list_of_dicts(generator): list_of_dicts = [{"a": 1}, {"b": 2}] assert generator._is_list_of_dicts(list_of_dicts) is True @@ -222,7 +239,7 @@ def test_generator_allocate_widget(generator): def test_generator_allocate_widget_with_suffix(generator): - generator._initilise_name_suffix = Mock(return_value=(":CAM:", ":CAM:", "R")) + generator._initialise_name_suffix = Mock(return_value=(":CAM:", ":CAM:", "R")) scrn_mapping = { "file": "ADAravis/ADAravis_summary.bob", @@ -322,7 +339,7 @@ def test_generator_layout_widgets(generator, index, x, y): # TODO: Split up test -def test_generator_build_screen(generator): +def test_generator_build_screen(generator, components): generator._create_widget = Mock(return_value=Mock()) generator.layout_widgets = Mock( return_value=[ @@ -337,11 +354,49 @@ def test_generator_build_screen(generator): screen_components = [Mock(), Mock(), Mock()] generator.build_widgets(screen_name, screen_components) - generator.build_groups(screen_name) + generator.build_groups(screen_name, components) generator.build_screen(screen_name) assert objectify.fromstring(str(generator.screen_)).xpath("//widget[@type='group']") +def test_build_groups_with_label(generator, components): + screen_name = "motor" + generator.widgets = [Mock(), Mock(), Mock()] + generator._create_widget = Mock(return_value=Mock()) + generator.layout_widgets = Mock( + return_value=[ + pwidget.EmbeddedDisplay(name="X", file="", x=0, y=0, width=205, height=120), + pwidget.EmbeddedDisplay( + name="Y", file="", x=0, y=150, width=205, height=120 + ), + ] + ) + generator._get_group_dimensions = Mock(return_value=(600, 400)) + + generator.build_groups(screen_name, components) + xml = objectify.fromstring(str(generator.group)) + assert xml.xpath("//name")[0] == "Motor Stage" + + +def test_build_groups(generator, components): + screen_name = "test" + generator.widgets = [Mock(), Mock(), Mock()] + generator._create_widget = Mock(return_value=Mock()) + generator.layout_widgets = Mock( + return_value=[ + pwidget.EmbeddedDisplay(name="X", file="", x=0, y=0, width=205, height=120), + pwidget.EmbeddedDisplay( + name="Y", file="", x=0, y=150, width=205, height=120 + ), + ] + ) + generator._get_group_dimensions = Mock(return_value=(600, 400)) + + generator.build_groups(screen_name, components) + xml = objectify.fromstring(str(generator.group)) + assert xml.xpath("//name")[0] == "test" + + def test_generator_write_screen(generator): screen_name = "test" generator.screen_ = pscreen.Screen("test") diff --git a/tests/test_models.py b/tests/test_models.py index e6a8cd55..a31d73ff 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -21,7 +21,10 @@ def beamline() -> Beamline: @pytest.fixture def component() -> Component: return Component( - prefix="BL01T-EA-TEST-02", desc="Test Device", status=["BL01T-MO-MOTOR-01:Y"] + prefix="BL01T-EA-TEST-02", + label="Test Device", + status=["BL01T-MO-MOTOR-01:Y"], + child_labels={"X": "X1", "Y": "Y1", "Z": "Z1"}, ) @@ -41,25 +44,27 @@ def test_beamline_object(beamline: Beamline): def test_component_object(component: Component): - assert component.desc == "Test Device" + assert component.label == "Test Device" assert component.extras is None assert component.P == "BL01T-EA-TEST-02" assert component.R is None assert component.attribute is None assert component.status == ["BL01T-MO-MOTOR-01:Y"] + assert component.child_labels == {"X": "X1", "Y": "Y1", "Z": "Z1"} def test_component_repr(component: Component): assert ( str(component) - == "prefix='BL01T-EA-TEST-02' desc='Test Device' extras=None\ - file=None macros=None status=['BL01T-MO-MOTOR-01:Y']" + == "prefix='BL01T-EA-TEST-02' label='Test Device' child_labels\ +={'X': 'X1', 'Y': 'Y1', 'Z': 'Z1'} extras=None file=None macros=None\ + status=['BL01T-MO-MOTOR-01:Y']" ) def test_component_bad_prefix(): with pytest.raises(ValueError): - Component(prefix="Test 2", desc="BAD_PREFIX") + Component(prefix="Test 2", label="BAD_PREFIX") def test_gui_component_entry(gui_components: GuiComponentEntry):