From 012c4f5b9d71c8a9078d2c4012a744439a540a1c Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Mon, 30 Mar 2026 13:47:18 +0000 Subject: [PATCH 01/12] Fix bug with not allowing lowercase log-levels --- src/techui_builder/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index 39ef411..e84b23a 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -53,7 +53,7 @@ def schema_callback(value: bool): def log_level(level: str): logging.basicConfig( - level=level, + level=level.upper(), format="%(message)s", handlers=[RichHandler(omit_repeated_times=False, markup=True)], ) From dcea4064aac155d733cdfe24abf278a76d790e26 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 31 Mar 2026 08:23:48 +0000 Subject: [PATCH 02/12] Tidy main slightly --- src/techui_builder/__main__.py | 90 +++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index e84b23a..c501edf 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -12,6 +12,8 @@ from techui_builder.builder import Builder from techui_builder.schema_generator import schema_generator +logger_ = logging.getLogger(__name__) + app = typer.Typer( pretty_exceptions_show_locals=False, help=""" @@ -59,45 +61,9 @@ def log_level(level: str): ) -# This is the default behaviour when no command provided -@app.callback(invoke_without_command=True) -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."), - ] = None, - version: Annotated[ - bool | None, typer.Option("--version", callback=version_callback) - ] = None, - loglevel: Annotated[ - str, - typer.Option( - "--log-level", - help="Set log level to INFO, DEBUG, WARNING, ERROR or CRITICAL", - case_sensitive=False, - callback=log_level, - ), - ] = "INFO", - schema: Annotated[ - bool | None, - typer.Option( - "--schema", - help="Generate schema for validating techui and ibek-mapping yaml files", - callback=schema_callback, - ), - ] = None, -) -> None: - """Default function called from cmd line tool.""" - - logger_ = logging.getLogger(__name__) - - bob_file = bobfile - - gui = Builder(techui=filename) - +def find_dirs(file_path: Path, beamline: str) -> tuple: # Get the relative path to the techui file from working dir - abs_path = filename.absolute() + abs_path = file_path.absolute() logger_.debug(f"techui.yaml absolute path: {abs_path}") # Get the current working dir @@ -109,7 +75,7 @@ def main( ( ixx_services.relative_to(cwd, walk_up=True) for parent in abs_path.parents - for ixx_services in parent.glob(f"{gui.conf.beamline.location}-services") + for ixx_services in parent.glob(f"{beamline}-services") ), None, ) @@ -122,11 +88,15 @@ def main( synoptic_dir = ixx_services_dir.joinpath("synoptic") logger_.debug(f"synoptic relative path: {synoptic_dir}") + return ixx_services_dir, synoptic_dir + + +def find_bob(bob_file: Path | None, synoptic_dir: 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("index.bob"), + synoptic_dir.glob(default_bobfile), None, ) if bob_file is None: @@ -140,6 +110,46 @@ def main( exit() logger_.debug(f"bob file: {bob_file}") + return bob_file + + +# This is the default behaviour when no command provided +@app.callback(invoke_without_command=True) +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."), + ] = None, + version: Annotated[ + bool | None, typer.Option("--version", callback=version_callback) + ] = None, + loglevel: Annotated[ + str, + typer.Option( + "--log-level", + "-l", + help="Set log level to INFO, DEBUG, WARNING, ERROR or CRITICAL", + case_sensitive=False, + callback=log_level, + ), + ] = "INFO", + schema: Annotated[ + bool | None, + typer.Option( + "--schema", + help="Generate schema for validating techui and ibek-mapping yaml files", + callback=schema_callback, + ), + ] = None, +) -> None: + """Default function called from cmd line tool.""" + + gui = Builder(techui=filename) + + ixx_services_dir, synoptic_dir = find_dirs(filename, gui.conf.beamline.location) + + bob_file = find_bob(bobfile, synoptic_dir) # # Overwrite after initialised to make sure this is picked up gui._services_dir = ixx_services_dir.joinpath("services") # noqa: SLF001 From 1acc6bd86f1e49fed76e906ec876030845a24c38 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 31 Mar 2026 08:26:32 +0000 Subject: [PATCH 03/12] Improve error message --- src/techui_builder/builder.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 0b5c81f..323ce90 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -199,7 +199,10 @@ def _validate_screen(self, screen_name: str): def create_screens(self): """Create the screens for each component in techui.yaml""" if len(self.entities) == 0: - logger_.critical("No ioc entities found, has setup() been run?") + logger_.critical( + "No ioc entities found. \ +This [italic]normally[/italic] suggests an issue with finding ixx-services." + ) exit() # Loop over every component defined in techui.yaml and locate From c7519d725618578d59c623217e880551501a4bda Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 7 Apr 2026 08:56:36 +0000 Subject: [PATCH 04/12] Improve layout of logger messages --- src/techui_builder/builder.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 323ce90..12df389 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -161,8 +161,8 @@ def _extract_services(self): self._extract_entities(ioc_yaml=service.joinpath("config/ioc.yaml")) except OSError: logger_.error( - f"No ioc.yaml file for service: [bold]{service.name}[/bold]. \ -Does it exist?" + f"No ioc.yaml file for service: [bold]{service.name}[/bold]." + " Does it exist?" ) def _extract_entities(self, ioc_yaml: Path): @@ -200,8 +200,8 @@ def create_screens(self): """Create the screens for each component in techui.yaml""" if len(self.entities) == 0: logger_.critical( - "No ioc entities found. \ -This [italic]normally[/italic] suggests an issue with finding ixx-services." + "No ioc entities found. This [italic]normally[/italic]" + " suggests an issue with finding ixx-services." ) exit() @@ -221,8 +221,8 @@ def create_screens(self): for extra_p in component.extras: if extra_p not in self.entities.keys(): logger_.error( - f"Extra prefix {extra_p} for {component_name} does not \ -exist." + f"Extra prefix {extra_p} for {component_name} does not" + " exist." ) continue screen_entities.extend(self.entities[extra_p]) @@ -241,9 +241,9 @@ def create_screens(self): else: logger_.warning( - f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold]\ - set in the component [bold]{component_name}[/bold] does not match any P field in the\ - ioc.yaml files in services" + f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold]" + f"set in the component [bold]{component_name}[/bold] does not match" + " any P field in the ioc.yaml files in services" ) def _generate_json_map(self, screen_path: Path, dest_path: Path) -> JsonMap: From a81cf445894a8d8406dbe1a31560e75ef8c53239 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 7 Apr 2026 08:56:59 +0000 Subject: [PATCH 05/12] Improve error for missing actions group --- src/techui_builder/builder.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 12df389..abb4774 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -490,4 +490,11 @@ def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None: return None except AttributeError: # TODO: Find better way of handling there being no "actions" group - logger_.error(f"Actions group not found in component: {element.text}") + name = element.name + + parent_name = p.name if (p := element.getparent()) is not None else None + + logger_.error( + f"Actions group not found in component [bold]{name}[/bold] on " + f"[bold]{parent_name}[/bold]" + ) From cffbcdb6b6d3a389a3489eb35984fbd4d17ecbce Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 7 Apr 2026 09:38:11 +0000 Subject: [PATCH 06/12] Create Logger class for easier and tidier customisability --- src/techui_builder/__main__.py | 8 ++------ src/techui_builder/_logger.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 src/techui_builder/_logger.py diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index c501edf..6b611de 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -5,9 +5,9 @@ from typing import Annotated import typer -from rich.logging import RichHandler from techui_builder import __version__ +from techui_builder._logger import Logger from techui_builder.autofill import Autofiller from techui_builder.builder import Builder from techui_builder.schema_generator import schema_generator @@ -54,11 +54,7 @@ def schema_callback(value: bool): def log_level(level: str): - logging.basicConfig( - level=level.upper(), - format="%(message)s", - handlers=[RichHandler(omit_repeated_times=False, markup=True)], - ) + Logger(level) def find_dirs(file_path: Path, beamline: str) -> tuple: diff --git a/src/techui_builder/_logger.py b/src/techui_builder/_logger.py new file mode 100644 index 0000000..37f7360 --- /dev/null +++ b/src/techui_builder/_logger.py @@ -0,0 +1,18 @@ +import logging +from dataclasses import dataclass, field + +from rich.logging import RichHandler + + +@dataclass +class Logger: + level: str = field(default="INFO") + + handler = RichHandler(omit_repeated_times=False, markup=True) + + def __post_init__(self): + logging.basicConfig( + level=self.level.upper(), + format="%(message)s", + handlers=[self.handler], + ) From bd2eb7ec982889acf63d3768aacce2190abec75b Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Tue, 7 Apr 2026 09:45:00 +0000 Subject: [PATCH 07/12] Fix tests --- src/techui_builder/builder.py | 1 + tests/test_builder.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index abb4774..606e8d0 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -490,6 +490,7 @@ def _get_action_group(element: ObjectifiedElement) -> ObjectifiedElement | None: return None except AttributeError: # TODO: Find better way of handling there being no "actions" group + # TODO: Do widgets always have a name attr, or _can_ it be empty?? name = element.name parent_name = p.name if (p := element.getparent()) is not None else None diff --git a/tests/test_builder.py b/tests/test_builder.py index a9b59ed..04abde8 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -232,7 +232,10 @@ def test_create_screens_no_entities(builder, caplog): builder.create_screens() for log_output in caplog.records: - assert "No ioc entities found, has setup() been run?" in log_output.message + assert ( + "No ioc entities found. This [italic]normally[/italic]" + " suggests an issue with finding ixx-services." + ) in log_output.message def test_create_screens_extra_p_does_not_exist(builder_with_setup, caplog): @@ -460,6 +463,8 @@ def test_get_action_group_no_action_elements(): def test_get_action_group_no_actions_group(caplog): # Use a blank xml element widget = objectify.ObjectifiedElement() + # TODO: Do widgets always have a name attr, or _can_ it be empty?? + widget.name = "Test" with caplog.at_level(logging.ERROR): _get_action_group(widget) From 19cbb0fe5afe2c69e0dbc4bfc7a6973aed454a9a Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Wed, 8 Apr 2026 08:16:16 +0000 Subject: [PATCH 08/12] Tidy patches in tests (move most to be decorators) --- tests/test_autofiller.py | 113 +++++++++++++++++++-------------------- tests/test_builder.py | 62 ++++++++++----------- tests/test_utils.py | 14 ++--- tests/test_validator.py | 12 ++--- 4 files changed, 100 insertions(+), 101 deletions(-) diff --git a/tests/test_autofiller.py b/tests/test_autofiller.py index 1f70280..dc9d355 100644 --- a/tests/test_autofiller.py +++ b/tests/test_autofiller.py @@ -9,14 +9,14 @@ from techui_builder.models import Component -def test_autofiller_read_bob(autofiller): - # Imported in to autofill from utils, so that needs to be patched - with patch("techui_builder.autofill.read_bob") as mock_read_bob: - mock_read_bob.return_value = (Mock(spec=ElementTree), Mock()) +# 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, autofiller): + mock_read_bob.return_value = (Mock(spec=ElementTree), Mock()) - autofiller.read_bob() + autofiller.read_bob() - mock_read_bob.assert_called() + mock_read_bob.assert_called() def test_autofiller_autofill_bob(autofiller): @@ -34,22 +34,20 @@ def test_autofiller_autofill_bob(autofiller): assert mock_widget.find("run_actions_on_mouse_click") == "true" -def test_autofiller_write_bob(autofiller): - with ( - patch("techui_builder.builder.etree.ElementTree") as mock_tree, - patch("techui_builder.builder.objectify.deannotate") as mock_deannotate, - ): - autofiller.tree = mock_tree +@patch("techui_builder.builder.objectify.deannotate") +@patch("techui_builder.builder.etree.ElementTree") +def test_autofiller_write_bob(mock_tree, mock_deannotate, autofiller): + autofiller.tree = mock_tree - autofiller.write_bob(Path("tests/test_files/test_autofilled_bob.bob")) + autofiller.write_bob(Path("tests/test_files/test_autofilled_bob.bob")) - mock_deannotate.assert_called_once() - mock_tree.write.assert_called_once_with( - Path("tests/test_files/test_autofilled_bob.bob"), - pretty_print=True, - encoding="utf-8", - xml_declaration=True, - ) + mock_deannotate.assert_called_once() + mock_tree.write.assert_called_once_with( + Path("tests/test_files/test_autofilled_bob.bob"), + pretty_print=True, + encoding="utf-8", + xml_declaration=True, + ) @pytest.mark.parametrize( @@ -74,7 +72,9 @@ def test_autofiller_write_bob(autofiller): ), ], ) +@patch("techui_builder.autofill._get_action_group") def test_autofiller_replace_content( + mock_get, autofiller, example_related_widget, prefix, @@ -84,49 +84,48 @@ def test_autofiller_replace_content( expected_desc, expected_file, ): - with patch("techui_builder.autofill._get_action_group") as mock_get: - mock_get.return_value = example_related_widget.actions.action - - # Cannot use a Mock object as need P to be computed - fake_component = Component( - prefix=prefix, - desc=description, - file=filename, - macros=macros, - ) - - autofiller.replace_content( - example_related_widget, - "test_component", - fake_component, - ) - - assert example_related_widget.pv_name == f"{prefix}:STA" - assert example_related_widget.actions.action.description.text == expected_desc - assert example_related_widget.actions.action.file.text == expected_file - if macros is not None: - for k, v in macros.items(): - assert example_related_widget.actions.action.macros[k] == macros[k] == v - - -def test_autofiller_replace_content_no_action_group(autofiller, caplog): + mock_get.return_value = example_related_widget.actions.action + + # Cannot use a Mock object as need P to be computed + fake_component = Component( + prefix=prefix, + desc=description, + file=filename, + macros=macros, + ) + + autofiller.replace_content( + example_related_widget, + "test_component", + fake_component, + ) + + assert example_related_widget.pv_name == f"{prefix}:STA" + assert example_related_widget.actions.action.description.text == expected_desc + assert example_related_widget.actions.action.file.text == expected_file + if macros is not None: + for k, v in macros.items(): + assert example_related_widget.actions.action.macros[k] == macros[k] == v + + +@patch("techui_builder.autofill._get_action_group") +def test_autofiller_replace_content_no_action_group(mock_get, autofiller, caplog): # Just to only run the code we want to test autofiller.macros = ["desc"] - with patch("techui_builder.autofill._get_action_group") as mock_get: - # Simulate no action group found - mock_get.return_value = None + # Simulate no action group found + mock_get.return_value = None - mock_component = Mock( - spec=Component, - desc="description", - ) + mock_component = Mock( + spec=Component, + desc="description", + ) - with caplog.at_level(logging.DEBUG): - autofiller.replace_content(None, "", mock_component) + with caplog.at_level(logging.DEBUG): + autofiller.replace_content(None, "", mock_component) - for log_output in caplog.records: - assert "Skipping replace_content for" in log_output.message + for log_output in caplog.records: + assert "Skipping replace_content for" in log_output.message def test_autofiller_replace_content_unsupported_macro(autofiller): diff --git a/tests/test_builder.py b/tests/test_builder.py index 04abde8..b0f0ec0 100644 --- a/tests/test_builder.py +++ b/tests/test_builder.py @@ -288,20 +288,22 @@ def test_write_json_map(builder): os.remove(dest_path) -def test_generate_json_map(builder_with_test_files, example_json_map, test_files): +# We don't want to access the _get_action_group function in this test +@patch("techui_builder.builder._get_action_group") +def test_generate_json_map( + mock_get_action_group, builder_with_test_files, example_json_map, test_files +): screen_path, dest_path = test_files - # We don't want to access the _get_action_group function in this test - with patch("techui_builder.builder._get_action_group") as mock_get_action_group: - mock_xml = objectify.Element("action") - mock_xml["file"] = "test_child_bob.bob" - mock_get_action_group.return_value = mock_xml + mock_xml = objectify.Element("action") + mock_xml["file"] = "test_child_bob.bob" + mock_get_action_group.return_value = mock_xml - test_json_map = builder_with_test_files._generate_json_map( - screen_path.absolute(), dest_path - ) + test_json_map = builder_with_test_files._generate_json_map( + screen_path.absolute(), dest_path + ) - assert test_json_map == example_json_map + assert test_json_map == example_json_map # TODO: write this test @@ -379,28 +381,26 @@ def test_fix_duplicate_names_recursive(builder, example_display_names_json): assert test_display_names_json == example_display_names_json +# We don't want to access the _get_action_group function in this test +@patch("techui_builder.builder._get_action_group") def test_generate_json_map_get_macros( - builder_with_test_files, example_json_map, test_files + mock_get_action_group, builder_with_test_files, example_json_map, test_files ): screen_path, dest_path = test_files # Set a custom macro to test against example_json_map.children[0].macros = {"macro": "value"} - # We don't want to access the _get_action_group function in this test - with patch("techui_builder.builder._get_action_group") as mock_get_action_group: - mock_xml = objectify.Element("action") - mock_xml["file"] = "test_child_bob.bob" - macros = objectify.SubElement(mock_xml, "macros") - # Set a macro to test - macros["macro"] = "value" - mock_get_action_group.return_value = mock_xml - - test_json_map = builder_with_test_files._generate_json_map( - screen_path, dest_path - ) + mock_xml = objectify.Element("action") + mock_xml["file"] = "test_child_bob.bob" + macros = objectify.SubElement(mock_xml, "macros") + # Set a macro to test + macros["macro"] = "value" + mock_get_action_group.return_value = mock_xml - assert test_json_map == example_json_map + test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) + + assert test_json_map == example_json_map def test_generate_json_map_xml_parse_error(builder_with_test_files, test_files): @@ -412,17 +412,17 @@ def test_generate_json_map_xml_parse_error(builder_with_test_files, test_files): assert test_json_map.error.startswith("XML parse error:") -def test_generate_json_map_other_exception(builder_with_test_files, test_files): +@patch("techui_builder.builder._get_action_group") +def test_generate_json_map_other_exception( + mock_get_action_group, builder_with_test_files, test_files +): screen_path, dest_path = test_files - with patch("techui_builder.builder._get_action_group") as mock_get_action_group: - mock_get_action_group.side_effect = Exception("Some exception") + mock_get_action_group.side_effect = Exception("Some exception") - test_json_map = builder_with_test_files._generate_json_map( - screen_path, dest_path - ) + test_json_map = builder_with_test_files._generate_json_map(screen_path, dest_path) - assert test_json_map.error != "" + assert test_json_map.error != "" def test_serialise_json_map(example_json_map): diff --git a/tests/test_utils.py b/tests/test_utils.py index 4fe352c..1c97092 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,15 +7,15 @@ from techui_builder.utils import get_widgets, read_bob -def test_read_bob(): - with patch("techui_builder.utils.get_widgets") as mock_get_widgets: - mock_get_widgets.return_value = {"test_widget": Mock(spec=ObjectifiedElement)} +@patch("techui_builder.utils.get_widgets") +def test_read_bob(mock_get_widgets): + mock_get_widgets.return_value = {"test_widget": Mock(spec=ObjectifiedElement)} - tree, widgets = read_bob(Path("tests/test_files/index.bob")) + tree, widgets = read_bob(Path("tests/test_files/index.bob")) - assert isinstance(tree, _ElementTree) - assert isinstance(widgets["test_widget"], ObjectifiedElement) - mock_get_widgets.assert_called_once() + assert isinstance(tree, _ElementTree) + assert isinstance(widgets["test_widget"], ObjectifiedElement) + mock_get_widgets.assert_called_once() def test_get_widgets(example_symbol_widget): diff --git a/tests/test_validator.py b/tests/test_validator.py index 08cd74e..fc1204e 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -21,13 +21,13 @@ def test_validator_check_bob(validator): assert list(validator.validate.keys())[0] == "motor-edited" -def test_validator_read_bob(validator): - with patch("techui_builder.validator.read_bob") as mock_read_bob: - # We need to set the spec of the first Mock so it knows - # it has a getroot() function - mock_read_bob.return_value = (Mock(spec=_ElementTree), Mock()) +@patch("techui_builder.validator.read_bob") +def test_validator_read_bob(mock_read_bob, validator): + # We need to set the spec of the first Mock so it knows + # it has a getroot() function + mock_read_bob.return_value = (Mock(spec=_ElementTree), Mock()) - validator._read_bob(validator.bobs[0]) + validator._read_bob(validator.bobs[0]) # TODO: Clean up this test... (make fixture for mock xml?) From a2e4aed031939a5e07dce15c176ac5121aeb592e Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Wed, 8 Apr 2026 13:52:09 +0000 Subject: [PATCH 09/12] Add tests for __main__.py helper and callback functions --- tests/test_cli.py | 108 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7c6416a..edd3fde 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,19 @@ +import logging +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest +import typer from typer.testing import CliRunner -from techui_builder.__main__ import app +from techui_builder.__main__ import ( + app, + default_bobfile, + find_bob, + find_dirs, + log_level, + schema_callback, +) runner = CliRunner() @@ -20,3 +33,96 @@ def test_app_version(): # def test_app_log_level(): # result = runner.invoke(app, ["--log-level", "INFO"]) # assert result.exit_code == 0 + + +@patch("techui_builder.__main__.schema_generator") +def test_schema_callback(mock_schema_generator): + with pytest.raises(typer.Exit): + schema_callback(True) + + +@patch("techui_builder.__main__.Logger") +def test_log_level(mock_logger): + log_level("INFO") + mock_logger.assert_called_once() + + +def test_find_dirs(caplog): + mock_services = MagicMock(spec=Path) + mock_services.relative_to.return_value = Path("mock_rel_path") + mock_parent = MagicMock(spec=Path) + mock_parent.glob.return_value = [mock_services] + mock_absolute = MagicMock() + mock_absolute.parents = [mock_parent] + mock_path = MagicMock(spec=Path) + mock_path.absolute.return_value = mock_absolute + + services, synoptic = find_dirs(mock_path, "ixx") + + assert synoptic == Path("mock_rel_path/synoptic") + + +def test_find_dirs_no_ixx_services_dir(caplog): + test_file = MagicMock(spec=Path) + test_file.parents = [] + + with caplog.at_level(logging.CRITICAL) and pytest.raises(SystemExit) as exc_info: + find_dirs(test_file, "ixx") + + for log_output in caplog.records: + assert "ixx-services not found." in log_output.message + + # The function calls exit() with no value code + assert exc_info.value.code is None + + +def test_find_bob(caplog): + bob_file = Mock(spec=Path) + bob_file.exists = MagicMock(return_value=True) + + with caplog.at_level(logging.DEBUG): + file = find_bob(bob_file, Mock(spec=Path)) + + # It should just return back the same file + assert bob_file == file + + +def test_find_bob_bob_file_does_not_exist(caplog): + 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)) + + for log_output in caplog.records: + assert f"Source bob file '{bad_bob_file}' not found." 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): + mock_bob_file = Path("mock_bob_file") + mock_synoptic_dir = MagicMock(spec=Path) + mock_synoptic_dir.glob.return_value = iter([mock_bob_file]) + + with caplog.at_level(logging.DEBUG): + _ = find_bob(None, mock_synoptic_dir) + + for log_output in caplog.records: + assert f"bob file: {mock_bob_file}" in log_output.message + + +def test_find_bob_no_bob_file_found(caplog): + mock_synoptic_dir = MagicMock(spec=Path) + 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) + + for log_output in caplog.records: + assert ( + f"Source bob file '{default_bobfile}' 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 From 1f845273b0ac0adc741efcd8e36a5c50141d4ef1 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Thu, 9 Apr 2026 09:02:23 +0000 Subject: [PATCH 10/12] Rework to simplify autofiller --- src/techui_builder/__main__.py | 4 ++-- src/techui_builder/autofill.py | 11 ++++++----- tests/conftest.py | 3 ++- tests/test_autofiller.py | 4 +--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/techui_builder/__main__.py b/src/techui_builder/__main__.py index 6b611de..08b6f04 100644 --- a/src/techui_builder/__main__.py +++ b/src/techui_builder/__main__.py @@ -166,9 +166,9 @@ def main( logger_.info(f"Screens generated for {gui.conf.beamline.location}.") - autofiller = Autofiller(bob_file) + autofiller = Autofiller(bob_file, gui.conf.components) autofiller.read_bob() - autofiller.autofill_bob(gui) + autofiller.autofill_bob() dest_bob = gui._write_directory.joinpath("index.bob") # noqa: SLF001 diff --git a/src/techui_builder/autofill.py b/src/techui_builder/autofill.py index e72f899..63b7e68 100644 --- a/src/techui_builder/autofill.py +++ b/src/techui_builder/autofill.py @@ -8,7 +8,7 @@ from lxml.etree import Element, SubElement, tostring from lxml.objectify import ObjectifiedElement, fromstring -from techui_builder.builder import Builder, _get_action_group +from techui_builder.builder import _get_action_group from techui_builder.models import Component from techui_builder.utils import read_bob @@ -18,6 +18,7 @@ @dataclass class Autofiller: path: Path + gui_components: dict[str, Component] macros: list[str] = field( default_factory=lambda: ["prefix", "desc", "file", "macros"] ) @@ -28,21 +29,21 @@ class Autofiller: def read_bob(self) -> None: self.tree, self.widgets = read_bob(self.path) - def autofill_bob(self, gui: "Builder"): + 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 gui.conf.components.keys(): + if symbol_name in self.gui_components.keys(): # Get first copy of component (should only be one) comp = next( - (comp for comp in gui.conf.components if comp == symbol_name), + (comp for comp in self.gui_components if comp == symbol_name), ) self.replace_content( widget=child, component_name=comp, - component=gui.conf.components[comp], + component=self.gui_components[comp], ) # Add option to allow left mouse click to run action diff --git a/tests/conftest.py b/tests/conftest.py index 8ad9d2b..1c05c40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from techui_builder.autofill import Autofiller from techui_builder.builder import Builder, JsonMap from techui_builder.generate import Generator +from techui_builder.models import Component from techui_builder.validator import Validator @@ -120,7 +121,7 @@ def generator(): def autofiller(): index_bob = Path(__file__).parent.joinpath(Path("t01-services/synoptic/index.bob")) - a = Autofiller(index_bob) + a = Autofiller(index_bob, {"test_widget": MagicMock(spec=Component)}) return a diff --git a/tests/test_autofiller.py b/tests/test_autofiller.py index dc9d355..aeae4bc 100644 --- a/tests/test_autofiller.py +++ b/tests/test_autofiller.py @@ -21,14 +21,12 @@ def test_autofiller_read_bob(mock_read_bob, autofiller): def test_autofiller_autofill_bob(autofiller): autofiller.replace_content = Mock() - # This mess of a Mock represents a basic Builder object with a components dict - mock_builder = Mock(conf=Mock(components={"test_widget": Mock(spec=Component)})) mock_widget = Element("widget") autofiller.widgets = {"test_widget": mock_widget} - autofiller.autofill_bob(mock_builder) + autofiller.autofill_bob() autofiller.replace_content.assert_called() assert mock_widget.find("run_actions_on_mouse_click") == "true" From c41cf27497e825871e3de3a349a41870684c6991 Mon Sep 17 00:00:00 2001 From: Ollie Copping Date: Fri, 10 Apr 2026 14:55:11 +0000 Subject: [PATCH 11/12] Add test for main entrypoint function --- tests/test_cli.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index edd3fde..1986be7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ find_bob, find_dirs, log_level, + main, schema_callback, ) @@ -126,3 +127,16 @@ def test_find_bob_no_bob_file_found(caplog): # The function calls exit() with no value code assert exc_info.value.code is None + + +@patch("techui_builder.__main__.find_bob") +@patch("techui_builder.__main__.find_dirs") +@patch("techui_builder.__main__.Autofiller") +@patch("techui_builder.__main__.Builder") +def test_main(mock_builder, mock_autofiller, mock_find_dirs, mock_find_bob): + mock_find_dirs.return_value = Mock(), Mock() + mock_path = Mock(spec=Path) + main(mock_path) + + mock_find_dirs.assert_called_once() + mock_find_bob.assert_called_once() From 24f5f0c66b53b636c924bd86222e31d4f37bf830 Mon Sep 17 00:00:00 2001 From: "Sode, Adedamola (DLSLtd,RAL,LSCI)" Date: Tue, 14 Apr 2026 14:03:38 +0000 Subject: [PATCH 12/12] typo --- src/techui_builder/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/techui_builder/builder.py b/src/techui_builder/builder.py index 606e8d0..1336afb 100644 --- a/src/techui_builder/builder.py +++ b/src/techui_builder/builder.py @@ -241,7 +241,7 @@ def create_screens(self): else: logger_.warning( - f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold]" + f"{self.techui.name}: The prefix [bold]{component.prefix}[/bold] " f"set in the component [bold]{component_name}[/bold] does not match" " any P field in the ioc.yaml files in services" )