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.
- techui_builder/__init__.py +16 -0
- techui_builder/__main__.py +180 -0
- techui_builder/_version.py +34 -0
- techui_builder/autofill.py +102 -0
- techui_builder/builder.py +486 -0
- techui_builder/generate.py +424 -0
- techui_builder/models.py +216 -0
- techui_builder/schema_generator.py +27 -0
- techui_builder/utils.py +32 -0
- techui_builder/validator.py +117 -0
- techui_builder-0.6.0a2.dist-info/METADATA +98 -0
- techui_builder-0.6.0a2.dist-info/RECORD +16 -0
- techui_builder-0.6.0a2.dist-info/WHEEL +5 -0
- techui_builder-0.6.0a2.dist-info/entry_points.txt +2 -0
- techui_builder-0.6.0a2.dist-info/licenses/LICENSE +201 -0
- techui_builder-0.6.0a2.dist-info/top_level.txt +1 -0
|
@@ -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}")
|