techui-builder 0.5.2a2__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.
@@ -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,179 @@
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-support/\n
33
+ | | `-- ...\n
34
+ . |-- techui.yaml\n
35
+ . `-- index.bob\n
36
+ """,
37
+ )
38
+
39
+ default_bobfile = "index.bob"
40
+
41
+
42
+ def version_callback(value: bool):
43
+ if value:
44
+ print(f"techui-builder version: {__version__}")
45
+ raise typer.Exit()
46
+
47
+
48
+ def schema_callback(value: bool):
49
+ if value:
50
+ schema_generator()
51
+ raise typer.Exit()
52
+
53
+
54
+ def log_level(level: str):
55
+ logging.basicConfig(
56
+ level=level,
57
+ format="%(message)s",
58
+ handlers=[RichHandler(omit_repeated_times=False, markup=True)],
59
+ )
60
+
61
+
62
+ # This is the default behaviour when no command provided
63
+ @app.callback(invoke_without_command=True)
64
+ def main(
65
+ filename: Annotated[Path, typer.Argument(help="The path to techui.yaml")],
66
+ bobfile: Annotated[
67
+ Path | None,
68
+ typer.Argument(help="Override for template bob file location."),
69
+ ] = None,
70
+ version: Annotated[
71
+ bool | None, typer.Option("--version", callback=version_callback)
72
+ ] = None,
73
+ loglevel: Annotated[
74
+ str,
75
+ typer.Option(
76
+ "--log-level",
77
+ help="Set log level to INFO, DEBUG, WARNING, ERROR or CRITICAL",
78
+ case_sensitive=False,
79
+ callback=log_level,
80
+ ),
81
+ ] = "INFO",
82
+ schema: Annotated[
83
+ bool | None,
84
+ typer.Option(
85
+ "--schema",
86
+ help="Generate schema for validating techui and ibek-mapping yaml files",
87
+ callback=schema_callback,
88
+ ),
89
+ ] = None,
90
+ ) -> None:
91
+ """Default function called from cmd line tool."""
92
+
93
+ logger_ = logging.getLogger(__name__)
94
+
95
+ bob_file = bobfile
96
+
97
+ gui = Builder(techui=filename)
98
+
99
+ # Get the relative path to the techui file from working dir
100
+ abs_path = filename.absolute()
101
+ logger_.debug(f"techui.yaml absolute path: {abs_path}")
102
+
103
+ # Get the current working dir
104
+ cwd = Path.cwd()
105
+ logger_.debug(f"Working directory: {cwd}")
106
+
107
+ # Get the relative path of ixx-services to techui.yaml
108
+ ixx_services_dir = next(
109
+ (
110
+ ixx_services.relative_to(cwd, walk_up=True)
111
+ for parent in abs_path.parents
112
+ for ixx_services in parent.glob(f"{gui.conf.beamline.short_dom}-services")
113
+ ),
114
+ None,
115
+ )
116
+ if ixx_services_dir is None:
117
+ logging.critical("ixx-services not found. Is you file structure correct?")
118
+ exit()
119
+ logger_.debug(f"ixx-services relative path: {ixx_services_dir}")
120
+
121
+ # Get the synoptic dir relative to the parent dir
122
+ synoptic_dir = ixx_services_dir.joinpath("synoptic")
123
+ logger_.debug(f"synoptic relative path: {synoptic_dir}")
124
+
125
+ if bob_file is None:
126
+ # Search default relative dir to techui filename
127
+ # There will only ever be one file, but if not return None
128
+ bob_file = next(
129
+ synoptic_dir.glob("index.bob"),
130
+ None,
131
+ )
132
+ if bob_file is None:
133
+ logging.critical(
134
+ f"Source bob file '{default_bobfile}' not found in \
135
+ {synoptic_dir}. Does it exist?"
136
+ )
137
+ exit()
138
+ elif not bob_file.exists():
139
+ logging.critical(f"Source bob file '{bob_file}' not found. Does it exist?")
140
+ exit()
141
+
142
+ logger_.debug(f"bob file: {bob_file}")
143
+
144
+ # # Overwrite after initialised to make sure this is picked up
145
+ gui._services_dir = ixx_services_dir.joinpath("services") # noqa: SLF001
146
+ gui._write_directory = synoptic_dir # noqa: SLF001
147
+
148
+ logger_.debug(
149
+ f"""
150
+
151
+ Builder created for {gui.conf.beamline.short_dom}.
152
+ Services directory: {gui._services_dir}
153
+ Write directory: {gui._write_directory}
154
+ """, # noqa: SLF001
155
+ )
156
+
157
+ gui.setup()
158
+ gui.create_screens()
159
+
160
+ logger_.info(f"Screens generated for {gui.conf.beamline.short_dom}.")
161
+
162
+ autofiller = Autofiller(bob_file)
163
+ autofiller.read_bob()
164
+ autofiller.autofill_bob(gui)
165
+
166
+ dest_bob = gui._write_directory.joinpath("index.bob") # noqa: SLF001
167
+
168
+ autofiller.write_bob(dest_bob)
169
+
170
+ logger_.info(f"Screens autofilled for {gui.conf.beamline.short_dom}.")
171
+
172
+ gui.write_json_map(synoptic=dest_bob, dest=gui._write_directory) # noqa: SLF001
173
+ logger_.info(
174
+ f"Json map generated for {gui.conf.beamline.short_dom} (from index.bob)"
175
+ )
176
+
177
+
178
+ if __name__ == "__main__":
179
+ 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.5.2a2'
32
+ __version_tuple__ = version_tuple = (0, 5, 2, 'a2')
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,102 @@
1
+ import logging
2
+ import os
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from lxml import objectify
8
+ from lxml.objectify import ObjectifiedElement
9
+
10
+ from techui_builder.builder import Builder, _get_action_group
11
+ from techui_builder.models import Component
12
+ from techui_builder.utils import read_bob
13
+
14
+ logger_ = logging.getLogger(__name__)
15
+
16
+
17
+ @dataclass
18
+ class Autofiller:
19
+ path: Path
20
+ macros: list[str] = field(default_factory=lambda: ["prefix", "desc", "file"])
21
+ widgets: dict[str, ObjectifiedElement] = field(
22
+ default_factory=defaultdict, init=False, repr=False
23
+ )
24
+
25
+ def read_bob(self) -> None:
26
+ self.tree, self.widgets = read_bob(self.path)
27
+
28
+ def autofill_bob(self, gui: "Builder"):
29
+ # Get names from component list
30
+
31
+ for symbol_name, child in self.widgets.items():
32
+ # If the name exists in the component list
33
+ if symbol_name in gui.conf.components.keys():
34
+ # Get first copy of component (should only be one)
35
+ comp = next(
36
+ (comp for comp in gui.conf.components if comp == symbol_name),
37
+ )
38
+
39
+ self.replace_content(
40
+ widget=child,
41
+ component_name=comp,
42
+ component=gui.conf.components[comp],
43
+ )
44
+
45
+ # Add option to allow left mouse click to run action
46
+ child["run_actions_on_mouse_click"] = "true"
47
+
48
+ def write_bob(self, filename: Path):
49
+ # Check if data/ dir exists and if not, make it
50
+ data_dir = filename.parent
51
+ if not data_dir.exists():
52
+ os.mkdir(data_dir)
53
+
54
+ # Remove any unnecessary xmlns:py and py:pytype metadata from tags
55
+ objectify.deannotate(self.tree, cleanup_namespaces=True)
56
+
57
+ self.tree.write(
58
+ filename,
59
+ pretty_print=True,
60
+ encoding="utf-8",
61
+ xml_declaration=True,
62
+ )
63
+ logger_.debug(f"Screen filled for {filename}")
64
+
65
+ def replace_content(
66
+ self,
67
+ widget: ObjectifiedElement,
68
+ component_name: str,
69
+ component: Component,
70
+ ):
71
+ for macro in self.macros:
72
+ # Get current component attribute
73
+ component_attr = getattr(component, macro)
74
+
75
+ # Fix to make sure widget is reverted back to widget that was passed in
76
+ current_widget = widget
77
+ match macro:
78
+ case "prefix":
79
+ tag_name = "pv_name"
80
+ component_attr += ":DEVSTA"
81
+ case "desc":
82
+ tag_name = "description"
83
+ current_widget = _get_action_group(widget)
84
+ if component_attr is None:
85
+ component_attr = component_name
86
+ case "file":
87
+ tag_name = "file"
88
+ current_widget = _get_action_group(widget)
89
+ if component_attr is None:
90
+ component_attr = f"{component_name}.bob"
91
+ case _:
92
+ raise ValueError("The provided macro type is not supported.")
93
+
94
+ if current_widget is None:
95
+ logger_.debug(
96
+ f"Skipping replace_content for {component_name} as no action\
97
+ group found"
98
+ )
99
+ continue
100
+
101
+ # Set component's tag text to the corresponding widget tag
102
+ current_widget[tag_name] = component_attr
@@ -0,0 +1,346 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from collections import defaultdict
5
+ from dataclasses import _MISSING_TYPE, dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+ from lxml import etree, objectify
11
+ from lxml.objectify import ObjectifiedElement
12
+
13
+ from techui_builder.generate import Generator
14
+ from techui_builder.models import Entity, TechUi
15
+ from techui_builder.validator import Validator
16
+
17
+ logger_ = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class JsonMap:
22
+ file: str
23
+ exists: bool = True
24
+ duplicate: bool = False
25
+ children: list["JsonMap"] = field(default_factory=list)
26
+ macros: dict[str, str] = field(default_factory=dict)
27
+ error: str = ""
28
+
29
+
30
+ @dataclass
31
+ class Builder:
32
+ """
33
+ This class provides the functionality to process the required
34
+ techui.yaml file into screens mapped from ioc.yaml and
35
+ *-mapping.yaml files.
36
+
37
+ By default it looks for a `techui.yaml` file in the same dir
38
+ of the script Guibuilder is called in. Optionally a custom path
39
+ can be declared.
40
+
41
+ """
42
+
43
+ techui: Path = field(default=Path("techui.yaml"))
44
+
45
+ entities: defaultdict[str, list[Entity]] = field(
46
+ default_factory=lambda: defaultdict(list), init=False
47
+ )
48
+ _services_dir: Path = field(init=False, repr=False)
49
+ _gui_map: dict = field(init=False, repr=False)
50
+ _write_directory: Path = field(default=Path("opis"), init=False, repr=False)
51
+
52
+ def __post_init__(self):
53
+ # Populate beamline and components
54
+ self.conf = TechUi.model_validate(
55
+ yaml.safe_load(self.techui.read_text(encoding="utf-8"))
56
+ )
57
+
58
+ def setup(self):
59
+ """Run intial setup, e.g. extracting entries from service ioc.yaml."""
60
+ self._extract_services()
61
+ synoptic_dir = self._write_directory
62
+
63
+ self.clean_files()
64
+
65
+ self.generator = Generator(synoptic_dir, self.conf.beamline.url)
66
+
67
+ def clean_files(self):
68
+ exclude = {"index.bob"}
69
+ bobs = [
70
+ bob
71
+ for bob in self._write_directory.glob("*.bob")
72
+ if bob.name not in exclude
73
+ ]
74
+
75
+ self.validator = Validator(bobs)
76
+ self.validator.check_bobs()
77
+
78
+ # Get bobs that are only present in the bobs list (i.e. generated)
79
+ self.generated_bobs = list(set(bobs) ^ set(self.validator.validate.values()))
80
+
81
+ logger_.info("Preserving edited screens for validation.")
82
+ logger_.debug(f"Screens to validate: {list(self.validator.validate.keys())}")
83
+
84
+ logger_.info("Cleaning synoptic/ of generated screens.")
85
+
86
+ try:
87
+ # Find the JsonMap file
88
+ json_map_file = next(self._write_directory.glob("JsonMap.json"))
89
+ # If it exists, we want to remove it too
90
+ generated_files = [*self.generated_bobs, json_map_file]
91
+ except StopIteration:
92
+ generated_files = self.generated_bobs
93
+
94
+ # Remove any generated files that exist
95
+ for file_ in generated_files:
96
+ logger_.debug(f"Removing generated file: {file_.name}")
97
+ os.remove(file_)
98
+
99
+ def _extract_services(self):
100
+ """
101
+ Finds the services folders in the services directory
102
+ and extracts all entites
103
+ """
104
+
105
+ # Loop over every dir in services, ignoring anything that isn't a service
106
+ for service in self._services_dir.glob(f"{self.conf.beamline.long_dom}-*-*-*"):
107
+ # If service doesn't exist, file open will fail throwing exception
108
+ try:
109
+ self._extract_entities(ioc_yaml=service.joinpath("config/ioc.yaml"))
110
+ except OSError:
111
+ logger_.error(
112
+ f"No ioc.yaml file for service: [bold]{service.name}[/bold]. \
113
+ Does it exist?"
114
+ )
115
+
116
+ def _extract_entities(self, ioc_yaml: Path):
117
+ """
118
+ Extracts the entries in ioc.yaml matching the defined prefix
119
+ """
120
+
121
+ with open(ioc_yaml) as ioc:
122
+ ioc_conf: dict[str, list[dict[str, str]]] = yaml.safe_load(ioc)
123
+ for entity in ioc_conf["entities"]:
124
+ if "P" in entity.keys():
125
+ # Create Entity and append to entity list
126
+ new_entity = Entity(
127
+ type=entity["type"],
128
+ desc=entity.get("desc", None),
129
+ P=entity["P"],
130
+ M=None if (val := entity.get("M")) is None else val,
131
+ R=None if (val := entity.get("R")) is None else val,
132
+ )
133
+ self.entities[new_entity.P].append(new_entity)
134
+
135
+ def _generate_screen(self, screen_name: str):
136
+ self.generator.build_screen(screen_name)
137
+ self.generator.write_screen(screen_name, self._write_directory)
138
+
139
+ def _validate_screen(self, screen_name: str):
140
+ # Get the generated widgets to validate against
141
+ widgets = self.generator.widgets
142
+ widget_group = self.generator.group
143
+ assert widget_group is not None
144
+ widget_group_name = widget_group.get_element_value("name")
145
+ self.validator.validate_bob(screen_name, widget_group_name, widgets)
146
+
147
+ def create_screens(self):
148
+ """Create the screens for each component in techui.yaml"""
149
+ if len(self.entities) == 0:
150
+ logger_.critical("No ioc entities found, has setup() been run?")
151
+ exit()
152
+
153
+ # Loop over every component defined in techui.yaml and locate
154
+ # any extras defined
155
+ for component_name, component in self.conf.components.items():
156
+ screen_entities: list[Entity] = []
157
+ # ONLY IF there is a matching component and entity, generate a screen
158
+ if component.prefix in self.entities.keys():
159
+ screen_entities.extend(self.entities[component.prefix])
160
+ if component.extras is not None:
161
+ # If component has any extras, add them to the entries to generate
162
+ for extra_p in component.extras:
163
+ if extra_p not in self.entities.keys():
164
+ logger_.error(
165
+ f"Extra prefix {extra_p} for {component_name} does not \
166
+ exist."
167
+ )
168
+ continue
169
+ screen_entities.extend(self.entities[extra_p])
170
+
171
+ # This is used by both generate and validate,
172
+ # so called beforehand for tidyness
173
+ self.generator.build_widgets(component_name, screen_entities)
174
+ self.generator.build_groups(component_name)
175
+
176
+ screens_to_validate = list(self.validator.validate.keys())
177
+
178
+ if component_name in screens_to_validate:
179
+ self._validate_screen(component_name)
180
+ else:
181
+ self._generate_screen(component_name)
182
+
183
+ else:
184
+ logger_.warning(
185
+ f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold]\
186
+ set in the component [bold]{component_name}[/bold] does not match any P field in the\
187
+ ioc.yaml files in services"
188
+ )
189
+
190
+ def _generate_json_map(
191
+ self, screen_path: Path, dest_path: Path, visited: set[Path] | None = None
192
+ ) -> JsonMap:
193
+ def _get_macros(element: ObjectifiedElement):
194
+ if hasattr(element, "macros"):
195
+ macros = element.macros.getchildren()
196
+ if macros is not None:
197
+ return {
198
+ str(macro.tag): macro.text
199
+ for macro in macros
200
+ if macro.text is not None
201
+ }
202
+ return {}
203
+
204
+ if visited is None:
205
+ visited = set()
206
+
207
+ current_node = JsonMap(str(screen_path.relative_to(self._write_directory)))
208
+
209
+ abs_path = screen_path.absolute()
210
+ dest_path = dest_path
211
+ if abs_path in visited:
212
+ current_node.exists = True
213
+ current_node.duplicate = True
214
+ return current_node
215
+ visited.add(abs_path)
216
+
217
+ try:
218
+ tree = objectify.parse(abs_path)
219
+ root: ObjectifiedElement = tree.getroot()
220
+
221
+ # Find all <widget> elements
222
+ widgets = [
223
+ w
224
+ for w in root.findall(".//widget")
225
+ if w.get("type", default=None)
226
+ # in ["symbol", "embedded", "action_button"]
227
+ in ["symbol", "action_button"]
228
+ ]
229
+
230
+ for widget_elem in widgets:
231
+ # Obtain macros associated with file_elem
232
+ macro_dict: dict[str, str] = {}
233
+ widget_type = widget_elem.get("type", default=None)
234
+
235
+ match widget_type:
236
+ case "symbol" | "action_button":
237
+ open_display = _get_action_group(widget_elem)
238
+ if open_display is None:
239
+ continue
240
+ file_elem = open_display.file
241
+
242
+ macro_dict = _get_macros(open_display)
243
+ # case "embedded":
244
+ # file_elem = widget_elem.file
245
+ # macro_dict = _get_macros(widget_elem)
246
+ case _:
247
+ continue
248
+
249
+ # Extract file path from file_elem
250
+ file_path = Path(file_elem.text.strip() if file_elem.text else "")
251
+ # If file is already a .bob file, skip it
252
+ if not file_path.suffix == ".bob":
253
+ continue
254
+
255
+ # TODO: misleading var name?
256
+ next_file_path = dest_path.joinpath(file_path)
257
+
258
+ # Crawl the next file
259
+ if next_file_path.is_file():
260
+ # TODO: investigate non-recursive approaches?
261
+ child_node = self._generate_json_map(
262
+ next_file_path, dest_path, visited
263
+ )
264
+ else:
265
+ child_node = JsonMap(str(file_path), exists=False)
266
+
267
+ child_node.macros = macro_dict
268
+ # TODO: make this work for only list[JsonMap]
269
+ assert isinstance(current_node.children, list)
270
+ # TODO: fix typing
271
+ current_node.children.append(child_node)
272
+
273
+ except etree.ParseError as e:
274
+ current_node.error = f"XML parse error: {e}"
275
+ except Exception as e:
276
+ current_node.error = str(e)
277
+
278
+ return current_node
279
+
280
+ def write_json_map(
281
+ self,
282
+ synoptic: Path = Path("example/t01-services/synoptic/index.bob"),
283
+ dest: Path = Path("example/t01-services/synoptic"),
284
+ ):
285
+ """
286
+ Maps the valid entries from the ioc.yaml file
287
+ to the required screen in *-mapping.yaml
288
+ """
289
+ if not synoptic.exists():
290
+ raise FileNotFoundError(
291
+ f"Cannot generate json map for {synoptic}. Has it been generated?"
292
+ )
293
+
294
+ map = self._generate_json_map(synoptic, dest)
295
+ with open(dest.joinpath("JsonMap.json"), "w") as f:
296
+ f.write(
297
+ json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o))
298
+ + "\n"
299
+ )
300
+
301
+
302
+ # Function to convert the JsonMap objects into dictionaries,
303
+ # while ignoring default values
304
+ def _serialise_json_map(map: JsonMap) -> dict[str, Any]:
305
+ def _check_default(key: str, value: Any):
306
+ # Is a default factory used? (e.g. list, dict, ...)
307
+ if not isinstance(
308
+ JsonMap.__dataclass_fields__[key].default_factory, _MISSING_TYPE
309
+ ):
310
+ # If so, check if value is the same as default factory
311
+ default = JsonMap.__dataclass_fields__[key].default_factory()
312
+ else:
313
+ # If not, check if value is the default value
314
+ default = JsonMap.__dataclass_fields__[key].default
315
+ return value == default
316
+
317
+ d = {}
318
+
319
+ # Loop over everything in the json map object's dictionary
320
+ for key, val in map.__dict__.items():
321
+ # If children has nested JsonMap object, serialise that too
322
+ if key == "children" and len(val) > 0:
323
+ val = [_serialise_json_map(v) for v in val]
324
+
325
+ # only include any items if they are not the default value
326
+ if _check_default(key, val):
327
+ continue
328
+
329
+ d[key] = val
330
+
331
+ return d
332
+
333
+
334
+ # File and desc are under the "actions",
335
+ # so the corresponding tag needs to be found
336
+ def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None:
337
+ try:
338
+ actions = element.actions
339
+ assert actions is not None
340
+ for action in actions.iterchildren("action"):
341
+ if action.get("type", default=None) == "open_display":
342
+ return action
343
+ return None
344
+ except AttributeError:
345
+ # TODO: Find better way of handling there being no "actions" group
346
+ logger_.error(f"Actions group not found in component: {element.text}")