Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4863a89d8 | |||
| 856f12e57c | |||
| 10e639d19a | |||
| c209cdaec4 | |||
| 169dd00fac | |||
| 58190c378a | |||
| aa16234e7f | |||
| c1f82fa0d2 |
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == {}
|
||||
@@ -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"
|
||||
|
||||
@@ -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])),
|
||||
|
||||
Reference in New Issue
Block a user