Compare commits

...

17 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
Jedd Morgan c53a51c8ad Jrm/can create model ingestion (#486)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* Add `canCreateModelIngestion` model permission check

* format

* oops
2026-01-29 14:23:44 +00:00
Jedd Morgan c1f27b78f9 feat(api)!: Add model permission checks (#485)
* Add model permission checks

* test_public

* This is the real fix

* mistake

* public api resource
2026-01-29 12:04:21 +01:00
Jedd Morgan 49d4b7d44d doc: MarkReceivedVersionInput clarification (#484)
* MarkReceivedVersionInput clarification

* Reformat
2026-01-27 19:52:30 +03:00
Jedd Morgan 7181f50dda update nullability of invitedBy (#483) 2026-01-15 20:06:13 +03:00
Mucahit Bilal GOKER 2f84214786 feat(ifc): add parentId to nested objects (#481)
* add parentId to nested objects

* rename to parentApplicationId

* implement jedd's feedback

* ruff check

---------

Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2026-01-08 09:04:12 +03:00
Jedd Morgan 0fe1af8e75 Update PostgreSQL connection string in docker-compose (#482) 2026-01-07 15:54:26 +00:00
Gergő Jedlicska 6297943fe1 gergo/version message for ingestion (#480)
* feat: use mise for docs build

* feat(modelingestion): add version message reporting
2026-01-05 11:46:31 +00:00
Gergő Jedlicska 428bbe2c3d gergo/queryIngestionFix (#479)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* feat: use mise for docs build

* fix: getting the ingestion query needs to use model ingestion id
2025-12-11 10:44:36 +01:00
Jedd Morgan 0ca22891bc fallback to cgal (#476) 2025-12-10 10:09:00 +00:00
26 changed files with 441 additions and 99 deletions
+4
View File
@@ -13,12 +13,14 @@ jobs:
name: Test (internal) name: Test (internal)
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
python-version: python-version:
- "3.10" - "3.10"
- "3.11" - "3.11"
- "3.12" - "3.12"
- "3.13" - "3.13"
- "3.14"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -62,12 +64,14 @@ jobs:
env: env:
IS_PUBLIC: "true" IS_PUBLIC: "true"
strategy: strategy:
fail-fast: false
matrix: matrix:
python-version: python-version:
- "3.10" - "3.10"
- "3.11" - "3.11"
- "3.12" - "3.12"
- "3.13" - "3.13"
- "3.14"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1 -4
View File
@@ -97,10 +97,7 @@ services:
STRATEGY_LOCAL: "true" STRATEGY_LOCAL: "true"
POSTGRES_URL: "postgres" POSTGRES_URL: 'postgres://speckle:speckle@postgres:5432/speckle'
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
ENABLE_MP: "false" ENABLE_MP: "false"
LOG_PRETTY: "true" LOG_PRETTY: "true"
+9 -9
View File
@@ -493,29 +493,29 @@ class AutomationContext:
Args: Args:
level: Result level. level: Result level.
category (str): A short tag for the event type. category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of affected_objects (Union[Base, List[Base]]): A single object, a list of
objects that are causing the info case. 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. message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides. visual_overrides: Case specific 3D visual overrides.
""" """
if isinstance(affected_objects, list): 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 object_list = affected_objects
else: else:
object_list = [affected_objects] object_list = [affected_objects]
ids: Dict[str, Optional[str]] = {} 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: for o in object_list:
# validate that the Base.id is not None. If its a None, throw an Exception if not getattr(o, "id", None):
if not o.id:
raise Exception( raise Exception(
f"You can only attach {level} results to objects with an id." 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( print(
f"Created new {level.value.upper()}" f"Created new {level.value.upper()}"
f" category: {category} caused by: {message}" f" category: {category} caused by: {message}"
@@ -12,12 +12,23 @@ def data_object_to_speckle(
step_element: entity_instance, step_element: entity_instance,
children: list[Base], children: list[Base],
current_storey: str | None = None, current_storey: str | None = None,
parent_element: entity_instance | None = None,
) -> DataObject: ) -> DataObject:
guid = cast(str, step_element.GlobalId) guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or guid) name = cast(str, step_element.Name or guid)
properties = extract_properties(step_element) properties = extract_properties(step_element)
# Add parent ID only if element's parent is also a DataObject (not a Collection)
# Collections are: IfcProject and IfcSpatialStructureElement types
if (
parent_element
and hasattr(parent_element, "GlobalId")
and not parent_element.is_a("IfcProject")
and not parent_element.is_a("IfcSpatialStructureElement")
):
properties["parentApplicationId"] = parent_element.GlobalId
# Add building storey information if available and not a building storey itself # Add building storey information if available and not a building storey itself
if current_storey and not step_element.is_a("IfcBuildingStorey"): if current_storey and not step_element.is_a("IfcBuildingStorey"):
properties["Building Storey"] = current_storey properties["Building Storey"] = current_storey
+7 -1
View File
@@ -51,4 +51,10 @@ def open_ifc(file_path: str) -> file:
def create_geometry_iterator(ifc_file: file | sqlite) -> iterator: def create_geometry_iterator(ifc_file: file | sqlite) -> iterator:
return iterator(_create_iterator_settings(), ifc_file, multiprocessing.cpu_count()) GEOMETRY_LIBRARY = "hybrid-opencascade-cgal" # First OCC then fallback to CGAL
return iterator(
_create_iterator_settings(),
ifc_file,
multiprocessing.cpu_count(),
geometry_library=GEOMETRY_LIBRARY, # type: ignore
)
+18 -6
View File
@@ -44,9 +44,13 @@ class ImportJob:
_display_value_cache: dict[int, list[Base]] = field(default_factory=dict) _display_value_cache: dict[int, list[Base]] = field(default_factory=dict)
"""Maps an instance step ID to a list of instances""" """Maps an instance step ID to a list of instances"""
def convert_element(self, step_element: entity_instance) -> Base: def convert_element(
self,
step_element: entity_instance,
parent_element: entity_instance | None = None,
) -> Base:
try: try:
return self._convert_element(step_element) return self._convert_element(step_element, parent_element)
except SpeckleException: except SpeckleException:
raise raise
except Exception as ex: except Exception as ex:
@@ -54,14 +58,18 @@ class ImportJob:
f"Failed to convert {step_element.is_a()} #{step_element.id()}" f"Failed to convert {step_element.is_a()} #{step_element.id()}"
) from ex ) from ex
def _convert_element(self, step_element: entity_instance) -> Base: def _convert_element(
self,
step_element: entity_instance,
parent_element: entity_instance | None = None,
) -> Base:
# Track current storey context and store for level proxies # Track current storey context and store for level proxies
previous_storey_data_object = self._current_storey_data_object previous_storey_data_object = self._current_storey_data_object
if step_element.is_a("IfcBuildingStorey"): if step_element.is_a("IfcBuildingStorey"):
# Convert the building storey to a DataObject for the level proxy # Convert the building storey to a DataObject for the level proxy
storey_display_value = self._display_value_cache.get(step_element.id(), []) storey_display_value = self._display_value_cache.get(step_element.id(), [])
self._current_storey_data_object = data_object_to_speckle( self._current_storey_data_object = data_object_to_speckle(
storey_display_value, step_element, [] storey_display_value, step_element, [], parent_element=None
) )
children = self._convert_children(step_element) children = self._convert_children(step_element)
@@ -86,7 +94,11 @@ class ImportJob:
) )
else: else:
result = data_object_to_speckle( result = data_object_to_speckle(
display_value, step_element, children, current_storey_name display_value,
step_element,
children,
current_storey_name,
parent_element,
) )
# Associate non-spatial elements with current storey for level proxies # Associate non-spatial elements with current storey for level proxies
if self._current_storey_data_object is not None and result.applicationId: if self._current_storey_data_object is not None and result.applicationId:
@@ -100,7 +112,7 @@ class ImportJob:
def _convert_children(self, step_element: entity_instance) -> list[Base]: def _convert_children(self, step_element: entity_instance) -> list[Base]:
return [ return [
self.convert_element(i) self.convert_element(i, parent_element=step_element)
for i in get_children(step_element) for i in get_children(step_element)
if self._should_convert(i) if self._should_convert(i)
] ]
+2 -1
View File
@@ -23,6 +23,7 @@ from specklepy.transports.server import ServerTransport
def open_and_convert_file( def open_and_convert_file(
file_path: str, file_path: str,
project: Project, project: Project,
version_message: str,
model_ingestion_id: str, model_ingestion_id: str,
client: SpeckleClient, client: SpeckleClient,
) -> Version: ) -> Version:
@@ -86,7 +87,7 @@ def open_and_convert_file(
project_id=project.id, project_id=project.id,
ingestion_id=model_ingestion_id, ingestion_id=model_ingestion_id,
root_object_id=root_id, root_object_id=root_id,
# version_message=version_message, version_message=version_message,
) )
) )
@@ -26,6 +26,10 @@ class ModelIngestionResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Create"}) metrics.track(metrics.SDK, self.account, {"name": "Ingestion Create"})
return super().create(input) return super().create(input)
def get_ingestion(self, project_id: str, model_ingestion_id: str) -> ModelIngestion:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Get"})
return super().get_ingestion(project_id, model_ingestion_id)
def update_progress(self, input: ModelIngestionUpdateInput) -> ModelIngestion: def update_progress(self, input: ModelIngestionUpdateInput) -> ModelIngestion:
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Update"}) metrics.track(metrics.SDK, self.account, {"name": "Ingestion Update"})
return super().update_progress(input) return super().update_progress(input)
@@ -8,6 +8,10 @@ from specklepy.core.api.inputs.model_inputs import (
) )
from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter
from specklepy.core.api.models import Model, ModelWithVersions, ResourceCollection from specklepy.core.api.models import Model, ModelWithVersions, ResourceCollection
from specklepy.core.api.models.current import (
ModelPermissionChecks,
PermissionCheckResult,
)
from specklepy.core.api.resources import ModelResource as CoreResource from specklepy.core.api.resources import ModelResource as CoreResource
from specklepy.logging import metrics from specklepy.logging import metrics
@@ -72,3 +76,17 @@ class ModelResource(CoreResource):
def update(self, input: UpdateModelInput) -> Model: def update(self, input: UpdateModelInput) -> Model:
metrics.track(metrics.SDK, self.account, {"name": "Model Update"}) metrics.track(metrics.SDK, self.account, {"name": "Model Update"})
return super().update(input) return super().update(input)
def get_permissions(self, model_id: str, project_id: str) -> ModelPermissionChecks:
metrics.track(metrics.SDK, self.account, {"name": "Model Get Permissions"})
return super().get_permissions(model_id, project_id)
def can_create_model_ingestion(
self, model_id: str, project_id: str
) -> PermissionCheckResult:
metrics.track(
metrics.SDK,
self.account,
{"name": "Model Get Permissions canCreateIngestion"},
)
return super().can_create_model_ingestion(model_id, project_id)
+1
View File
@@ -7,6 +7,7 @@ class ProjectVisibility(str, Enum):
PRIVATE = "PRIVATE" PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC" PUBLIC = "PUBLIC"
UNLISTED = "UNLISTED" UNLISTED = "UNLISTED"
"""Deprecated, use PUBLIC instead"""
WORKSPACE = "WORKSPACE" WORKSPACE = "WORKSPACE"
@@ -40,6 +40,7 @@ class ModelIngestionSuccessInput(GraphQLBaseModel):
ingestion_id: str ingestion_id: str
project_id: str project_id: str
root_object_id: str root_object_id: str
version_message: str | None
class ModelIngestionFailedInput(GraphQLBaseModel): class ModelIngestionFailedInput(GraphQLBaseModel):
@@ -34,4 +34,8 @@ class MarkReceivedVersionInput(GraphQLBaseModel):
version_id: str version_id: str
project_id: str project_id: str
source_application: str source_application: str
"""
IMPORTANT: this is meant to be the slug of the application that has done the
receiving, not to be confused with `Version.sourceApplication`
"""
message: Optional[str] = None message: Optional[str] = None
+7 -2
View File
@@ -105,7 +105,7 @@ class PendingStreamCollaborator(GraphQLBaseModel):
project_name: str project_name: str
title: str title: str
role: str role: str
invited_by: LimitedUser invited_by: LimitedUser | None = None
user: LimitedUser | None = None user: LimitedUser | None = None
token: str | None token: str | None
@@ -137,6 +137,12 @@ class Version(GraphQLBaseModel):
source_application: str | None source_application: str | None
class ModelPermissionChecks(GraphQLBaseModel):
can_update: "PermissionCheckResult"
can_delete: "PermissionCheckResult"
can_create_version: "PermissionCheckResult"
class Model(GraphQLBaseModel): class Model(GraphQLBaseModel):
author: LimitedUser | None author: LimitedUser | None
created_at: datetime created_at: datetime
@@ -156,7 +162,6 @@ class ProjectPermissionChecks(GraphQLBaseModel):
can_create_model: "PermissionCheckResult" can_create_model: "PermissionCheckResult"
can_delete: "PermissionCheckResult" can_delete: "PermissionCheckResult"
can_load: "PermissionCheckResult" can_load: "PermissionCheckResult"
can_publish: "PermissionCheckResult"
class Project(GraphQLBaseModel): class Project(GraphQLBaseModel):
@@ -1,4 +1,4 @@
from typing import Any, Optional, Tuple from typing import Any, Tuple
from gql import Client, gql from gql import Client, gql
@@ -30,7 +30,7 @@ class ModelIngestionResource(ResourceBase):
account: Account, account: Account,
basepath: str, basepath: str,
client: Client, client: Client,
server_version: Optional[Tuple[Any, ...]], server_version: Tuple[Any, ...] | None,
) -> None: ) -> None:
super().__init__( super().__init__(
account=account, account=account,
@@ -40,24 +40,23 @@ class ModelIngestionResource(ResourceBase):
server_version=server_version, server_version=server_version,
) )
def get_ingestion(self, project_id: str, model_id: str) -> ModelIngestion: def get_ingestion(self, project_id: str, model_ingestion_id: str) -> ModelIngestion:
QUERY = gql( QUERY = gql(
""" """
query Query($projectId: String!, $modelId: String!) { query Query($projectId: String!, $modelIngestionId: ID!) {
data:project(id: $projectId) { data:project(id: $projectId) {
data:model(id: $modelId) { data:ingestion(id: $modelIngestionId) {
data:ingestion { id
id createdAt
createdAt updatedAt
modelId modelId
cancellationRequested cancellationRequested
statusData { statusData {
... on HasModelIngestionStatus { ... on HasModelIngestionStatus {
status status
} }
... on HasProgressMessage { ... on HasProgressMessage {
progressMessage progressMessage
}
} }
} }
} }
@@ -68,14 +67,14 @@ class ModelIngestionResource(ResourceBase):
variables = { variables = {
"projectId": project_id, "projectId": project_id,
"modelId": model_id, "modelIngestionId": model_ingestion_id,
} }
return self.make_request_and_parse_response( return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[ModelIngestion]]], DataResponse[DataResponse[ModelIngestion]],
QUERY, QUERY,
variables, variables,
).data.data.data ).data.data
def create(self, input: ModelIngestionCreateInput) -> ModelIngestion: def create(self, input: ModelIngestionCreateInput) -> ModelIngestion:
QUERY = gql( QUERY = gql(
@@ -10,6 +10,10 @@ from specklepy.core.api.inputs.model_inputs import (
) )
from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter from specklepy.core.api.inputs.project_inputs import ProjectModelsFilter
from specklepy.core.api.models import Model, ModelWithVersions, ResourceCollection from specklepy.core.api.models import Model, ModelWithVersions, ResourceCollection
from specklepy.core.api.models.current import (
ModelPermissionChecks,
PermissionCheckResult,
)
from specklepy.core.api.resource import ResourceBase from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse from specklepy.core.api.responses import DataResponse
@@ -299,3 +303,71 @@ class ModelResource(ResourceBase):
return self.make_request_and_parse_response( return self.make_request_and_parse_response(
DataResponse[DataResponse[Model]], QUERY, variables DataResponse[DataResponse[Model]], QUERY, variables
).data.data ).data.data
def get_permissions(self, project_id: str, model_id: str) -> ModelPermissionChecks:
QUERY = gql(
"""
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
canUpdate {
authorized
code
message
}
canDelete {
authorized
code
message
}
canCreateVersion {
authorized
code
message
}
}
}
}
}
"""
)
variables = {"projectId": project_id, "modelId": model_id}
return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[ModelPermissionChecks]]],
QUERY,
variables,
).data.data.data
def can_create_model_ingestion(
self, project_id: str, model_id: str
) -> PermissionCheckResult:
QUERY = gql(
"""
query ModelPermissions($projectId: String!, $modelId: String!) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:permissions {
data:canCreateIngestion {
authorized
code
message
}
}
}
}
}
"""
)
variables = {"projectId": project_id, "modelId": model_id}
return self.make_request_and_parse_response(
DataResponse[
DataResponse[DataResponse[DataResponse[PermissionCheckResult]]]
],
QUERY,
variables,
).data.data.data.data
+31 -30
View File
@@ -1,7 +1,7 @@
import contextlib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from inspect import isclass from inspect import isclass
from types import UnionType
from typing import ( from typing import (
Any, Any,
ClassVar, ClassVar,
@@ -13,11 +13,13 @@ from typing import (
Tuple, Tuple,
Type, Type,
Union, Union,
get_origin,
get_type_hints, get_type_hints,
) )
from warnings import warn from warnings import warn
from pydantic.alias_generators import to_pascal from pydantic.alias_generators import to_pascal
from typing_extensions import get_args
from specklepy.logging.exceptions import SpeckleException from specklepy.logging.exceptions import SpeckleException
from specklepy.transports.memory import MemoryTransport 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_: if value in t._value2member_map_:
return True, t(value) return True, t(value)
if getattr(t, "__module__", None) == "typing": if isinstance(t, ForwardRef):
if isinstance(t, ForwardRef): return True, value
return True, value
origin = t.__origin__ if getattr(t, "__module__", None) in ["typing", "types"]:
# below is what in nicer for >= py38 origin = get_origin(t)
# origin = get_origin(t) args = get_args(t)
# recursive validation for Unions on both types preferring the fist type # recursive validation for Unions on both types preferring the fist type
if origin is Union: if origin is Union or isinstance(t, UnionType):
# below is what in nicer for >= py38
# t_1, t_2 = get_args(t)
args = t.__args__ # type: ignore
for arg_t in args: for arg_t in args:
t_success, t_value = _validate_type(arg_t, value) ok, v = _validate_type(arg_t, value)
if t_success: if ok:
return True, t_value return True, v
return False, value return False, value
if origin is dict: if origin is dict:
if not isinstance(value, dict): if not isinstance(value, dict):
return False, value return False, value
if value == {}: if not value:
return True, value return True, value
if not getattr(t, "__args__", None): if not args:
return True, value return True, value
t_key, t_value = t.__args__ # type: ignore t_key, t_value = args
if ( if (
getattr(t_key, "__name__", None), 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 origin is list:
if not isinstance(value, list): if not isinstance(value, list):
return False, value return False, value
if value == []: if not value:
return True, value return True, value
if not hasattr(t, "__args__"): if not args:
return True, value return True, value
t_items = t.__args__[0] # type: ignore t_items = args[0]
if getattr(t_items, "__name__", None) == "T": if getattr(t_items, "__name__", None) == "T":
return True, value return True, value
first_item_valid, _ = _validate_type(t_items, value[0]) 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 origin is tuple:
if not isinstance(value, tuple): if not isinstance(value, tuple):
return False, value return False, value
if not hasattr(t, "__args__"): if not args:
return True, value return True, value
args = t.__args__ # type: ignore args = t.__args__ # type: ignore
if args == tuple(): if not args:
return True, value return True, value
# we're not checking for empty tuple, cause tuple lengths must match # we're not checking for empty tuple, cause tuple lengths must match
if len(args) != len(value): 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 origin is set:
if not isinstance(value, set): if not isinstance(value, set):
return False, value return False, value
if not hasattr(t, "__args__"): if not args:
return True, value return True, value
t_items = t.__args__[0] # type: ignore t_items = t.__args__[0] # type: ignore
first_item_valid, _ = _validate_type(t_items, next(iter(value))) 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): if isinstance(value, t):
return True, value return True, value
with contextlib.suppress(ValueError, TypeError): if t is float and type(value) is int:
if t is float and value is not None: return True, float(value)
return True, float(value)
# TODO: dafuq, i had to add this not list check # with contextlib.suppress(ValueError, TypeError):
# but it would also fail for objects and other complex values # if t is float and value is not None:
if t is str and value and not isinstance(value, list): # return True, float(value)
return True, str(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 return False, value
@@ -50,12 +50,10 @@ class TestActiveUserResourcePermissions:
assert hasattr(permissions, "can_create_model") assert hasattr(permissions, "can_create_model")
assert hasattr(permissions, "can_delete") assert hasattr(permissions, "can_delete")
assert hasattr(permissions, "can_load") assert hasattr(permissions, "can_load")
assert hasattr(permissions, "can_publish")
assert permissions.can_create_model.authorized is True assert permissions.can_create_model.authorized is True
assert permissions.can_delete.authorized is True assert permissions.can_delete.authorized is True
assert permissions.can_load.authorized is True assert permissions.can_load.authorized is True
assert permissions.can_publish.authorized is True
def test_active_user_get_projects_with_permissions_with_filter( def test_active_user_get_projects_with_permissions_with_filter(
self, client: SpeckleClient, test_project: Project self, client: SpeckleClient, test_project: Project
@@ -79,6 +79,15 @@ class TestIngestionResource:
return ingestion return ingestion
def test_get_ingestion(
self, client: SpeckleClient, project: Project, ingestion: ModelIngestion
):
queried_ingestion = client.model_ingestion.get_ingestion(
project.id, ingestion.id
)
assert queried_ingestion.id == ingestion.id
assert queried_ingestion.status_data.status == ingestion.status_data.status
def test_update_progress( def test_update_progress(
self, client: SpeckleClient, ingestion: ModelIngestion, project: Project self, client: SpeckleClient, ingestion: ModelIngestion, project: Project
): ):
@@ -198,7 +207,7 @@ class TestIngestionResource:
ingestion_id=ingestion.id, ingestion_id=ingestion.id,
root_object_id=object_id, root_object_id=object_id,
project_id=project.id, project_id=project.id,
# version_message=None, version_message=None,
) )
res = client.model_ingestion.complete(input) res = client.model_ingestion.complete(input)
@@ -255,6 +264,7 @@ class TestIngestionResource:
with pytest.raises(GraphQLException): with pytest.raises(GraphQLException):
_ = client.model_ingestion.fail_with_error(input) _ = client.model_ingestion.fail_with_error(input)
@pytest.mark.skip(reason="TEST FAILS - server behaviour was changed")
def test_complete_non_existent_root_object( def test_complete_non_existent_root_object(
self, client: SpeckleClient, ingestion: ModelIngestion, project: Project self, client: SpeckleClient, ingestion: ModelIngestion, project: Project
): ):
@@ -262,7 +272,7 @@ class TestIngestionResource:
ingestion_id=ingestion.id, ingestion_id=ingestion.id,
root_object_id="asdfasdfasdfasfd", root_object_id="asdfasdfasdfasfd",
project_id=project.id, project_id=project.id,
# version_message=None, version_message=None,
) )
with pytest.raises(GraphQLException): with pytest.raises(GraphQLException):
_ = client.model_ingestion.complete(input) _ = client.model_ingestion.complete(input)
@@ -1,6 +1,7 @@
import pytest import pytest
from specklepy.api.client import SpeckleClient from specklepy.api.client import SpeckleClient
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs.model_inputs import ( from specklepy.core.api.inputs.model_inputs import (
CreateModelInput, CreateModelInput,
DeleteModelInput, DeleteModelInput,
@@ -12,11 +13,14 @@ from specklepy.core.api.inputs.project_inputs import (
) )
from specklepy.core.api.models.current import ( from specklepy.core.api.models.current import (
Model, Model,
ModelPermissionChecks,
PermissionCheckResult,
Project, Project,
ProjectWithModels, ProjectWithModels,
ResourceCollection, ResourceCollection,
) )
from specklepy.logging.exceptions import GraphQLException from specklepy.logging.exceptions import GraphQLException
from tests.integration.conftest import is_internal, is_public
@pytest.mark.run() @pytest.mark.run()
@@ -24,7 +28,9 @@ class TestModelResource:
@pytest.fixture() @pytest.fixture()
def test_project(self, client: SpeckleClient) -> Project: def test_project(self, client: SpeckleClient) -> Project:
project = client.project.create( project = client.project.create(
ProjectCreateInput(name="Test project", description="", visibility=None) ProjectCreateInput(
name="Test project", description="", visibility=ProjectVisibility.PUBLIC
)
) )
return project return project
@@ -149,3 +155,52 @@ class TestModelResource:
with pytest.raises(GraphQLException): with pytest.raises(GraphQLException):
client.model.delete(delete_data) client.model.delete(delete_data)
def test_model_get_permissions(
self,
client: SpeckleClient,
second_client: SpeckleClient,
test_project: Project,
test_model: Model,
):
result = client.model.get_permissions(test_project.id, test_model.id)
assert isinstance(result, ModelPermissionChecks)
assert result.can_update.authorized is True
assert result.can_create_version.authorized is True
assert result.can_delete.authorized is True
guest = second_client.model.get_permissions(test_project.id, test_model.id)
assert isinstance(guest, ModelPermissionChecks)
assert guest.can_update.authorized is False
assert guest.can_create_version.authorized is False
assert guest.can_delete.authorized is False
@pytest.mark.skipif(
is_public(), reason="API only available on server versions 3.0.11 or greater"
)
def test_can_create_model_ingestion_internal_server(
self,
client: SpeckleClient,
test_project: Project,
test_model: Model,
):
result = client.model.can_create_model_ingestion(test_project.id, test_model.id)
assert isinstance(result, PermissionCheckResult)
assert result.authorized is True
@pytest.mark.skipif(
is_internal(),
reason="API only available on server versions 3.0.11 or greater",
)
def test_can_create_model_ingestion_public_server(
self,
client: SpeckleClient,
test_project: Project,
test_model: Model,
):
with pytest.raises(GraphQLException) as ex:
_ = client.model.can_create_model_ingestion(test_project.id, test_model.id)
assert "GRAPHQL_VALIDATION_FAILED" in str(ex.value)
@@ -24,6 +24,17 @@ class TestProjectResource:
) )
return project return project
@pytest.fixture()
def test_public_project(self, client: SpeckleClient) -> Project:
project = client.project.create(
ProjectCreateInput(
name="test project123",
description="desc",
visibility=ProjectVisibility.PUBLIC,
)
)
return project
@pytest.mark.parametrize( @pytest.mark.parametrize(
"name, description, visibility", "name, description, visibility",
[ [
@@ -50,7 +61,7 @@ class TestProjectResource:
assert result.id is not None assert result.id is not None
assert result.name == name assert result.name == name
assert result.description == (description or "") assert result.description == (description or "")
# we've disabled creation of public projects for now, they fall back to unlisted # we've disabled creation of unlisted projects for now, they fall back to public
if visibility == ProjectVisibility.UNLISTED: if visibility == ProjectVisibility.UNLISTED:
assert result.visibility == ProjectVisibility.PUBLIC assert result.visibility == ProjectVisibility.PUBLIC
else: else:
@@ -67,13 +78,32 @@ class TestProjectResource:
assert result.created_at == test_project.created_at assert result.created_at == test_project.created_at
def test_project_get_permissions( def test_project_get_permissions(
self, client: SpeckleClient, test_project: Project self,
client: SpeckleClient,
second_client: SpeckleClient,
test_project: Project,
test_public_project: Project,
): ):
result = client.project.get_permissions(test_project.id) result_private = client.project.get_permissions(test_project.id)
assert isinstance(result_private, ProjectPermissionChecks)
assert result_private.can_create_model.authorized is True
assert result_private.can_delete.authorized is True
assert result_private.can_load.authorized is True
result = client.project.get_permissions(test_public_project.id)
assert isinstance(result, ProjectPermissionChecks) assert isinstance(result, ProjectPermissionChecks)
assert result.can_create_model.authorized is True assert result.can_create_model.authorized is True
assert result.can_delete.authorized is True assert result.can_delete.authorized is True
assert result.can_load.authorized is True
guest = second_client.project.get_permissions(test_public_project.id)
assert isinstance(result, ProjectPermissionChecks)
assert guest.can_create_model.authorized is False
assert guest.can_delete.authorized is False
assert guest.can_load.authorized is False
def test_project_update(self, client: SpeckleClient, test_project: Project): def test_project_update(self, client: SpeckleClient, test_project: Project):
new_name = "MY new name" new_name = "MY new name"
+4
View File
@@ -34,6 +34,10 @@ def is_public() -> bool:
return os.getenv("IS_PUBLIC", "false").lower() == "true" return os.getenv("IS_PUBLIC", "false").lower() == "true"
def is_internal() -> bool:
return not is_public()
def seed_user(host: str) -> Dict[str, str]: def seed_user(host: str) -> Dict[str, str]:
seed = uuid.uuid4().hex seed = uuid.uuid4().hex
user_dict = { user_dict = {
+9 -9
View File
@@ -157,10 +157,10 @@ def test_parse_project():
def test_parse_model(): def test_parse_model():
wrap = StreamWrapper( 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" assert wrap.type == "branch"
@@ -191,10 +191,10 @@ def test_parse_object_fe2():
def test_parse_version(): def test_parse_version():
wrap = StreamWrapper( wrap = StreamWrapper(
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838@c42d5cbac1" "https://app.speckle.systems/projects/8be1007be1/models/cc7578012d@7199443eff"
) )
wrap_quoted = StreamWrapper( 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.type == "commit"
assert wrap_quoted.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/0c6ad366c4/globals/abd3787893",
"https://testing.speckle.dev/streams/4c3ce1459c/commits/8b9b831792", "https://testing.speckle.dev/streams/4c3ce1459c/commits/8b9b831792",
"https://testing.speckle.dev/streams/a75ab4f10f/objects/5530363e6d51c904903dafc3ea1d2ec6", "https://testing.speckle.dev/streams/a75ab4f10f/objects/5530363e6d51c904903dafc3ea1d2ec6",
"https://latest.speckle.systems/projects/843d07eb10", "https://app.speckle.systems/projects/8be1007be1",
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838", "https://app.speckle.systems/projects/8be1007be1/models/cc7578012d",
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838@c42d5cbac1", "https://app.speckle.systems/projects/8be1007be1/models/cc7578012d@7199443eff",
"https://latest.speckle.systems/projects/843d07eb10/models/4e7345c838%40c42d5cbac1", "https://app.speckle.systems/projects/8be1007be1/models/cc7578012d%407199443eff",
"https://latest.speckle.systems/projects/24c3741255/models/b48d1b10f5a732f4ca4144286391282c", "https://app.speckle.systems/projects/8be1007be1/models/9b5e57dca804a923a8d42d55dcc0191a",
] ]
for url in urls: for url in urls:
wrap = StreamWrapper(url) 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, alignmentH=AlignmentHorizontal.Center,
alignmentV=AlignmentVertical.Center, alignmentV=AlignmentVertical.Center,
plane=sample_plane, plane=sample_plane,
maxWidth=20, maxWidth=20.0,
units=Units.m, 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): def test_text_creation_extended(sample_point: Point, sample_plane: Plane):
text_value = "text" text_value = "text"
max_width = 20 max_width = 20.0
text_obj = Text( text_obj = Text(
value=text_value, 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 = FrozenYoghurt()
order.servings = 2 order.servings = 2
order.price = "7" # type: ignore - it will get converted order.price = 7
order.customer = "izzy" order.customer = "izzy"
order.dietary = DietaryRestrictions.VEGAN order.dietary = DietaryRestrictions.VEGAN
order.tag = "preorder" order.tag = "preorder"
+5 -6
View File
@@ -31,13 +31,13 @@ fake_bases = [FakeBase("foo"), FakeBase("bar")]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"input_type, value, is_valid, return_value", "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"),
( (
str, str,
{"foo": "bar"}, {"foo": "bar"},
True, False,
"{'foo': 'bar'}", {"foo": "bar"},
), ),
(float, 1, True, 1), (float, 1, True, 1),
# why are we allowing this??? We're lying to our users and ourselves too. # 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}), (Dict[int, Base], {1: test_base}, True, {1: test_base}),
(Tuple[int, str, str], (1, "foo", "bar"), True, (1, "foo", "bar")), (Tuple[int, str, str], (1, "foo", "bar"), True, (1, "foo", "bar")),
(Tuple, (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"), False, (1, "foo", "bar")),
(Tuple[str, str, str], (1, "foo", "bar"), True, ("1", "foo", "bar")), (Tuple[str, Optional[str], str], (1, None, "bar"), False, (1, None, "bar")),
(Tuple[str, Optional[str], str], (1, None, "bar"), True, ("1", None, "bar")),
(Set[bool], set([1, 2]), False, set([1, 2])), (Set[bool], set([1, 2]), False, set([1, 2])),
(Set[int], set([1, 2]), True, set([1, 2])), (Set[int], set([1, 2]), True, set([1, 2])),
(Set[int], set([None, 2]), True, set([None, 2])), (Set[int], set([None, 2]), True, set([None, 2])),