techui-builder 0.3.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of techui-builder might be problematic. Click here for more details.

@@ -0,0 +1,16 @@
1
+ """Top level API.
2
+
3
+ .. data:: __version__
4
+ :type: str
5
+
6
+ Version number as calculated by poetry-dynamic-versioning
7
+ """
8
+
9
+ from techui_builder.builder import Builder
10
+
11
+ from ._version import __version__
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "Builder",
16
+ ]
@@ -0,0 +1,188 @@
1
+ """Interface for ``python -m techui_builder``."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from rich.logging import RichHandler
9
+
10
+ from techui_builder import __version__
11
+ from techui_builder.autofill import Autofiller
12
+ from techui_builder.builder import Builder
13
+ from techui_builder.schema_generator import schema_generator
14
+
15
+ app = typer.Typer(
16
+ pretty_exceptions_show_locals=False,
17
+ help="""
18
+ A script for building Phoebus GUIs.
19
+
20
+ This is the required file structure:\n
21
+ \n
22
+ ixx-services\n
23
+ |-- services\n
24
+ | |-- blxxi-ea-device-01\n
25
+ | | `-- config\n
26
+ | | `-- ioc.yaml\n
27
+ | |-- ...\n
28
+ | `-- blxxi-va-device-01\n
29
+ | `-- config\n
30
+ | `-- ioc.yaml\n
31
+ `-- synoptic\n
32
+ . |-- techui.yaml\n
33
+ . `-- opis-src\n
34
+ . `-- index-src.bob\n
35
+ """,
36
+ )
37
+
38
+ default_bobfile = "index-src.bob"
39
+
40
+
41
+ def version_callback(value: bool):
42
+ if value:
43
+ print(f"techui-builder version: {__version__}")
44
+ raise typer.Exit()
45
+
46
+
47
+ def schema_callback(value: bool):
48
+ if value:
49
+ schema_generator()
50
+ raise typer.Exit()
51
+
52
+
53
+ def log_level(level: str):
54
+ logging.basicConfig(
55
+ level=level,
56
+ format="%(message)s",
57
+ handlers=[RichHandler(omit_repeated_times=False, markup=True)],
58
+ )
59
+
60
+
61
+ # This is the default behaviour when no command provided
62
+ @app.callback(invoke_without_command=True)
63
+ def main(
64
+ filename: Annotated[Path, typer.Argument(help="The path to techui.yaml")],
65
+ bobfile: Annotated[
66
+ Path | None,
67
+ typer.Argument(help="Override for template bob file location."),
68
+ ] = None,
69
+ version: Annotated[
70
+ bool | None, typer.Option("--version", callback=version_callback)
71
+ ] = None,
72
+ loglevel: Annotated[
73
+ str,
74
+ typer.Option(
75
+ "--log-level",
76
+ help="Set log level to INFO, DEBUG, WARNING, ERROR or CRITICAL",
77
+ case_sensitive=False,
78
+ callback=log_level,
79
+ ),
80
+ ] = "INFO",
81
+ schema: Annotated[
82
+ bool | None, typer.Option("--schema", callback=schema_callback)
83
+ ] = None,
84
+ ) -> None:
85
+ """Default function called from cmd line tool."""
86
+
87
+ LOGGER = logging.getLogger(__name__)
88
+
89
+ bob_file = bobfile
90
+
91
+ gui = Builder(techui=filename)
92
+
93
+ # This next part is assuming the file structure:
94
+ #
95
+ # ixx-services
96
+ # |-- services
97
+ # | |-- blxxi-ea-device-01
98
+ # | | `-- config
99
+ # | | `-- ioc.yaml
100
+ # | |-- ...
101
+ # | `-- blxxi-va-device-01
102
+ # | `-- config
103
+ # | `-- ioc.yaml
104
+ # `-- synoptic
105
+ # . |-- techui.yaml
106
+ # . `-- opis-src
107
+ # . `-- index-src.bob
108
+ #
109
+
110
+ # Get the relative path to the create_gui file from working dir
111
+ abs_path = filename.absolute()
112
+ LOGGER.debug(f"techui.yaml absolute path: {abs_path}")
113
+
114
+ # Get the current working dir
115
+ cwd = Path.cwd()
116
+ LOGGER.debug(f"Working directory: {cwd}")
117
+
118
+ # Get the relative path of ixx-services to create_gui.yaml
119
+ ixx_services_dir = next(
120
+ (
121
+ ixx_services.relative_to(cwd, walk_up=True)
122
+ for parent in abs_path.parents
123
+ for ixx_services in parent.glob(f"{gui.conf.beamline.short_dom}-services")
124
+ ),
125
+ None,
126
+ )
127
+ if ixx_services_dir is None:
128
+ logging.critical("ixx-services not found. Is you file structure correct?")
129
+ exit()
130
+ LOGGER.debug(f"ixx-services relative path: {ixx_services_dir}")
131
+
132
+ # Get the synoptic dir relative to the parent dir
133
+ synoptic_dir = ixx_services_dir.joinpath("synoptic")
134
+ LOGGER.debug(f"synoptic relative path: {synoptic_dir}")
135
+
136
+ if bob_file is None:
137
+ # Search default relative dir to techui filename
138
+ # There will only ever be one file, but if not return None
139
+ bob_file = next(
140
+ synoptic_dir.joinpath("opis-src").glob("index-src.bob"),
141
+ None,
142
+ )
143
+ if bob_file is None:
144
+ logging.critical(
145
+ f"Source bob file '{default_bobfile}' not found in \
146
+ {synoptic_dir.joinpath('opis-src')}. Does it exist?"
147
+ )
148
+ exit()
149
+ elif not bob_file.exists():
150
+ logging.critical(f"Source bob file '{bob_file}' not found. Does it exist?")
151
+ exit()
152
+
153
+ LOGGER.debug(f"bob file: {bob_file}")
154
+
155
+ # # Overwrite after initialised to make sure this is picked up
156
+ gui._services_dir = ixx_services_dir.joinpath("services") # noqa: SLF001
157
+ gui._write_directory = synoptic_dir.joinpath("opis") # noqa: SLF001
158
+
159
+ LOGGER.debug(
160
+ f"""
161
+
162
+ Builder created for {gui.conf.beamline.short_dom}.
163
+ Services directory: {gui._services_dir}
164
+ Write directory: {gui._write_directory}
165
+ """, # noqa: SLF001
166
+ )
167
+
168
+ gui.setup()
169
+ gui.generate_screens()
170
+
171
+ LOGGER.info(f"Screens generated for {gui.conf.beamline.short_dom}.")
172
+
173
+ autofiller = Autofiller(bob_file)
174
+ autofiller.read_bob()
175
+ autofiller.autofill_bob(gui)
176
+
177
+ dest_bob = gui._write_directory.joinpath("index.bob") # noqa: SLF001
178
+
179
+ autofiller.write_bob(dest_bob)
180
+
181
+ LOGGER.info(f"Screens autofilled for {gui.conf.beamline.short_dom}.")
182
+
183
+ gui.write_json_map(synoptic=dest_bob, dest=gui._write_directory) # noqa: SLF001
184
+ LOGGER.info(f"Json map generated for {gui.conf.beamline.long_dom} (from index.bob)")
185
+
186
+
187
+ if __name__ == "__main__":
188
+ app()
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.3.0a1'
32
+ __version_tuple__ = version_tuple = (0, 3, 0, 'a1')
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,127 @@
1
+ import logging
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from lxml import objectify
7
+ from lxml.objectify import ObjectifiedElement
8
+
9
+ from techui_builder.builder import Builder, _get_action_group
10
+ from techui_builder.models import Component
11
+
12
+ LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class Autofiller:
17
+ path: Path
18
+ macros: list[str] = field(default_factory=lambda: ["prefix", "desc", "file"])
19
+
20
+ def read_bob(self) -> None:
21
+ # Read the bob file
22
+ self.tree = objectify.parse(self.path)
23
+
24
+ # Find the root tag (in this case: <display version="2.0.0">)
25
+ self.root = self.tree.getroot()
26
+
27
+ def autofill_bob(self, gui: "Builder"):
28
+ # Get names from component list
29
+
30
+ # Loop over objects in the xml
31
+ # i.e. every tag below <display version="2.0.0">
32
+ # but not any nested tags below them
33
+ for child in self.root.iterchildren():
34
+ # If widget is a symbol (i.e. a component)
35
+ if child.tag == "widget" and child.get("type", default=None) == "symbol":
36
+ # Extract it's name
37
+ symbol_name = child.name
38
+
39
+ # If the name exists in the component list
40
+ if symbol_name in gui.conf.components.keys():
41
+ # Get first copy of component (should only be one)
42
+ comp = next(
43
+ (comp for comp in gui.conf.components if comp == symbol_name),
44
+ )
45
+
46
+ self.replace_macros(
47
+ widget=child,
48
+ component_name=comp,
49
+ component=gui.conf.components[comp],
50
+ )
51
+
52
+ def write_bob(self, filename: Path):
53
+ # Check if data/ dir exists and if not, make it
54
+ data_dir = filename.parent
55
+ if not data_dir.exists():
56
+ os.mkdir(data_dir)
57
+
58
+ self.tree.write(
59
+ filename,
60
+ pretty_print=True, # type: ignore
61
+ encoding="utf-8", # type: ignore
62
+ xml_declaration=True, # type: ignore
63
+ )
64
+ LOGGER.debug(f"Screen filled for {filename}")
65
+
66
+ def _sub_macro(
67
+ self,
68
+ tag_name: str,
69
+ macro: str,
70
+ element: ObjectifiedElement,
71
+ current_macro: str,
72
+ ) -> None:
73
+ # Extract it's current tag text, or if empty set to $(<macro>)
74
+ old: str = (
75
+ el.text
76
+ if (el := element.find(tag_name)) is not None and el.text is not None
77
+ else f"$({macro})"
78
+ )
79
+
80
+ # Replace instance of {<macro>} with the component's corresponding attribute
81
+ new: str = old.replace(f"$({macro})", current_macro)
82
+
83
+ # Set component's tag text to the autofilled macro
84
+ element[tag_name] = new
85
+
86
+ def replace_macros(
87
+ self,
88
+ widget: ObjectifiedElement,
89
+ component_name: str,
90
+ component: Component,
91
+ ):
92
+ for macro in self.macros:
93
+ # Get current component attribute
94
+ component_attr = getattr(component, macro)
95
+ # If it is None, then it was not provided so ignore
96
+ if component_attr is None and macro != "desc":
97
+ continue
98
+
99
+ # Fix to make sure widget is reverted back to widget that was passed in
100
+ current_widget = widget
101
+ match macro:
102
+ case "prefix":
103
+ tag_name = "pv_name"
104
+ case "desc":
105
+ tag_name = "description"
106
+ current_widget = _get_action_group(widget)
107
+ if component_attr is None:
108
+ component_attr = component_name
109
+ case "file":
110
+ tag_name = "file"
111
+ current_widget = _get_action_group(widget)
112
+ case _:
113
+ raise ValueError("The provided macro type is not supported.")
114
+
115
+ if current_widget is None:
116
+ LOGGER.debug(
117
+ f"Skipping replace_macros for {component_name} as no action\
118
+ group found"
119
+ )
120
+ continue
121
+
122
+ self._sub_macro(
123
+ tag_name=tag_name,
124
+ macro=macro,
125
+ element=current_widget,
126
+ current_macro=component_attr,
127
+ )
@@ -0,0 +1,285 @@
1
+ import json
2
+ import logging
3
+ from collections import defaultdict
4
+ from dataclasses import _MISSING_TYPE, dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+ from lxml import etree, objectify
10
+ from lxml.objectify import ObjectifiedElement
11
+
12
+ from techui_builder.generate import Generator
13
+ from techui_builder.models import Entity, TechUi
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class json_map:
20
+ file: str
21
+ exists: bool = True
22
+ duplicate: bool = False
23
+ children: list["json_map"] = field(default_factory=list)
24
+ macros: dict[str, str] = field(default_factory=dict)
25
+ error: str = ""
26
+
27
+
28
+ @dataclass
29
+ class Builder:
30
+ """
31
+ This class provides the functionality to process the required
32
+ techui.yaml file into screens mapped from ioc.yaml and
33
+ *-mapping.yaml files.
34
+
35
+ By default it looks for a `create_gui.yaml` file in the same dir
36
+ of the script Guibuilder is called in. Optionally a custom path
37
+ can be declared.
38
+
39
+ """
40
+
41
+ techui: Path = field(default=Path("techui.yaml"))
42
+
43
+ entities: defaultdict[str, list[Entity]] = field(
44
+ default_factory=lambda: defaultdict(list), init=False
45
+ )
46
+ _services_dir: Path = field(init=False, repr=False)
47
+ _gui_map: dict = field(init=False, repr=False)
48
+ _write_directory: Path = field(default=Path("opis"), init=False, repr=False)
49
+
50
+ def __post_init__(self):
51
+ # Populate beamline and components
52
+ self.conf = TechUi.model_validate(
53
+ yaml.safe_load(self.techui.read_text(encoding="utf-8"))
54
+ )
55
+
56
+ def setup(self):
57
+ """Run intial setup, e.g. extracting entries from service ioc.yaml."""
58
+ self._extract_services()
59
+ self.generator = Generator(self._services_dir.parent)
60
+
61
+ def _extract_services(self):
62
+ """
63
+ Finds the services folders in the services directory
64
+ and extracts all entites
65
+ """
66
+
67
+ # Loop over every dir in services, ignoring anything that isn't a service
68
+ for service in self._services_dir.glob(f"{self.conf.beamline.long_dom}-*-*-*"):
69
+ # If service doesn't exist, file open will fail throwing exception
70
+ try:
71
+ self._extract_entities(ioc_yaml=service.joinpath("config/ioc.yaml"))
72
+ except OSError:
73
+ LOGGER.error(
74
+ f"No ioc.yaml file for service: [bold]{service.name}[/bold]. \
75
+ Does it exist?"
76
+ )
77
+
78
+ def _extract_entities(self, ioc_yaml: Path):
79
+ """
80
+ Extracts the entries in ioc.yaml matching the defined prefix
81
+ """
82
+
83
+ with open(ioc_yaml) as ioc:
84
+ ioc_conf: dict[str, list[dict[str, str]]] = yaml.safe_load(ioc)
85
+ for entity in ioc_conf["entities"]:
86
+ if "P" in entity.keys():
87
+ # Create Entity and append to entity list
88
+ new_entity = Entity(
89
+ type=entity["type"],
90
+ desc=entity.get("desc", None),
91
+ P=entity["P"],
92
+ M=None
93
+ if (val := entity.get("M")) is None
94
+ else val.removeprefix(":"),
95
+ R=None
96
+ if (val := entity.get("R")) is None
97
+ else val.removeprefix(":"),
98
+ )
99
+ self.entities[new_entity.P].append(new_entity)
100
+
101
+ def _generate_screen(self, screen_name: str, screen_components: list[Entity]):
102
+ self.generator.load_screen(screen_name, screen_components)
103
+ self.generator.build_groups()
104
+ self.generator.write_screen(self._write_directory)
105
+
106
+ def generate_screens(self):
107
+ """Generate the screens for each component in techui.yaml"""
108
+ if len(self.entities) == 0:
109
+ LOGGER.critical("No ioc entities found, has setup() been run?")
110
+ exit()
111
+
112
+ # Loop over every component defined in techui.yaml and locate
113
+ # any extras defined
114
+ for component_name, component in self.conf.components.items():
115
+ screen_entities: list[Entity] = []
116
+ # ONLY IF there is a matching component and entity, generate a screen
117
+ if component.prefix in self.entities.keys():
118
+ screen_entities.extend(self.entities[component.prefix])
119
+ if component.extras is not None:
120
+ # If component has any extras, add them to the entries to generate
121
+ for extra_p in component.extras:
122
+ if extra_p not in self.entities.keys():
123
+ LOGGER.error(
124
+ f"Extra prefix {extra_p} for {component_name} does not \
125
+ exist."
126
+ )
127
+ continue
128
+ screen_entities.extend(self.entities[extra_p])
129
+
130
+ self._generate_screen(component_name, screen_entities)
131
+ else:
132
+ LOGGER.warning(
133
+ f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold]\
134
+ set in the component [bold]{component_name}[/bold] does not match any P field in the\
135
+ ioc.yaml files in services"
136
+ )
137
+
138
+ def _generate_json_map(
139
+ self, screen_path: Path, dest_path: Path, visited: set[Path] | None = None
140
+ ) -> json_map:
141
+ def _get_macros(element: ObjectifiedElement):
142
+ if hasattr(element, "macros"):
143
+ macros = element.macros.getchildren()
144
+ if macros is not None:
145
+ return {
146
+ str(macro.tag): macro.text
147
+ for macro in macros
148
+ if macro.text is not None
149
+ }
150
+ return {}
151
+
152
+ if visited is None:
153
+ visited = set()
154
+
155
+ current_node = json_map(str(screen_path))
156
+
157
+ abs_path = screen_path
158
+ dest_path = dest_path
159
+ if abs_path in visited:
160
+ current_node.exists = True
161
+ current_node.duplicate = True
162
+ return current_node
163
+ visited.add(abs_path)
164
+
165
+ try:
166
+ tree = objectify.parse(abs_path)
167
+ root: ObjectifiedElement = tree.getroot()
168
+
169
+ # Find all <widget> elements
170
+ widgets = [
171
+ w
172
+ for w in root.findall(".//widget")
173
+ if w.get("type", default=None)
174
+ # in ["symbol", "embedded", "action_button"]
175
+ in ["symbol", "action_button"]
176
+ ]
177
+
178
+ for widget_elem in widgets:
179
+ # Obtain macros associated with file_elem
180
+ macro_dict: dict[str, str] = {}
181
+ widget_type = widget_elem.get("type", default=None)
182
+
183
+ match widget_type:
184
+ case "symbol" | "action_button":
185
+ open_display = _get_action_group(widget_elem)
186
+ if open_display is None:
187
+ continue
188
+ file_elem = open_display.file
189
+
190
+ macro_dict = _get_macros(open_display)
191
+ # case "embedded":
192
+ # file_elem = widget_elem.file
193
+ # macro_dict = _get_macros(widget_elem)
194
+ case _:
195
+ continue
196
+
197
+ # Extract file path from file_elem
198
+ file_path = Path(file_elem.text.strip() if file_elem.text else "")
199
+ # If file is already a .bob file, skip it
200
+ if not file_path.suffix == ".bob":
201
+ continue
202
+
203
+ # TODO: misleading var name?
204
+ next_file_path = dest_path.joinpath(file_path)
205
+
206
+ # Crawl the next file
207
+ if next_file_path.is_file():
208
+ # TODO: investigate non-recursive approaches?
209
+ child_node = self._generate_json_map(
210
+ next_file_path, dest_path, visited
211
+ )
212
+ else:
213
+ child_node = json_map(str(file_path), exists=False)
214
+
215
+ child_node.macros = macro_dict
216
+ # TODO: make this work for only list[json_map]
217
+ assert isinstance(current_node.children, list)
218
+ # TODO: fix typing
219
+ current_node.children.append(child_node)
220
+
221
+ except etree.ParseError as e:
222
+ current_node.error = f"XML parse error: {e}"
223
+ except Exception as e:
224
+ current_node.error = str(e)
225
+
226
+ return current_node
227
+
228
+ def write_json_map(
229
+ self,
230
+ synoptic: Path = Path("example/bl01t-services/synoptic/opis/index.bob"),
231
+ dest: Path = Path("example/bl01t-services/synoptic/opis/json_map.json"),
232
+ ):
233
+ """
234
+ Maps the valid entries from the ioc.yaml file
235
+ to the required screen in *-mapping.yaml
236
+ """
237
+ if not synoptic.exists():
238
+ raise Exception(
239
+ f"Cannot generate json map for {synoptic}. Has it been generated?"
240
+ )
241
+
242
+ map = self._generate_json_map(synoptic, dest)
243
+ with open(dest.joinpath("json_map.json"), "w") as f:
244
+ f.write(json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o)))
245
+
246
+
247
+ # Function to convert the json_map objects into dictionaries,
248
+ # while ignoring default values
249
+ def _serialise_json_map(map: json_map) -> dict[str, Any]:
250
+ def _check_default(key: str, value: Any):
251
+ # Is a default factory used? (e.g. list, dict, ...)
252
+ if not isinstance(
253
+ json_map.__dataclass_fields__[key].default_factory, _MISSING_TYPE
254
+ ):
255
+ # If so, check if value is the same as default factory
256
+ default = json_map.__dataclass_fields__[key].default_factory()
257
+ else:
258
+ # If not, check if value is the default value
259
+ default = json_map.__dataclass_fields__[key].default
260
+ return value == default
261
+
262
+ # Loop over everything in the json map object's dictionary
263
+ for val in map.__dict__.values():
264
+ # If a value is another nedted json_map object, serialise that too
265
+ if isinstance(val, json_map):
266
+ val = _serialise_json_map(val)
267
+
268
+ # only include any items if they are not the default value
269
+ d = {k: v for (k, v) in map.__dict__.items() if not _check_default(k, v)}
270
+ return d
271
+
272
+
273
+ # File and desc are under the "actions",
274
+ # so the corresponding tag needs to be found
275
+ def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None:
276
+ try:
277
+ actions = element.actions
278
+ assert actions is not None
279
+ for action in actions.iterchildren("action"):
280
+ if action.get("type", default=None) == "open_display":
281
+ return action
282
+ return None
283
+ except AttributeError:
284
+ # TODO: Find better way of handling there being no "actions" group
285
+ LOGGER.error(f"Actions group not found in component: {element.text}")