From aa16234e7fa3bb6ce951ab5cb36b9b40829b9365 Mon Sep 17 00:00:00 2001 From: Jonathon Broughton <760691+jsdbroughton@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:21:59 +0000 Subject: [PATCH] feat(automate): allow automation results with no affected objects (#488) * allow empty affected objects * adds unit tests for `attach_result_to_objects` method Introduces tests for handling empty object lists and objects with IDs. Enhances error handling for cases where objects lack IDs, ensuring robustness in the functionality. Confirms that the method correctly appends results under various scenarios. * line length --- src/speckle_automate/automation_context.py | 18 +-- .../test_attach_result_to_objects.py | 110 ++++++++++++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/unit/speckle_automate/test_attach_result_to_objects.py diff --git a/src/speckle_automate/automation_context.py b/src/speckle_automate/automation_context.py index 04994c1..4f2c6cd 100644 --- a/src/speckle_automate/automation_context.py +++ b/src/speckle_automate/automation_context.py @@ -493,29 +493,29 @@ class AutomationContext: Args: level: Result level. category (str): A short tag for the event type. - affected_objects (Union[Base, List[Base]]): A single object or a list of - objects that are causing the info case. + affected_objects (Union[Base, List[Base]]): A single object, a list of + objects, or an empty list. When empty, a result case is still + appended with no object IDs (e.g. for skipped rules or version-level + messages). message (Optional[str]): Optional message. metadata: User provided metadata key value pairs visual_overrides: Case specific 3D visual overrides. """ if isinstance(affected_objects, list): - if len(affected_objects) < 1: - raise ValueError( - f"Need atleast one object to report a(n) {level.value.upper()}" - ) object_list = affected_objects else: object_list = [affected_objects] ids: Dict[str, Optional[str]] = {} + # When objects are provided, each must have an id (empty list allowed for + # version-level/skipped results). for o in object_list: - # validate that the Base.id is not None. If its a None, throw an Exception - if not o.id: + if not getattr(o, "id", None): raise Exception( f"You can only attach {level} results to objects with an id." ) - ids[o.id] = o.applicationId + ids[o.id] = getattr(o, "applicationId", None) + print( f"Created new {level.value.upper()}" f" category: {category} caused by: {message}" diff --git a/tests/unit/speckle_automate/test_attach_result_to_objects.py b/tests/unit/speckle_automate/test_attach_result_to_objects.py new file mode 100644 index 0000000..a5b5612 --- /dev/null +++ b/tests/unit/speckle_automate/test_attach_result_to_objects.py @@ -0,0 +1,110 @@ +"""Unit tests for AutomationContext.attach_result_to_objects contract.""" + +from unittest.mock import MagicMock + +import pytest + +from speckle_automate import AutomationContext +from speckle_automate.schema import ( + AutomationRunData, + ObjectResultLevel, + VersionCreationTrigger, + VersionCreationTriggerPayload, +) +from specklepy.objects.base import Base + + +def _minimal_automation_context() -> AutomationContext: + run_data = AutomationRunData( + project_id="p", + speckle_server_url="http://localhost", + automation_id="a", + automation_run_id="r", + function_run_id="f", + triggers=[ + VersionCreationTrigger( + trigger_type="versionCreation", + payload=VersionCreationTriggerPayload(model_id="m", version_id="v"), + ) + ], + ) + return AutomationContext( + automation_run_data=run_data, + speckle_client=MagicMock(), + _server_transport=MagicMock(), + _speckle_token="", + ) + + +def test_attach_result_to_objects_accepts_empty_list() -> None: + """Empty affected_objects appends one result case with no object IDs.""" + ctx = _minimal_automation_context() + assert len(ctx._automation_result.object_results) == 0 + + ctx.attach_result_to_objects( + ObjectResultLevel.WARNING, + "SkippedRule", + [], + message="No elements to check.", + ) + + assert len(ctx._automation_result.object_results) == 1 + case = ctx._automation_result.object_results[0] + assert case.level == ObjectResultLevel.WARNING + assert case.category == "SkippedRule" + assert case.object_app_ids == {} + assert case.message == "No elements to check." + + +def test_attach_result_to_objects_with_objects_appends_case_with_ids() -> None: + """Single or multiple objects with id produce result case with object_app_ids.""" + ctx = _minimal_automation_context() + obj1 = Base() + obj1.id = "id-one" + obj1.applicationId = "app-one" + obj2 = Base() + obj2.id = "id-two" + + ctx.attach_result_to_objects( + ObjectResultLevel.ERROR, + "BadType", + [obj1, obj2], + message="Invalid type.", + ) + + assert len(ctx._automation_result.object_results) == 1 + case = ctx._automation_result.object_results[0] + assert case.level == ObjectResultLevel.ERROR + assert case.category == "BadType" + assert case.object_app_ids == {"id-one": "app-one", "id-two": None} + assert case.message == "Invalid type." + + +def test_attach_result_to_objects_raises_when_object_has_no_id() -> None: + """At least one object without id raises.""" + ctx = _minimal_automation_context() + obj = Base() + obj.id = None + + with pytest.raises(Exception, match="results to objects with an id"): + ctx.attach_result_to_objects( + ObjectResultLevel.ERROR, + "Bad", + obj, + message="No id.", + ) + + assert len(ctx._automation_result.object_results) == 0 + + +def test_attach_info_to_objects_accepts_empty_list() -> None: + """attach_info_to_objects (convenience method) also accepts empty list.""" + ctx = _minimal_automation_context() + + ctx.attach_info_to_objects("VersionLevel", [], message="No levels in model.") + + assert len(ctx._automation_result.object_results) == 1 + case = ctx._automation_result.object_results[0] + assert case.level == ObjectResultLevel.INFO + assert case.category == "VersionLevel" + assert case.object_app_ids == {}