Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 72 additions & 37 deletions src/techui_builder/autofill.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,104 @@
import logging
import os
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path

from lxml import objectify
from lxml.etree import Element, SubElement, tostring
from lxml.etree import Element, ElementTree, SubElement, tostring
from lxml.objectify import ObjectifiedElement, fromstring

from techui_builder.models import Component
from techui_builder.utils import _get_action_group, read_bob
from techui_builder.utils import _get_action_group, _get_nav_tabs, read_bob

logger_ = logging.getLogger(__name__)

WidgetDict = dict[str, ObjectifiedElement]
TreeWidgetDictTuple = tuple[ElementTree, WidgetDict]
IndexObjectDict = dict[Path, TreeWidgetDictTuple]


@dataclass
class Autofiller:
path: Path
index_paths: list[Path]
base_index_path: Path
gui_components: dict[str, Component]
macros: list[str] = field(
default_factory=lambda: ["prefix", "desc", "file", "macros"]
)
widgets: dict[str, ObjectifiedElement] = field(
default_factory=defaultdict, init=False, repr=False
)

def read_bob(self) -> None:
self.tree, self.widgets = read_bob(self.path)

def autofill_bob(self):
# Get names from component list

for symbol_name, child in self.widgets.items():
# If the name exists in the component list
if symbol_name in self.gui_components.keys():
# Get first copy of component (should only be one)
comp = next(
(comp for comp in self.gui_components if comp == symbol_name),
)

self.replace_content(
widget=child,
component_name=comp,
component=self.gui_components[comp],
)

# Add option to allow left mouse click to run action
child["run_actions_on_mouse_click"] = "true"

def write_bob(self, filename: Path):
index_trees: IndexObjectDict = field(default_factory=dict, init=False, repr=False)

def read_bobs(self) -> None:
for path in self.index_paths:
tree, widget_dict = read_bob(path)
self.index_trees[path] = (tree, widget_dict)

def autofill_bobs(self) -> None:
self._autofill_from_path(self.base_index_path)

def _autofill_from_path(self, path: Path):
tree, widgets = self.index_trees[path]

logger_.debug(f"Autofilling screen: {path.name}")

for widget_name, widget in widgets.items():
match widget.get("type", default=None):
case "navtabs":
logger_.debug(
f"Navtabs widget found on {path.name}. Autofilling the tabs..."
)
tabs = _get_nav_tabs(widget)
if tabs is not None:
for tab in tabs:
if tab.file.text is None:
continue
file_path = Path(tab.file.text)
if file_path.suffix != ".bob":
continue
root = tree.getroot()
if root.base is None:
continue
root_dir = Path(root.base).parent
resolved_path = root_dir / file_path
if resolved_path in self.index_trees:
self._autofill_from_path(resolved_path)
else:
if resolved_path.exists():
nav_tree, nav_widgets = read_bob(resolved_path)
self.index_trees[resolved_path] = (
nav_tree,
nav_widgets,
)
self._autofill_from_path(resolved_path)
case _:
if widget_name in self.gui_components:
self.replace_content(
widget=widget,
component_name=widget_name,
component=self.gui_components[widget_name],
)
widget["run_actions_on_mouse_click"] = "true"

def _write_bob(self, path: Path, tree: ElementTree) -> None:
# tree, _ = self.index_trees[path]
# Check if data/ dir exists and if not, make it
data_dir = filename.parent
data_dir = path.parent
if not data_dir.exists():
os.mkdir(data_dir)

# Remove any unnecessary xmlns:py and py:pytype metadata from tags
objectify.deannotate(self.tree, cleanup_namespaces=True)
objectify.deannotate(tree, cleanup_namespaces=True)

self.tree.write(
filename,
tree.write(
path,
pretty_print=True,
encoding="utf-8",
xml_declaration=True,
)
logger_.debug(f"Screen filled for {filename}")
logger_.debug(f"Screen filled for {path}")

def write_bobs(self) -> None:
for path, (tree, _) in self.index_trees.items():
self._write_bob(path, tree)

def replace_content(
self,
Expand Down
65 changes: 43 additions & 22 deletions src/techui_builder/main_app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re
from pathlib import Path
from typing import Annotated

Expand All @@ -10,7 +11,7 @@

logger_ = logging.getLogger(__name__)

default_bobfile = "index.bob"
_DEFAULT_BOBFILE_RE = re.compile(r"index(?:-(?:\w)*)*\.bob")


app = typer.Typer(context_settings={"allow_interspersed_args": True})
Expand Down Expand Up @@ -70,26 +71,46 @@ def find_dirs(file_path: Path, beamline: str) -> tuple:
return ixx_services_dir, synoptic_dir


def find_bob(bob_file: Path | None, synoptic_dir: Path):
def find_index_bobs(
bob_file: Path | None, synoptic_dir: Path
) -> tuple[Path, list[Path]]:
if bob_file is None:
# Search default relative dir to techui filename
# There will only ever be one file, but if not return None
bob_file = next(
synoptic_dir.glob(default_bobfile),
None,
# There should be at least one file, but if not return None
bob_files = [
p for p in synoptic_dir.iterdir() if _DEFAULT_BOBFILE_RE.match(p.name)
]
if not bob_files:
logging.critical(
f"Source bob file not found in {synoptic_dir}. Does it exist?"
)
exit()
elif bob_file.exists():
# Search for bob files with similar names
_SIMILAR_BOBFILE_RE = re.compile( # noqa: N806
rf"{bob_file.name.removesuffix('.bob')}(?:-(?:\w)*)*\.bob"
)
if bob_file is None:
bob_files = [
p for p in synoptic_dir.iterdir() if _SIMILAR_BOBFILE_RE.match(p.name)
]
if not bob_files:
logging.critical(
f"Source bob file '{default_bobfile}' not found in \
{synoptic_dir}. Does it exist?"
f"Source bob file not found in {synoptic_dir}. Does it exist?"
)
exit()
elif not bob_file.exists():
logging.critical(f"Source bob file '{bob_file}' not found. Does it exist?")
else:
logging.critical("There was an issue finding source bob files. Do they exist?")
exit()

logger_.debug(f"bob file: {bob_file}")
return bob_file
index_bob = (
bob_file
if bob_file
else next(
(f for f in bob_files if f.name == "index.bob"),
bob_files[0],
)
)
return index_bob, bob_files


# This is the 'build' behaviour
Expand All @@ -98,7 +119,10 @@ def main(
filename: Annotated[Path, typer.Argument(help="The path to techui.yaml")],
bobfile: Annotated[
Path | None,
typer.Argument(help="Override for template bob file location."),
typer.Argument(
help="Override for template bob file location. This will be used to find"
" and other template bob files in the same location with similar names."
),
] = None,
loglevel: Annotated[
str,
Expand All @@ -117,7 +141,7 @@ def main(

ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.domain)

bob_file = find_bob(bobfile, synoptic_dir)
index_bob_path, bob_files = find_index_bobs(bobfile, synoptic_dir)

# # Overwrite after initialised to make sure this is picked up
gui._services_dir = ixx_services_dir / "services" # noqa: SLF001
Expand All @@ -137,12 +161,9 @@ def main(

logger_.info(f"Screens generated for {gui.conf.beamline.domain}.")

autofiller = Autofiller(bob_file, gui.conf.components)
autofiller.read_bob()
autofiller.autofill_bob()

dest_bob = gui._write_directory / "index.bob" # noqa: SLF001

autofiller.write_bob(dest_bob)
autofiller = Autofiller(bob_files, index_bob_path, gui.conf.components)
autofiller.read_bobs()
autofiller.autofill_bobs()
autofiller.write_bobs()

logger_.info(f"Screens autofilled for {gui.conf.beamline.domain}.")
3 changes: 2 additions & 1 deletion src/techui_builder/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,15 @@ def get_widgets(root: ObjectifiedElement):
# If widget is a symbol (i.e. a component)
if child.tag == "widget":
match child.get("type", default=None):
case "action_button" | "symbol":
case "action_button" | "symbol" | "navtabs":
name = child.name.text
assert name is not None
widgets[name] = child
case "group":
# Get all the widgets inside of the group objects
groups_widgets = get_widgets(child)
widgets.update(groups_widgets)

return widgets


Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ def generator(techui_support, tmp_t01_services):
def autofiller(tmp_t01_services):
index_bob = tmp_t01_services / "synoptic/index.bob"

a = Autofiller(index_bob, {"test_widget": MagicMock(spec=Component)})
a = Autofiller([index_bob], index_bob, {"test_widget": MagicMock(spec=Component)})

return a

Expand Down
63 changes: 54 additions & 9 deletions tests/test_autofiller.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch

import pytest
Expand All @@ -10,35 +11,70 @@

# Imported in to autofill from utils, so that needs to be patched
@patch("techui_builder.autofill.read_bob")
def test_autofiller_read_bob(mock_read_bob: MagicMock, autofiller):
def test_autofiller_read_bobs(mock_read_bob: MagicMock, autofiller):
mock_read_bob.return_value = (Mock(spec=ElementTree), Mock())

autofiller.read_bob()
autofiller.read_bobs()

mock_read_bob.assert_called()


def test_autofiller_autofill_bob(autofiller):
def test_autofiller_autofill_bobs(autofiller):
autofiller._autofill_from_path = Mock()

autofiller.autofill_bobs()

autofiller._autofill_from_path.assert_called_once()


def test_autofiller_autofill_from_path_no_navtabs(autofiller):
autofiller.replace_content = Mock()

mock_path = MagicMock(spec=Path)
mock_widget = Element("widget")
mock_widget.type = "symbol"
widgets = {"test_widget": mock_widget}
mock_index_trees = {mock_path: (MagicMock(), widgets)}

autofiller.widgets = {"test_widget": mock_widget}
autofiller.index_trees = mock_index_trees

autofiller.autofill_bob()
autofiller._autofill_from_path(mock_path)

autofiller.replace_content.assert_called()
assert mock_widget.find("run_actions_on_mouse_click") == "true"


def test_autofiller_autofill_from_path_with_navtabs(
autofiller, example_xml_navtabs_widget, caplog
):
autofiller.replace_content = Mock()

mock_path = MagicMock(spec=Path)
mock_widget = example_xml_navtabs_widget
widgets = {"test_widget": mock_widget}
mock_index_trees = {mock_path: (MagicMock(), widgets)}

autofiller.index_trees = mock_index_trees

with caplog.at_level(logging.DEBUG):
autofiller._autofill_from_path(mock_path)

assert any(
log_output.message.startswith("Navtabs widget found on")
for log_output in caplog.records
)

# The example navtab widget doesn't have any widgets in self.gui_components,
# so replace_content() doesn't get called
autofiller.replace_content.assert_not_called()


@patch("techui_builder.autofill.objectify.deannotate")
@patch("lxml.etree.ElementTree")
@patch("techui_builder.autofill.ElementTree")
Comment thread
OCopping marked this conversation as resolved.
def test_autofiller_write_bob(
mock_tree: MagicMock, mock_deannotate: MagicMock, autofiller, tmp_test_files
):
autofiller.tree = mock_tree

autofiller.write_bob(tmp_test_files / "test_autofilled_bob.bob")
autofiller._write_bob(tmp_test_files / "test_autofilled_bob.bob", mock_tree)

mock_deannotate.assert_called_once()
mock_tree.write.assert_called_once_with(
Expand All @@ -49,6 +85,15 @@ def test_autofiller_write_bob(
)


def test_autofiller_write_bobs(autofiller):
autofiller._write_bob = Mock()
autofiller.index_trees = {MagicMock(spec=Path): (MagicMock(), MagicMock())}

autofiller.write_bobs()

autofiller._write_bob.assert_called_once()


@pytest.mark.parametrize(
"prefix, description, filename, macros, expected_desc, expected_file",
[
Expand Down
Loading
Loading