diff --git a/src/techui_builder/autofill.py b/src/techui_builder/autofill.py index 80e74a50..33f45d25 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -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, diff --git a/src/techui_builder/main_app.py b/src/techui_builder/main_app.py index 275e2be6..d20fbfe0 100644 --- a/src/techui_builder/main_app.py +++ b/src/techui_builder/main_app.py @@ -1,4 +1,5 @@ import logging +import re from pathlib import Path from typing import Annotated @@ -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}) @@ -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 @@ -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, @@ -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 @@ -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}.") diff --git a/src/techui_builder/utils.py b/src/techui_builder/utils.py index 3b5523fd..7393e983 100644 --- a/src/techui_builder/utils.py +++ b/src/techui_builder/utils.py @@ -27,7 +27,7 @@ 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 @@ -35,6 +35,7 @@ def get_widgets(root: ObjectifiedElement): # Get all the widgets inside of the group objects groups_widgets = get_widgets(child) widgets.update(groups_widgets) + return widgets diff --git a/tests/conftest.py b/tests/conftest.py index fc936ec8..d96898b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_autofiller.py b/tests/test_autofiller.py index c015e077..f41be6a9 100644 --- a/tests/test_autofiller.py +++ b/tests/test_autofiller.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path from unittest.mock import MagicMock, Mock, patch import pytest @@ -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") 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( @@ -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", [ diff --git a/tests/test_cli.py b/tests/test_cli.py index a18cc871..080477d2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,9 +11,8 @@ # from techui_builder.main_app import app as main_app from techui_builder.main_app import ( - default_bobfile, - find_bob, find_dirs, + find_index_bobs, log_level, main, ) @@ -132,40 +131,51 @@ def test_find_dirs_jxx_services(caplog: pytest.LogCaptureFixture): assert "ixx-services relative path:" in caplog.text -def test_find_bob(caplog: pytest.LogCaptureFixture): - bob_file = Mock(spec=Path) - bob_file.exists = MagicMock(return_value=True) +def test_find_index_bobs(caplog: pytest.LogCaptureFixture): + mock_bob_file = Mock(spec=Path) + mock_bob_file.name = "index-mock.bob" + mock_bob_file.exists = MagicMock(return_value=True) + + mock_synoptic_dir = MagicMock(spec=Path) + mock_synoptic_dir.iterdir = MagicMock( + spec=Path.iterdir, return_value=[mock_bob_file] + ) with caplog.at_level(logging.DEBUG): - file = find_bob(bob_file, Mock(spec=Path)) + index_bob, bob_files = find_index_bobs(mock_bob_file, mock_synoptic_dir) # It should just return back the same file - assert bob_file == file + assert mock_bob_file == index_bob + assert bob_files == [index_bob] def test_find_bob_bob_file_does_not_exist(caplog: pytest.LogCaptureFixture): bad_bob_file = Path("bad_bob_file") with caplog.at_level(logging.CRITICAL) and pytest.raises(SystemExit) as exc_info: - find_bob(bad_bob_file, Mock(spec=Path)) + find_index_bobs(bad_bob_file, Mock(spec=Path)) for log_output in caplog.records: - assert f"Source bob file '{bad_bob_file}' not found." in log_output.message + assert "There was an issue finding source bob files." in log_output.message # The function calls exit() with no value code assert exc_info.value.code is None def test_find_bob_no_bob_file_finds_default_bob_file(caplog: pytest.LogCaptureFixture): - mock_bob_file = Path("mock_bob_file") + mock_bob_file = MagicMock(spec=Path) + mock_bob_file.name = "index-mock.bob" + mock_synoptic_dir = MagicMock(spec=Path) - mock_synoptic_dir.glob.return_value = iter([mock_bob_file]) + mock_synoptic_dir.iterdir = MagicMock( + spec=Path.iterdir, return_value=[mock_bob_file] + ) with caplog.at_level(logging.DEBUG): - _ = find_bob(None, mock_synoptic_dir) + index_bob, bob_files = find_index_bobs(None, mock_synoptic_dir) - for log_output in caplog.records: - assert f"bob file: {mock_bob_file}" in log_output.message + assert mock_bob_file == index_bob + assert bob_files == [index_bob] def test_find_bob_no_bob_file_found(caplog: pytest.LogCaptureFixture): @@ -173,19 +183,16 @@ def test_find_bob_no_bob_file_found(caplog: pytest.LogCaptureFixture): mock_synoptic_dir.glob.return_value = iter([]) with caplog.at_level(logging.CRITICAL) and pytest.raises(SystemExit) as exc_info: - _ = find_bob(None, mock_synoptic_dir) + _ = find_index_bobs(None, mock_synoptic_dir) for log_output in caplog.records: - assert ( - f"Source bob file '{default_bobfile}' not found in {mock_synoptic_dir}" - in log_output.message - ) + assert f"Source bob file not found in {mock_synoptic_dir}" in log_output.message # The function calls exit() with no value code assert exc_info.value.code is None -@patch("techui_builder.main_app.find_bob") +@patch("techui_builder.main_app.find_index_bobs") @patch("techui_builder.main_app.find_dirs") @patch("techui_builder.main_app.Autofiller") @patch("techui_builder.main_app.Builder") @@ -193,14 +200,15 @@ def test_main( mock_builder: MagicMock, mock_autofiller: MagicMock, mock_find_dirs: MagicMock, - mock_find_bob: MagicMock, + mock_find_index_bobs: MagicMock, ): + mock_index_path = MagicMock(spec=Path) mock_find_dirs.return_value = MagicMock(spec=Path), MagicMock(spec=Path) - mock_path = MagicMock(spec=Path) - main(mock_path) + mock_find_index_bobs.return_value = mock_index_path, [mock_index_path] + main(mock_index_path) mock_find_dirs.assert_called_once() - mock_find_bob.assert_called_once() + mock_find_index_bobs.assert_called_once() def test_main_json_map_no_bob_generation(caplog: pytest.LogCaptureFixture):