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/builder.py b/src/techui_builder/builder.py index 3f44b0ec..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,11 +48,11 @@ 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() - self.support_path = self._write_directory.joinpath("techui-support") + self.support_path = self._write_directory / "techui-support" self._read_map() @@ -65,7 +69,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( @@ -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 @@ -109,7 +113,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 @@ -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 ab916257..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) @@ -195,7 +197,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..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( @@ -407,7 +416,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..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) @@ -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/src/techui_builder/models.py b/src/techui_builder/models.py index 63909e13..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, @@ -21,8 +19,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 +39,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))?$", @@ -58,10 +63,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 +108,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 +118,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 +142,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_PATTERN_COMPACT, + "type": "string", + }, + ), + ] label: Annotated[str | None, Field(description="Component label")] = None child_labels: Annotated[ dict[str, str] | None, Field(description="Component Children Label") @@ -235,34 +282,9 @@ 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 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/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/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 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() 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"