diff --git a/example/t01-services/synoptic/index.bob b/example/t01-services/synoptic/index.bob index e6cd7aca..200ed601 100644 --- a/example/t01-services/synoptic/index.bob +++ b/example/t01-services/synoptic/index.bob @@ -1,7 +1,13 @@ - - + + Display + + bl01t + t01 + Test Beamline + t01-opis.diamond.ac.uk + dcam1_Group 250 @@ -12,6 +18,7 @@ true dcam1 + BL01T-DI-CAM-01:STA techui-support/symbols/camera.svg @@ -26,11 +33,10 @@ replace + true $(pv_name) $(pv_value) $(actions) - BL01T-DI-CAM-01:STA - true dcam1_L:abel @@ -51,6 +57,7 @@ $(actions) true diode1 + BL01T-DI-PHDGN-01:STA techui-support/symbols/oscilloscope.svg @@ -65,11 +72,10 @@ $(actions) replace + true $(pv_name) $(pv_value) $(actions) - BL01T-DI-PHDGN-01:STA - true diode1_Label @@ -102,10 +108,10 @@ $(actions) replace + true $(pv_name) $(pv_value) $(actions) - true temp1_label @@ -141,10 +147,10 @@ $(actions) replace + true $(pv_name) $(pv_value) $(actions) - true motor1_label diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index 97a4fb4f..1cab69cd 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -31,7 +31,7 @@ `-- synoptic\n . |-- techui-support/\n | | `-- ...\n - . |-- techui.yaml\n + . |-- techui.yaml?\n . `-- index.bob\n """, no_args_is_help=True, diff --git a/src/techui_builder/autofill.py b/src/techui_builder/autofill.py index c4afbd13..9e64db04 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -29,7 +29,7 @@ class Autofiller: def read_bobs(self) -> None: for path in self.index_paths: - tree, widget_dict = read_bob(path) + tree, widget_dict, _ = read_bob(path) self.index_trees[path] = (tree, widget_dict) def autofill_bobs(self) -> None: @@ -63,7 +63,7 @@ def _autofill_from_path(self, path: Path): self._autofill_from_path(resolved_path) else: if resolved_path.exists(): - nav_tree, nav_widgets = read_bob(resolved_path) + nav_tree, nav_widgets, _ = read_bob(resolved_path) self.index_trees[resolved_path] = ( nav_tree, nav_widgets, diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py new file mode 100644 index 00000000..c7b3e5bb --- /dev/null +++ b/src/techui_builder/bob_parser.py @@ -0,0 +1,53 @@ +import logging +from dataclasses import dataclass +from pathlib import Path + +from lxml.objectify import ObjectifiedElement + +from techui_builder.models import BobFile, BobWidget +from techui_builder.utils import WidgetType, _get_macros, read_bob + +logger_ = logging.getLogger(__name__) + + +@dataclass +class BobParser: + bob_path: Path + + def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: + result = [] + + for element in container.iterchildren("widget"): + name_element = element.find("name") + name = name_element.text if name_element is not None else "" + prefix_element = element.find("pv_name") + prefix = prefix_element.text if prefix_element is not None else "" + + widget = BobWidget( + name=name or "", + widget_type=element.get("type", default=""), + macros=_get_macros(element), + element=element, + children=None, + prefix=prefix or "", + ) + if widget.widget_type == "group": + widget.children = self._parse_widgets(element) + + if widget.widget_type in WidgetType: + result.append(widget) + + return result + + def parse_bob( + self, + ) -> BobFile: + tree, _, display_macros = read_bob(self.bob_path) + root = tree.getroot() + + return BobFile( + path=self.bob_path, + macros=display_macros, + tree=tree, + widgets=self._parse_widgets(root), + ) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 18d84909..9524b0d0 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -9,7 +9,14 @@ from jinja2 import Template from techui_builder.generate import Generator -from techui_builder.models import Entity, SupportEntity, TechUi, TechUiSupport +from techui_builder.models import ( + BobFile, + Component, + Entity, + SupportEntity, + TechUi, + TechUiSupport, +) from techui_builder.validator import Validator logger_ = logging.getLogger(__name__) @@ -32,7 +39,8 @@ class Builder: """ - techui: Path = field(default=Path("techui.yaml")) + bob_description: BobFile + techui: Path | None = field(init=True, repr=False) entities: defaultdict[str, list[Entity]] = field( default_factory=lambda: defaultdict(list), init=False @@ -40,11 +48,41 @@ class Builder: _services_dir: Path = field(init=False, repr=False) _write_directory: Path = field(init=False, repr=False) + def _iter_prefixed_widgets(self, widgets): + for widget in widgets: + if widget.prefix and widget.prefix.strip(): + yield widget + + if widget.widget_type == "group": + yield from self._iter_prefixed_widgets(widget.children or []) + def __post_init__(self): - # Populate beamline and components - self.conf = TechUi.model_validate( - yaml.safe_load(self.techui.read_text(encoding="utf-8")) - ) + components = {} + + for widget in self._iter_prefixed_widgets(self.bob_description.widgets): + values = { + key: value + for key, value in widget.macros.items() + if key in Component.model_fields and key != "prefix" + } + values["prefix"] = widget.prefix.strip() + values.setdefault("label", widget.name) + components[widget.name] = values + + macros = self.bob_description.macros + techui_data = { + "beamline": { + key: macros[key] for key in ("location", "domain", "desc", "url") + }, + "components": components, + } + + if self.techui is not None and self.techui.is_file(): + yaml_data = yaml.safe_load(self.techui.read_text(encoding="utf-8")) or {} + techui_data["beamline"].update(yaml_data.get("beamline", {})) + components.update(yaml_data.get("components", {})) + + self.conf = TechUi.model_validate(techui_data) def setup(self): """ @@ -62,7 +100,7 @@ def setup(self): self.generator = Generator( self._write_directory, - self.conf.beamline.url, + self.bob_description.macros["url"], self.support_path, self.techui_support, ) @@ -109,7 +147,9 @@ def _extract_services(self): """ # Loop over every dir in services, ignoring anything that isn't a service - for service in self._services_dir.glob(f"{self.conf.beamline.location}-*-*-*"): + for service in self._services_dir.glob( + f"{self.bob_description.macros['location']}-*-*-*" + ): service_name = service.name # If service doesn't exist, file open will fail throwing exception try: @@ -198,6 +238,8 @@ def _validate_screen(self, screen_name: str): def create_screens(self): """Create the screens for each component in techui.yaml""" + + techui_name = self.techui.name if self.techui is not None else "techui.yaml" if len(self.entities) == 0: logger_.critical( "No ioc entities found. This [italic]normally[/italic]" @@ -244,7 +286,7 @@ def create_screens(self): else: logger_.warning( - f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold] " + f"{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" ) diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index d20fbfe0..58d187b5 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -7,6 +7,7 @@ from techui_builder._logger import log_level from techui_builder.autofill import Autofiller +from techui_builder.bob_parser import BobParser from techui_builder.builder import Builder logger_ = logging.getLogger(__name__) @@ -20,13 +21,13 @@ def find_dirs(file_path: Path, beamline: str) -> tuple: # Get the relative path to the techui file from working dir abs_path = file_path.absolute() - logger_.debug(f"techui.yaml absolute path: {abs_path}") + logger_.debug(f"index.bob absolute path: {abs_path}") # Get the current working dir cwd = Path.cwd() logger_.debug(f"Working directory: {cwd}") - directory = beamline + directory = beamline.lower() # Get the relative path of ixx-services to techui.yaml ixx_services_dir = next( @@ -114,16 +115,9 @@ def find_index_bobs( # This is the 'build' behaviour -@app.command("build", help="Run `techui-builder build` for a given techui.yaml") +@app.command("build", help="Run `techui-builder build` for a given index.bob") def main( - filename: Annotated[Path, typer.Argument(help="The path to techui.yaml")], - bobfile: Annotated[ - Path | None, - typer.Argument( - help="Override for template bob file location. This will be used to find" - " and other template bob files in the same location with similar names." - ), - ] = None, + filename: Annotated[Path, typer.Argument(help="The path to index.bob")], loglevel: Annotated[ str, typer.Option( @@ -137,23 +131,49 @@ def main( ) -> None: """Function to run when `techui-builder build` is called.""" - gui = Builder(techui=filename) - - ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.domain) + # ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.domain) - index_bob_path, bob_files = find_index_bobs(bobfile, synoptic_dir) + # index_bob_path, bob_files = find_index_bobs(filename, synoptic_dir) # # Overwrite after initialised to make sure this is picked up + # gui._services_dir = ixx_services_dir / "services" # noqa: SLF001 + # gui._write_directory = synoptic_dir # noqa: SLF001 + + # logger_.debug( + # f""" + + # Builder created for {gui.conf.beamline.domain}. + # Services directory: {gui._services_dir} + # Write directory: {gui._write_directory} + # """, # noqa: SLF001 + # ) + + # gui.setup() + # gui.create_screens() + + # logger_.info(f"Screens generated for {gui.conf.beamline.domain}.") + + # autofiller = Autofiller(bob_files, index_bob_path, gui.conf.components) + # Extract information from index.bob + bp = BobParser(filename) + synoptic_macros = bp.parse_bob() + ixx_services_dir, synoptic_dir = find_dirs( + filename, synoptic_macros.macros["domain"] + ) + # Need to provide builder the macros to work on generating and validating bob files. + index_bob_path, bob_files = find_index_bobs(filename, synoptic_dir) + + gui = Builder(synoptic_macros, synoptic_dir / "techui.yaml") gui._services_dir = ixx_services_dir / "services" # noqa: SLF001 gui._write_directory = synoptic_dir # noqa: SLF001 logger_.debug( f""" -Builder created for {gui.conf.beamline.domain}. -Services directory: {gui._services_dir} -Write directory: {gui._write_directory} -""", # noqa: SLF001 + Builder created for {gui.conf.beamline.domain}. + Services directory: {gui._services_dir} + Write directory: {gui._write_directory} + """, # noqa: SLF001 ) gui.setup() @@ -162,7 +182,6 @@ def main( logger_.info(f"Screens generated for {gui.conf.beamline.domain}.") autofiller = Autofiller(bob_files, index_bob_path, gui.conf.components) - autofiller.read_bobs() autofiller.autofill_bobs() autofiller.write_bobs() diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index d926baed..6f2c56aa 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -1,7 +1,10 @@ import logging import re +from pathlib import Path from typing import Annotated, Any +from lxml.etree import ElementTree +from lxml.objectify import ObjectifiedElement from pydantic import ( BaseModel, ConfigDict, @@ -13,7 +16,7 @@ logger_ = logging.getLogger(__name__) - +type MacroMap = dict[str, str] # Patterns: # long: 'bl23b' # short: 'b23', 'ixx-1' @@ -294,7 +297,17 @@ class Entity(BaseModel): "ADAravis.aravisCamera" ), ] - prefix: Annotated[str, Field(description="PV Prefix for module entity")] + prefix: Annotated[ + str, + Field( + description="Component PV Prefix", + # Make sure vscode is aware of schema validation + json_schema_extra={ + "pattern": _DLS_PREFIX_PATTERN_COMPACT, + "type": "string", + }, + ), + ] desc: Annotated[ str | None, Field(description="Optional description of module entity") ] = None @@ -329,3 +342,56 @@ class TechUiSupport(BaseModel): dict[str, SupportEntity], Field(description="The dictionary of techui-support.yaml entities"), ] + + +""" +BobParser Models +""" + + +class BobWidget(BaseModel): + """Class representation of Bob Widget""" + + name: Annotated[str, Field(description="Widget name")] + widget_type: Annotated[str, Field(description="Widget type")] + macros: Annotated[MacroMap, Field(description="Macro dictionary")] + prefix: Annotated[ + str, + Field( + description="Widget PV Name", + # Make sure vscode is aware of schema validation + json_schema_extra={ + "pattern": _DLS_PREFIX_PATTERN_VERBOSE, + "type": "string", + }, + ), + ] + element: Annotated[ + ObjectifiedElement, + Field(description="XML tree representation of the widget", repr=False), + ] + children: Annotated[ + list["BobWidget"] | None, + Field(description="Children of parent BobWidget", default_factory=list), + ] + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) + + +class BobFile(BaseModel): + """Class representation of BobFile suchs as index.bob and {component}.bob""" + + path: Annotated[Path, Field(description="Path to file")] + macros: Annotated[MacroMap, Field(description="Macro dictionary")] + tree: Annotated[ + ElementTree, + Field(description="XML tree representation of the file", repr=False), + ] + widgets: Annotated[ + list[BobWidget], + Field(description="Widgets in the .bob file", default_factory=list), + ] + model_config = ConfigDict( + arbitrary_types_allowed=True, + ) diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index 8e67e459..7ae358d9 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -26,7 +26,9 @@ def read_bob(path): widgets = get_widgets(root) - return tree, widgets + macros = _get_macros(root) + + return tree, widgets, macros def get_widgets(root: ObjectifiedElement): diff --git a/src/techui_builder/validator.py b/src/techui_builder/validator.py index 2f5257cc..23988f5b 100644 --- a/src/techui_builder/validator.py +++ b/src/techui_builder/validator.py @@ -36,8 +36,8 @@ def _check_bob(self, bob_path: Path): self.validate[bob_path.name.removesuffix(".bob")] = bob_path def _read_bob(self, path: Path): - tree, widgets = read_bob(path) - return tree.getroot(), widgets + tree, widget, _ = read_bob(path) + return tree.getroot(), widget def validate_bob( self, diff --git a/tests/conftest.py b/tests/conftest.py index d96898b4..17da3bad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,11 +8,12 @@ from phoebusgen import widget as pwidget from techui_builder.autofill import Autofiller +from techui_builder.bob_parser import BobParser from techui_builder.builder import Builder from techui_builder.generate import Generator from techui_builder.generate_jsonmap import JsonMapGenerator from techui_builder.jsonmap.nodes import ScreenNode -from techui_builder.models import Component, SupportEntity +from techui_builder.models import BobFile, Component, SupportEntity from techui_builder.status import GenerateStatusPvs from techui_builder.validator import Validator @@ -25,6 +26,12 @@ def tmp_t01_services(tmp_path) -> Path: return tmp_path / "t01-services" +@pytest.fixture +def bob_parser(tmp_t01_services) -> BobFile: + bob_document = BobParser(tmp_t01_services / "synoptic/index.bob").parse_bob() + return bob_document + + @pytest.fixture def tmp_test_files(tmp_path) -> Path: # Copy test files to tmp_path so they are visible to the tests @@ -34,11 +41,12 @@ def tmp_test_files(tmp_path) -> Path: @pytest.fixture -def builder(tmp_t01_services): +def builder(tmp_t01_services, bob_parser): ixx_services = tmp_t01_services + bob_document = bob_parser techui_path = ixx_services / "synoptic/techui.yaml" - b = Builder(techui_path) + b = Builder(bob_document, techui_path) b._services_dir = ixx_services / "services" b._write_directory = ixx_services / "synoptic" return b diff --git a/tests/test_autofiller.py b/tests/test_autofiller.py index f41be6a9..4a256450 100644 --- a/tests/test_autofiller.py +++ b/tests/test_autofiller.py @@ -12,7 +12,7 @@ # Imported in to autofill from utils, so that needs to be patched @patch("techui_builder.autofill.read_bob") def test_autofiller_read_bobs(mock_read_bob: MagicMock, autofiller): - mock_read_bob.return_value = (Mock(spec=ElementTree), Mock()) + mock_read_bob.return_value = (Mock(spec=ElementTree), Mock(), Mock()) autofiller.read_bobs() diff --git a/tests/test_builder.py b/tests/test_builder.py index bc9766f1..0fe13686 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -20,7 +20,7 @@ def test_beamline_attributes(builder, attr, expected): @pytest.mark.parametrize( "index, name, label, P, R, attribute, file, extras, child_labels", [ - (0, "fshtr", "Fast Shutter", "BL01T-EA-FSHTR-01", None, None, None, None, None), + (4, "fshtr", "Fast Shutter", "BL01T-EA-FSHTR-01", None, None, None, None, None), ( 1, "diode1", @@ -33,7 +33,7 @@ def test_beamline_attributes(builder, attr, expected): None, ), ( - 4, + 3, "motor1", "Motor Stage", "BL01T-MO-MOTOR-01", @@ -185,8 +185,9 @@ def test_gb_extract_services_both_yaml_files( builder._extract_entities = Mock() # overwrite to not see the bl01t service dirs - builder.conf.beamline.location = "bl01z" + builder.bob_description.macros["location"] = "bl01z" builder._services_dir = tmp_path + # Temporary files to test against (tmp_path / "bl01z-ea-temp-01").mkdir() (tmp_path / "bl01z-ea-temp-01/config").mkdir() diff --git a/tests/test_cli.py b/tests/test_cli.py index 080477d2..d368f324 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -192,6 +192,7 @@ def test_find_bob_no_bob_file_found(caplog: pytest.LogCaptureFixture): assert exc_info.value.code is None +@patch("techui_builder.main_app.BobParser") @patch("techui_builder.main_app.find_index_bobs") @patch("techui_builder.main_app.find_dirs") @patch("techui_builder.main_app.Autofiller") @@ -201,12 +202,18 @@ def test_main( mock_autofiller: MagicMock, mock_find_dirs: MagicMock, mock_find_index_bobs: MagicMock, + mock_bob_parser: MagicMock, ): mock_index_path = MagicMock(spec=Path) mock_find_dirs.return_value = MagicMock(spec=Path), MagicMock(spec=Path) mock_find_index_bobs.return_value = mock_index_path, [mock_index_path] + mock_bob_parser.return_value.parse_bob.return_value.macros.macros = { + "domain": "ixx" + } + main(mock_index_path) + mock_bob_parser.assert_called_once_with(mock_index_path) mock_find_dirs.assert_called_once() mock_find_index_bobs.assert_called_once() diff --git a/tests/test_utils.py b/tests/test_utils.py index 5a13a244..ee712e77 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,10 +19,11 @@ def test_read_bob(mock_get_widgets: MagicMock, tmp_test_files): mock_get_widgets.return_value = {"test_widget": MagicMock(spec=ObjectifiedElement)} - tree, widgets = read_bob(tmp_test_files / "index.bob") + tree, widgets, macros = read_bob(tmp_test_files / "index.bob") assert isinstance(tree, _ElementTree) assert isinstance(widgets["test_widget"], ObjectifiedElement) + assert isinstance(macros, dict) mock_get_widgets.assert_called_once() diff --git a/tests/test_validator.py b/tests/test_validator.py index de60c188..de7447ca 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -24,7 +24,7 @@ def test_validator_check_bob(validator): def test_validator_read_bob(mock_read_bob: MagicMock, validator): # 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()) + mock_read_bob.return_value = (Mock(spec=_ElementTree), Mock(), Mock()) validator._read_bob(validator.bobs[0])