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.
- techui_builder/__init__.py +16 -0
- techui_builder/__main__.py +188 -0
- techui_builder/_version.py +34 -0
- techui_builder/autofill.py +127 -0
- techui_builder/builder.py +285 -0
- techui_builder/generate.py +405 -0
- techui_builder/models.py +173 -0
- techui_builder/schema_generator.py +28 -0
- techui_builder-0.3.0a1.dist-info/METADATA +95 -0
- techui_builder-0.3.0a1.dist-info/RECORD +14 -0
- techui_builder-0.3.0a1.dist-info/WHEEL +5 -0
- techui_builder-0.3.0a1.dist-info/entry_points.txt +2 -0
- techui_builder-0.3.0a1.dist-info/licenses/LICENSE +201 -0
- techui_builder-0.3.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from lxml import objectify
|
|
9
|
+
from phoebusgen import screen as Screen
|
|
10
|
+
from phoebusgen import widget as Widget
|
|
11
|
+
from phoebusgen.widget.widgets import ActionButton, EmbeddedDisplay, Group
|
|
12
|
+
|
|
13
|
+
from techui_builder.models import Entity
|
|
14
|
+
|
|
15
|
+
LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Generator:
|
|
20
|
+
services_dir: Path = field(repr=False)
|
|
21
|
+
|
|
22
|
+
screen_name: str = field(init=False)
|
|
23
|
+
screen_components: list[Entity] = field(init=False)
|
|
24
|
+
|
|
25
|
+
# These are global params for the class (not accessible by user)
|
|
26
|
+
ibek_map: dict = field(init=False, repr=False)
|
|
27
|
+
default_size: int = field(default=100, init=False, repr=False)
|
|
28
|
+
P: str = field(default="P", init=False, repr=False)
|
|
29
|
+
M: str = field(default="M", init=False, repr=False)
|
|
30
|
+
R: str = field(default="R", init=False, repr=False)
|
|
31
|
+
widgets: list[ActionButton | EmbeddedDisplay] = field(
|
|
32
|
+
default_factory=list[ActionButton | EmbeddedDisplay], init=False, repr=False
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# Add group padding, and self.widget_x for placing widget in x direction relative to
|
|
36
|
+
# other widgets, with a widget count to reset the self.widget_x dimension when the
|
|
37
|
+
# allowed number of horizontal stacks is exceeded.
|
|
38
|
+
widget_x: int = field(default=0, init=False, repr=False)
|
|
39
|
+
widget_count: int = field(default=0, init=False, repr=False)
|
|
40
|
+
group_padding: int = field(default=50, init=False, repr=False)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
self._read_map()
|
|
44
|
+
|
|
45
|
+
def _read_map(self):
|
|
46
|
+
"""Read the ibek-mapping.yaml file from techui-support."""
|
|
47
|
+
ibek_map = self.services_dir.parent.parent.joinpath(
|
|
48
|
+
"src/techui_support/ibek_mapping.yaml"
|
|
49
|
+
).absolute()
|
|
50
|
+
LOGGER.debug(f"ibek_mapping.yaml location: {ibek_map}")
|
|
51
|
+
|
|
52
|
+
with open(ibek_map) as map:
|
|
53
|
+
self.ibek_map = yaml.safe_load(map)
|
|
54
|
+
|
|
55
|
+
def load_screen(self, screen_name: str, screen_components: list[Entity]):
|
|
56
|
+
self.screen_name = screen_name
|
|
57
|
+
self.screen_components = screen_components
|
|
58
|
+
|
|
59
|
+
def _get_screen_dimensions(self, file: str) -> tuple[int, int]:
|
|
60
|
+
"""
|
|
61
|
+
Parses the bob files for information on the height
|
|
62
|
+
and width of the screen
|
|
63
|
+
"""
|
|
64
|
+
# Read the bob file
|
|
65
|
+
tree = objectify.parse(file)
|
|
66
|
+
root = tree.getroot()
|
|
67
|
+
height_element = root.height
|
|
68
|
+
if height_element is not None:
|
|
69
|
+
height = (
|
|
70
|
+
self.default_size if (val := height_element.text) is None else int(val)
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
height = self.default_size
|
|
74
|
+
assert "Could not obtain the size of the widget"
|
|
75
|
+
|
|
76
|
+
width_element = root.width
|
|
77
|
+
if width_element is not None:
|
|
78
|
+
width = (
|
|
79
|
+
self.default_size if (val := width_element.text) is None else int(val)
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
width = self.default_size
|
|
83
|
+
assert "Could not obtain the size of the widget"
|
|
84
|
+
|
|
85
|
+
return (height, width)
|
|
86
|
+
|
|
87
|
+
def _get_widget_dimensions(
|
|
88
|
+
self, widget: EmbeddedDisplay | ActionButton
|
|
89
|
+
) -> tuple[int, int]:
|
|
90
|
+
"""
|
|
91
|
+
Parses the widget for information on the height
|
|
92
|
+
and width of the widget
|
|
93
|
+
"""
|
|
94
|
+
# Read the bob file
|
|
95
|
+
root = objectify.fromstring(str(widget))
|
|
96
|
+
height_element = root.height
|
|
97
|
+
if height_element is not None:
|
|
98
|
+
height = (
|
|
99
|
+
self.default_size if (val := height_element.text) is None else int(val)
|
|
100
|
+
)
|
|
101
|
+
else:
|
|
102
|
+
height = self.default_size
|
|
103
|
+
assert "Could not obtain the size of the widget"
|
|
104
|
+
|
|
105
|
+
width_element = root.width
|
|
106
|
+
if width_element is not None:
|
|
107
|
+
width = (
|
|
108
|
+
self.default_size if (val := width_element.text) is None else int(val)
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
width = self.default_size
|
|
112
|
+
assert "Could not obtain the size of the widget"
|
|
113
|
+
|
|
114
|
+
return (height, width)
|
|
115
|
+
|
|
116
|
+
def _get_widget_position(
|
|
117
|
+
self, object: EmbeddedDisplay | ActionButton
|
|
118
|
+
) -> tuple[int, int]:
|
|
119
|
+
"""
|
|
120
|
+
Parses the widget for information on the y
|
|
121
|
+
and x of the widget
|
|
122
|
+
"""
|
|
123
|
+
# Read the bob file
|
|
124
|
+
root = objectify.fromstring(str(object))
|
|
125
|
+
y_element = root.y
|
|
126
|
+
if y_element is not None:
|
|
127
|
+
y = self.default_size if (val := y_element.text) is None else int(val)
|
|
128
|
+
else:
|
|
129
|
+
y = self.default_size
|
|
130
|
+
assert "Could not obtain the size of the widget"
|
|
131
|
+
|
|
132
|
+
x_element = root.x
|
|
133
|
+
if x_element is not None:
|
|
134
|
+
x = self.default_size if (val := x_element.text) is None else int(val)
|
|
135
|
+
else:
|
|
136
|
+
x = self.default_size
|
|
137
|
+
assert "Could not obtain the size of the widget"
|
|
138
|
+
|
|
139
|
+
return (y, x)
|
|
140
|
+
|
|
141
|
+
# Make groups
|
|
142
|
+
def _get_group_dimensions(self, widget_list: list[EmbeddedDisplay | ActionButton]):
|
|
143
|
+
"""
|
|
144
|
+
Takes in a list of widgets and finds the
|
|
145
|
+
maximum height in the list
|
|
146
|
+
"""
|
|
147
|
+
x_list: list[int] = []
|
|
148
|
+
y_list: list[int] = []
|
|
149
|
+
height_list: list[int] = []
|
|
150
|
+
width_list: list[int] = []
|
|
151
|
+
for widget in widget_list:
|
|
152
|
+
root = objectify.fromstring(str(widget))
|
|
153
|
+
x = root.x
|
|
154
|
+
if x is not None:
|
|
155
|
+
x_list.append(
|
|
156
|
+
self.default_size if (val := x.text) is None else int(val)
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
x_list.append(self.default_size)
|
|
160
|
+
|
|
161
|
+
height = root.height
|
|
162
|
+
if height is not None:
|
|
163
|
+
height_list.append(
|
|
164
|
+
self.default_size if (val := height.text) is None else int(val)
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
height_list.append(self.default_size)
|
|
168
|
+
|
|
169
|
+
width = root.width
|
|
170
|
+
if width is not None:
|
|
171
|
+
width_list.append(
|
|
172
|
+
self.default_size if (val := width.text) is None else int(val)
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
width_list.append(self.default_size)
|
|
176
|
+
|
|
177
|
+
y = root.y
|
|
178
|
+
if y is not None:
|
|
179
|
+
y_list.append(
|
|
180
|
+
self.default_size if (val := y.text) is None else int(val)
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
y_list.append(self.default_size)
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
max(y_list) + max(height_list) + self.group_padding,
|
|
187
|
+
max(x_list) + max(width_list) + self.group_padding,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _create_widget(
|
|
191
|
+
self, component: Entity
|
|
192
|
+
) -> EmbeddedDisplay | ActionButton | None:
|
|
193
|
+
# if statement below is check if the suffix is
|
|
194
|
+
# missing from the component description. If
|
|
195
|
+
# not missing, use as name of widget, if missing,
|
|
196
|
+
# use type as name.
|
|
197
|
+
if component.M is not None:
|
|
198
|
+
name: str = component.M
|
|
199
|
+
suffix: str = component.M
|
|
200
|
+
suffix_label: str | None = self.M
|
|
201
|
+
elif component.R is not None:
|
|
202
|
+
name = component.R
|
|
203
|
+
suffix = component.R
|
|
204
|
+
suffix_label = self.R
|
|
205
|
+
else:
|
|
206
|
+
name = component.type
|
|
207
|
+
suffix = ""
|
|
208
|
+
suffix_label = None
|
|
209
|
+
|
|
210
|
+
base_dir = self.services_dir.parent.parent.parent
|
|
211
|
+
|
|
212
|
+
# Get the relative path to techui-support
|
|
213
|
+
support_path = base_dir.joinpath("src/techui_support")
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
scrn_mapping = self.ibek_map[component.type]
|
|
217
|
+
except KeyError:
|
|
218
|
+
LOGGER.warning(
|
|
219
|
+
f"No available widget for {component.type} in screen \
|
|
220
|
+
{self.screen_name}. Skipping..."
|
|
221
|
+
)
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# Get relative path to screen
|
|
225
|
+
scrn_path = support_path.joinpath(f"bob/{scrn_mapping['file']}")
|
|
226
|
+
LOGGER.debug(f"Screen path: {scrn_path}")
|
|
227
|
+
|
|
228
|
+
# Path of screen relative to data/ so it knows where to open the file from
|
|
229
|
+
data_scrn_path = scrn_path.relative_to(
|
|
230
|
+
self.services_dir.joinpath("synoptic/opis"), walk_up=True
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Get dimensions of screen from TechUI repository
|
|
234
|
+
if scrn_mapping["type"] == "embedded":
|
|
235
|
+
height, width = self._get_screen_dimensions(str(scrn_path))
|
|
236
|
+
new_widget = Widget.EmbeddedDisplay(
|
|
237
|
+
name,
|
|
238
|
+
str(data_scrn_path),
|
|
239
|
+
0,
|
|
240
|
+
0, # Change depending on the order
|
|
241
|
+
width,
|
|
242
|
+
height,
|
|
243
|
+
)
|
|
244
|
+
# Add macros to the widgets
|
|
245
|
+
new_widget.macro(self.P, component.P)
|
|
246
|
+
if suffix_label is not None:
|
|
247
|
+
new_widget.macro(f"{suffix_label}", suffix)
|
|
248
|
+
|
|
249
|
+
# The only other option is for related displays
|
|
250
|
+
else:
|
|
251
|
+
height, width = (40, 100)
|
|
252
|
+
|
|
253
|
+
new_widget = Widget.ActionButton(
|
|
254
|
+
name,
|
|
255
|
+
component.P,
|
|
256
|
+
f"{component.P}:{suffix_label}",
|
|
257
|
+
0,
|
|
258
|
+
0,
|
|
259
|
+
width,
|
|
260
|
+
height,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Add action to action button: to open related display
|
|
264
|
+
if suffix_label is not None:
|
|
265
|
+
new_widget.action_open_display(
|
|
266
|
+
file=str(data_scrn_path),
|
|
267
|
+
target="tab",
|
|
268
|
+
macros={
|
|
269
|
+
"P": component.P,
|
|
270
|
+
f"{suffix_label}": suffix,
|
|
271
|
+
},
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
new_widget.action_open_display(
|
|
275
|
+
file=str(data_scrn_path),
|
|
276
|
+
target="tab",
|
|
277
|
+
macros={
|
|
278
|
+
"P": component.P,
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# For some reason the version of action buttons is 3.0.0?
|
|
283
|
+
new_widget.version("2.0.0")
|
|
284
|
+
|
|
285
|
+
return new_widget
|
|
286
|
+
|
|
287
|
+
def layout_widgets(self, widgets: list[EmbeddedDisplay | ActionButton]):
|
|
288
|
+
group_spacing: int = 30
|
|
289
|
+
max_group_height: int = 800
|
|
290
|
+
spacing_x: int = 20
|
|
291
|
+
spacing_y: int = 30
|
|
292
|
+
# Group tiles by size
|
|
293
|
+
groups: dict[tuple[int, int], list[EmbeddedDisplay | ActionButton]] = (
|
|
294
|
+
defaultdict(list)
|
|
295
|
+
)
|
|
296
|
+
for widget in widgets:
|
|
297
|
+
key = self._get_widget_dimensions(widget)
|
|
298
|
+
|
|
299
|
+
groups[key].append(widget)
|
|
300
|
+
|
|
301
|
+
# Sort groups by width (optional)
|
|
302
|
+
sorted_widgets: list[EmbeddedDisplay | ActionButton] = []
|
|
303
|
+
sorted_groups = sorted(groups.items(), key=lambda g: g[0][0], reverse=True)
|
|
304
|
+
current_x: int = 0
|
|
305
|
+
current_y: int = 0
|
|
306
|
+
column_width: int = 0
|
|
307
|
+
column_levels: list[list[EmbeddedDisplay | ActionButton]] = []
|
|
308
|
+
|
|
309
|
+
for (h, w), group in sorted_groups:
|
|
310
|
+
for widget in group:
|
|
311
|
+
placed = False
|
|
312
|
+
for level in column_levels:
|
|
313
|
+
level_y, _ = self._get_widget_position(level[0])
|
|
314
|
+
_, widget_width = self._get_widget_dimensions(widget)
|
|
315
|
+
level_width = (
|
|
316
|
+
sum(
|
|
317
|
+
(self._get_widget_dimensions(t))[1] + spacing_x
|
|
318
|
+
for t in level
|
|
319
|
+
)
|
|
320
|
+
- spacing_x
|
|
321
|
+
) # Find the width of the row
|
|
322
|
+
if (
|
|
323
|
+
level_y + h <= max_group_height
|
|
324
|
+
and level_width + widget_width <= column_width
|
|
325
|
+
):
|
|
326
|
+
_, width_1 = self._get_widget_dimensions(level[-1])
|
|
327
|
+
_, x_1 = self._get_widget_position(level[-1])
|
|
328
|
+
widget.x(x_1 + width_1 + spacing_x)
|
|
329
|
+
widget.y(level_y)
|
|
330
|
+
level.append(widget)
|
|
331
|
+
placed = True
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
if not placed:
|
|
335
|
+
if current_y + h > max_group_height:
|
|
336
|
+
# Moves to the next column
|
|
337
|
+
current_x += column_width + group_spacing
|
|
338
|
+
current_y = 0
|
|
339
|
+
column_width = 0
|
|
340
|
+
column_levels = []
|
|
341
|
+
# Places widgets in rows in one column
|
|
342
|
+
widget.x(current_x)
|
|
343
|
+
widget.y(current_y)
|
|
344
|
+
column_levels.append([widget])
|
|
345
|
+
current_y += h + spacing_y
|
|
346
|
+
column_width = max(column_width, w)
|
|
347
|
+
sorted_widgets.append(widget)
|
|
348
|
+
|
|
349
|
+
return sorted_widgets
|
|
350
|
+
|
|
351
|
+
def build_groups(self):
|
|
352
|
+
"""
|
|
353
|
+
Create a group to fill with widgets
|
|
354
|
+
"""
|
|
355
|
+
# Create screen
|
|
356
|
+
self.screen_ = Screen.Screen(self.screen_name)
|
|
357
|
+
# Empty widget buffer
|
|
358
|
+
self.widgets = []
|
|
359
|
+
|
|
360
|
+
# create widget and group objects
|
|
361
|
+
|
|
362
|
+
# order is an enumeration of the components, used to list them,
|
|
363
|
+
# and serves as functionality in the math for formatting.
|
|
364
|
+
for component in self.screen_components:
|
|
365
|
+
new_widget = self._create_widget(component=component)
|
|
366
|
+
if new_widget is None:
|
|
367
|
+
continue
|
|
368
|
+
self.widgets.append(new_widget)
|
|
369
|
+
|
|
370
|
+
if self.widgets == []:
|
|
371
|
+
# No widgets found, so just back out
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
self.widgets = self.layout_widgets(self.widgets)
|
|
375
|
+
|
|
376
|
+
# Create a list of dimensions for the groups
|
|
377
|
+
# that will be created.
|
|
378
|
+
height, width = self._get_group_dimensions(self.widgets)
|
|
379
|
+
|
|
380
|
+
self.group = Group(
|
|
381
|
+
self.screen_name,
|
|
382
|
+
0,
|
|
383
|
+
0,
|
|
384
|
+
width,
|
|
385
|
+
height,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
self.group.version("2.0.0")
|
|
389
|
+
self.group.add_widget(self.widgets)
|
|
390
|
+
self.screen_.add_widget(self.group)
|
|
391
|
+
|
|
392
|
+
def write_screen(self, directory: Path):
|
|
393
|
+
"""Write the screen to file"""
|
|
394
|
+
|
|
395
|
+
if self.widgets == []:
|
|
396
|
+
LOGGER.warning(
|
|
397
|
+
f"Could not write screen: {self.screen_name} \
|
|
398
|
+
as no widgets were available"
|
|
399
|
+
)
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
if not directory.exists():
|
|
403
|
+
os.mkdir(directory)
|
|
404
|
+
self.screen_.write_screen(f"{directory}/{self.screen_name}.bob")
|
|
405
|
+
LOGGER.info(f"{self.screen_name}.bob has been created successfully")
|
techui_builder/models.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
BaseModel,
|
|
7
|
+
ConfigDict,
|
|
8
|
+
Field,
|
|
9
|
+
RootModel,
|
|
10
|
+
StringConstraints,
|
|
11
|
+
computed_field,
|
|
12
|
+
field_validator,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Patterns:
|
|
19
|
+
# long: 'bl23b'
|
|
20
|
+
# short: 'b23' (non-branch)
|
|
21
|
+
# branch short: 'j23'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_DLS_PREFIX_RE = re.compile(
|
|
25
|
+
r"""
|
|
26
|
+
^ # start of string
|
|
27
|
+
(?= # lookahead to ensure the following pattern matches
|
|
28
|
+
[A-Za-z0-9-]{13,16} # match 13 to 16 alphanumeric characters or hyphens
|
|
29
|
+
[:A-Za-z0-9]* # match zero or more colons or alphanumeric characters
|
|
30
|
+
[.A-Za-z0-9] # match a dot or alphanumeric character
|
|
31
|
+
)
|
|
32
|
+
(?!.*--) # negative lookahead to ensure no double hyphens
|
|
33
|
+
(?!.*:\..) # negative lookahead to ensure no colon followed by a dot
|
|
34
|
+
( # start of capture group 1
|
|
35
|
+
(?:[A-Za-z0-9]{2,5}-){3} # match 2 to 5 alphanumeric characters followed
|
|
36
|
+
# by a hyphen, repeated 3 times
|
|
37
|
+
[\d]* # match zero or more digits
|
|
38
|
+
[^:]? # match zero or one non-colon character
|
|
39
|
+
)
|
|
40
|
+
(?::([a-zA-Z0-9:]*))? # match zero or one colon followed by zero or more
|
|
41
|
+
# alphanumeric characters or colons (capture group 2)
|
|
42
|
+
(?:\.([a-zA-Z0-9]+))? # match zero or one dot followed by one or more
|
|
43
|
+
# alphanumeric characters (capture group 3)
|
|
44
|
+
$ # end of string
|
|
45
|
+
""",
|
|
46
|
+
re.VERBOSE,
|
|
47
|
+
)
|
|
48
|
+
_LONG_DOM_RE = re.compile(r"^[a-z]{2}\d{2}[a-z]$")
|
|
49
|
+
_SHORT_DOM_RE = re.compile(r"^[a-z]\d{2}$")
|
|
50
|
+
_BRANCH_SHORT_DOM_RE = re.compile(r"^[a-z]\d{2}-\d$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Beamline(BaseModel):
|
|
54
|
+
dom: str = Field(
|
|
55
|
+
description="Domain e.g. 'bl23b' (long), 'b23' (short), or 'j23' (branch short)"
|
|
56
|
+
)
|
|
57
|
+
desc: str = Field(description="Description")
|
|
58
|
+
model_config = ConfigDict(extra="forbid")
|
|
59
|
+
|
|
60
|
+
@field_validator("dom")
|
|
61
|
+
@classmethod
|
|
62
|
+
def normalize_dom(cls, v: str) -> str:
|
|
63
|
+
v = v.strip().lower()
|
|
64
|
+
if _LONG_DOM_RE.fullmatch(v):
|
|
65
|
+
# already long: bl23b
|
|
66
|
+
return v
|
|
67
|
+
if _SHORT_DOM_RE.fullmatch(v):
|
|
68
|
+
# e.g. b23 -> bl23b
|
|
69
|
+
return f"bl{v[1:3]}{v[0]}"
|
|
70
|
+
if _BRANCH_SHORT_DOM_RE.fullmatch(v):
|
|
71
|
+
# e.g. j23 -> bl23j
|
|
72
|
+
return f"bl{v[1:3]}j"
|
|
73
|
+
raise ValueError("Invalid dom. Expected long or short")
|
|
74
|
+
|
|
75
|
+
@computed_field
|
|
76
|
+
@property
|
|
77
|
+
def long_dom(self) -> str:
|
|
78
|
+
# dom is normalized to long already
|
|
79
|
+
return self.dom
|
|
80
|
+
|
|
81
|
+
@computed_field
|
|
82
|
+
@property
|
|
83
|
+
def short_dom(self) -> str:
|
|
84
|
+
# Convert long -> short form: bl23b -> b23, bl23j -> j23
|
|
85
|
+
# long form is 'bl' + digits + tail-letter
|
|
86
|
+
return f"{self.dom[4]}{self.dom[2:4]}"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Component(BaseModel):
|
|
90
|
+
prefix: str
|
|
91
|
+
desc: str | None = None
|
|
92
|
+
extras: list[str] | None = None
|
|
93
|
+
file: str | None = None
|
|
94
|
+
model_config = ConfigDict(extra="forbid")
|
|
95
|
+
|
|
96
|
+
@field_validator("prefix")
|
|
97
|
+
@classmethod
|
|
98
|
+
def _check_prefix(cls, v: str) -> str:
|
|
99
|
+
if not _DLS_PREFIX_RE.match(v):
|
|
100
|
+
raise ValueError(f"prefix '{v}' does not match DLS prefix pattern")
|
|
101
|
+
return v
|
|
102
|
+
|
|
103
|
+
@field_validator("extras")
|
|
104
|
+
@classmethod
|
|
105
|
+
def _check_extras(cls, v: list[str]) -> list[str]:
|
|
106
|
+
for p in v:
|
|
107
|
+
if not _DLS_PREFIX_RE.match(p):
|
|
108
|
+
raise ValueError(f"extras item '{p}' does not match DLS prefix pattern")
|
|
109
|
+
# ensure unique (schema enforces too)
|
|
110
|
+
if len(set(v)) != len(v):
|
|
111
|
+
raise ValueError("extras must contain unique items")
|
|
112
|
+
return v
|
|
113
|
+
|
|
114
|
+
@computed_field
|
|
115
|
+
@property
|
|
116
|
+
def P(self) -> str | None:
|
|
117
|
+
match = re.match(_DLS_PREFIX_RE, self.prefix)
|
|
118
|
+
if match:
|
|
119
|
+
return match.group(1)
|
|
120
|
+
|
|
121
|
+
@computed_field
|
|
122
|
+
@property
|
|
123
|
+
def R(self) -> str | None:
|
|
124
|
+
match = re.match(_DLS_PREFIX_RE, self.prefix)
|
|
125
|
+
if match:
|
|
126
|
+
return match.group(2)
|
|
127
|
+
|
|
128
|
+
@computed_field
|
|
129
|
+
@property
|
|
130
|
+
def attribute(self) -> str | None:
|
|
131
|
+
match = re.match(_DLS_PREFIX_RE, self.prefix)
|
|
132
|
+
if match:
|
|
133
|
+
return match.group(3)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TechUi(BaseModel):
|
|
137
|
+
beamline: Beamline
|
|
138
|
+
components: dict[str, Component]
|
|
139
|
+
model_config = ConfigDict(extra="forbid")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
Ibek mapping models
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
BobPath = Annotated[
|
|
147
|
+
str, StringConstraints(pattern=r"^(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.bob$")
|
|
148
|
+
]
|
|
149
|
+
# Must contain at least one $(NAME) macro
|
|
150
|
+
MacroString = Annotated[
|
|
151
|
+
str,
|
|
152
|
+
StringConstraints(pattern=r"^[A-Za-z0-9_:\-./\s\$\(\)]+$"),
|
|
153
|
+
]
|
|
154
|
+
ScreenType = Literal["embedded", "related"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class GuiComponentEntry(BaseModel):
|
|
158
|
+
file: BobPath
|
|
159
|
+
prefix: MacroString
|
|
160
|
+
type: ScreenType
|
|
161
|
+
model_config = ConfigDict(extra="forbid")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class GuiComponents(RootModel[dict[str, GuiComponentEntry]]):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class Entity(BaseModel):
|
|
169
|
+
type: str
|
|
170
|
+
P: str
|
|
171
|
+
desc: str | None = None
|
|
172
|
+
M: str | None
|
|
173
|
+
R: str | None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
|
|
5
|
+
from techui_builder.models import (
|
|
6
|
+
GuiComponents,
|
|
7
|
+
TechUi,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
SCHEMAS_DIR = Path("schemas")
|
|
11
|
+
SCHEMAS_DIR.mkdir(exist_ok=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_yaml_schema(model_name: str, schema_dict: dict) -> None:
|
|
15
|
+
out = SCHEMAS_DIR / f"{model_name}.schema.yml"
|
|
16
|
+
with out.open("w", encoding="utf-8") as f:
|
|
17
|
+
yaml.safe_dump(schema_dict, f, sort_keys=False)
|
|
18
|
+
print(f"✅ Wrote {out}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def schema_generator() -> None:
|
|
22
|
+
# techui
|
|
23
|
+
tu = TechUi.model_json_schema()
|
|
24
|
+
write_yaml_schema("techui", tu)
|
|
25
|
+
|
|
26
|
+
# ibek_mapping
|
|
27
|
+
ibek = GuiComponents.model_json_schema()
|
|
28
|
+
write_yaml_schema("ibek_mapping", ibek)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: techui-builder
|
|
3
|
+
Version: 0.3.0a1
|
|
4
|
+
Summary: A package for building Phoebus GUIs
|
|
5
|
+
Author-email: Oliver Copping <oliver.copping@diamond.ac.uk>, Adedamola Sode <adedamola.sode@diamond.ac.uk>, Niamh Dougan <niamh.dougan@diamond.ac.uk>
|
|
6
|
+
Project-URL: GitHub, https://github.com/DiamondLightSource/techui-builder
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
10
|
+
Requires-Python: <4.0,>=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
14
|
+
Requires-Dist: phoebusgen>=3.0.0
|
|
15
|
+
Requires-Dist: lxml>=5.4.0
|
|
16
|
+
Requires-Dist: typer>=0.16.0
|
|
17
|
+
Requires-Dist: rich>=14.1.0
|
|
18
|
+
Requires-Dist: pydantic>=2.11.7
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
[](https://github.com/DiamondLightSource/techui-builder/actions/workflows/ci.yml)
|
|
22
|
+
[](https://codecov.io/gh/DiamondLightSource/techui-builder)
|
|
23
|
+
[](https://pypi.org/project/techui-builder)
|
|
24
|
+
[](https://www.apache.org/licenses/LICENSE-2.0)
|
|
25
|
+
|
|
26
|
+
# techui_builder
|
|
27
|
+
|
|
28
|
+
A package for building Phoebus GUIs
|
|
29
|
+
|
|
30
|
+
Techui-builder is a module for building and organising phoebus gui screens using a builder-ibek yaml description of an IOC, with a user created create_gui.yaml file containing a description of the screens the user wants to create.
|
|
31
|
+
|
|
32
|
+
Source | <https://github.com/DiamondLightSource/techui-builder>
|
|
33
|
+
:---: | :---:
|
|
34
|
+
PyPI | `pip install techui-builder`
|
|
35
|
+
Releases | <https://github.com/DiamondLightSource/techui-builder/releases>
|
|
36
|
+
|
|
37
|
+
The process to use this module goes as follows (WIP):
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
1. Docker
|
|
41
|
+
2. VSCode
|
|
42
|
+
3. CS-Studio (Phoebus)
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
1. Clone this module with the `--recursive` flag to pull in [techui-support](git@github.com:DiamondLightSource/techui-support.git).
|
|
46
|
+
2. Open the project using VSCode.
|
|
47
|
+
3. Reopen the project in a container. Make sure you are using the vscode extension: Dev Containers by Microsoft.
|
|
48
|
+
|
|
49
|
+
## Setting Up
|
|
50
|
+
|
|
51
|
+
> [!WARNING]
|
|
52
|
+
> This module currently only works for `example-synoptic/bl23b-services` - use this directory file structure as a guideline.
|
|
53
|
+
|
|
54
|
+
1. Add the beamline `ixx-services` repo to your VSCode workspace, ensuring each IOC service has been converted to the [ibek](git@github.com:epics-containers/ibek.git) format:
|
|
55
|
+
```
|
|
56
|
+
|-- ixx-services
|
|
57
|
+
| |-- services
|
|
58
|
+
| | |-- $(dom)-my-device-01
|
|
59
|
+
| | | |-- config
|
|
60
|
+
| | | | |-- ioc.yaml
|
|
61
|
+
```
|
|
62
|
+
2. Create your handmade synoptic screen in Phoebus and place in `ixx-services/src-bob/$(dom)-synoptic-src.bob`.
|
|
63
|
+
3. Amend any references to `example-synoptic` with the path to your local `ixx-services` - [generate_synoptic.py](example-synoptic/generate_synoptic.py) and [generate.py](src/techui_builder/generate.py).
|
|
64
|
+
4. Construct a `create_gui.yaml` file at the root of `ixx-services` containing all the components from the services:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
beamline:
|
|
68
|
+
dom: {beamline name}
|
|
69
|
+
desc: {beamline description}
|
|
70
|
+
|
|
71
|
+
components:
|
|
72
|
+
{component name}:
|
|
73
|
+
desc: {component description}
|
|
74
|
+
prefix: {PV prefix}
|
|
75
|
+
extras:
|
|
76
|
+
- {extra prefix 1}
|
|
77
|
+
- {extra prefix 2}
|
|
78
|
+
```
|
|
79
|
+
> [!NOTE]
|
|
80
|
+
> `extras` is optional, but allows any embedded screen to be added to make a summary screen e.g. combining all imgs, pirgs and ionps associated with a vacuum space.
|
|
81
|
+
|
|
82
|
+
## Generating Synoptic
|
|
83
|
+
> [!WARNING]
|
|
84
|
+
> Again, this is hardcoded to work for `example-synoptic/bl23b-services` so amend filepaths accordingly.
|
|
85
|
+
|
|
86
|
+
`$ python example-synoptic/generate_synoptic.py`
|
|
87
|
+
|
|
88
|
+
This generates the filled, top level blxxx-synoptic.bob and all component screens inside `ixx-services/services/data`.
|
|
89
|
+
|
|
90
|
+
## Viewing the Synoptic
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
$ module load phoebus
|
|
94
|
+
$ phoebus.sh -resource /path/to/blxxx-synoptic.bob
|
|
95
|
+
```
|