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.

Potentially problematic release.


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

@@ -0,0 +1,424 @@
1
+ import logging
2
+ import os
3
+ import re
4
+ from collections import defaultdict
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+ from lxml import objectify
11
+ from phoebusgen import screen as pscreen
12
+ from phoebusgen import widget as pwidget
13
+ from phoebusgen.widget.widgets import ActionButton, EmbeddedDisplay, Group
14
+
15
+ from techui_builder.models import Entity
16
+
17
+ logger_ = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class Generator:
22
+ synoptic_dir: Path = field(repr=False)
23
+ beamline_url: str = field(repr=False)
24
+
25
+ # These are global params for the class (not accessible by user)
26
+ support_path: Path = field(init=False, repr=False)
27
+ techui_support: dict = field(init=False, repr=False)
28
+ default_size: int = field(default=100, init=False, repr=False)
29
+ P: str = field(default="P", init=False, repr=False)
30
+ M: str = field(default="M", init=False, repr=False)
31
+ R: str = field(default="R", init=False, repr=False)
32
+ widgets: list[ActionButton | EmbeddedDisplay] = field(
33
+ default_factory=list[ActionButton | EmbeddedDisplay], init=False, repr=False
34
+ )
35
+ group: Group | None = field(default=None, init=False, repr=False)
36
+
37
+ # Add group padding, and self.widget_x for placing widget in x direction relative to
38
+ # other widgets, with a widget count to reset the self.widget_x dimension when the
39
+ # allowed number of horizontal stacks is exceeded.
40
+ widget_x: int = field(default=0, init=False, repr=False)
41
+ widget_count: int = field(default=0, init=False, repr=False)
42
+ group_padding: int = field(default=50, init=False, repr=False)
43
+
44
+ def __post_init__(self):
45
+ # This needs to be before _read_map()
46
+ self.support_path = self.synoptic_dir.joinpath("techui-support")
47
+
48
+ self._read_map()
49
+
50
+ def _read_map(self):
51
+ """Read the techui-support.yaml file from techui-support."""
52
+ support_yaml = self.support_path.joinpath("techui-support.yaml").absolute()
53
+ logger_.debug(f"techui-support.yaml location: {support_yaml}")
54
+
55
+ with open(support_yaml) as map:
56
+ self.techui_support = yaml.safe_load(map)
57
+
58
+ def _get_screen_dimensions(self, file: str) -> tuple[int, int]:
59
+ """
60
+ Parses the bob files for information on the height
61
+ and width of the screen
62
+ """
63
+ # Read the bob file
64
+ tree = objectify.parse(file)
65
+ root = tree.getroot()
66
+ try:
67
+ height_element = root.height
68
+ height = (
69
+ self.default_size if (val := height_element.text) is None else int(val)
70
+ )
71
+ except AttributeError:
72
+ height = self.default_size
73
+ assert "Could not obtain the height of the widget"
74
+
75
+ try:
76
+ width_element = root.width
77
+ width = (
78
+ self.default_size if (val := width_element.text) is None else int(val)
79
+ )
80
+ except AttributeError:
81
+ width = self.default_size
82
+ assert "Could not obtain the width of the widget"
83
+
84
+ return (height, width)
85
+
86
+ def _get_widget_dimensions(
87
+ self, widget: EmbeddedDisplay | ActionButton
88
+ ) -> tuple[int, int]:
89
+ """
90
+ Parses the widget for information on the height
91
+ and width of the widget
92
+ """
93
+ # Read the bob file
94
+ root = objectify.fromstring(str(widget))
95
+ try:
96
+ height_element = root.height
97
+ height = (
98
+ self.default_size if (val := height_element.text) is None else int(val)
99
+ )
100
+ except AttributeError:
101
+ height = self.default_size
102
+ assert "Could not obtain the size of the widget"
103
+
104
+ try:
105
+ width_element = root.width
106
+ width = (
107
+ self.default_size if (val := width_element.text) is None else int(val)
108
+ )
109
+ except AttributeError:
110
+ width = self.default_size
111
+ assert "Could not obtain the size of the widget"
112
+
113
+ return (height, width)
114
+
115
+ def _get_widget_position(
116
+ self, object: EmbeddedDisplay | ActionButton
117
+ ) -> tuple[int, int]:
118
+ """
119
+ Parses the widget for information on the y
120
+ and x of the widget
121
+ """
122
+ # Read the bob file
123
+ root = objectify.fromstring(str(object))
124
+
125
+ try:
126
+ y_element = root.y
127
+ y = self.default_size if (val := y_element.text) is None else int(val)
128
+ except AttributeError:
129
+ y = self.default_size
130
+ assert "Could not obtain the size of the widget"
131
+
132
+ try:
133
+ x_element = root.x
134
+ x = self.default_size if (val := x_element.text) is None else int(val)
135
+ except AttributeError:
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 and maximum width in the list
146
+ """
147
+ width_list: list[int] = []
148
+ height_list: list[int] = []
149
+ for widget in widget_list:
150
+ y, x = self._get_widget_position(widget)
151
+ height, width = self._get_widget_dimensions(widget)
152
+ comparable_width = x + width
153
+ comparable_height = y + height
154
+ width_list.append(comparable_width)
155
+ height_list.append(comparable_height)
156
+
157
+ return (
158
+ max(height_list) + self.group_padding,
159
+ max(width_list) + self.group_padding,
160
+ )
161
+
162
+ def _initialise_name_suffix(self, component: Entity) -> tuple[str, str, str | None]:
163
+ if component.M is not None:
164
+ name: str = component.M
165
+ suffix: str = component.M
166
+ suffix_label: str | None = self.M
167
+ elif component.R is not None:
168
+ name = component.R
169
+ suffix = component.R
170
+ suffix_label = self.R
171
+ else:
172
+ name = component.P
173
+ suffix = ""
174
+ suffix_label = ""
175
+
176
+ return (name, suffix, suffix_label)
177
+
178
+ def _is_list_of_dicts(self, scrn_mapping: Mapping) -> bool:
179
+ return isinstance(scrn_mapping, Sequence) and all(
180
+ isinstance(scrn, Mapping) for scrn in scrn_mapping
181
+ )
182
+
183
+ def _allocate_widget(
184
+ self, scrn_mapping: Mapping, component: Entity
185
+ ) -> EmbeddedDisplay | ActionButton | None | list[EmbeddedDisplay | ActionButton]:
186
+ name, suffix, suffix_label = self._initialise_name_suffix(component)
187
+ # Get relative path to screen
188
+ scrn_path = self.support_path.joinpath(f"bob/{scrn_mapping['file']}")
189
+ logger_.debug(f"Screen path: {scrn_path}")
190
+
191
+ # Path of screen relative to data/ so it knows where to open the file from
192
+ data_scrn_path = scrn_path.relative_to(self.synoptic_dir, walk_up=True)
193
+
194
+ # For Gui Components with multiple components embedded, we add a suffix field
195
+ # to the components, and adjust the name and suffix accordingly
196
+ try:
197
+ if scrn_mapping["suffix"] is not None:
198
+ suffix: str = scrn_mapping["suffix"]
199
+ match: re.Match[str] | None = re.match(
200
+ r"^\$\(([A-Z])\)\$\(([A-Z])\)$", scrn_mapping["prefix"]
201
+ )
202
+ if match:
203
+ suffix_label: str | None = match.group(2)
204
+ name: str = suffix
205
+ except KeyError:
206
+ pass
207
+
208
+ if scrn_mapping["type"] == "embedded":
209
+ height, width = self._get_screen_dimensions(str(scrn_path))
210
+ new_widget = pwidget.EmbeddedDisplay(
211
+ name.removeprefix(":").removesuffix(":"),
212
+ str(data_scrn_path),
213
+ 0,
214
+ 0, # Change depending on the order
215
+ width,
216
+ height,
217
+ )
218
+ # Add macros to the widgets
219
+ new_widget.macro(self.P, component.P)
220
+ if suffix_label != "":
221
+ new_widget.macro(
222
+ f"{suffix_label}", suffix.removeprefix(":").removesuffix(":")
223
+ )
224
+ # TODO: Change this to pvi_button
225
+ if True:
226
+ new_widget.macro("IOC", f"{self.beamline_url}/{component.P.lower()}")
227
+
228
+ # The only other option is for related displays
229
+ else:
230
+ height, width = (40, 100)
231
+
232
+ new_widget = pwidget.ActionButton(
233
+ name.removeprefix(":").removesuffix(":"),
234
+ name.removeprefix(":").removesuffix(":"),
235
+ "",
236
+ 0,
237
+ 0,
238
+ width,
239
+ height,
240
+ )
241
+
242
+ # Add action to action button: to open related display
243
+ if suffix_label != "":
244
+ new_widget.action_open_display(
245
+ file=str(data_scrn_path),
246
+ target="tab",
247
+ macros={
248
+ "P": component.P,
249
+ f"{suffix_label}": suffix,
250
+ },
251
+ )
252
+ else:
253
+ new_widget.action_open_display(
254
+ file=str(data_scrn_path),
255
+ target="tab",
256
+ macros={
257
+ "P": component.P,
258
+ },
259
+ )
260
+
261
+ # For some reason the version of action buttons is 3.0.0?
262
+ new_widget.version("2.0.0")
263
+ return new_widget
264
+
265
+ def _create_widget(
266
+ self, name: str, component: Entity
267
+ ) -> EmbeddedDisplay | ActionButton | None | list[EmbeddedDisplay | ActionButton]:
268
+ # if statement below is check if the suffix is
269
+ # missing from the component description. If
270
+ # not missing, use as name of widget, if missing,
271
+ # use type as name.
272
+ new_widget = []
273
+
274
+ try:
275
+ scrn_mapping = self.techui_support[component.type]
276
+ except KeyError:
277
+ logger_.warning(
278
+ f"No available widget for {component.type} in screen \
279
+ {name}. Skipping..."
280
+ )
281
+ return None
282
+
283
+ if self._is_list_of_dicts(scrn_mapping):
284
+ for value in scrn_mapping:
285
+ new_widget.append(self._allocate_widget(value, component))
286
+ else:
287
+ new_widget = self._allocate_widget(scrn_mapping, component)
288
+
289
+ return new_widget
290
+
291
+ def layout_widgets(self, widgets: list[EmbeddedDisplay | ActionButton]):
292
+ group_spacing: int = 30
293
+ max_group_height: int = 800
294
+ spacing_x: int = 20
295
+ spacing_y: int = 30
296
+ # Group tiles by size
297
+ groups: dict[tuple[int, int], list[EmbeddedDisplay | ActionButton]] = (
298
+ defaultdict(list)
299
+ )
300
+ for widget in widgets:
301
+ key = self._get_widget_dimensions(widget)
302
+
303
+ groups[key].append(widget)
304
+
305
+ # Sort groups by width (optional)
306
+ sorted_widgets: list[EmbeddedDisplay | ActionButton] = []
307
+ sorted_groups = sorted(groups.items(), key=lambda g: g[0][0], reverse=True)
308
+ current_x: int = 0
309
+ current_y: int = 0
310
+ column_width: int = 0
311
+ column_levels: list[list[EmbeddedDisplay | ActionButton]] = []
312
+
313
+ for (h, w), group in sorted_groups:
314
+ for widget in group:
315
+ placed = False
316
+ for level in column_levels:
317
+ level_y, _ = self._get_widget_position(level[0])
318
+ _, widget_width = self._get_widget_dimensions(widget)
319
+ level_width = (
320
+ sum(
321
+ (self._get_widget_dimensions(t))[1] + spacing_x
322
+ for t in level
323
+ )
324
+ - spacing_x
325
+ ) # Find the width of the row
326
+ if (
327
+ level_y + h <= max_group_height
328
+ and level_width + widget_width <= column_width
329
+ ):
330
+ _, width_1 = self._get_widget_dimensions(level[-1])
331
+ _, x_1 = self._get_widget_position(level[-1])
332
+ widget.x(x_1 + width_1 + spacing_x)
333
+ widget.y(level_y)
334
+ level.append(widget)
335
+ placed = True
336
+ break
337
+
338
+ if not placed:
339
+ if current_y + h > max_group_height:
340
+ # Moves to the next column
341
+ current_x += column_width + group_spacing
342
+ current_y = 0
343
+ column_width = 0
344
+ column_levels = []
345
+ # Places widgets in rows in one column
346
+ widget.x(current_x)
347
+ widget.y(current_y)
348
+ column_levels.append([widget])
349
+ current_y += h + spacing_y
350
+ column_width = max(column_width, w)
351
+ sorted_widgets.append(widget)
352
+
353
+ return sorted_widgets
354
+
355
+ def build_widgets(self, screen_name: str, screen_components: list[Entity]):
356
+ # Empty widget buffer
357
+ self.widgets = []
358
+
359
+ # order is an enumeration of the components, used to list them,
360
+ # and serves as functionality in the math for formatting.
361
+ for component in screen_components:
362
+ new_widget = self._create_widget(name=screen_name, component=component)
363
+ if new_widget is None:
364
+ continue
365
+ if isinstance(new_widget, list):
366
+ self.widgets.extend(new_widget)
367
+ continue
368
+ self.widgets.append(new_widget)
369
+
370
+ def build_groups(self, screen_name: str):
371
+ """
372
+ Create a group to fill with widgets
373
+ """
374
+
375
+ if self.widgets == []:
376
+ # No widgets found, so just back out
377
+ return
378
+
379
+ self.widgets = self.layout_widgets(self.widgets)
380
+ # Create a list of dimensions for the groups
381
+ # that will be created.
382
+ height, width = self._get_group_dimensions(self.widgets)
383
+
384
+ self.group = Group(
385
+ screen_name,
386
+ 0,
387
+ 0,
388
+ width,
389
+ height,
390
+ )
391
+
392
+ # TODO: we shouldn't need this assert; fix
393
+ assert self.group is not None
394
+ self.group.version("2.0.0")
395
+ self.group.add_widget(self.widgets)
396
+
397
+ def build_screen(self, screen_name):
398
+ """
399
+ Build the screen with the widget groups.
400
+ """
401
+ # Create screen
402
+ self.screen_ = pscreen.Screen(screen_name)
403
+
404
+ # TODO: I don't like this
405
+ if self.group is None:
406
+ # No group found, so just back out
407
+ return
408
+
409
+ self.screen_.add_widget(self.group)
410
+
411
+ def write_screen(self, screen_name: str, directory: Path):
412
+ """Write the screen to file"""
413
+
414
+ if self.widgets == []:
415
+ logger_.warning(
416
+ f"Could not write screen: {screen_name} \
417
+ as no widgets were available"
418
+ )
419
+ return
420
+
421
+ if not directory.exists():
422
+ os.mkdir(directory)
423
+ self.screen_.write_screen(f"{directory}/{screen_name}.bob")
424
+ logger_.info(f"{screen_name}.bob has been created successfully")
@@ -0,0 +1,185 @@
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', 'ixx-1'
21
+
22
+
23
+ _DLS_PREFIX_RE = re.compile(
24
+ r"""
25
+ ^ # start of string
26
+ (?= # lookahead to ensure the following pattern matches
27
+ [A-Za-z0-9-]{13,16} # match 13 to 16 alphanumeric characters or hyphens
28
+ [:A-Za-z0-9]* # match zero or more colons or alphanumeric characters
29
+ [.A-Za-z0-9] # match a dot or alphanumeric character
30
+ )
31
+ (?!.*--) # negative lookahead to ensure no double hyphens
32
+ (?!.*:\..) # negative lookahead to ensure no colon followed by a dot
33
+ ( # start of capture group 1
34
+ (?:[A-Za-z0-9]{2,5}-){3} # match 2 to 5 alphanumeric characters followed
35
+ # by a hyphen, repeated 3 times
36
+ [\d]* # match zero or more digits
37
+ [^:]? # match zero or one non-colon character
38
+ )
39
+ (?::([a-zA-Z0-9:]*))? # match zero or one colon followed by zero or more
40
+ # alphanumeric characters or colons (capture group 2)
41
+ (?:\.([a-zA-Z0-9]+))? # match zero or one dot followed by one or more
42
+ # alphanumeric characters (capture group 3)
43
+ $ # end of string
44
+ """,
45
+ re.VERBOSE,
46
+ )
47
+ _LONG_DOM_RE = re.compile(r"^[a-zA-Z]{2}\d{2}[a-zA-Z]$")
48
+ _SHORT_DOM_RE = re.compile(r"^[a-zA-Z]{1}\d{2}(-[0-9]{1})?$")
49
+ _OPIS_URL_RE = re.compile(r"^(https:\/\/)?([a-z0-9]{3}-(?:[0-9]-)?opis(?:.[a-z0-9]*)*)")
50
+
51
+
52
+ class Beamline(BaseModel):
53
+ short_dom: str = Field(description="Short BL domain e.g. b23, ixx-1")
54
+ long_dom: str = Field(description="Full BL domain e.g. bl23b")
55
+ desc: str = Field(description="Description")
56
+ model_config = ConfigDict(extra="forbid")
57
+ url: str = Field(description="URL of ixx-opis")
58
+
59
+ @field_validator("short_dom")
60
+ @classmethod
61
+ def normalize_short_dom(cls, v: str) -> str:
62
+ v = v.strip().lower()
63
+
64
+ if _SHORT_DOM_RE.fullmatch(v):
65
+ # e.g. b23 -> bl23b
66
+ return v
67
+
68
+ raise ValueError("Invalid short dom.")
69
+
70
+ @field_validator("long_dom")
71
+ @classmethod
72
+ def normalize_long_dom(cls, v: str) -> str:
73
+ v = v.strip().lower()
74
+ if _LONG_DOM_RE.fullmatch(v):
75
+ # already long: bl23b
76
+ return v
77
+
78
+ raise ValueError("Invalid long dom.")
79
+
80
+ @field_validator("url")
81
+ @classmethod
82
+ def check_url(cls, url: str) -> str:
83
+ url = url.strip().lower()
84
+ match = _OPIS_URL_RE.match(url)
85
+ if match is not None and match.group(2):
86
+ # url in correct format
87
+ # e.g. t01-opis.diamond.ac.uk
88
+ if not match.group(1):
89
+ # make sure url leads with 'https://'
90
+ # otherwise phoebus treats it as a local file path
91
+ url = f"https://{match.group(2)}"
92
+ return url
93
+
94
+ raise ValueError("Invalid opis URL.")
95
+
96
+
97
+ class Component(BaseModel):
98
+ prefix: str
99
+ desc: str | None = None
100
+ extras: list[str] | None = None
101
+ file: str | None = None
102
+ model_config = ConfigDict(extra="forbid")
103
+
104
+ @field_validator("prefix")
105
+ @classmethod
106
+ def _check_prefix(cls, v: str) -> str:
107
+ if not _DLS_PREFIX_RE.match(v):
108
+ raise ValueError(f"prefix '{v}' does not match DLS prefix pattern")
109
+ return v
110
+
111
+ @field_validator("extras")
112
+ @classmethod
113
+ def _check_extras(cls, v: list[str]) -> list[str]:
114
+ for p in v:
115
+ if not _DLS_PREFIX_RE.match(p):
116
+ raise ValueError(f"extras item '{p}' does not match DLS prefix pattern")
117
+ # ensure unique (schema enforces too)
118
+ if len(set(v)) != len(v):
119
+ raise ValueError("extras must contain unique items")
120
+ return v
121
+
122
+ @computed_field
123
+ @property
124
+ def P(self) -> str | None: # noqa: N802
125
+ match = re.match(_DLS_PREFIX_RE, self.prefix)
126
+ if match:
127
+ return match.group(1)
128
+
129
+ @computed_field
130
+ @property
131
+ def R(self) -> str | None: # noqa: N802
132
+ match = re.match(_DLS_PREFIX_RE, self.prefix)
133
+ if match:
134
+ return match.group(2)
135
+
136
+ @computed_field
137
+ @property
138
+ def attribute(self) -> str | None:
139
+ match = re.match(_DLS_PREFIX_RE, self.prefix)
140
+ if match:
141
+ return match.group(3)
142
+
143
+
144
+ class TechUi(BaseModel):
145
+ beamline: Beamline
146
+ components: dict[str, Component]
147
+ model_config = ConfigDict(extra="forbid")
148
+
149
+
150
+ """
151
+ techui-support mapping models
152
+ """
153
+
154
+ BobPath = Annotated[
155
+ str, StringConstraints(pattern=r"^(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+\.bob$")
156
+ ]
157
+ # Must contain at least one $(NAME) macro
158
+ MacroString = Annotated[
159
+ str,
160
+ StringConstraints(pattern=r"^[A-Za-z0-9_:\-./\s\$\(\)]+$"),
161
+ ]
162
+ ScreenType = Literal["embedded", "related"]
163
+
164
+
165
+ class GuiComponentEntry(BaseModel):
166
+ file: BobPath
167
+ prefix: MacroString
168
+ suffix: MacroString | None = None
169
+ type: ScreenType
170
+ model_config = ConfigDict(extra="forbid")
171
+
172
+
173
+ GuiComponentUnion = list[GuiComponentEntry] | GuiComponentEntry
174
+
175
+
176
+ class GuiComponents(RootModel[dict[str, GuiComponentUnion]]):
177
+ pass
178
+
179
+
180
+ class Entity(BaseModel):
181
+ type: str
182
+ P: str
183
+ desc: str | None = None
184
+ M: str | None
185
+ R: str | None
@@ -0,0 +1,27 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from techui_builder.models import (
5
+ GuiComponents,
6
+ TechUi,
7
+ )
8
+
9
+ SCHEMAS_DIR = Path("schemas")
10
+ SCHEMAS_DIR.mkdir(exist_ok=True)
11
+
12
+
13
+ def write_json_schema(model_name: str, schema_dict: dict) -> None:
14
+ out = SCHEMAS_DIR / f"{model_name}.schema.json"
15
+ with out.open("w", encoding="utf-8") as f:
16
+ json.dump(schema_dict, f, sort_keys=False)
17
+ print(f"✅ Wrote {out}")
18
+
19
+
20
+ def schema_generator() -> None:
21
+ # techui
22
+ tu = TechUi.model_json_schema()
23
+ write_json_schema("techui", tu)
24
+
25
+ # ibek_mapping
26
+ tu_support = GuiComponents.model_json_schema()
27
+ write_json_schema("techui.support", tu_support)
@@ -0,0 +1,32 @@
1
+ from lxml import objectify
2
+ from lxml.objectify import ObjectifiedElement
3
+
4
+
5
+ def read_bob(path):
6
+ # Read the bob file
7
+ tree = objectify.parse(path)
8
+
9
+ # Find the root tag (in this case: <display version="2.0.0">)
10
+ root = tree.getroot()
11
+
12
+ widgets = get_widgets(root)
13
+
14
+ return tree, widgets
15
+
16
+
17
+ def get_widgets(root: ObjectifiedElement):
18
+ widgets: dict[str, ObjectifiedElement] = {}
19
+ # Loop over objects in the xml
20
+ # i.e. every tag below <display version="2.0.0">
21
+ # but not any nested tags below them
22
+ for child in root.iterchildren():
23
+ # If widget is a symbol (i.e. a component)
24
+ if child.tag == "widget" and child.get("type", default=None) in [
25
+ "symbol",
26
+ "group",
27
+ ]:
28
+ name = child.name.text
29
+ assert name is not None
30
+ widgets[name] = child
31
+
32
+ return widgets