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