techui-builder 0.6.0a2__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,486 @@
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 epicsdbbuilder.recordbase import Record
11
+ from lxml import etree, objectify
12
+ from lxml.objectify import ObjectifiedElement
13
+ from softioc.builder import records
14
+
15
+ from techui_builder.generate import Generator
16
+ from techui_builder.models import Entity, TechUi
17
+ from techui_builder.validator import Validator
18
+
19
+ logger_ = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class JsonMap:
24
+ file: str
25
+ display_name: str | None
26
+ exists: bool = True
27
+ duplicate: bool = False
28
+ children: list["JsonMap"] = field(default_factory=list)
29
+ macros: dict[str, str] = field(default_factory=dict)
30
+ error: str = ""
31
+
32
+
33
+ @dataclass
34
+ class Builder:
35
+ """
36
+ This class provides the functionality to process the required
37
+ techui.yaml file into screens mapped from ioc.yaml and
38
+ *-mapping.yaml files.
39
+
40
+ By default it looks for a `techui.yaml` file in the same dir
41
+ of the script Guibuilder is called in. Optionally a custom path
42
+ can be declared.
43
+
44
+ """
45
+
46
+ techui: Path = field(default=Path("techui.yaml"))
47
+
48
+ entities: defaultdict[str, list[Entity]] = field(
49
+ default_factory=lambda: defaultdict(list), init=False
50
+ )
51
+ devsta_pvs: dict[str, Record] = field(default_factory=dict, init=False)
52
+ _services_dir: Path = field(init=False, repr=False)
53
+ _gui_map: dict = field(init=False, repr=False)
54
+ _write_directory: Path = field(default=Path("opis"), init=False, repr=False)
55
+
56
+ def __post_init__(self):
57
+ # Populate beamline and components
58
+ self.conf = TechUi.model_validate(
59
+ yaml.safe_load(self.techui.read_text(encoding="utf-8"))
60
+ )
61
+
62
+ def setup(self):
63
+ """Run intial setup, e.g. extracting entries from service ioc.yaml."""
64
+ self._extract_services()
65
+ synoptic_dir = self._write_directory
66
+
67
+ self.clean_files()
68
+
69
+ self.generator = Generator(synoptic_dir, self.conf.beamline.url)
70
+
71
+ def clean_files(self):
72
+ exclude = {"index.bob"}
73
+ bobs = [
74
+ bob
75
+ for bob in self._write_directory.glob("*.bob")
76
+ if bob.name not in exclude
77
+ ]
78
+
79
+ self.validator = Validator(bobs)
80
+ self.validator.check_bobs()
81
+
82
+ # Get bobs that are only present in the bobs list (i.e. generated)
83
+ self.generated_bobs = list(set(bobs) ^ set(self.validator.validate.values()))
84
+
85
+ logger_.info("Preserving edited screens for validation.")
86
+ logger_.debug(f"Screens to validate: {list(self.validator.validate.keys())}")
87
+
88
+ logger_.info("Cleaning synoptic/ of generated screens.")
89
+
90
+ try:
91
+ # Find the JsonMap file
92
+ json_map_file = next(self._write_directory.glob("JsonMap.json"))
93
+ # If it exists, we want to remove it too
94
+ generated_files = [*self.generated_bobs, json_map_file]
95
+ except StopIteration:
96
+ generated_files = self.generated_bobs
97
+
98
+ # Remove any generated files that exist
99
+ for file_ in generated_files:
100
+ logger_.debug(f"Removing generated file: {file_.name}")
101
+ os.remove(file_)
102
+
103
+ def _create_devsta_pv(self, prefix: str, inputs: list[str]):
104
+ # Extract all input PVs, provided a default "" if not provided
105
+ values = [(inputs[i] if i < len(inputs) else "") for i in range(12)]
106
+ inpa, inpb, inpc, inpd, inpe, inpf, inpg, inph, inpi, inpj, inpk, inpl = values
107
+
108
+ devsta_pv = records.calc( # pyright: ignore[reportAttributeAccessIssue]
109
+ f"{prefix}:DEVSTA",
110
+ CALC="(A|B|C|D|E|F|G|H|I|J|K|L)>0?1:0",
111
+ SCAN="1 second",
112
+ ACKT="NO",
113
+ INPA=inpa,
114
+ INPB=inpb,
115
+ INPC=inpc,
116
+ INPD=inpd,
117
+ INPE=inpe,
118
+ INPF=inpf,
119
+ INPG=inpg,
120
+ INPH=inph,
121
+ INPI=inpi,
122
+ INPJ=inpj,
123
+ INPK=inpk,
124
+ INPL=inpl,
125
+ )
126
+
127
+ self.devsta_pvs[prefix] = devsta_pv
128
+
129
+ def write_devsta_pvs(self):
130
+ conf_dir = self._write_directory.joinpath("config")
131
+
132
+ # Create the config/ dir if it doesn't exist
133
+ if not conf_dir.exists():
134
+ os.mkdir(conf_dir)
135
+
136
+ with open(conf_dir.joinpath("devsta.db"), "w") as f:
137
+ # Add a header explaining the file is autogenerated
138
+ f.write("#" * 51 + "\n")
139
+ f.write(
140
+ "#" * 2
141
+ + " THIS FILE HAS BEEN AUTOGENERATED; DO NOT EDIT "
142
+ + "#" * 2
143
+ + "\n"
144
+ )
145
+ f.write("#" * 51 + "\n")
146
+
147
+ # Write the devsta PVs
148
+ for dpv in self.devsta_pvs.values():
149
+ dpv.Print(f)
150
+
151
+ def _extract_services(self):
152
+ """
153
+ Finds the services folders in the services directory
154
+ and extracts all entites
155
+ """
156
+
157
+ # Loop over every dir in services, ignoring anything that isn't a service
158
+ for service in self._services_dir.glob(f"{self.conf.beamline.long_dom}-*-*-*"):
159
+ # If service doesn't exist, file open will fail throwing exception
160
+ try:
161
+ self._extract_entities(ioc_yaml=service.joinpath("config/ioc.yaml"))
162
+ except OSError:
163
+ logger_.error(
164
+ f"No ioc.yaml file for service: [bold]{service.name}[/bold]. \
165
+ Does it exist?"
166
+ )
167
+
168
+ def _extract_entities(self, ioc_yaml: Path):
169
+ """
170
+ Extracts the entries in ioc.yaml matching the defined prefix
171
+ """
172
+
173
+ with open(ioc_yaml) as ioc:
174
+ ioc_conf: dict[str, list[dict[str, str]]] = yaml.safe_load(ioc)
175
+ for entity in ioc_conf["entities"]:
176
+ if "P" in entity.keys():
177
+ # Create Entity and append to entity list
178
+ new_entity = Entity(
179
+ type=entity["type"],
180
+ desc=entity.get("desc", None),
181
+ P=entity["P"],
182
+ M=None if (val := entity.get("M")) is None else val,
183
+ R=None if (val := entity.get("R")) is None else val,
184
+ )
185
+ self.entities[new_entity.P].append(new_entity)
186
+
187
+ def _generate_screen(self, screen_name: str):
188
+ self.generator.build_screen(screen_name)
189
+ self.generator.write_screen(screen_name, self._write_directory)
190
+
191
+ def _validate_screen(self, screen_name: str):
192
+ # Get the generated widgets to validate against
193
+ widgets = self.generator.widgets
194
+ widget_group = self.generator.group
195
+ assert widget_group is not None
196
+ widget_group_name = widget_group.get_element_value("name")
197
+ self.validator.validate_bob(screen_name, widget_group_name, widgets)
198
+
199
+ def create_screens(self):
200
+ """Create the screens for each component in techui.yaml"""
201
+ if len(self.entities) == 0:
202
+ logger_.critical("No ioc entities found, has setup() been run?")
203
+ exit()
204
+
205
+ # Loop over every component defined in techui.yaml and locate
206
+ # any extras defined
207
+ for component_name, component in self.conf.components.items():
208
+ screen_entities: list[Entity] = []
209
+
210
+ if component.devsta is not None:
211
+ self._create_devsta_pv(component.prefix, component.devsta)
212
+
213
+ # ONLY IF there is a matching component and entity, generate a screen
214
+ if component.prefix in self.entities.keys():
215
+ screen_entities.extend(self.entities[component.prefix])
216
+ if component.extras is not None:
217
+ # If component has any extras, add them to the entries to generate
218
+ for extra_p in component.extras:
219
+ if extra_p not in self.entities.keys():
220
+ logger_.error(
221
+ f"Extra prefix {extra_p} for {component_name} does not \
222
+ exist."
223
+ )
224
+ continue
225
+ screen_entities.extend(self.entities[extra_p])
226
+
227
+ # This is used by both generate and validate,
228
+ # so called beforehand for tidyness
229
+ self.generator.build_widgets(component_name, screen_entities)
230
+ self.generator.build_groups(component_name)
231
+
232
+ screens_to_validate = list(self.validator.validate.keys())
233
+
234
+ if component_name in screens_to_validate:
235
+ self._validate_screen(component_name)
236
+ else:
237
+ self._generate_screen(component_name)
238
+
239
+ else:
240
+ logger_.warning(
241
+ f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold]\
242
+ set in the component [bold]{component_name}[/bold] does not match any P field in the\
243
+ ioc.yaml files in services"
244
+ )
245
+
246
+ def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap:
247
+ """Recursively generate JSON map from .bob file tree"""
248
+
249
+ # Create initial node at top of .bob file
250
+ current_node = JsonMap(
251
+ str(screen_path.relative_to(self._write_directory)),
252
+ display_name=None,
253
+ )
254
+
255
+ abs_path = screen_path.absolute()
256
+
257
+ try:
258
+ # Create xml tree from .bob file
259
+ tree = objectify.parse(abs_path)
260
+ root: ObjectifiedElement = tree.getroot()
261
+
262
+ # Set top level display name from root element
263
+ current_node.display_name = self._parse_display_name(
264
+ root.name.text, screen_path
265
+ )
266
+
267
+ # Find all <widget> elements
268
+ widgets = [
269
+ w
270
+ for w in root.findall(".//widget")
271
+ if w.get("type", default=None)
272
+ # in ["symbol", "embedded", "action_button"]
273
+ in ["symbol", "action_button", "embedded"]
274
+ ]
275
+
276
+ for widget_elem in widgets:
277
+ # Obtain macros associated with file_elem
278
+ macro_dict: dict[str, str] = {}
279
+ widget_type = widget_elem.get("type", default=None)
280
+
281
+ match widget_type:
282
+ case "symbol" | "action_button":
283
+ open_display = _get_action_group(widget_elem)
284
+ if open_display is None:
285
+ continue
286
+
287
+ # Use file, name, and macro elements
288
+ file_elem = open_display.file
289
+ name_elem = widget_elem.name.text
290
+ macro_dict = self._get_macros(open_display)
291
+
292
+ case "embedded":
293
+ file_elem = self._extract_action_button_file_from_embedded(
294
+ widget_elem.file, dest_path
295
+ )
296
+ name_elem = widget_elem.name.text
297
+ macro_dict = self._get_macros(widget_elem)
298
+
299
+ case _:
300
+ continue
301
+
302
+ # Extract file path from file_elem
303
+ file_path = Path(file_elem.text.strip() if file_elem.text else "")
304
+
305
+ # If file is already a .bob file, skip it
306
+ if not file_path.suffix == ".bob":
307
+ continue
308
+
309
+ # Create valid displayName
310
+ display_name = self._parse_display_name(name_elem, file_path)
311
+
312
+ # TODO: misleading var name?
313
+ next_file_path = dest_path.joinpath(file_path)
314
+
315
+ # Crawl the next file
316
+ if next_file_path.is_file():
317
+ # TODO: investigate non-recursive approaches?
318
+ child_node = self._generate_json_map(next_file_path, dest_path)
319
+ else:
320
+ child_node = JsonMap(str(file_path), display_name, exists=False)
321
+
322
+ child_node.macros = macro_dict
323
+ # TODO: make this work for only list[JsonMap]
324
+ assert isinstance(current_node.children, list)
325
+ # TODO: fix typing
326
+ current_node.children.append(child_node)
327
+
328
+ except etree.ParseError as e:
329
+ current_node.error = f"XML parse error: {e}"
330
+ except Exception as e:
331
+ current_node.error = str(e)
332
+
333
+ self._fix_duplicate_names(current_node)
334
+
335
+ return current_node
336
+
337
+ def _extract_action_button_file_from_embedded(
338
+ self, file_elem: ObjectifiedElement, dest_path: Path
339
+ ) -> ObjectifiedElement:
340
+ file_path = Path(file_elem.text.strip() if file_elem.text else "")
341
+ file_path = dest_path.joinpath(file_path)
342
+ if not file_path.exists():
343
+ rel_file_path = Path(str(file_elem.base)).relative_to(
344
+ dest_path.absolute(), walk_up=True
345
+ )
346
+ file_path = dest_path.joinpath(rel_file_path)
347
+ tree = objectify.parse(file_path.absolute())
348
+ root: ObjectifiedElement = tree.getroot()
349
+
350
+ # Find all <widget> elements
351
+ widgets = [
352
+ w
353
+ for w in root.findall(".//widget")
354
+ if w.get("type", default=None) == "action_button"
355
+ ]
356
+
357
+ for widget_elem in widgets:
358
+ open_display = _get_action_group(widget_elem)
359
+ if open_display is None:
360
+ continue
361
+ file_elem = open_display.file
362
+ return file_elem
363
+ return file_elem
364
+
365
+ def _get_macros(self, element: ObjectifiedElement):
366
+ if hasattr(element, "macros"):
367
+ macros = element.macros.getchildren()
368
+ if macros is not None:
369
+ return {
370
+ str(macro.tag): macro.text
371
+ for macro in macros
372
+ if macro.text is not None
373
+ }
374
+ return {}
375
+
376
+ def _parse_display_name(self, name: str | None, file_path: Path) -> str | None:
377
+ """Parse display name from <name> tag or file_path"""
378
+
379
+ if name:
380
+ # Return name tag text as displayName
381
+ return name
382
+
383
+ elif file_path.name:
384
+ # Use tail without file ext as displayName
385
+ return file_path.name[: -sum(len(suffix) for suffix in file_path.suffixes)]
386
+
387
+ else:
388
+ # Populate displayName with null
389
+ return None
390
+
391
+ def _fix_duplicate_names(self, node: JsonMap) -> None:
392
+ """Recursively fix duplicate display names in children"""
393
+ if not node.children:
394
+ return
395
+
396
+ # Count occurrences of each display_name
397
+ name_counts: defaultdict[str | None, int] = defaultdict(int)
398
+ for child in node.children:
399
+ if child.display_name:
400
+ name_counts[child.display_name] += 1
401
+
402
+ # Track which number we're on for each duplicate name
403
+ name_indices: defaultdict[str | None, int] = defaultdict(int)
404
+
405
+ # Update display names for duplicates
406
+ for child in node.children:
407
+ if child.display_name and name_counts[child.display_name] > 1:
408
+ name_indices[child.display_name] += 1
409
+ child.display_name = (
410
+ f"{child.display_name} {name_indices[child.display_name]}"
411
+ )
412
+
413
+ # Recursively fix children
414
+ self._fix_duplicate_names(child)
415
+
416
+ def write_json_map(
417
+ self,
418
+ synoptic: Path = Path("example/t01-services/synoptic/index.bob"),
419
+ dest: Path = Path("example/t01-services/synoptic"),
420
+ ):
421
+ """
422
+ Maps the valid entries from the ioc.yaml file
423
+ to the required screen in *-mapping.yaml
424
+ """
425
+ if not synoptic.exists():
426
+ raise FileNotFoundError(
427
+ f"Cannot generate json map for {synoptic}. Has it been generated?"
428
+ )
429
+
430
+ map = self._generate_json_map(synoptic, dest)
431
+ with open(dest.joinpath("JsonMap.json"), "w") as f:
432
+ f.write(
433
+ json.dumps(map, indent=4, default=lambda o: _serialise_json_map(o))
434
+ + "\n"
435
+ )
436
+
437
+
438
+ # Function to convert the JsonMap objects into dictionaries,
439
+ # while ignoring default values
440
+ def _serialise_json_map(map: JsonMap) -> dict[str, Any]:
441
+ def _check_default(key: str, value: Any):
442
+ # Is a default factory used? (e.g. list, dict, ...)
443
+ if not isinstance(
444
+ JsonMap.__dataclass_fields__[key].default_factory, _MISSING_TYPE
445
+ ):
446
+ # If so, check if value is the same as default factory
447
+ default = JsonMap.__dataclass_fields__[key].default_factory()
448
+ else:
449
+ # If not, check if value is the default value
450
+ default = JsonMap.__dataclass_fields__[key].default
451
+ return value == default
452
+
453
+ d = {}
454
+
455
+ # Loop over everything in the json map object's dictionary
456
+ for key, val in map.__dict__.items():
457
+ # If children has nested JsonMap object, serialise that too
458
+ if key == "children" and len(val) > 0:
459
+ val = [_serialise_json_map(v) for v in val]
460
+
461
+ # only include any items if they are not the default value
462
+ if _check_default(key, val):
463
+ continue
464
+
465
+ d[key] = val
466
+
467
+ # Rename display_name to displayName for JSON camel case convention
468
+ if "display_name" in d:
469
+ d["displayName"] = d.pop("display_name")
470
+
471
+ return d
472
+
473
+
474
+ # File and desc are under the "actions",
475
+ # so the corresponding tag needs to be found
476
+ def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None:
477
+ try:
478
+ actions = element.actions
479
+ assert actions is not None
480
+ for action in actions.iterchildren("action"):
481
+ if action.get("type", default=None) == "open_display":
482
+ return action
483
+ return None
484
+ except AttributeError:
485
+ # TODO: Find better way of handling there being no "actions" group
486
+ logger_.error(f"Actions group not found in component: {element.text}")