diff --git a/.github/workflows/_container.yml b/.github/workflows/_container.yml index da5e4936..4a02677d 100644 --- a/.github/workflows/_container.yml +++ b/.github/workflows/_container.yml @@ -10,6 +10,7 @@ jobs: uses: actions/checkout@v4 with: # Need this to get version number from last tag + submodules: recursive fetch-depth: 0 - name: Set up Docker Buildx diff --git a/.github/workflows/_tox.yml b/.github/workflows/_tox.yml index a13536d3..d6ae245d 100644 --- a/.github/workflows/_tox.yml +++ b/.github/workflows/_tox.yml @@ -6,7 +6,6 @@ on: description: What to run under tox required: true - jobs: run: runs-on: "ubuntu-latest" @@ -14,6 +13,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Install python packages uses: ./.github/actions/install_requirements diff --git a/.gitignore b/.gitignore index 0f33bf29..1fd923b4 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ lockfiles/ # ruff cache .ruff_cache/ + +# submodules +./techui-support/ +./*-services/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.gitmodules @@ -0,0 +1 @@ + diff --git a/.pdm-python b/.pdm-python new file mode 100644 index 00000000..f60efe3b --- /dev/null +++ b/.pdm-python @@ -0,0 +1 @@ +/workspaces/phoebus-guibuilder/.venv/bin/python \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/src/phoebus_guibuilder/__init__.py b/src/phoebus_guibuilder/__init__.py index 8df6208a..e2e97e62 100644 --- a/src/phoebus_guibuilder/__init__.py +++ b/src/phoebus_guibuilder/__init__.py @@ -6,6 +6,9 @@ Version number as calculated by poetry-dynamic-versioning """ +from phoebus_guibuilder.datatypes import Beamline, Component, Entry +from phoebus_guibuilder.guibuilder import Guibuilder + from ._version import __version__ -__all__ = ["__version__"] +__all__ = ["__version__", "Beamline", "Component", "Entry", "Guibuilder"] diff --git a/src/phoebus_guibuilder/__main__.py b/src/phoebus_guibuilder/__main__.py index c73b9103..a99a6d60 100644 --- a/src/phoebus_guibuilder/__main__.py +++ b/src/phoebus_guibuilder/__main__.py @@ -3,7 +3,10 @@ from argparse import ArgumentParser from collections.abc import Sequence -from . import __version__, guibuilder + +from . import __version__ +from .guibuilder import Guibuilder + __all__ = ["main"] @@ -19,8 +22,8 @@ def main(args: Sequence[str] | None = None) -> None: version=__version__, ) _args = parser.parse_args(args) - - guibuilder.main(_args.filename) + gb = Guibuilder(_args.filename) + gb.extract_from_create_gui() if __name__ == "__main__": diff --git a/src/phoebus_guibuilder/datatypes.py b/src/phoebus_guibuilder/datatypes.py new file mode 100644 index 00000000..0055eda3 --- /dev/null +++ b/src/phoebus_guibuilder/datatypes.py @@ -0,0 +1,67 @@ +import re +from dataclasses import dataclass + + +@dataclass +class Beamline: + dom: str + desc: str + + +@dataclass +class Entry: + type: str + DESC: str | None + P: str + M: str | None + R: str | None + + +@dataclass +class Component: + name: str + desc: str + prefix: str + filename: str | None = None + + def __post_init__(self): + self._extract_p_and_r() + + def __repr__(self) -> str: + return f"Component(name={self.name}, desc={self.desc}, \ + prefix={self.P}, suffix={self.R}, filename={self.filename})" + + def _extract_p_and_r(self): + pattern = re.compile( + r""" + ^ # start of string + (?= # lookahead to ensure the following pattern matches + [A-Za-z0-9-]{14,16} # match 14 to 16 alphanumeric characters or hyphens + [:A-Za-z0-9]* # match zero or more colons or alphanumeric characters + [.A-Za-z0-9] # match a dot or alphanumeric character + ) + (?!.*--) # negative lookahead to ensure no double hyphens + (?!.*:\..) # negative lookahead to ensure no colon followed by a dot + ( # start of capture group 1 + (?:[A-Za-z0-9]{2,5}-){3} # match 2 to 5 alphanumeric characters followed + # by a hyphen, repeated 3 times + [\d]* # match zero or more digits + [^:]? # match zero or one non-colon character + ) + (?::([a-zA-Z0-9:]*))? # match zero or one colon followed by zero or more + # alphanumeric characters or colons (capture group 2) + (?:\.([a-zA-Z0-9]+))? # match zero or one dot followed by one or more + # alphanumeric characters (capture group 3) + $ # end of string + """, + re.VERBOSE, + ) + + match = re.match(pattern, self.prefix) + if match: + self.P: str = match.group(1) + self.R: str = match.group(2) + # TODO: Is this needed? + self.attribute: str | None = match.group(3) + else: + raise AttributeError(f"No valid PV prefix found for {self.name}.") diff --git a/src/phoebus_guibuilder/guibuilder.py b/src/phoebus_guibuilder/guibuilder.py index c3be9e46..68d05e93 100644 --- a/src/phoebus_guibuilder/guibuilder.py +++ b/src/phoebus_guibuilder/guibuilder.py @@ -1,171 +1,150 @@ import os -import pprint import re -from dataclasses import dataclass import yaml -pp = pprint.PrettyPrinter() - - -@dataclass -class Beamline: - dom: str - desc: str - - -@dataclass -class Entry: - type: str - DESC: str | None - P: str - M: str | None - R: str | None - - -@dataclass -class Component: - name: str - desc: str - prefix: str - filename: str | None = None - - def __post_init__(self): - self._extract_p_and_r() - - def __repr__(self) -> str: - return f"Component(name={self.name}, desc={self.desc}, prefix={self.P}, \ -suffix={self.R}, filename={self.filename})" - - def _extract_p_and_r(self): - pattern = re.compile( - r""" - ^ # start of string - (?= # lookahead to ensure the following pattern matches - [A-Za-z0-9-]{14,16} # match 14 to 16 alphanumeric characters or hyphens - [:A-Za-z0-9]* # match zero or more colons or alphanumeric characters - [.A-Za-z0-9] # match a dot or alphanumeric character - ) - (?!.*--) # negative lookahead to ensure no double hyphens - (?!.*:\..) # negative lookahead to ensure no colon followed by a dot - ( # start of capture group 1 - (?:[A-Za-z0-9]{2,5}-){3} # match 2 to 5 alphanumeric characters followed - # by a hyphen, repeated 3 times - [\d]* # match zero or more digits - [^:]? # match zero or one non-colon character - ) - (?::([a-zA-Z0-9:]*))? # match zero or one colon followed by zero or more - # alphanumeric characters or colons (capture group 2) - (?:\.([a-zA-Z0-9]+))? # match zero or one dot followed by one or more - # alphanumeric characters (capture group 3) - $ # end of string - """, - re.VERBOSE, - ) - - match = re.match(pattern, self.prefix) - if match: - self.P: str = match.group(1) - self.R: str = match.group(2) - # TODO: Is this needed? - self.attribute: str | None = match.group(3) - else: - raise AttributeError(f"No valid PV prefix found for {self.name}.") - - -def main(filename: str): - components: list[Component] = [] # TODO Manage global lists better - - with open(filename) as f: - conf = yaml.safe_load(f) - - bl: dict[str, str] = conf["beamline"] - comps: dict[str, dict[str, str]] = conf["components"] - - beamline = Beamline(**bl) - - for key, comp in comps.items(): - components.append(Component(key, **comp)) - - print("BEAMLINE:") - pp.pprint(beamline) - - print("") - print("COMPONENTS") - pp.pprint(components) - - -##################################################### -# TODO Functionality should be in phoebusguibuilder class -# class Phoebusguibuilder(beamline: Beamline, components: list[Component]): - - -def find_services_folders(beamline: Beamline, components: list[Component]): - services_directory = ( - beamline.dom + "-services/services" - ) # TODO: rm hardcoding, map to services. - path = f"/dls/science/users/uns32131/{services_directory}" - files = os.listdir(path) - - # Attempting to match the prefix to the files in the services directory - pattern = "^(.*)-(.*)-(.*)" - - for component in components: - domain: re.Match[str] | None = re.match(pattern, component.P) - assert domain is not None, "Empty Prefix Field" - - for file in files: - match = re.match(pattern, file) - if match: - if match.group(1) == domain.group(1).lower(): - if os.path.exists(f"{path}/{file}/config/ioc.yaml"): - valid_entities = extract_valid_entities( - ioc_yaml=f"{path}/{file}/config/ioc.yaml", - component=component, +from phoebus_guibuilder.datatypes import Beamline, Component, Entry + + +class Guibuilder: + """ + This class provides the functionality to process the required + create_gui.yaml file into screens mapped from ioc.yaml and + gui_map.yaml files. + + """ + + def __init__(self, create_gui_yaml: str): + self.components: list[Component] = [] + + self.beamline: Beamline + + self.valid_entities: list[Entry] = [] + + self.create_gui: str = create_gui_yaml + + self.extract_from_create_gui() + + def extract_from_create_gui( + self, + ): + """ + Extracts from the create_gui.yaml file to generate + the required Beamline and components structures. + """ + + with open(self.create_gui) as f: + conf = yaml.safe_load(f) + bl: dict[str, str] = conf["beamline"] + comps: dict[str, dict[str, str]] = conf["components"] + + self.beamline = Beamline(**bl) + + for key, comp in comps.items(): + self.components.append(Component(key, **comp)) + + def find_services_folders( + self, + ): + """ + Finds the related folders in the services directory + and extracts the related entites with the matching prefixes + """ + self.git_pull_submodules() + + services_directory = ( + self.beamline.dom + "-services/services" + ) # TODO: rm hardcoding, map to services. + path = f"{services_directory}" + files = os.listdir(path) + + # Attempting to match the prefix to the files in the services directory + pattern = "^(.*)-(.*)-(.*)" + + for component in self.components: + domain: re.Match[str] | None = re.match(pattern, component.P) + assert domain is not None, "Empty Prefix Field" + + for file in files: + match = re.match(pattern, file) + if match: + if match.group(1) == domain.group(1).lower(): + if os.path.exists(f"{path}/{file}/config/ioc.yaml"): + self.extract_valid_entities( + ioc_yaml=f"{path}/{file}/config/ioc.yaml", + component=component, + ) + else: + print(f"No ioc.yaml file for service: {file}") + os.system(f"rm -rf ./{self.beamline.dom}-services/ ./techui-support/") + + def extract_valid_entities(self, ioc_yaml: str, component: Component): + """ + Extracts the entities in ioc.yaml matching the defined prefix + """ + + entities: list[dict[str, str]] = [] + component_match = f"{component.P}:{component.R}" + + with open(ioc_yaml) as ioc: + conf = yaml.safe_load(ioc) + entities = conf["entities"] + for entity in entities: + if ( + "P" in entity.keys() and entity["P"] == component_match + ): # the suffix could be M, could be R + self.valid_entities.append( + Entry( + type=entity["type"], + DESC=None, + P=entity["P"], + M=None, + R=None, ) - return valid_entities - else: - print(f"No ioc.yaml file for service: {file}") - - -def extract_valid_entities(ioc_yaml: str, component: Component) -> list[Entry]: - print(type(ioc_yaml)) - entities: list[dict[str, str]] = [] - valid_entities: list[Entry] = [] - component_match = f"{component.P}:{component.R}" - with open(ioc_yaml) as ioc: - conf = yaml.safe_load(ioc) - entities = conf["entities"] - for entity in entities: - if ( - "P" in entity.keys() and entity["P"] == component_match - ): # the suffix could be M, could be R - valid_entities.append( - Entry(type=entity["type"], DESC=None, P=entity["P"], M=None, R=None) - ) - - return valid_entities - - -def gui_map(entrys: list[Entry]): - gui_map = "/dls/science/users/uns32131/BLGui/BLGuiApp/opi/bob/gui_map.yaml" - - with open(gui_map) as map: - conf = yaml.safe_load(map) - - for entry in entrys: - print(entry.type) - if conf[entry.type]: - print( - conf[entry.type]["file"] - ) # Find correct .bob file, and injet macros - # TODO: create a copy of the file, and replace the required macros - # TODO: return the file to guibuilder - - else: - print("No BOB available") - - -# find_services_folders() -# print(valid_entities) -# gui_map(valid_entities) + ) + if "M" in entity.keys(): + self.valid_entities[-1].M = entity["M"] + + def gui_map(self, entrys: list[Entry]): + """ + Maps the valid entities from the ioc.yaml file + to the required screen in gui_map.yaml + """ + + gui_map = "./GuiMap/gui_map.yaml" + + with open(gui_map) as map: + conf = yaml.safe_load(map) + + for entry in entrys: + print(entry.type) + if conf[entry.type]: + print( + conf[entry.type]["file"] + ) # Find correct .bob file, and injet macros + # TODO: create a copy of the file, and replace the required macros + # TODO: return the file to guibuilder + + else: + print("No BOB available") + + def git_pull_submodules(self): + """ + Method which helps pull the required modules in as + submodules and removes all traces of submodules. + """ + services_repo = f"git submodule add --force\ + https://github.com/epics-containers/{self.beamline.dom}-services.git" + gui_map_repo = "git submodule add --force https://github.com/adedamola-sode/techui-support.git" + + submodules = "echo ''> .gitmodules & git submodule sync" + rm_repos = f"rm -rf ./{self.beamline.dom}-services/ ./techui-support/" + unstage = f"git restore --staged .gitmodules\ + {self.beamline.dom}-services techui-support" + + os.system(submodules) + os.system(rm_repos) + os.system(services_repo) + os.system(gui_map_repo) + os.system(unstage) + os.system(submodules) \ No newline at end of file diff --git a/tests/test_guibuilder.py b/tests/test_guibuilder.py new file mode 100644 index 00000000..4cc99761 --- /dev/null +++ b/tests/test_guibuilder.py @@ -0,0 +1,44 @@ +import os + +from phoebus_guibuilder.guibuilder import Guibuilder + + +def test_guibuilder(): + gb = Guibuilder("./example/create_gui.yaml") # + assert gb.beamline.dom == "p47" + assert gb.beamline.desc == "Test Beamline" + assert gb.components[0].name == "fshtr" + assert gb.components[0].desc == "Fast Shutter" + assert gb.components[0].P == "BL47P-EA-FSHTR-01" + assert gb.components[0].R is None + assert gb.components[0].attribute is None + + +def test_git_pull_submodules(): + gb = Guibuilder("./example/create_gui.yaml") + gb.extract_from_create_gui() + gb.git_pull_submodules() + services_directory = f"./{gb.beamline.dom}-services" + techui_directory = "./techui-support" + assert os.path.isdir(services_directory) + assert os.path.isdir(techui_directory) + + +def test_gb_extract_services(): + gb = Guibuilder("./example/create_gui.yaml") + gb.find_services_folders() + assert gb.valid_entities[0].type == "pmac.autohome" + assert gb.valid_entities[0].DESC is None + assert gb.valid_entities[0].P == "BL47P-MO-MAP-01:STAGE" + assert gb.valid_entities[0].M is None + assert gb.valid_entities[0].R is None + assert gb.valid_entities[1].type == "pmac.dls_pmac_asyn_motor" + assert gb.valid_entities[1].DESC is None + assert gb.valid_entities[1].P == "BL47P-MO-MAP-01:STAGE" + assert gb.valid_entities[1].M == ":X" + assert gb.valid_entities[1].R is None + assert gb.valid_entities[2].type == "pmac.dls_pmac_asyn_motor" + assert gb.valid_entities[2].DESC is None + assert gb.valid_entities[2].P == "BL47P-MO-MAP-01:STAGE" + assert gb.valid_entities[2].M == ":A" + assert gb.valid_entities[1].R is None diff --git a/tests/test_objects.py b/tests/test_objects.py index acb0357a..e2ee0f80 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -1,6 +1,6 @@ import pytest -from phoebus_guibuilder.guibuilder import Beamline, Component +from phoebus_guibuilder.datatypes import Beamline, Component @pytest.fixture @@ -28,11 +28,8 @@ def test_component_object(component): def test_component_repr(component): - assert ( - str(component) - == "Component(name=TESTDEV, desc=Test Device, \ -prefix=BL01T-EA-TEST-02, suffix=None, filename=None)" - ) + assert (str(component)== "Component(name=TESTDEV, desc=Test Device, \ + prefix=BL01T-EA-TEST-02, suffix=None, filename=None)") def test_component_bad_prefix():