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