diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 18d84909..7702594d 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -1,7 +1,6 @@ import logging import os import re -from collections import defaultdict from dataclasses import dataclass, field from pathlib import Path @@ -34,9 +33,7 @@ class Builder: techui: Path = field(default=Path("techui.yaml")) - entities: defaultdict[str, list[Entity]] = field( - default_factory=lambda: defaultdict(list), init=False - ) + entities: dict[str, Entity] = field(default_factory=dict, init=False) _services_dir: Path = field(init=False, repr=False) _write_directory: Path = field(init=False, repr=False) @@ -180,10 +177,21 @@ def _extract_entities(self, service_name: str, service_yaml: Path): macros=macros, ) - pv_root = prefix.split(":", maxsplit=1)[0] - self.entities[pv_root].append(new_entity) + self.entities[prefix] = new_entity break + def get_entities_for_prefix(self, prefix: str) -> list[Entity]: + """Return entities matching a full PV prefix, or all entities under a root.""" + results = [] + if prefix in self.entities: + # Exact match (no suffix) — also catches root entity in loop below + results.append(self.entities[prefix]) + # Children with suffixes (e.g. BL19I-MO-DCM-01:MOT1) + for entity in self.entities.values(): + if entity.base_prefix == prefix and entity is not self.entities.get(prefix): + results.append(entity) + return results + def _generate_screen(self, screen_name: str): self.generator.build_screen(screen_name) self.generator.write_screen(screen_name, self._write_directory) @@ -210,41 +218,39 @@ def create_screens(self): for component_name, component in self.conf.components.items(): screen_entities: list[Entity] = [] - # 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: - if extra_p not in self.entities.keys(): - logger_.error( - f"Extra prefix {extra_p} for {component_name} does not" - " exist." - ) - continue - screen_entities.extend(self.entities[extra_p]) - - # This is used by both generate and validate, - # so called beforehand for tidiness - self.generator.build_widgets(component_name, screen_entities) - self.generator.build_groups(component_name, self.conf.components) - - 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: + matched = self.get_entities_for_prefix(component.prefix) + if not matched: logger_.warning( - f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold] " - f"set in the component [bold]{component_name}[/bold] does not match" - " any P field in the ioc.yaml files in services" + f"{self.techui.name}: prefix [bold]{component.prefix}[/bold] " + f"for component [bold]{component_name}[/bold] matched no entities." ) + continue + + # Populate child labels for any entities + for entity in matched: + entity.child_labels = component.child_labels + + screen_entities.extend(matched) + + if component.extras is not None: + for extra_p in component.extras: + extra_matched = self.get_entities_for_prefix(extra_p) + if not extra_matched: + logger_.error( + f"Extra prefix {extra_p} for {component_name} matched no" + " entities." + ) + continue + screen_entities.extend(extra_matched) + + # This is used by both generate and validate, + # so called beforehand for tidiness + self.generator.build_widgets(component_name, screen_entities) + self.generator.build_groups(component_name, self.conf.components) + + 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) diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index fbb3f456..42277c05 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -217,7 +217,7 @@ def _allocate_widget( component.macros[macro_key] = macro_val # If no child label was specified... - if not self.label_flag and "label" not in component.macros: + if not self.label_flag: # Prefer an explicit "label" or "suffix" key in screen_macros, # otherwise fall back to the first value (by insertion order). fallback_keys = ("label", "suffix") diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 81f50bc8..6c8b8eb7 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -307,6 +307,12 @@ class Entity(BaseModel): Field(description="Macros for the matching screen (can be empty)"), ] + @computed_field(repr=False, return_type=str) + @property + def base_prefix(self) -> str: + """PV prefix, excluding any colons or suffix, e.g. 'BL01T-MO-MOTOR-01'.""" + return self.prefix.split(":", maxsplit=1)[0] + class SupportEntityScreen(BaseModel): """One screen entry for a support module.""" diff --git a/tests/conftest.py b/tests/conftest.py index 78fbfb5f..a069459b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,6 +131,40 @@ def builder_with_setup(builder: Builder, techui_support): return builder +@pytest.fixture +def builder_with_entities(builder: Builder): + """Builder with a pre-populated entity registry for testing lookups.""" + from techui_builder.models import Entity + + builder.entities = { + "BL01T-MO-DCM-01": Entity( + service_name="bl01t-mo-dcm-01", + type="pmac.GeoBrick", + prefix="BL01T-MO-DCM-01", + macros={"P": "BL01T-MO-DCM-01"}, + ), + "BL01T-MO-DCM-01:BRAGG": Entity( + service_name="bl01t-mo-dcm-01", + type="pmac.dls_pmac_asyn_motor", + prefix="BL01T-MO-DCM-01:BRAGG", + macros={"P": "BL01T-MO-DCM-01", "M": ":BRAGG"}, + ), + "BL01T-MO-DCM-01:GAP": Entity( + service_name="bl01t-mo-dcm-01", + type="pmac.dls_pmac_asyn_motor", + prefix="BL01T-MO-DCM-01:GAP", + macros={"P": "BL01T-MO-DCM-01", "M": ":GAP"}, + ), + "BL01T-MO-STEP-13": Entity( + service_name="bl01t-mo-step-13", + type="pmac.GeoBrick", + prefix="BL01T-MO-STEP-13", + macros={"P": "BL01T-MO-STEP-13"}, + ), + } + return builder + + @pytest.fixture def builder_with_test_files(builder: Builder, tmp_test_files): builder._write_directory = tmp_test_files diff --git a/tests/test_builder.py b/tests/test_builder.py index bc9766f1..02a9d41f 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -79,25 +79,22 @@ def test_missing_service(builder, caplog: pytest.LogCaptureFixture): @pytest.mark.parametrize( - "index, type, desc, pv, macros", + "type, desc, pv, macros", [ - (0, "pmac.GeoBrick", None, "BL01T-MO-BRICK-01", {"P": "BL01T-MO-BRICK-01"}), + ("pmac.GeoBrick", None, "BL01T-MO-BRICK-01", {"P": "BL01T-MO-BRICK-01"}), ( - 0, "pmac.autohome", None, "BL01T-MO-MOTOR-01", {"P": "BL01T-MO-MOTOR-01"}, ), ( - 1, "pmac.dls_pmac_asyn_motor", None, "BL01T-MO-MOTOR-01:X", {"P": "BL01T-MO-MOTOR-01", "M": ":X"}, ), ( - 2, "pmac.dls_pmac_asyn_motor", None, "BL01T-MO-MOTOR-01:A", @@ -105,19 +102,16 @@ def test_missing_service(builder, caplog: pytest.LogCaptureFixture): ), ], ) -def test_gb_extract_entities_ioc_yaml( - builder, techui_support, index, type, desc, pv, macros -): +def test_gb_extract_entities_ioc_yaml(builder, techui_support, type, desc, pv, macros): # We don't want to use builder_with_setup as that calls _extract_services() # and in turn that calls _extract_entities() builder.techui_support = techui_support - prefix = pv.split(":", maxsplit=1)[0] builder._extract_entities( "bl01t-mo-motor-01", builder._services_dir / "bl01t-mo-motor-01/config/ioc.yaml", ) - entity = builder.entities[prefix][index] + entity = builder.entities[pv] assert entity.type == type assert entity.desc == desc assert entity.prefix == pv @@ -125,10 +119,9 @@ def test_gb_extract_entities_ioc_yaml( @pytest.mark.parametrize( - "index, type, desc, pv, macros", + "type, desc, pv, macros", [ ( - 0, "fastcs.TemperatureController", None, "BL01T-EA-TEMP-01", @@ -137,19 +130,17 @@ def test_gb_extract_entities_ioc_yaml( ], ) def test_gb_extract_entities_fastcs_yaml( - builder, techui_support, index, type, desc, pv, macros + builder, techui_support, type, desc, pv, macros ): # We don't want to use builder_with_setup as that calls _extract_services() # and in turn that calls _extract_entities() builder.techui_support = techui_support - prefix = pv.split(":", maxsplit=1)[0] - builder._extract_entities( "bl01t-ea-temp-01", builder._services_dir / "bl01t-ea-temp-01/config/fastcs.yaml", ) - entity = builder.entities[prefix][index] + entity = builder.entities[pv] assert entity.type == type assert entity.desc == desc assert entity.prefix == pv @@ -266,3 +257,43 @@ def test_create_screens_extra_p_does_not_exist( for log_output in caplog.records: assert "Extra prefix BAD-PV" in log_output.message + + +@pytest.mark.parametrize( + "prefix", + [ + "BL01T-MO-DCM-01:BRAGG", + "BL01T-MO-STEP-13", + ], +) +def test_get_entities_for_prefix_single(builder_with_entities, prefix): + """Single PV lookup returns exactly one entity.""" + result = builder_with_entities.get_entities_for_prefix(prefix) + assert {r.prefix for r in result} == {prefix} + + +def test_get_entities_for_prefix_root_with_children(builder_with_entities): + """Root prefix lookup collects all matching children.""" + result = builder_with_entities.get_entities_for_prefix("BL01T-MO-DCM-01") + expected = { + "BL01T-MO-DCM-01", + "BL01T-MO-DCM-01:BRAGG", + "BL01T-MO-DCM-01:GAP", + } + assert {r.prefix for r in result} == expected + + +def test_get_entities_for_prefix_no_match(builder_with_entities): + """Lookup of a non-existent prefix returns empty list.""" + result = builder_with_entities.get_entities_for_prefix("BL01T-NONEXISTENT-01") + assert result == [] + + +def test_get_entities_for_prefix_root_without_root_entity(builder_with_entities): + """Root prefix lookup collects children even when no root entity exists.""" + del builder_with_entities.entities["BL01T-MO-DCM-01"] + result = builder_with_entities.get_entities_for_prefix("BL01T-MO-DCM-01") + assert {r.prefix for r in result} == { + "BL01T-MO-DCM-01:BRAGG", + "BL01T-MO-DCM-01:GAP", + }