From e6be2f2700b87bda30cee1fac684f89d0f60a2be Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Tue, 29 Sep 2026 10:55:51 +0000 Subject: [PATCH 01/15] ref: changed the pipeline of main.py to go to bob_parser first --- src/techui_builder/__main__.py | 2 +- src/techui_builder/autofill.py | 4 +-- src/techui_builder/bob_parser.py | 31 ++++++++++++++++++ src/techui_builder/main_app.py | 56 ++++++++++++++------------------ src/techui_builder/utils.py | 4 ++- 5 files changed, 62 insertions(+), 35 deletions(-) create mode 100644 src/techui_builder/bob_parser.py 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 ee50cd71..afc55404 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -34,7 +34,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: @@ -68,7 +68,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..daa525fd --- /dev/null +++ b/src/techui_builder/bob_parser.py @@ -0,0 +1,31 @@ +import logging +from dataclasses import dataclass, field +from pathlib import Path + +from lxml.etree import ElementTree +from lxml.objectify import ObjectifiedElement + +from techui_builder.utils import ( + read_bob, +) + +logger_ = logging.getLogger(__name__) + +macros = dict[str, str] +WidgetDict = dict[str, ObjectifiedElement] +TreeWidgetDictTuple = tuple[ElementTree, WidgetDict] +MacroTreeTuple = tuple[macros, TreeWidgetDictTuple] +IndexObjectDict = dict[Path, MacroTreeTuple] + + +@dataclass +class BobParser: + bob_path: Path + index_trees: IndexObjectDict = field(default_factory=dict, init=False, repr=False) + + def read_bob(self): + # Read the bob file + tree, widget_dict, macros = read_bob(self.bob_path) + self.index_trees[self.bob_path] = (macros, (tree, widget_dict)) + print(self.index_trees) + return self.index_trees diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index d20fbfe0..7d5bfe66 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -6,8 +6,7 @@ import typer from techui_builder._logger import log_level -from techui_builder.autofill import Autofiller -from techui_builder.builder import Builder +from techui_builder.bob_parser import BobParser logger_ = logging.getLogger(__name__) @@ -114,16 +113,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,33 +129,35 @@ def main( ) -> None: """Function to run when `techui-builder build` is called.""" - gui = Builder(techui=filename) + # 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 + # gui._services_dir = ixx_services_dir / "services" # noqa: SLF001 + # gui._write_directory = synoptic_dir # noqa: SLF001 - logger_.debug( - f""" + # 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() - gui.create_screens() + # gui.setup() + # gui.create_screens() - logger_.info(f"Screens generated for {gui.conf.beamline.domain}.") + # 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() + # autofiller = Autofiller(bob_files, index_bob_path, gui.conf.components) + # Extract information from index.bob + bp = BobParser(filename) + bp.read_bob() + # autofiller.autofill_bobs() + # autofiller.write_bobs() - logger_.info(f"Screens autofilled for {gui.conf.beamline.domain}.") + # logger_.info(f"Screens autofilled for {gui.conf.beamline.domain}.") diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index cb0c50fc..7d73f785 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -36,7 +36,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): From d2b6ecdf2f93c52010cb3a122969757b0b34e281 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Tue, 29 Sep 2026 13:09:14 +0000 Subject: [PATCH 02/15] ref: using models instead of linked dictionaries for Widget and File type --- src/techui_builder/bob_parser.py | 57 +++++++++++++++++++++----------- src/techui_builder/main_app.py | 12 +++++-- src/techui_builder/models.py | 47 +++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 23 deletions(-) diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index daa525fd..a3466932 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -1,31 +1,50 @@ import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from lxml.etree import ElementTree from lxml.objectify import ObjectifiedElement -from techui_builder.utils import ( - read_bob, -) +from techui_builder.models import BobFile, BobWidget +from techui_builder.utils import WidgetType, _get_macros, read_bob logger_ = logging.getLogger(__name__) -macros = dict[str, str] -WidgetDict = dict[str, ObjectifiedElement] -TreeWidgetDictTuple = tuple[ElementTree, WidgetDict] -MacroTreeTuple = tuple[macros, TreeWidgetDictTuple] -IndexObjectDict = dict[Path, MacroTreeTuple] - @dataclass class BobParser: bob_path: Path - index_trees: IndexObjectDict = field(default_factory=dict, init=False, repr=False) - - def read_bob(self): - # Read the bob file - tree, widget_dict, macros = read_bob(self.bob_path) - self.index_trees[self.bob_path] = (macros, (tree, widget_dict)) - print(self.index_trees) - return self.index_trees + + 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 "" + + widget = BobWidget( + name=name or "", + widget_type=element.get("type", default=""), + macros=_get_macros(element), + element=element, + children=None, + ) + 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/main_app.py b/src/techui_builder/main_app.py index 7d5bfe66..139a3145 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -19,13 +19,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( @@ -156,7 +156,13 @@ def main( # autofiller = Autofiller(bob_files, index_bob_path, gui.conf.components) # Extract information from index.bob bp = BobParser(filename) - bp.read_bob() + synoptic_macros = bp.parse_bob() + print(synoptic_macros) + 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. + # autofiller.autofill_bobs() # autofiller.write_bobs() diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index cddf4918..f869f974 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' @@ -363,3 +366,45 @@ 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")] + 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, + ) From 8919bd5b64bdc5b9fd2558a968c10b6211b6b08a Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Tue, 29 Sep 2026 14:01:05 +0000 Subject: [PATCH 03/15] ref: Replacing techui with bob_description --- src/techui_builder/builder.py | 18 +++++++++++------- src/techui_builder/main_app.py | 3 ++- src/techui_builder/models.py | 13 ++++++++++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 7702594d..eaecc159 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -8,7 +8,7 @@ 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, Entity, SupportEntity, TechUi, TechUiSupport from techui_builder.validator import Validator logger_ = logging.getLogger(__name__) @@ -31,7 +31,8 @@ class Builder: """ - techui: Path = field(default=Path("techui.yaml")) + bob_description: BobFile + techui: Path = field(init=False, repr=False) entities: dict[str, Entity] = field(default_factory=dict, init=False) _services_dir: Path = field(init=False, repr=False) @@ -39,9 +40,10 @@ class Builder: def __post_init__(self): # Populate beamline and components - self.conf = TechUi.model_validate( - yaml.safe_load(self.techui.read_text(encoding="utf-8")) - ) + if self.techui: + self.conf = TechUi.model_validate( + yaml.safe_load(self.techui.read_text(encoding="utf-8")) + ) def setup(self): """ @@ -59,7 +61,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, ) @@ -106,7 +108,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: diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 139a3145..284ce313 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -162,7 +162,8 @@ def main( 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) + print(index_bob_path, bob_files) # autofiller.autofill_bobs() # autofiller.write_bobs() diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index f869f974..0f21a238 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -297,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 @@ -379,6 +389,7 @@ class BobWidget(BaseModel): 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, _DLS_PREFIX_PATTERN_VERBOSE] element: Annotated[ ObjectifiedElement, Field(description="XML tree representation of the widget", repr=False), From 75dfa1a98a141086249d54263bc349fa3c987cba Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Tue, 29 Sep 2026 14:52:19 +0000 Subject: [PATCH 04/15] ref: trying to establish contract between builder and parser --- src/techui_builder/bob_parser.py | 3 +++ src/techui_builder/builder.py | 15 ++++++++++++++- src/techui_builder/models.py | 12 +++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index a3466932..c7b3e5bb 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -20,6 +20,8 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: 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 "", @@ -27,6 +29,7 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: macros=_get_macros(element), element=element, children=None, + prefix=prefix or "", ) if widget.widget_type == "group": widget.children = self._parse_widgets(element) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index eaecc159..544d1a9b 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -8,7 +8,14 @@ from jinja2 import Template from techui_builder.generate import Generator -from techui_builder.models import BobFile, 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__) @@ -44,6 +51,12 @@ def __post_init__(self): self.conf = TechUi.model_validate( yaml.safe_load(self.techui.read_text(encoding="utf-8")) ) + for widget in self.bob_description.widgets: + if widget.prefix: + self.conf.components[widget.name] = Component( + prefix=widget.prefix, + label=widget.name, + ) def setup(self): """ diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 0f21a238..e370804c 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -389,7 +389,17 @@ class BobWidget(BaseModel): 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, _DLS_PREFIX_PATTERN_VERBOSE] + 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), From 9ba06cad9958239274637c274f0ac1981a393571 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Wed, 30 Sep 2026 08:56:00 +0000 Subject: [PATCH 05/15] ref: converted bob_descriptions into components --- src/techui_builder/builder.py | 21 +++++++++++++++------ src/techui_builder/main_app.py | 30 +++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 544d1a9b..87dd9689 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -39,7 +39,7 @@ class Builder: """ bob_description: BobFile - techui: Path = field(init=False, repr=False) + techui: Path = field(init=True, repr=False) entities: dict[str, Entity] = field(default_factory=dict, init=False) _services_dir: Path = field(init=False, repr=False) @@ -52,11 +52,20 @@ def __post_init__(self): yaml.safe_load(self.techui.read_text(encoding="utf-8")) ) for widget in self.bob_description.widgets: - if widget.prefix: - self.conf.components[widget.name] = Component( - prefix=widget.prefix, - label=widget.name, - ) + values = { + "prefix": widget.prefix, + "label": widget.name, + } + values.update( + { + key: value + for key, value in widget.macros.items() + if key in Component.model_fields and key not in {"prefix", "label"} + } + ) + + if values["prefix"]: + self.conf.components[widget.name] = Component(**values) def setup(self): """ diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 284ce313..497c201f 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -6,7 +6,9 @@ import typer 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__) @@ -129,8 +131,6 @@ 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) # index_bob_path, bob_files = find_index_bobs(filename, synoptic_dir) @@ -164,7 +164,27 @@ def main( # 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) print(index_bob_path, bob_files) - # autofiller.autofill_bobs() - # autofiller.write_bobs() - # logger_.info(f"Screens autofilled for {gui.conf.beamline.domain}.") + 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 + ) + + 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) + autofiller.autofill_bobs() + autofiller.write_bobs() + + logger_.info(f"Screens autofilled for {gui.conf.beamline.domain}.") From 52e0a99e8fb59f9425cfdc7ef439fc550d6f7f98 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Wed, 30 Sep 2026 12:20:30 +0000 Subject: [PATCH 06/15] tests: fixed tests to work with the new bob parser stage --- example/t01-services/synoptic/index.bob | 22 ++++++++++++++-------- tests/conftest.py | 14 +++++++++++--- tests/test_autofiller.py | 2 +- tests/test_builder.py | 3 ++- tests/test_cli.py | 7 +++++++ tests/test_utils.py | 3 ++- 6 files changed, 37 insertions(+), 14 deletions(-) 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/tests/conftest.py b/tests/conftest.py index c85ee2b4..817d8404 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, SupportEntityScreen +from techui_builder.models import BobFile, Component, SupportEntity, SupportEntityScreen from techui_builder.status import GenerateStatusPvs from techui_builder.validator import Validator @@ -27,6 +28,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 @@ -36,11 +43,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 fc1087d8..c4f910a6 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 02a9d41f..e6fdad85 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -176,8 +176,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 e7d6b298..2fdb10c2 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() From 54a5f1cae019bd677032904c568dcb1db5df0d73 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Thu, 1 Oct 2026 10:15:55 +0000 Subject: [PATCH 07/15] tests:changed builder to use both index.bob and techui.yaml --- src/techui_builder/builder.py | 52 +++++++++++++++++++++------------- src/techui_builder/main_app.py | 2 -- tests/test_builder.py | 4 +-- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 87dd9689..b1d7217c 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -39,33 +39,47 @@ class Builder: """ bob_description: BobFile - techui: Path = field(init=True, repr=False) + techui: Path | None = field(init=True, repr=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) + 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 - if self.techui: - self.conf = TechUi.model_validate( - yaml.safe_load(self.techui.read_text(encoding="utf-8")) - ) - for widget in self.bob_description.widgets: + components = {} + + for widget in self._iter_prefixed_widgets(self.bob_description.widgets): values = { - "prefix": widget.prefix, - "label": widget.name, + key: value + for key, value in widget.macros.items() + if key in Component.model_fields and key != "prefix" } - values.update( - { - key: value - for key, value in widget.macros.items() - if key in Component.model_fields and key not in {"prefix", "label"} - } - ) - - if values["prefix"]: - self.conf.components[widget.name] = Component(**values) + 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): """ diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 497c201f..58d187b5 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -157,13 +157,11 @@ def main( # Extract information from index.bob bp = BobParser(filename) synoptic_macros = bp.parse_bob() - print(synoptic_macros) 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) - print(index_bob_path, bob_files) gui = Builder(synoptic_macros, synoptic_dir / "techui.yaml") gui._services_dir = ixx_services_dir / "services" # noqa: SLF001 diff --git a/tests/test_builder.py b/tests/test_builder.py index e6fdad85..e89a3abc 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", From 7cc0de96a9ef3b2ecf09d92c14546daa79044af1 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Thu, 1 Oct 2026 10:25:25 +0000 Subject: [PATCH 08/15] linting:optional techui.yaml, and new read_bob adjustments --- src/techui_builder/builder.py | 4 +++- src/techui_builder/validator.py | 4 ++-- tests/test_validator.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index b1d7217c..2855bc18 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -246,6 +246,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]" @@ -261,7 +263,7 @@ def create_screens(self): matched = self.get_entities_for_prefix(component.prefix) if not matched: logger_.warning( - f"{self.techui.name}: prefix [bold]{component.prefix}[/bold] " + f"{techui_name}: prefix [bold]{component.prefix}[/bold] " f"for component [bold]{component_name}[/bold] matched no entities." ) continue 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/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]) From dcae2d15d91fa2a9d782566b54e46cdf66a274a5 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Mon, 12 Oct 2026 12:59:48 +0000 Subject: [PATCH 09/15] ref: Changed from grouping to action button macros --- src/techui_builder/bob_parser.py | 27 ++++++++++++++++++++++----- src/techui_builder/main_app.py | 1 + src/techui_builder/models.py | 5 +++-- src/techui_builder/utils.py | 4 +++- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index c7b3e5bb..f5e28fda 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -4,8 +4,8 @@ from lxml.objectify import ObjectifiedElement -from techui_builder.models import BobFile, BobWidget -from techui_builder.utils import WidgetType, _get_macros, read_bob +from techui_builder.models import BobFile, BobWidget, MacroMap +from techui_builder.utils import WidgetType, _get_action_group, _get_macros, read_bob logger_ = logging.getLogger(__name__) @@ -18,21 +18,38 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: result = [] for element in container.iterchildren("widget"): + # Find the name and prefix of the 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 "" + # Collect the macros from the widget through its action group if it has one + action_group = _get_action_group(element) + raw_macros = _get_macros( + action_group if action_group is not None else element + ) + + # Create a MacroMap from the raw macros + macros: MacroMap = {} + + for key, value in raw_macros.items(): + macros[key] = value + + for key in ("extras", "status"): + value = raw_macros.get(key) + if value is not None: + macros[key] = value.split(", ") + + # Create a BobWidget instance for the widget widget = BobWidget( name=name or "", widget_type=element.get("type", default=""), - macros=_get_macros(element), + macros=macros, 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) diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 58d187b5..6bcac91a 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -157,6 +157,7 @@ def main( # Extract information from index.bob bp = BobParser(filename) synoptic_macros = bp.parse_bob() + print(synoptic_macros) ixx_services_dir, synoptic_dir = find_dirs( filename, synoptic_macros.macros["domain"] ) diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index e370804c..523ce331 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -16,7 +16,8 @@ logger_ = logging.getLogger(__name__) -type MacroMap = dict[str, str] +type RawMacroMap = dict[str, str] +type MacroMap = dict[str, str | list[str] | int | float] # Patterns: # long: 'bl23b' # short: 'b23', 'ixx-1' @@ -417,7 +418,7 @@ 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")] + macros: Annotated[RawMacroMap, Field(description="Macro dictionary")] tree: Annotated[ ElementTree, Field(description="XML tree representation of the file", repr=False), diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index 7d73f785..f5060a41 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -5,6 +5,8 @@ from lxml.etree import Element, SubElement, tostring from lxml.objectify import ObjectifiedElement, fromstring +from techui_builder.models import RawMacroMap + logger_ = logging.getLogger(__name__) __all__ = [ @@ -62,7 +64,7 @@ def get_widgets(root: ObjectifiedElement): return widgets -def _get_macros(element: ObjectifiedElement): +def _get_macros(element: ObjectifiedElement) -> RawMacroMap: if hasattr(element, "macros"): macros = element.macros.getchildren() if macros is not None: From 51c920faa33282508ca6283934341f8a09c2602b Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Mon, 12 Oct 2026 13:15:59 +0000 Subject: [PATCH 10/15] Rebased on main --- src/techui_builder/bob_parser.py | 4 ++-- tests/test_builder.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index f5e28fda..8b23c86e 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -5,7 +5,7 @@ from lxml.objectify import ObjectifiedElement from techui_builder.models import BobFile, BobWidget, MacroMap -from techui_builder.utils import WidgetType, _get_action_group, _get_macros, read_bob +from techui_builder.utils import WidgetType, _get_actions_group, _get_macros, read_bob logger_ = logging.getLogger(__name__) @@ -25,7 +25,7 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: prefix = prefix_element.text if prefix_element is not None else "" # Collect the macros from the widget through its action group if it has one - action_group = _get_action_group(element) + action_group = _get_actions_group(element) raw_macros = _get_macros( action_group if action_group is not None else element ) diff --git a/tests/test_builder.py b/tests/test_builder.py index e89a3abc..e6fdad85 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", [ - (4, "fshtr", "Fast Shutter", "BL01T-EA-FSHTR-01", None, None, None, None, None), + (0, "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, ), ( - 3, + 4, "motor1", "Motor Stage", "BL01T-MO-MOTOR-01", From cf029b49ae75c0665f915a29151f23d897a54647 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Mon, 12 Oct 2026 14:18:46 +0000 Subject: [PATCH 11/15] Fixed _read_bob in validator.py --- src/techui_builder/validator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/techui_builder/validator.py b/src/techui_builder/validator.py index 23988f5b..8049375d 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, widget, _ = read_bob(path) - return tree.getroot(), widget + tree, widgets, _ = read_bob(path) + return tree.getroot(), widgets def validate_bob( self, From fcc23dab038368ac74a2628c8fba232090fa5ce5 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Wed, 14 Oct 2026 14:53:57 +0000 Subject: [PATCH 12/15] tests: added test_bob_parser --- example/t01-services/synoptic/index.bob | 2 +- src/techui_builder/bob_parser.py | 31 +++++++++++------------ src/techui_builder/jsonmap/naming.py | 18 ++++++++++++++ src/techui_builder/utils.py | 3 +++ tests/conftest.py | 33 ++++++++++++++++++++++++- tests/test_bob_parser.py | 26 +++++++++++++++++++ 6 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 tests/test_bob_parser.py diff --git a/example/t01-services/synoptic/index.bob b/example/t01-services/synoptic/index.bob index 200ed601..2cbd3b80 100644 --- a/example/t01-services/synoptic/index.bob +++ b/example/t01-services/synoptic/index.bob @@ -56,7 +56,7 @@ $(actions) true - diode1 + name= diode1, label= Diode 1, extras= [BL01T-DI-PHDGN-01, BL01T-DI-PHDGM-01] BL01T-DI-PHDGN-01:STA techui-support/symbols/oscilloscope.svg diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index 8b23c86e..fa3dee13 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -1,11 +1,12 @@ import logging +import re from dataclasses import dataclass from pathlib import Path from lxml.objectify import ObjectifiedElement from techui_builder.models import BobFile, BobWidget, MacroMap -from techui_builder.utils import WidgetType, _get_actions_group, _get_macros, read_bob +from techui_builder.utils import WidgetType, index_bob_name_pattern, read_bob logger_ = logging.getLogger(__name__) @@ -24,22 +25,20 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: prefix_element = element.find("pv_name") prefix = prefix_element.text if prefix_element is not None else "" - # Collect the macros from the widget through its action group if it has one - action_group = _get_actions_group(element) - raw_macros = _get_macros( - action_group if action_group is not None else element - ) - - # Create a MacroMap from the raw macros macros: MacroMap = {} - - for key, value in raw_macros.items(): - macros[key] = value - - for key in ("extras", "status"): - value = raw_macros.get(key) - if value is not None: - macros[key] = value.split(", ") + macro_pairs = re.findall(index_bob_name_pattern, name if name else "") + + if macro_pairs: + macro_dict = dict(macro_pairs) + name = macro_dict.get("name", "") + + list_types = ("extras", "status") + for key, value in macro_dict.items(): + if key not in list_types: + macros[key] = value + else: + value = value[1:-1] + macros[key] = [v.strip() for v in value.split(",") if v.strip()] # Create a BobWidget instance for the widget widget = BobWidget( diff --git a/src/techui_builder/jsonmap/naming.py b/src/techui_builder/jsonmap/naming.py index 4640a854..063794a9 100644 --- a/src/techui_builder/jsonmap/naming.py +++ b/src/techui_builder/jsonmap/naming.py @@ -1,11 +1,25 @@ """Display-name rules for ScreenNodes and duplicate-name fixing.""" +import re from collections import defaultdict from collections.abc import Mapping from pathlib import Path from techui_builder.jsonmap.nodes import ScreenNode from techui_builder.models import Component +from techui_builder.utils import index_bob_name_pattern + + +def get_name_from_list(name: str) -> str: + """Extract the 'name' value from a string using the index_bob_name_pattern regex.""" + + macro_pairs = re.findall(index_bob_name_pattern, name) + + if macro_pairs: + name_dict = dict(macro_pairs) + return name_dict.get("name", "") + + return name def find_techui_label( @@ -17,6 +31,8 @@ def find_techui_label( if name is None: return None + name = get_name_from_list(name) + if name in components and components[name].label is not None: return components[name].label @@ -43,6 +59,8 @@ def find_techui_label( def name_or_file_stem(name: str | None, file_path: Path) -> str | None: """The name if it is non-empty, else the file name without suffixes, else None.""" + name = get_name_from_list(name) if name else None + if name: # Return name tag text as displayName return name diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index f5060a41..40feb690 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -16,8 +16,11 @@ "_get_actions_group", "_get_macros", "_get_nav_tabs", + "index_bob_name_pattern", ] +index_bob_name_pattern = r"(\w+)\s*=\s*(\[.*?\]|.*?)(?=\s*,\s*\w+\s*=|$)" + class WidgetType(StrEnum): """Widget types in a .bob file.""" diff --git a/tests/conftest.py b/tests/conftest.py index 11f51bec..6b15a10d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,13 @@ 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 BobFile, Component, Entity, SupportEntity, SupportEntityScreen +from techui_builder.models import ( + BobFile, + Component, + Entity, + SupportEntity, + SupportEntityScreen, +) from techui_builder.status import GenerateStatusPvs from techui_builder.validator import Validator @@ -34,6 +40,11 @@ def bob_parser(tmp_t01_services) -> BobFile: return bob_document +@pytest.fixture +def bob_parser_object(tmp_t01_services) -> BobParser: + return BobParser(tmp_t01_services / "synoptic/index.bob") + + @pytest.fixture def tmp_test_files(tmp_path) -> Path: # Copy test files to tmp_path so they are visible to the tests @@ -542,6 +553,26 @@ def example_xml_symbol_widget(): return widget_element +@pytest.fixture +def example_xml_symbol_widget_bob_parser(): + # You cannot set a text tag of an ObjectifiedElement, + # so we need to make an etree.Element and convert it ... + widget_element = Element("widget") + widget_element.set("type", "symbol") + widget_element.set("version", "2.0.0") + name_element = SubElement(widget_element, "name") + name_element.text = "name= new, label=test, status=[BL01T-TS-TEST-01]" + width_element = SubElement(widget_element, "width") + width_element.text = "205" + height_element = SubElement(widget_element, "height") + height_element.text = "120" + + container = Element("display") + container.append(widget_element) + # ... which requires this horror + return fromstring(tostring(container)) + + @pytest.fixture def example_xml_navtabs_widget(tmp_test_files): # You cannot set a text tag of an ObjectifiedElement, diff --git a/tests/test_bob_parser.py b/tests/test_bob_parser.py new file mode 100644 index 00000000..231c7949 --- /dev/null +++ b/tests/test_bob_parser.py @@ -0,0 +1,26 @@ +from unittest.mock import MagicMock + +from lxml.etree import _ElementTree + +from techui_builder.bob_parser import BobWidget + + +def test_parse_bob(bob_parser_object, tmp_t01_services): + test_bob_path = tmp_t01_services / "synoptic/index.bob" + bob_parser_object._parse_widgets = MagicMock() + bob_file = bob_parser_object.parse_bob() + + assert bob_file is not None + assert bob_file.path == test_bob_path + assert isinstance(bob_file.tree, _ElementTree) + assert isinstance(bob_file.widgets, list) + assert isinstance(bob_file.macros, dict) + + +def test_parse_widgets(bob_parser_object, example_xml_symbol_widget_bob_parser): + widget = bob_parser_object._parse_widgets(example_xml_symbol_widget_bob_parser) + + for w in widget: + assert isinstance(w, BobWidget) + assert w.name == "new" + assert w.macros["status"] == ["BL01T-TS-TEST-01"] From 246ebb166afb9ac3af19690f39f46371350d5fc2 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Mon, 19 Oct 2026 09:23:11 +0000 Subject: [PATCH 13/15] Made recommended changes, doc strings, widget.prfix.strip(), uneeded comments, and {}s --- example/t01-services/synoptic/index.bob | 2 +- src/techui_builder/bob_parser.py | 14 ++++++++++++++ src/techui_builder/builder.py | 5 +---- src/techui_builder/jsonmap/naming.py | 14 +++++++++++--- src/techui_builder/main_app.py | 25 ------------------------- src/techui_builder/utils.py | 2 +- 6 files changed, 28 insertions(+), 34 deletions(-) diff --git a/example/t01-services/synoptic/index.bob b/example/t01-services/synoptic/index.bob index 2cbd3b80..ee555aa6 100644 --- a/example/t01-services/synoptic/index.bob +++ b/example/t01-services/synoptic/index.bob @@ -56,7 +56,7 @@ $(actions) true - name= diode1, label= Diode 1, extras= [BL01T-DI-PHDGN-01, BL01T-DI-PHDGM-01] + {name= diode1, label= Diode 1, extras= [BL01T-DI-PHDGN-01, BL01T-DI-PHDGM-01]} BL01T-DI-PHDGN-01:STA techui-support/symbols/oscilloscope.svg diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index fa3dee13..df46cf9a 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -13,9 +13,19 @@ @dataclass class BobParser: + """ + The BobParser class is responsible for parsing .bob files and extracting relevant + information such as widgets and macros. It provides methods to read the .bob file, + parse its contents, and return a structured representation of the data. + """ + bob_path: Path def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: + """ + Parses the widgets from the given container element + and returns a list of BobWidget instances. + """ result = [] for element in container.iterchildren("widget"): @@ -58,6 +68,10 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: def parse_bob( self, ) -> BobFile: + """ + Parses the .bob file specified by the bob_path + attribute and returns a BobFile instance + """ tree, _, display_macros = read_bob(self.bob_path) root = tree.getroot() diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 2855bc18..4de15f54 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -47,12 +47,9 @@ class Builder: def _iter_prefixed_widgets(self, widgets): for widget in widgets: - if widget.prefix and widget.prefix.strip(): + if widget.prefix: yield widget - if widget.widget_type == "group": - yield from self._iter_prefixed_widgets(widget.children or []) - def __post_init__(self): components = {} diff --git a/src/techui_builder/jsonmap/naming.py b/src/techui_builder/jsonmap/naming.py index 063794a9..3305a1e2 100644 --- a/src/techui_builder/jsonmap/naming.py +++ b/src/techui_builder/jsonmap/naming.py @@ -1,5 +1,6 @@ """Display-name rules for ScreenNodes and duplicate-name fixing.""" +import logging import re from collections import defaultdict from collections.abc import Mapping @@ -9,15 +10,22 @@ from techui_builder.models import Component from techui_builder.utils import index_bob_name_pattern +logger_ = logging.getLogger(__name__) + def get_name_from_list(name: str) -> str: """Extract the 'name' value from a string using the index_bob_name_pattern regex.""" macro_pairs = re.findall(index_bob_name_pattern, name) - if macro_pairs: - name_dict = dict(macro_pairs) - return name_dict.get("name", "") + try: + if macro_pairs: + name_dict = dict(macro_pairs) + return name_dict.get("name", "") + except Exception as e: + # Log the error and the input name for debugging purposes + logger_.warning(f"Error extracting name from list: {e}. Input name: {name}") + return name # Return the original name if extraction fails return name diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 3c2fa574..8ce902c6 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -131,33 +131,8 @@ def main( ) -> None: """Function to run when `techui-builder build` is called.""" - # ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.domain) - - # 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() - print(synoptic_macros) ixx_services_dir, synoptic_dir = find_dirs( filename, synoptic_macros.macros["domain"] ) diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index 40feb690..ea3715b1 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -19,7 +19,7 @@ "index_bob_name_pattern", ] -index_bob_name_pattern = r"(\w+)\s*=\s*(\[.*?\]|.*?)(?=\s*,\s*\w+\s*=|$)" +index_bob_name_pattern = r"{?(\w+)\s*=\s*(\[.*?\]|.*?)(?=\s*,\s*\w+\s*=|\s*}|$)" class WidgetType(StrEnum): From b8d843864dfa14f9e8a534af22a4974745171b95 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Fri, 23 Oct 2026 08:03:10 +0000 Subject: [PATCH 14/15] Add regex checking for widget name --- src/techui_builder/bob_parser.py | 17 ++++++---- src/techui_builder/jsonmap/naming.py | 4 +-- src/techui_builder/utils.py | 50 ++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index df46cf9a..46ddca81 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -6,7 +6,7 @@ from lxml.objectify import ObjectifiedElement from techui_builder.models import BobFile, BobWidget, MacroMap -from techui_builder.utils import WidgetType, index_bob_name_pattern, read_bob +from techui_builder.utils import _INDEX_BOB_NAME_RE, WidgetType, read_bob logger_ = logging.getLogger(__name__) @@ -29,6 +29,8 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: result = [] for element in container.iterchildren("widget"): + if element.get("type", default="") not in WidgetType: + continue # Find the name and prefix of the widget name_element = element.find("name") name = name_element.text if name_element is not None else "" @@ -36,14 +38,17 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: prefix = prefix_element.text if prefix_element is not None else "" macros: MacroMap = {} - macro_pairs = re.findall(index_bob_name_pattern, name if name else "") + macro_pairs = re.fullmatch(_INDEX_BOB_NAME_RE, name if name else "") - if macro_pairs: - macro_dict = dict(macro_pairs) - name = macro_dict.get("name", "") + if macro_pairs is None: + print(f"No match for {name!r}") + else: + print(f"Match: {macro_pairs.group(0)!r}") + for number, value in enumerate(macro_pairs.groups(), start=1): + print(f"Group {number}: {value!r}") list_types = ("extras", "status") - for key, value in macro_dict.items(): + for key, value in macro_pairs.groupdict().items(): if key not in list_types: macros[key] = value else: diff --git a/src/techui_builder/jsonmap/naming.py b/src/techui_builder/jsonmap/naming.py index 3305a1e2..0c0d8361 100644 --- a/src/techui_builder/jsonmap/naming.py +++ b/src/techui_builder/jsonmap/naming.py @@ -8,7 +8,7 @@ from techui_builder.jsonmap.nodes import ScreenNode from techui_builder.models import Component -from techui_builder.utils import index_bob_name_pattern +from techui_builder.utils import _INDEX_BOB_NAME_RE logger_ = logging.getLogger(__name__) @@ -16,7 +16,7 @@ def get_name_from_list(name: str) -> str: """Extract the 'name' value from a string using the index_bob_name_pattern regex.""" - macro_pairs = re.findall(index_bob_name_pattern, name) + macro_pairs = re.findall(_INDEX_BOB_NAME_RE, name) try: if macro_pairs: diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index ea3715b1..e0f30e5b 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -1,4 +1,5 @@ import logging +import re from enum import StrEnum from lxml import objectify @@ -16,10 +17,55 @@ "_get_actions_group", "_get_macros", "_get_nav_tabs", - "index_bob_name_pattern", + "_INDEX_BOB_NAME_RE", ] -index_bob_name_pattern = r"{?(\w+)\s*=\s*(\[.*?\]|.*?)(?=\s*,\s*\w+\s*=|\s*}|$)" +# index_bob_name_pattern = r"{?(\w+)\s*=\s*(\[.*?\]|.*?)(?=\s*,\s*\w+\s*=|\s*}$)" + +_INDEX_BOB_NAME_PATTERN_VERBOSE = r""" + (?:^{)? # optional '{' at the start of the string (the ^ only + # anchors the '{') + + (?!.*\s,) # LOOKAHEAD 1 (negative): no whitespace directly before a + # comma later on the line + (?!\]*\s) # LOOKAHEAD 2 (negative): fail on whitespace here, or ']'s + # then whitespace + + (?: # start of alternation + + (?:([\w]+)) # GROUP 1: the key, one or more word chars + \s?=\s? # '=' with at most one space either side + + (?=[\w\s:\-.]+) # LOOKAHEAD A (positive): at least one "value char" must + # come next + # (value char = [\w\s:\-.] = word char, whitespace, ':', + # '-' or '.') + | # OR + (?=\[?[\w\s:\-.]+(?:,\s?)?\]?) + # LOOKAHEAD B (positive): an optional '[', value chars, an + # optional ',' + one space and an optional ']' must come + # next (never changes the result) + + (?:(?: + ([\w\s:\-.]+) # GROUP 2: a bare value, one or more value chars + (?:,\s?)? # optional trailing comma (consumed, not captured) + )) + + | # OR + ( # GROUP 3: a bracketed list, e.g. "[a, b, c]" + \[ # literal '[' + (?: + (?:[\w\s:\-.]+) # an item: one or more value chars + (?:,\s?)? # optional comma + )* # zero or more items + \] # literal ']' + ) + ) # end of alternation + + (?:}$)? # optional '}' at the end of the string + """ + +_INDEX_BOB_NAME_RE = re.compile(_INDEX_BOB_NAME_PATTERN_VERBOSE, re.VERBOSE) class WidgetType(StrEnum): From 920dba3f039eabe28ab6d97e7fed544809b7340c Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 23 Oct 2026 13:21:41 +0000 Subject: [PATCH 15/15] Fix regex matching to name "dictionary" --- src/techui_builder/bob_parser.py | 33 ++++++++++------- src/techui_builder/utils.py | 63 ++++++++++++++++---------------- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/techui_builder/bob_parser.py b/src/techui_builder/bob_parser.py index 46ddca81..6c49a01e 100644 --- a/src/techui_builder/bob_parser.py +++ b/src/techui_builder/bob_parser.py @@ -1,5 +1,4 @@ import logging -import re from dataclasses import dataclass from pathlib import Path @@ -29,31 +28,39 @@ def _parse_widgets(self, container: ObjectifiedElement) -> list[BobWidget]: result = [] for element in container.iterchildren("widget"): + if element.get("type", default="") == WidgetType.GROUP: + self._parse_widgets(element) if element.get("type", default="") not in WidgetType: continue # Find the name and prefix of the widget name_element = element.find("name") - name = name_element.text if name_element is not None else "" + name = ( + name_element.text + if (name_element is not None and name_element.text is not None) + else "" + ) prefix_element = element.find("pv_name") prefix = prefix_element.text if prefix_element is not None else "" macros: MacroMap = {} - macro_pairs = re.fullmatch(_INDEX_BOB_NAME_RE, name if name else "") + matches = _INDEX_BOB_NAME_RE.finditer(name) + macro_pairs = { + match.group("key") or "name": match.group("val") for match in matches + } if macro_pairs is None: print(f"No match for {name!r}") - else: - print(f"Match: {macro_pairs.group(0)!r}") - for number, value in enumerate(macro_pairs.groups(), start=1): - print(f"Group {number}: {value!r}") + continue + + for key, value in macro_pairs.items(): + print(f"Match: {key, value}") list_types = ("extras", "status") - for key, value in macro_pairs.groupdict().items(): - if key not in list_types: - macros[key] = value - else: - value = value[1:-1] - macros[key] = [v.strip() for v in value.split(",") if v.strip()] + if key not in list_types: + macros[key] = value + else: + value = value[1:-1] + macros[key] = [v.strip() for v in value.split(",") if v.strip()] # Create a BobWidget instance for the widget widget = BobWidget( diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index e0f30e5b..6794d793 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -25,48 +25,49 @@ _INDEX_BOB_NAME_PATTERN_VERBOSE = r""" (?:^{)? # optional '{' at the start of the string (the ^ only # anchors the '{') - (?!.*\s,) # LOOKAHEAD 1 (negative): no whitespace directly before a # comma later on the line (?!\]*\s) # LOOKAHEAD 2 (negative): fail on whitespace here, or ']'s # then whitespace - - (?: # start of alternation - - (?:([\w]+)) # GROUP 1: the key, one or more word chars - \s?=\s? # '=' with at most one space either side - - (?=[\w\s:\-.]+) # LOOKAHEAD A (positive): at least one "value char" must - # come next + (?: # start of one key=value unit + (?:(?P[\w]+)) # GROUP key: the key, one or more word chars + (?:\s?=\s?) # '=' with at most one space either side + (?: # start of lookahead alternation (only one must pass) + (?=[\w\s:\-.]+) # LOOKAHEAD A (positive): a "value char" comes next # (value char = [\w\s:\-.] = word char, whitespace, ':', - # '-' or '.') - | # OR - (?=\[?[\w\s:\-.]+(?:,\s?)?\]?) + # '-' or '.') + | # OR + (?=\[?[\w\s:\-.]+(?:,\s?)?\]?) # LOOKAHEAD B (positive): an optional '[', value chars, an - # optional ',' + one space and an optional ']' must come - # next (never changes the result) - - (?:(?: - ([\w\s:\-.]+) # GROUP 2: a bare value, one or more value chars - (?:,\s?)? # optional trailing comma (consumed, not captured) - )) - - | # OR - ( # GROUP 3: a bracketed list, e.g. "[a, b, c]" - \[ # literal '[' - (?: - (?:[\w\s:\-.]+) # an item: one or more value chars - (?:,\s?)? # optional comma - )* # zero or more items - \] # literal ']' + # optional ',' + one space and an optional ']' come next + # (this is the one that lets a leading '[' through) + ) # end of lookahead alternation + (?P # GROUP val: the value, either a list or a bare value + (?: # branch 1: a bracketed list, e.g. "[a, b, c]" + \[ # literal '[' + (?: + (?:[\w\s:\-.]+) # an item: one or more value chars + (?:,\s?)? # optional comma + )* # zero or more items + \] # literal ']' + ) + | # OR + (?:[\w\s:\-.]+) # branch 2: a bare value, one or more value chars ) - ) # end of alternation - + ) # end of key=value unit + (?:,\s?)? # optional trailing comma (consumed, not captured) (?:}$)? # optional '}' at the end of the string - """ +""" _INDEX_BOB_NAME_RE = re.compile(_INDEX_BOB_NAME_PATTERN_VERBOSE, re.VERBOSE) +# JSON Schema (ECMA 262) has no concept of VERBOSE mode, so derive a +# compact equivalent once at import time for use in json_schema_extra. +# (i.e. This is to handle the inline comments) +_INDEX_BOB_NAME_PATTERN_COMPACT = re.sub( + r"\s+", "", re.sub(r"#.*", "", _INDEX_BOB_NAME_PATTERN_VERBOSE) +) + class WidgetType(StrEnum): """Widget types in a .bob file."""