Compare commits

..

8 Commits

Author SHA1 Message Date
Jedd Morgan f4863a89d8 Trying my best to clean up the mess 2026-04-14 18:06:03 +01:00
Jedd Morgan 856f12e57c Don't type check unions recursivly 2026-04-14 16:05:27 +01:00
Jedd Morgan 10e639d19a fail fast false 2026-04-07 20:24:50 +01:00
Jedd Morgan c209cdaec4 test public too 2026-04-07 18:51:17 +01:00
Jedd Morgan 169dd00fac Skip broken test 2026-04-07 18:50:34 +01:00
Jonathon Broughton 58190c378a Add Python version 3.14 to CI workflow 2026-03-30 18:18:31 +01:00
Jonathon Broughton aa16234e7f 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
2026-02-24 20:21:59 +00:00
Jonathon Broughton c1f82fa0d2 fix(tests): Update broken test cases for StreamWrapper URLs (#489)
* Update test cases for StreamWrapper URLs

* Update branch name in StreamWrapper test

* Update project URLs in test_wrapper.py

* Uncomment URLs in test_to_string function

Uncommented specific URLs in the test case to enable testing.
2026-02-23 11:29:35 +01:00
9 changed files with 172 additions and 57 deletions
+4
View File
@@ -13,12 +13,14 @@ jobs:
name: Test (internal)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
steps:
- uses: actions/checkout@v4
@@ -62,12 +64,14 @@ jobs:
env:
IS_PUBLIC: "true"
strategy:
fail-fast: false
matrix:
python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
steps:
- uses: actions/checkout@v4
+9 -9
View File
@@ -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}"
+31 -30
View File
@@ -1,7 +1,7 @@
import contextlib
from dataclasses import dataclass, field
from enum import Enum
from inspect import isclass
from types import UnionType
from typing import (
Any,
ClassVar,
@@ -13,11 +13,13 @@ from typing import (
Tuple,
Type,
Union,
get_origin,
get_type_hints,
)
from warnings import warn
from pydantic.alias_generators import to_pascal
from typing_extensions import get_args
from specklepy.logging.exceptions import SpeckleException
from specklepy.transports.memory import MemoryTransport
@@ -220,32 +222,28 @@ def _validate_type(t: Optional[type], value: Any) -> Tuple[bool, Any]:
if value in t._value2member_map_:
return True, t(value)
if getattr(t, "__module__", None) == "typing":
if isinstance(t, ForwardRef):
return True, value
if isinstance(t, ForwardRef):
return True, value
origin = t.__origin__
# below is what in nicer for >= py38
# origin = get_origin(t)
if getattr(t, "__module__", None) in ["typing", "types"]:
origin = get_origin(t)
args = get_args(t)
# recursive validation for Unions on both types preferring the fist type
if origin is Union:
# below is what in nicer for >= py38
# t_1, t_2 = get_args(t)
args = t.__args__ # type: ignore
if origin is Union or isinstance(t, UnionType):
for arg_t in args:
t_success, t_value = _validate_type(arg_t, value)
if t_success:
return True, t_value
ok, v = _validate_type(arg_t, value)
if ok:
return True, v
return False, value
if origin is dict:
if not isinstance(value, dict):
return False, value
if value == {}:
if not value:
return True, value
if not getattr(t, "__args__", None):
if not args:
return True, value
t_key, t_value = t.__args__ # type: ignore
t_key, t_value = args
if (
getattr(t_key, "__name__", None),
@@ -265,11 +263,11 @@ def _validate_type(t: Optional[type], value: Any) -> Tuple[bool, Any]:
if origin is list:
if not isinstance(value, list):
return False, value
if value == []:
if not value:
return True, value
if not hasattr(t, "__args__"):
if not args:
return True, value
t_items = t.__args__[0] # type: ignore
t_items = args[0]
if getattr(t_items, "__name__", None) == "T":
return True, value
first_item_valid, _ = _validate_type(t_items, value[0])
@@ -280,10 +278,10 @@ def _validate_type(t: Optional[type], value: Any) -> Tuple[bool, Any]:
if origin is tuple:
if not isinstance(value, tuple):
return False, value
if not hasattr(t, "__args__"):
if not args:
return True, value
args = t.__args__ # type: ignore
if args == tuple():
if not args:
return True, value
# we're not checking for empty tuple, cause tuple lengths must match
if len(args) != len(value):
@@ -299,7 +297,7 @@ def _validate_type(t: Optional[type], value: Any) -> Tuple[bool, Any]:
if origin is set:
if not isinstance(value, set):
return False, value
if not hasattr(t, "__args__"):
if not args:
return True, value
t_items = t.__args__[0] # type: ignore
first_item_valid, _ = _validate_type(t_items, next(iter(value)))
@@ -310,13 +308,16 @@ def _validate_type(t: Optional[type], value: Any) -> Tuple[bool, Any]:
if isinstance(value, t):
return True, value
with contextlib.suppress(ValueError, TypeError):
if t is float and value is not None:
return True, float(value)
# TODO: dafuq, i had to add this not list check
# but it would also fail for objects and other complex values
if t is str and value and not isinstance(value, list):
return True, str(value)
if t is float and type(value) is int:
return True, float(value)
# with contextlib.suppress(ValueError, TypeError):
# if t is float and value is not None:
# return True, float(value)
# # TODO: dafuq, i had to add this not list check
# # but it would also fail for objects and other complex values
# if t is str and value and not isinstance(value, list):
# return True, str(value)
return False, value
@@ -264,6 +264,7 @@ class TestIngestionResource:
with pytest.raises(GraphQLException):
_ = client.model_ingestion.fail_with_error(input)
@pytest.mark.skip(reason="TEST FAILS - server behaviour was changed")
def test_complete_non_existent_root_object(
self, client: SpeckleClient, ingestion: ModelIngestion, project: Project
):
+9 -9
View File
@@ -157,10 +157,10 @@ def test_parse_project():
def test_parse_model():
wrap = StreamWrapper(
"https://latest.speckle.systems/projects/843d07eb10/models/d9eb4918c8"
"https://app.speckle.systems/projects/8be1007be1/models/cc7578012d"
)
assert wrap.branch_name == "building wrapper"
assert wrap.branch_name == "speckle tower revit 2025"
assert wrap.type == "branch"
@@ -191,10 +191,10 @@ def test_parse_object_fe2():
def test_parse_version():
wrap = StreamWrapper(
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838@c42d5cbac1"
"https://app.speckle.systems/projects/8be1007be1/models/cc7578012d@7199443eff"
)
wrap_quoted = StreamWrapper(
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838%40c42d5cbac1"
"https://app.speckle.systems/projects/8be1007be1/models/cc7578012d%407199443eff"
)
assert wrap.type == "commit"
assert wrap_quoted.type == "commit"
@@ -208,11 +208,11 @@ def test_to_string():
"https://testing.speckle.dev/streams/0c6ad366c4/globals/abd3787893",
"https://testing.speckle.dev/streams/4c3ce1459c/commits/8b9b831792",
"https://testing.speckle.dev/streams/a75ab4f10f/objects/5530363e6d51c904903dafc3ea1d2ec6",
"https://latest.speckle.systems/projects/843d07eb10",
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838",
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838@c42d5cbac1",
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838%40c42d5cbac1",
"https://latest.speckle.systems/projects/24c3741255/models/b48d1b10f5a732f4ca4144286391282c",
"https://app.speckle.systems/projects/8be1007be1",
"https://app.speckle.systems/projects/8be1007be1/models/cc7578012d",
"https://app.speckle.systems/projects/8be1007be1/models/cc7578012d@7199443eff",
"https://app.speckle.systems/projects/8be1007be1/models/cc7578012d%407199443eff",
"https://app.speckle.systems/projects/8be1007be1/models/9b5e57dca804a923a8d42d55dcc0191a",
]
for url in urls:
wrap = StreamWrapper(url)
+2 -2
View File
@@ -35,7 +35,7 @@ def sample_text_all_properties(sample_point: Point, sample_plane: Plane) -> Text
alignmentH=AlignmentHorizontal.Center,
alignmentV=AlignmentVertical.Center,
plane=sample_plane,
maxWidth=20,
maxWidth=20.0,
units=Units.m,
)
@@ -56,7 +56,7 @@ def test_text_creation_minimal(sample_point: Point):
def test_text_creation_extended(sample_point: Point, sample_plane: Plane):
text_value = "text"
max_width = 20
max_width = 20.0
text_obj = Text(
value=text_value,
@@ -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 == {}
+1 -1
View File
@@ -143,7 +143,7 @@ def test_type_checking() -> None:
order = FrozenYoghurt()
order.servings = 2
order.price = "7" # type: ignore - it will get converted
order.price = 7
order.customer = "izzy"
order.dietary = DietaryRestrictions.VEGAN
order.tag = "preorder"
+5 -6
View File
@@ -31,13 +31,13 @@ fake_bases = [FakeBase("foo"), FakeBase("bar")]
@pytest.mark.parametrize(
"input_type, value, is_valid, return_value",
[
(str, 10, True, "10"),
(str, 10, False, 10),
(str, "foo_bar", True, "foo_bar"),
(
str,
{"foo": "bar"},
True,
"{'foo': 'bar'}",
False,
{"foo": "bar"},
),
(float, 1, True, 1),
# why are we allowing this??? We're lying to our users and ourselves too.
@@ -85,9 +85,8 @@ fake_bases = [FakeBase("foo"), FakeBase("bar")]
(Dict[int, Base], {1: test_base}, True, {1: test_base}),
(Tuple[int, str, str], (1, "foo", "bar"), True, (1, "foo", "bar")),
(Tuple, (1, "foo", "bar"), True, (1, "foo", "bar")),
# given our current rules, this is the reality. Its just sad...
(Tuple[str, str, str], (1, "foo", "bar"), True, ("1", "foo", "bar")),
(Tuple[str, Optional[str], str], (1, None, "bar"), True, ("1", None, "bar")),
(Tuple[str, str, str], (1, "foo", "bar"), False, (1, "foo", "bar")),
(Tuple[str, Optional[str], str], (1, None, "bar"), False, (1, None, "bar")),
(Set[bool], set([1, 2]), False, set([1, 2])),
(Set[int], set([1, 2]), True, set([1, 2])),
(Set[int], set([None, 2]), True, set([None, 2])),