From 1fdb0dcd8e54a374c8db06873a7485037cc637a7 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 24 Jul 2026 09:07:17 +0000 Subject: [PATCH 1/5] Convert all .joinpath() to / Fix test_main --- src/techui_builder/builder.py | 6 +++--- src/techui_builder/generate.py | 2 +- src/techui_builder/generate_jsonmap.py | 2 +- src/techui_builder/main_app.py | 4 ++-- tests/conftest.py | 2 +- tests/test_cli.py | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 3f44b0ec..3698ef89 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -48,7 +48,7 @@ def setup(self): from service ioc.yaml or fastcs.yaml. """ # This needs to be before _read_map() - self.support_path = self._write_directory.joinpath("techui-support") + self.support_path = self._write_directory / "techui-support" self._read_map() @@ -65,7 +65,7 @@ def setup(self): def _read_map(self): """Read the techui-support.yaml file from techui-support.""" - support_yaml = self.support_path.joinpath("techui-support.yaml").absolute() + support_yaml = self.support_path / "techui-support.yaml" logger_.debug(f"techui-support.yaml location: {support_yaml}") self.techui_support = TechUiSupport.model_validate( @@ -109,7 +109,7 @@ def _extract_services(self): service_name = service.name # If service doesn't exist, file open will fail throwing exception try: - service_yaml_dir = service.joinpath("config") + service_yaml_dir = service / "config" yaml_matches = [ p diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index ab916257..1617997b 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -195,7 +195,7 @@ def _allocate_widget( "Only related displays can have remote screens" ) else: - screen_path = self.support_path.joinpath(f"bob/{file}") + screen_path = self.support_path / f"bob/{file}" logger_.debug(f"Screen path: {screen_path}") # Path of screen relative to synoptic/ diff --git a/src/techui_builder/generate_jsonmap.py b/src/techui_builder/generate_jsonmap.py index bb4846e4..a48ff8f8 100644 --- a/src/techui_builder/generate_jsonmap.py +++ b/src/techui_builder/generate_jsonmap.py @@ -407,7 +407,7 @@ def write_json_map( ) map = self.generate_json_map(self.bob_path, self._parent_path) - with open(self._write_directory.joinpath("JsonMap.json"), "w") as f: + with open(self._write_directory / "JsonMap.json", "w") as f: f.write( json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o)) + "\n" diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 49098ab9..e580796a 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -120,7 +120,7 @@ def main( bob_file = find_bob(bobfile, synoptic_dir) # # Overwrite after initialised to make sure this is picked up - gui._services_dir = ixx_services_dir.joinpath("services") # noqa: SLF001 + gui._services_dir = ixx_services_dir / "services" # noqa: SLF001 gui._write_directory = synoptic_dir # noqa: SLF001 logger_.debug( @@ -141,7 +141,7 @@ def main( autofiller.read_bob() autofiller.autofill_bob() - dest_bob = gui._write_directory.joinpath("index.bob") # noqa: SLF001 + dest_bob = gui._write_directory / "index.bob" # noqa: SLF001 autofiller.write_bob(dest_bob) diff --git a/tests/conftest.py b/tests/conftest.py index 964c008e..28eb8175 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -292,7 +292,7 @@ def example_json_map_pvi_screens(): @pytest.fixture def generator(techui_support, tmp_t01_services): synoptic_dir = tmp_t01_services / "synoptic" - techui_support_path = synoptic_dir.joinpath("techui-support") + techui_support_path = synoptic_dir / "techui-support" g = Generator(synoptic_dir, "test_url", techui_support_path, techui_support) diff --git a/tests/test_cli.py b/tests/test_cli.py index 199749eb..a18cc871 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -195,8 +195,8 @@ def test_main( mock_find_dirs: MagicMock, mock_find_bob: MagicMock, ): - mock_find_dirs.return_value = Mock(), Mock() - mock_path = Mock(spec=Path) + mock_find_dirs.return_value = MagicMock(spec=Path), MagicMock(spec=Path) + mock_path = MagicMock(spec=Path) main(mock_path) mock_find_dirs.assert_called_once() From 60d5888d32e9bafe2357e85850fe594526098ca8 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 28 Jul 2026 08:29:03 +0000 Subject: [PATCH 2/5] Update models to include regex for fields (so VSCode is aware) --- example/t01-services/synoptic/techui.yaml | 2 + src/techui_builder/models.py | 54 ++++++++++++++++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/example/t01-services/synoptic/techui.yaml b/example/t01-services/synoptic/techui.yaml index fdc94103..9c1c19c5 100644 --- a/example/t01-services/synoptic/techui.yaml +++ b/example/t01-services/synoptic/techui.yaml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=../../../schemas/techui.schema.json + # create_gui example for Phoebus GuiBuilder beamline: location: bl01t diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 63909e13..8d913df7 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -58,10 +58,40 @@ class Beamline(BaseModel): """Global Beamline values read from `beamline:` table in techui.yaml""" - domain: Annotated[str, Field(description="Short BL location e.g. b23, ixx-1")] - location: Annotated[str, Field(description="Full BL domain e.g. bl23b")] + domain: Annotated[ + str, + Field( + description="Short BL location e.g. b23, ixx-1", + # Make sure vscode is aware of schema validation + json_schema_extra={ + "pattern": _DOMAIN_RE.pattern, + "type": "string", + }, + ), + ] + location: Annotated[ + str, + Field( + description="Full BL domain e.g. bl23b", + # Make sure vscode is aware of schema validation + json_schema_extra={ + "pattern": _LOCATION_RE.pattern, + "type": "string", + }, + ), + ] desc: Annotated[str, Field(description="Description")] - url: Annotated[str, Field(description="URL of ixx-opis")] + url: Annotated[ + str, + Field( + description="URL of ixx-opis", + # Make sure vscode is aware of schema validation + json_schema_extra={ + "pattern": _OPIS_URL_RE.pattern, + "type": "string", + }, + ), + ] model_config = ConfigDict(extra="forbid") @field_validator("domain") @@ -73,7 +103,7 @@ def normalize_domain(cls, v: str) -> str: # e.g. t01 return v - raise ValueError("Invalid beamline domain.") + raise ValueError("Invalid beamline domain. It needs to be in the form of t01.") @field_validator("location") @classmethod @@ -83,7 +113,9 @@ def normalize_location(cls, v: str) -> str: # already long: bl01t return v - raise ValueError("Invalid beamline location.") + raise ValueError( + "Invalid beamline location. It needs to be in the form of bl01t." + ) @field_validator("url") @classmethod @@ -105,7 +137,17 @@ def check_url(cls, url: str) -> str: class Component(BaseModel): """One UI Component from techui.yaml `components:` dictionary""" - prefix: Annotated[str, Field(description="Component PV Prefix")] + prefix: Annotated[ + str, + Field( + description="Component PV Prefix", + # Make sure vscode is aware of schema validation + json_schema_extra={ + "pattern": _DLS_PREFIX_RE.pattern, + "type": "string", + }, + ), + ] label: Annotated[str | None, Field(description="Component label")] = None child_labels: Annotated[ dict[str, str] | None, Field(description="Component Children Label") From 07877807d4aedc043fd095396d22c85da2711e5a Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 28 Jul 2026 08:34:28 +0000 Subject: [PATCH 3/5] Various docstring and comment updates --- src/techui_builder/builder.py | 19 ++++++++++++------- src/techui_builder/generate.py | 2 ++ src/techui_builder/generate_jsonmap.py | 19 ++++++++++++++----- src/techui_builder/main_app.py | 4 ++-- src/techui_builder/models.py | 2 +- src/techui_builder/status.py | 5 +++++ src/techui_builder/validator.py | 2 ++ 7 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 3698ef89..18d84909 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -19,11 +19,15 @@ class Builder: """ This class provides the functionality to process the required - techui.yaml file into screens mapped from ioc.yaml and - *-mapping.yaml files. + techui.yaml file into screens mapped from ioc.yaml and fastcs.yaml files. + It does this by leveraging *-support.yaml mapping files in submodules that + include the respective screens. For example, all generic screens are + situated in techui-support, with a corresponding techui-support.yaml + containing the mappings. It is possible to submodule custom *-support + repos with more bespoke screens as well. By default it looks for a `techui.yaml` file in the same dir - of the script Guibuilder is called in. Optionally a custom path + of the script techui-builder is called in. Optionally a custom path can be declared. """ @@ -44,7 +48,7 @@ def __post_init__(self): def setup(self): """ - Run intial setup, e.g. extracting entries + Run initial setup, e.g. extracting entries from service ioc.yaml or fastcs.yaml. """ # This needs to be before _read_map() @@ -101,7 +105,7 @@ def clean_files(self): def _extract_services(self): """ Finds the services folders in the services directory - and extracts all entites + and extracts all entities. """ # Loop over every dir in services, ignoring anything that isn't a service @@ -140,13 +144,14 @@ def _extract_services(self): def _extract_entities(self, service_name: str, service_yaml: Path): """ - Extracts the entries in ioc.yaml matching the defined prefix + Extracts the entries in ioc.yaml or fastcs.yaml matching the defined prefix. """ with open(service_yaml) as ioc: ioc_conf: dict[str, list[dict[str, str]]] = yaml.safe_load(ioc) for key in ioc_conf.keys(): + # ioc.yaml includes 'entities' and fastcs.yaml includes 'controllers' _regex = re.compile(r"^(?:(entities)|(controllers))$") match = _regex.match(key) if match: @@ -226,7 +231,7 @@ def create_screens(self): screen_entities.extend(self.entities[extra_p]) # This is used by both generate and validate, - # so called beforehand for tidyness + # so called beforehand for tidiness self.generator.build_widgets(component_name, screen_entities) self.generator.build_groups(component_name, self.conf.components) diff --git a/src/techui_builder/generate.py b/src/techui_builder/generate.py index 1617997b..d870914b 100644 --- a/src/techui_builder/generate.py +++ b/src/techui_builder/generate.py @@ -18,6 +18,8 @@ @dataclass class Generator: + """Helper class containing functions to generate Component screens.""" + synoptic_dir: Path = field(repr=False) beamline_url: str = field(repr=False) diff --git a/src/techui_builder/generate_jsonmap.py b/src/techui_builder/generate_jsonmap.py index a48ff8f8..5075b3d0 100644 --- a/src/techui_builder/generate_jsonmap.py +++ b/src/techui_builder/generate_jsonmap.py @@ -43,6 +43,8 @@ def log_level(level: str): @dataclass class JsonMap: + """Dataclass to handle the structure of a JsonMap element.""" + file: str display_name: str | None exists: bool = True @@ -54,6 +56,8 @@ class JsonMap: @dataclass class JsonMapGenerator: + """Helper class containing functions to generate a JsonMap file.""" + bob_path: Path = field(default=Path("index.bob")) techui: Path = field(default=Path("techui.yaml")) output: Path | None = field(default=None) @@ -95,7 +99,7 @@ def generate_json_map( def _get_display_name( name_element: str | None, component_name: str | None, file_path: Path ): - # Validated screen names don't get renegerated + # Validated screen names don't get regenerated name = name_element display_name = self._get_component_label( name_element, @@ -114,7 +118,12 @@ def _child_file_crawl( display_name: str | None, macro_dictionary: dict[str, Any], ): - # TODO: misleading var name? + """ + Function to determine if the child node file exists, and if it + does, recursively generate a JsonMap element for it and it's children. + + If it can't be found, a minimal JsonMap element is returned. + """ child_file_path = destination_path / file_path_text match = _PVI_FILE_RE.fullmatch(file_path_text) @@ -331,7 +340,7 @@ def _get_component_label( if name_elem in child_labels.values(): display_name = name_elem # In the case of screens not regenerated, such as validated screens, - # the name text will not be updated to the childlabel,so we check + # the name text will not be updated to the child_label,so we check # keys solely for generating the json_map from the top level .bob. elif name_elem in child_labels: display_name = child_labels[name_elem] @@ -398,8 +407,8 @@ def write_json_map( self, ): """ - Maps the valid entries from the ioc.yaml file - to the required screen in *-mapping.yaml + Maps the valid entries from the ioc.yaml or fastcs.yaml file + to the required screen in *-support.yaml """ if not self.bob_path.exists(): raise FileNotFoundError( diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index e580796a..275e2be6 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -93,7 +93,7 @@ def find_bob(bob_file: Path | None, synoptic_dir: Path): # This is the 'build' behaviour -@app.command("build", help="Run techui-builder for a given techui.yaml") +@app.command("build", help="Run `techui-builder build` for a given techui.yaml") def main( filename: Annotated[Path, typer.Argument(help="The path to techui.yaml")], bobfile: Annotated[ @@ -111,7 +111,7 @@ def main( ), ] = "INFO", ) -> None: - """Default function called from cmd line tool.""" + """Function to run when `techui-builder build` is called.""" gui = Builder(techui=filename) diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 8d913df7..d6625446 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -304,7 +304,7 @@ class GuiComponents(RootModel[dict[str, GuiComponentUnion]]): class Entity(BaseModel): - """One table of IOC variables extracted from an ioc.yaml file""" + """One table of IOC variables extracted from an ioc.yaml or fastcs.yaml file""" service_name: Annotated[str, Field(description="Service name of the IOC")] type: Annotated[ diff --git a/src/techui_builder/status.py b/src/techui_builder/status.py index 88e6a82a..d6170a84 100644 --- a/src/techui_builder/status.py +++ b/src/techui_builder/status.py @@ -23,6 +23,11 @@ @dataclass class GenerateStatusPvs: + """ + Helper class containing functions to generate Status PVs + and store them in a status.db file. + """ + techui_path: Path = field(repr=False) status_pvs: dict[str, Record] = field(default_factory=dict, init=False) output: Path | None = field(default=None) diff --git a/src/techui_builder/validator.py b/src/techui_builder/validator.py index 280883cd..2f5257cc 100644 --- a/src/techui_builder/validator.py +++ b/src/techui_builder/validator.py @@ -14,6 +14,8 @@ @dataclass class Validator: + """Helper class containing functions to validate Component screens.""" + bobs: list[Path] validate: dict[str, Path] = field( default_factory=defaultdict, init=False, repr=False From b66e07dc8d2d9c2a95753ba6eada0f40e805e98c Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 28 Jul 2026 09:57:00 +0000 Subject: [PATCH 4/5] Fix Beamline prefix schema addition --- src/techui_builder/models.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index d6625446..7431cfab 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -21,8 +21,7 @@ # short: 'b23', 'ixx-1' -_DLS_PREFIX_RE = re.compile( - r""" +_DLS_PREFIX_PATTERN_VERBOSE = r""" ^ # start of string (?= # lookahead to ensure the following pattern matches [A-Za-z0-9-]{13,16} # match 13 to 16 alphanumeric characters or hyphens @@ -42,9 +41,17 @@ (?:\.([a-zA-Z0-9_]+))? # match zero or one dot followed by one or more # alphanumeric characters (capture group 3) $ # end of string - """, - re.VERBOSE, + """ + +_DLS_PREFIX_RE = re.compile(_DLS_PREFIX_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) +_DLS_PREFIX_PATTERN_COMPACT = re.sub( + r"\s+", "", re.sub(r"#.*", "", _DLS_PREFIX_PATTERN_VERBOSE) ) + # Checks for database link flags, e.g. NPP MS, at the end _DATABASE_FLAGS_RE = re.compile( r"(?:PP|NPP)?" + r"(?:[ ]+(?:MS|NMS|MSS|MSI))?$", @@ -143,7 +150,7 @@ class Component(BaseModel): description="Component PV Prefix", # Make sure vscode is aware of schema validation json_schema_extra={ - "pattern": _DLS_PREFIX_RE.pattern, + "pattern": _DLS_PREFIX_PATTERN_COMPACT, "type": "string", }, ), From ff196ec1e091f1cfa38ea5740b3008e7cf0e62a7 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 28 Jul 2026 10:00:42 +0000 Subject: [PATCH 5/5] Remove old schema constraints and replace with new TechUiSupport one These old models are no longer needed with the addition of the TechUiSupport model, as they basically do the same thing. --- src/techui_builder/models.py | 29 +------------------------- src/techui_builder/schema_generator.py | 4 ++-- tests/test_models.py | 24 --------------------- 3 files changed, 3 insertions(+), 54 deletions(-) diff --git a/src/techui_builder/models.py b/src/techui_builder/models.py index 7431cfab..2cfc27cc 100644 --- a/src/techui_builder/models.py +++ b/src/techui_builder/models.py @@ -1,13 +1,11 @@ import logging import re -from typing import Annotated, Any, Literal +from typing import Annotated, Any from pydantic import ( BaseModel, ConfigDict, Field, - RootModel, - StringConstraints, computed_field, field_validator, model_validator, @@ -284,31 +282,6 @@ class TechUi(BaseModel): techui-support mapping models """ -BobPath = Annotated[ - str, StringConstraints(pattern=r"^(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.bob$") -] -# Must contain at least one $(NAME) macro -MacroString = Annotated[ - str, - StringConstraints(pattern=r"^[A-Za-z0-9_:\-./\s\$\(\)]+$"), -] -ScreenType = Literal["embedded", "related"] - - -class GuiComponentEntry(BaseModel): - file: BobPath - prefix: MacroString - suffix: MacroString | None = None - type: ScreenType - model_config = ConfigDict(extra="forbid") - - -GuiComponentUnion = list[GuiComponentEntry] | GuiComponentEntry - - -class GuiComponents(RootModel[dict[str, GuiComponentUnion]]): - pass - class Entity(BaseModel): """One table of IOC variables extracted from an ioc.yaml or fastcs.yaml file""" diff --git a/src/techui_builder/schema_generator.py b/src/techui_builder/schema_generator.py index 3e18c524..91568fc8 100644 --- a/src/techui_builder/schema_generator.py +++ b/src/techui_builder/schema_generator.py @@ -5,8 +5,8 @@ import typer from techui_builder.models import ( - GuiComponents, TechUi, + TechUiSupport, ) SCHEMAS_DIR = Path("schemas") @@ -40,5 +40,5 @@ def schema_generator() -> None: write_json_schema("techui", tu) # ibek_mapping - tu_support = GuiComponents.model_json_schema() + tu_support = TechUiSupport.model_json_schema() write_json_schema("techui.support", tu_support) diff --git a/tests/test_models.py b/tests/test_models.py index 7d6c080d..ff9303fa 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,8 +3,6 @@ from techui_builder.models import ( Beamline, Component, - GuiComponentEntry, - GuiComponents, ) @@ -28,13 +26,6 @@ def component() -> Component: ) -@pytest.fixture -def gui_components() -> GuiComponentEntry: - return GuiComponentEntry( - file="digitelMpc/digitelMpcIonp.bob", prefix="$(P)", type="embedded" - ) - - # @pytest.mark.parametrize("beamline,expected",[]) def test_beamline_object(beamline: Beamline): assert beamline.location == "bl01t" @@ -65,18 +56,3 @@ def test_component_repr(component: Component): def test_component_bad_prefix(): with pytest.raises(ValueError): Component(prefix="Test 2", label="BAD_PREFIX") - - -def test_gui_component_entry(gui_components: GuiComponentEntry): - assert gui_components.file == "digitelMpc/digitelMpcIonp.bob" - assert gui_components.prefix == "$(P)" - assert gui_components.type == "embedded" - - -def test_gui_components_object(gui_components: GuiComponentEntry): - gc = GuiComponents({"digitelMpc.digitelMpcIonp": [gui_components]}) - entry = gc.root["digitelMpc.digitelMpcIonp"][0] # type: ignore - assert entry.file == "digitelMpc/digitelMpcIonp.bob" - - assert entry.prefix == "$(P)" - assert entry.type == "embedded"