Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20a412bc65 | |||
| aa16234e7f | |||
| c1f82fa0d2 | |||
| c53a51c8ad | |||
| c1f27b78f9 | |||
| 49d4b7d44d | |||
| 7181f50dda | |||
| 2f84214786 | |||
| 0fe1af8e75 | |||
| 6297943fe1 | |||
| 428bbe2c3d | |||
| 0ca22891bc | |||
| fd8c2a32f9 |
@@ -97,10 +97,7 @@ services:
|
||||
|
||||
STRATEGY_LOCAL: "true"
|
||||
|
||||
POSTGRES_URL: "postgres"
|
||||
POSTGRES_USER: "speckle"
|
||||
POSTGRES_PASSWORD: "speckle"
|
||||
POSTGRES_DB: "speckle"
|
||||
POSTGRES_URL: 'postgres://speckle:speckle@postgres:5432/speckle'
|
||||
ENABLE_MP: "false"
|
||||
|
||||
LOG_PRETTY: "true"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -61,4 +61,4 @@ def cmd_line_import() -> None:
|
||||
if __name__ == "__main__":
|
||||
start = time.time()
|
||||
cmd_line_import()
|
||||
print(f"Total time (including cleanup): {(time.time() - start) * 1000}ms")
|
||||
print(f"Total time (including cleanup): {(time.time() - start):.3f}s")
|
||||
|
||||
@@ -12,12 +12,23 @@ def data_object_to_speckle(
|
||||
step_element: entity_instance,
|
||||
children: list[Base],
|
||||
current_storey: str | None = None,
|
||||
parent_element: entity_instance | None = None,
|
||||
) -> DataObject:
|
||||
guid = cast(str, step_element.GlobalId)
|
||||
name = cast(str, step_element.Name or guid)
|
||||
|
||||
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
|
||||
if current_storey and not step_element.is_a("IfcBuildingStorey"):
|
||||
properties["Building Storey"] = current_storey
|
||||
|
||||
@@ -51,4 +51,10 @@ def open_ifc(file_path: str) -> file:
|
||||
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
@@ -22,12 +22,15 @@ from specklepy.objects import Base
|
||||
from specklepy.objects.data_objects import DataObject
|
||||
from specklepy.objects.models.collections.collection import Collection
|
||||
from specklepy.objects.proxies import InstanceProxy
|
||||
from specklepy.progress.ingestion_progress import IngestionProgressManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportJob:
|
||||
ifc_file: file
|
||||
|
||||
progress: IngestionProgressManager
|
||||
|
||||
_render_material_manager: RenderMaterialProxyManager = field(
|
||||
default_factory=lambda: RenderMaterialProxyManager()
|
||||
)
|
||||
@@ -39,14 +42,19 @@ class ImportJob:
|
||||
)
|
||||
geometries_count: int = 0
|
||||
geometries_used: int = 0
|
||||
elements_converted: int = 0
|
||||
_current_storey_data_object: DataObject | None = field(default=None, init=False)
|
||||
|
||||
_display_value_cache: dict[int, list[Base]] = field(default_factory=dict)
|
||||
"""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:
|
||||
return self._convert_element(step_element)
|
||||
return self._convert_element(step_element, parent_element)
|
||||
except SpeckleException:
|
||||
raise
|
||||
except Exception as ex:
|
||||
@@ -54,14 +62,18 @@ class ImportJob:
|
||||
f"Failed to convert {step_element.is_a()} #{step_element.id()}"
|
||||
) 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
|
||||
previous_storey_data_object = self._current_storey_data_object
|
||||
if step_element.is_a("IfcBuildingStorey"):
|
||||
# Convert the building storey to a DataObject for the level proxy
|
||||
storey_display_value = self._display_value_cache.get(step_element.id(), [])
|
||||
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)
|
||||
@@ -86,7 +98,11 @@ class ImportJob:
|
||||
)
|
||||
else:
|
||||
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
|
||||
if self._current_storey_data_object is not None and result.applicationId:
|
||||
@@ -96,11 +112,17 @@ class ImportJob:
|
||||
|
||||
# Restore previous storey context
|
||||
self._current_storey_data_object = previous_storey_data_object
|
||||
self.elements_converted += 1
|
||||
if self.progress.should_report_progress():
|
||||
self.progress.report(
|
||||
f"Converted {self.elements_converted:,} elements", None
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _convert_children(self, step_element: entity_instance) -> list[Base]:
|
||||
return [
|
||||
self.convert_element(i)
|
||||
self.convert_element(i, parent_element=step_element)
|
||||
for i in get_children(step_element)
|
||||
if self._should_convert(i)
|
||||
]
|
||||
@@ -120,12 +142,16 @@ class ImportJob:
|
||||
def convert(self) -> Base:
|
||||
start = time.time()
|
||||
self.pre_process_geometry()
|
||||
print(f"Geometry conversion complete after {(time.time() - start) * 1000}ms")
|
||||
print(
|
||||
f"Geometry conversion complete after {(time.time() - start):.3f}s" # noqa: E501
|
||||
)
|
||||
print(f"Created {self.geometries_count} geometries")
|
||||
|
||||
start = time.time()
|
||||
root = self._convert_project_tree()
|
||||
print(f"Object tree conversion complete after {(time.time() - start) * 1000}ms")
|
||||
print(
|
||||
f"Element tree conversion complete after {(time.time() - start):.3f}s" # noqa: E501
|
||||
)
|
||||
print(f"Used {self.geometries_used} geometries")
|
||||
return root
|
||||
|
||||
@@ -133,7 +159,10 @@ class ImportJob:
|
||||
iterator = create_geometry_iterator(self.ifc_file)
|
||||
if not iterator.initialize():
|
||||
raise SpeckleException("Failed to find any geometry in file")
|
||||
|
||||
self.progress.report("Converting geometries", None)
|
||||
self.geometries_count = 0
|
||||
|
||||
while True:
|
||||
shape = cast(TriangulationElement, iterator.get())
|
||||
self.geometries_count += 1
|
||||
@@ -145,6 +174,11 @@ class ImportJob:
|
||||
raise SpeckleException(
|
||||
f"Failed to convert geometry with id: {id}"
|
||||
) from ex
|
||||
|
||||
if self.progress.should_report_progress():
|
||||
self.progress.report(
|
||||
f"Converted {self.geometries_count:,} geometries", None
|
||||
)
|
||||
if not iterator.next():
|
||||
break
|
||||
|
||||
@@ -185,6 +219,8 @@ class ImportJob:
|
||||
raise SpeckleException("Expected exactly one IfcProject in file")
|
||||
project = projects[0]
|
||||
|
||||
self.progress.report("Converting elements", None)
|
||||
|
||||
tree = self.convert_element(project)
|
||||
if not isinstance(tree, Collection):
|
||||
raise TypeError("Expected root object to convert to a Collection")
|
||||
|
||||
+31
-25
@@ -11,18 +11,25 @@ from specklepy.core.api.inputs.model_ingestion_inputs import (
|
||||
ModelIngestionFailedInput,
|
||||
ModelIngestionStartProcessingInput,
|
||||
ModelIngestionSuccessInput,
|
||||
ModelIngestionUpdateInput,
|
||||
SourceDataInput,
|
||||
)
|
||||
from specklepy.core.api.models.current import Project, Version
|
||||
from specklepy.core.api.operations import send
|
||||
from specklepy.logging import metrics
|
||||
from specklepy.progress.ingestion_progress import IngestionProgressManager
|
||||
from specklepy.progress.progress_transport import ProgressTransport
|
||||
from specklepy.transports.server import ServerTransport
|
||||
|
||||
# Since progress messages are currently blocking (no async), we're being extra coarse
|
||||
# with progress updates to ensure we're not waisting time sending updates.
|
||||
# We could maybe go a little lower, but for now I'm not risking degrading performance
|
||||
PROGRESS_INTERVAL_SECONDS = 10
|
||||
|
||||
|
||||
def open_and_convert_file(
|
||||
file_path: str,
|
||||
project: Project,
|
||||
version_message: str | None,
|
||||
model_ingestion_id: str,
|
||||
client: SpeckleClient,
|
||||
) -> Version:
|
||||
@@ -32,7 +39,7 @@ def open_and_convert_file(
|
||||
path = Path(file_path)
|
||||
|
||||
specklepy_version = importlib.metadata.version("specklepy")
|
||||
client.model_ingestion.start_processing(
|
||||
ingestion = client.model_ingestion.start_processing(
|
||||
ModelIngestionStartProcessingInput(
|
||||
project_id=project.id,
|
||||
ingestion_id=model_ingestion_id,
|
||||
@@ -45,39 +52,38 @@ def open_and_convert_file(
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
progress = IngestionProgressManager(
|
||||
client, ingestion, PROGRESS_INTERVAL_SECONDS
|
||||
)
|
||||
account = client.account
|
||||
server_url = account.serverInfo.url
|
||||
assert server_url
|
||||
remote_transport = ServerTransport(project.id, account=account)
|
||||
progress_transport = ProgressTransport(
|
||||
progress,
|
||||
)
|
||||
|
||||
progress.report("Opening file", None)
|
||||
ifc_file = open_ifc(file_path) # pyright: ignore[reportUnknownVariableType]
|
||||
|
||||
client.model_ingestion.update_progress(
|
||||
ModelIngestionUpdateInput(
|
||||
project_id=project.id,
|
||||
ingestion_id=model_ingestion_id,
|
||||
progress_message="File validated, converting",
|
||||
progress=None,
|
||||
)
|
||||
)
|
||||
import_job = ImportJob(ifc_file) # pyright: ignore[reportUnknownArgumentType]
|
||||
import_job = ImportJob(ifc_file, progress) # pyright: ignore[reportUnknownArgumentType]
|
||||
data = import_job.convert()
|
||||
|
||||
print(f"File conversion complete after {(time.time() - start) * 1000}ms")
|
||||
print(
|
||||
f"File conversion complete after {(time.time() - start):.3f}s" # noqa: E501
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
|
||||
client.model_ingestion.update_progress(
|
||||
ModelIngestionUpdateInput(
|
||||
project_id=project.id,
|
||||
ingestion_id=model_ingestion_id,
|
||||
progress_message="Conversion complete, sending",
|
||||
progress=None,
|
||||
)
|
||||
progress.report("Uploading objects", None)
|
||||
root_id = send(
|
||||
data,
|
||||
transports=[remote_transport, progress_transport],
|
||||
use_default_cache=False,
|
||||
)
|
||||
print(
|
||||
f"Sending to speckle complete after: {(time.time() - start):.3f}s" # noqa: E501
|
||||
)
|
||||
root_id = send(data, transports=[remote_transport], use_default_cache=False)
|
||||
print(f"Sending to speckle complete after: {(time.time() - start) * 1000}ms")
|
||||
|
||||
start = time.time()
|
||||
|
||||
@@ -86,7 +92,7 @@ def open_and_convert_file(
|
||||
project_id=project.id,
|
||||
ingestion_id=model_ingestion_id,
|
||||
root_object_id=root_id,
|
||||
# version_message=version_message,
|
||||
version_message=version_message,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -94,9 +100,9 @@ def open_and_convert_file(
|
||||
version = client.version.get(version_id, project.id)
|
||||
|
||||
end = time.time()
|
||||
print(f"Version committed after: {(end - start) * 1000}ms")
|
||||
print(f"Version committed after: {(end - start):.3f}s")
|
||||
|
||||
print(f"Total time (to commit): {(end - very_start) * 1000}ms")
|
||||
print(f"Total time (to commit): {(end - very_start):.3f}s")
|
||||
del ifc_file
|
||||
|
||||
custom_properties = {"ui": "dui3", "actionSource": "import"}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import time
|
||||
|
||||
from speckleifc.main import open_and_convert_file
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.core.api.credentials import get_accounts_for_server
|
||||
from specklepy.logging import metrics
|
||||
|
||||
|
||||
def _manual_import() -> None:
|
||||
from specklepy.core.api.inputs.model_ingestion_inputs import (
|
||||
ModelIngestionCreateInput,
|
||||
SourceDataInput,
|
||||
)
|
||||
|
||||
PROJECT_ID = "412a3c3927"
|
||||
MODEL_ID = "223e61212d"
|
||||
SERVER_URL = "latest.speckle.systems"
|
||||
FILE_PATH = r"C:\Test Files\ifc\AC20-FZK-Haus.ifc" # noqa: E501
|
||||
|
||||
metrics.set_host_app(
|
||||
"ifc",
|
||||
)
|
||||
|
||||
account = get_accounts_for_server(SERVER_URL)[0]
|
||||
client = SpeckleClient(SERVER_URL, use_ssl=not SERVER_URL.startswith("http://"))
|
||||
client.authenticate_with_account(account)
|
||||
|
||||
ingestion = client.model_ingestion.create(
|
||||
ModelIngestionCreateInput(
|
||||
model_id=MODEL_ID,
|
||||
project_id=PROJECT_ID,
|
||||
progress_message="",
|
||||
source_data=SourceDataInput(
|
||||
source_application_slug="speckleifc",
|
||||
source_application_version="0.0.0",
|
||||
file_name=None,
|
||||
file_size_bytes=None,
|
||||
),
|
||||
max_idle_timeout_seconds=2700, # 45mins
|
||||
)
|
||||
)
|
||||
project = client.project.get(PROJECT_ID)
|
||||
|
||||
open_and_convert_file(FILE_PATH, project, None, ingestion.id, client)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start = time.time()
|
||||
|
||||
_manual_import()
|
||||
print(f"Total time (including cleanup): {(time.time() - start):.3f}s")
|
||||
@@ -26,6 +26,10 @@ class ModelIngestionResource(CoreResource):
|
||||
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Create"})
|
||||
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:
|
||||
metrics.track(metrics.SDK, self.account, {"name": "Ingestion Update"})
|
||||
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.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.logging import metrics
|
||||
|
||||
@@ -72,3 +76,17 @@ class ModelResource(CoreResource):
|
||||
def update(self, input: UpdateModelInput) -> Model:
|
||||
metrics.track(metrics.SDK, self.account, {"name": "Model Update"})
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,7 @@ class ProjectVisibility(str, Enum):
|
||||
PRIVATE = "PRIVATE"
|
||||
PUBLIC = "PUBLIC"
|
||||
UNLISTED = "UNLISTED"
|
||||
"""Deprecated, use PUBLIC instead"""
|
||||
WORKSPACE = "WORKSPACE"
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class ModelIngestionCreateInput(GraphQLBaseModel):
|
||||
project_id: str
|
||||
progress_message: str
|
||||
source_data: SourceDataInput
|
||||
max_idle_timeout_seconds: int | None = None
|
||||
|
||||
|
||||
class ModelIngestionStartProcessingInput(GraphQLBaseModel):
|
||||
@@ -40,6 +41,7 @@ class ModelIngestionSuccessInput(GraphQLBaseModel):
|
||||
ingestion_id: str
|
||||
project_id: str
|
||||
root_object_id: str
|
||||
version_message: str | None
|
||||
|
||||
|
||||
class ModelIngestionFailedInput(GraphQLBaseModel):
|
||||
@@ -48,18 +50,6 @@ class ModelIngestionFailedInput(GraphQLBaseModel):
|
||||
error_reason: str
|
||||
error_stacktrace: str | None
|
||||
|
||||
@staticmethod
|
||||
def from_exception(
|
||||
ingestion_id: str, project_id: str, exception: Exception, message: str | None
|
||||
) -> "ModelIngestionFailedInput":
|
||||
"""test"""
|
||||
return ModelIngestionFailedInput(
|
||||
ingestion_id=ingestion_id,
|
||||
project_id=project_id,
|
||||
error_reason=message if message else str(exception),
|
||||
error_stacktrace=str(exception),
|
||||
)
|
||||
|
||||
|
||||
class ModelIngestionCancelledInput(GraphQLBaseModel):
|
||||
ingestion_id: str
|
||||
|
||||
@@ -34,4 +34,8 @@ class MarkReceivedVersionInput(GraphQLBaseModel):
|
||||
version_id: str
|
||||
project_id: 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
|
||||
|
||||
@@ -105,7 +105,7 @@ class PendingStreamCollaborator(GraphQLBaseModel):
|
||||
project_name: str
|
||||
title: str
|
||||
role: str
|
||||
invited_by: LimitedUser
|
||||
invited_by: LimitedUser | None = None
|
||||
user: LimitedUser | None = None
|
||||
token: str | None
|
||||
|
||||
@@ -137,6 +137,12 @@ class Version(GraphQLBaseModel):
|
||||
source_application: str | None
|
||||
|
||||
|
||||
class ModelPermissionChecks(GraphQLBaseModel):
|
||||
can_update: "PermissionCheckResult"
|
||||
can_delete: "PermissionCheckResult"
|
||||
can_create_version: "PermissionCheckResult"
|
||||
|
||||
|
||||
class Model(GraphQLBaseModel):
|
||||
author: LimitedUser | None
|
||||
created_at: datetime
|
||||
@@ -156,7 +162,6 @@ class ProjectPermissionChecks(GraphQLBaseModel):
|
||||
can_create_model: "PermissionCheckResult"
|
||||
can_delete: "PermissionCheckResult"
|
||||
can_load: "PermissionCheckResult"
|
||||
can_publish: "PermissionCheckResult"
|
||||
|
||||
|
||||
class Project(GraphQLBaseModel):
|
||||
@@ -249,12 +254,15 @@ class FileUploadUrl(GraphQLBaseModel):
|
||||
class ModelIngestionStatusData(GraphQLBaseModel):
|
||||
status: ModelIngestionStatus
|
||||
progress_message: str | None = None
|
||||
version_id: str | None = None
|
||||
|
||||
|
||||
class ModelIngestion(GraphQLBaseModel):
|
||||
id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
cancellation_requested: bool
|
||||
model_id: str
|
||||
project_id: str
|
||||
user_id: str
|
||||
cancellation_requested: bool
|
||||
status_data: ModelIngestionStatusData
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Tuple
|
||||
|
||||
from gql import Client, gql
|
||||
|
||||
@@ -30,7 +30,7 @@ class ModelIngestionResource(ResourceBase):
|
||||
account: Account,
|
||||
basepath: str,
|
||||
client: Client,
|
||||
server_version: Optional[Tuple[Any, ...]],
|
||||
server_version: Tuple[Any, ...] | None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
account=account,
|
||||
@@ -40,24 +40,29 @@ class ModelIngestionResource(ResourceBase):
|
||||
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 Query($projectId: String!, $modelId: String!) {
|
||||
query Query($projectId: String!, $modelIngestionId: ID!) {
|
||||
data:project(id: $projectId) {
|
||||
data:model(id: $modelId) {
|
||||
data:ingestion {
|
||||
id
|
||||
createdAt
|
||||
modelId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
status
|
||||
}
|
||||
... on HasProgressMessage {
|
||||
progressMessage
|
||||
}
|
||||
data:ingestion(id: $modelIngestionId) {
|
||||
id
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
status
|
||||
}
|
||||
... on HasProgressMessage {
|
||||
progressMessage
|
||||
}
|
||||
... on ModelIngestionSuccessStatus
|
||||
{
|
||||
versionId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,14 +73,14 @@ class ModelIngestionResource(ResourceBase):
|
||||
|
||||
variables = {
|
||||
"projectId": project_id,
|
||||
"modelId": model_id,
|
||||
"modelIngestionId": model_ingestion_id,
|
||||
}
|
||||
|
||||
return self.make_request_and_parse_response(
|
||||
DataResponse[DataResponse[DataResponse[ModelIngestion]]],
|
||||
DataResponse[DataResponse[ModelIngestion]],
|
||||
QUERY,
|
||||
variables,
|
||||
).data.data.data
|
||||
).data.data
|
||||
|
||||
def create(self, input: ModelIngestionCreateInput) -> ModelIngestion:
|
||||
QUERY = gql(
|
||||
@@ -88,6 +93,8 @@ class ModelIngestionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
@@ -125,6 +132,8 @@ class ModelIngestionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
@@ -160,6 +169,8 @@ class ModelIngestionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
@@ -197,6 +208,8 @@ class ModelIngestionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
@@ -278,6 +291,8 @@ class ModelIngestionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
@@ -321,6 +336,8 @@ class ModelIngestionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
@@ -371,6 +388,8 @@ class ModelIngestionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
|
||||
@@ -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.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.responses import DataResponse
|
||||
|
||||
@@ -299,3 +303,71 @@ class ModelResource(ResourceBase):
|
||||
return self.make_request_and_parse_response(
|
||||
DataResponse[DataResponse[Model]], QUERY, variables
|
||||
).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
|
||||
|
||||
@@ -224,6 +224,8 @@ class SubscriptionResource(ResourceBase):
|
||||
createdAt
|
||||
updatedAt
|
||||
modelId
|
||||
projectId
|
||||
userId
|
||||
cancellationRequested
|
||||
statusData {
|
||||
... on HasModelIngestionStatus {
|
||||
@@ -232,6 +234,10 @@ class SubscriptionResource(ResourceBase):
|
||||
... on HasProgressMessage {
|
||||
progressMessage
|
||||
}
|
||||
... on ModelIngestionSuccessStatus
|
||||
{
|
||||
versionId
|
||||
}
|
||||
}
|
||||
}
|
||||
type
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from time import monotonic
|
||||
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
from specklepy.core.api.inputs.model_ingestion_inputs import ModelIngestionUpdateInput
|
||||
from specklepy.core.api.models.current import ModelIngestion
|
||||
|
||||
|
||||
class IngestionProgressManager:
|
||||
"""
|
||||
Provides a performant way to report ingestion progress.
|
||||
|
||||
Allows callers to throttle ingestion progress messages based on an update interval.
|
||||
Throttling prevents callers from overwhelming the server with constant progress updates,
|
||||
and minimises blocking high-speed operations with too frequent progress updates.
|
||||
|
||||
Callers can use this pattern for reporting throttled progress
|
||||
```
|
||||
if progress.should_report_progress():
|
||||
progress.report(f"Converting geometries ({current:,}/{total:,})", current - total)
|
||||
```
|
||||
|
||||
And for unthrottled progress (e.g. between phases)
|
||||
```
|
||||
progress.report(f"Next phases has started (0/{total:,})", 0)
|
||||
|
||||
This class is similar to the `IngestionProgressManager` in the .NET SDK
|
||||
Unlike in .NET, we recommend using a very coarse `update_interval_seconds`; since we're not using async messages,
|
||||
they are blocking and will degrade performance if used too frequently.
|
||||
```
|
||||
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
speckle_client: SpeckleClient,
|
||||
ingestion: ModelIngestion,
|
||||
update_interval_seconds: float,
|
||||
):
|
||||
self.speckle_client = speckle_client
|
||||
self.ingestion = ingestion
|
||||
self.update_interval = update_interval_seconds
|
||||
|
||||
self._last_updated_at = 0.0
|
||||
|
||||
def report(self, progress_message: str, progress: float | None) -> ModelIngestion:
|
||||
"""
|
||||
Reports a progress update
|
||||
"""
|
||||
self._last_updated_at = monotonic()
|
||||
formatted_progress = f"{progress:.0%}" if progress else ""
|
||||
print(f"Progress update: {progress_message} {formatted_progress}")
|
||||
|
||||
return self.speckle_client.model_ingestion.update_progress(
|
||||
ModelIngestionUpdateInput(
|
||||
ingestion_id=self.ingestion.id,
|
||||
project_id=self.ingestion.project_id,
|
||||
progress_message=progress_message,
|
||||
progress=progress,
|
||||
)
|
||||
)
|
||||
|
||||
def should_report_progress(self) -> bool:
|
||||
"""
|
||||
Returns `true` if it's time for an update,
|
||||
`false` if it's too soon since the last update
|
||||
"""
|
||||
elapsed = monotonic() - self._last_updated_at
|
||||
return elapsed >= self.update_interval
|
||||
@@ -0,0 +1,64 @@
|
||||
from typing import Dict, List
|
||||
|
||||
from specklepy.progress.ingestion_progress import IngestionProgressManager
|
||||
from specklepy.transports.abstract_transport import AbstractTransport
|
||||
|
||||
|
||||
class ProgressTransport(AbstractTransport):
|
||||
"""
|
||||
This transport does not persist objects anywhere,
|
||||
instead it simply reacts to save_object being called,
|
||||
and reports throttled progress.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
progress: IngestionProgressManager,
|
||||
name="Progress",
|
||||
progress_message_template="Uploading objects {:,}",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._name = name
|
||||
self._progress = progress
|
||||
self._progress_message_template = progress_message_template
|
||||
self.saved_object_count = 0
|
||||
|
||||
def _throttle_progress(self) -> None:
|
||||
if self._progress.should_report_progress():
|
||||
self._progress.report(
|
||||
self._progress_message_template.format(self.saved_object_count), None
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"ProgressTransport(objects: {self.saved_object_count})"
|
||||
|
||||
def save_object(self, id: str, serialized_object: str) -> None:
|
||||
self.saved_object_count += 1
|
||||
self._throttle_progress()
|
||||
|
||||
def save_object_from_transport(
|
||||
self, id: str, source_transport: AbstractTransport
|
||||
) -> None:
|
||||
self.saved_object_count += 1
|
||||
self._throttle_progress()
|
||||
|
||||
def get_object(self, id: str) -> str | None:
|
||||
return None
|
||||
|
||||
def has_objects(self, id_list: List[str]) -> Dict[str, bool]:
|
||||
return {id: False for id in id_list}
|
||||
|
||||
def begin_write(self) -> None:
|
||||
self.saved_object_count = 0
|
||||
|
||||
def end_write(self) -> None:
|
||||
pass
|
||||
|
||||
def copy_object_and_children(
|
||||
self, id: str, target_transport: AbstractTransport
|
||||
) -> str:
|
||||
raise NotImplementedError
|
||||
@@ -50,12 +50,10 @@ class TestActiveUserResourcePermissions:
|
||||
assert hasattr(permissions, "can_create_model")
|
||||
assert hasattr(permissions, "can_delete")
|
||||
assert hasattr(permissions, "can_load")
|
||||
assert hasattr(permissions, "can_publish")
|
||||
|
||||
assert permissions.can_create_model.authorized is True
|
||||
assert permissions.can_delete.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(
|
||||
self, client: SpeckleClient, test_project: Project
|
||||
|
||||
@@ -35,7 +35,7 @@ from tests.integration.conftest import is_public
|
||||
@pytest.mark.skipif(is_public(), reason="The public API does not support these tests")
|
||||
class TestIngestionResource:
|
||||
@pytest.fixture
|
||||
def project(self, client: SpeckleClient):
|
||||
def project(self, client: SpeckleClient) -> Project:
|
||||
return client.project.create(
|
||||
ProjectCreateInput(
|
||||
name="test", description=None, visibility=ProjectVisibility.PUBLIC
|
||||
@@ -43,12 +43,12 @@ class TestIngestionResource:
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def model(self, client: SpeckleClient, project: Project):
|
||||
def model(self, client: SpeckleClient, project: Project) -> Model:
|
||||
return client.model.create(
|
||||
CreateModelInput(name="test", description=None, project_id=project.id)
|
||||
)
|
||||
|
||||
@pytest.fixture()
|
||||
@pytest.fixture
|
||||
def ingestion(
|
||||
self, client: SpeckleClient, model: Model, project: Project
|
||||
) -> ModelIngestion:
|
||||
@@ -71,14 +71,29 @@ class TestIngestionResource:
|
||||
assert isinstance(ingestion.updated_at, datetime)
|
||||
assert isinstance(ingestion.cancellation_requested, bool)
|
||||
assert isinstance(ingestion.model_id, str)
|
||||
assert isinstance(ingestion.project_id, str)
|
||||
assert isinstance(ingestion.user_id, str)
|
||||
assert isinstance(ingestion.status_data, ModelIngestionStatusData)
|
||||
assert isinstance(ingestion.status_data.progress_message, str | None)
|
||||
assert ingestion.status_data.version_id is None
|
||||
assert ingestion.status_data.status == ModelIngestionStatus.PROCESSING
|
||||
assert not ingestion.cancellation_requested
|
||||
assert ingestion.model_id == model.id
|
||||
assert ingestion.project_id == project.id
|
||||
assert ingestion.user_id == client.account.userInfo.id
|
||||
|
||||
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(
|
||||
self, client: SpeckleClient, ingestion: ModelIngestion, project: Project
|
||||
):
|
||||
@@ -198,15 +213,16 @@ class TestIngestionResource:
|
||||
ingestion_id=ingestion.id,
|
||||
root_object_id=object_id,
|
||||
project_id=project.id,
|
||||
# version_message=None,
|
||||
version_message=None,
|
||||
)
|
||||
|
||||
res = client.model_ingestion.complete(input)
|
||||
version_id = client.model_ingestion.complete(input)
|
||||
res = client.model_ingestion.get_ingestion(project.id, ingestion.id)
|
||||
|
||||
assert isinstance(res, str)
|
||||
version = client.version.get(res, project.id)
|
||||
assert isinstance(version_id, str)
|
||||
version = client.version.get(version_id, project.id)
|
||||
assert isinstance(version, Version)
|
||||
|
||||
assert res.status_data.version_id == version_id
|
||||
# trying to complete for a second time should throw
|
||||
# with pytest.raises(GraphQLException):
|
||||
# _ = client.ingestion.complete(input)
|
||||
@@ -225,12 +241,6 @@ class TestIngestionResource:
|
||||
assert not res.cancellation_requested
|
||||
assert res.status_data.status == ModelIngestionStatus.CANCELLED
|
||||
|
||||
# Cancel again, should be idempotent
|
||||
res = client.model_ingestion.fail_with_cancel(input)
|
||||
assert res.status_data.progress_message is None
|
||||
assert not res.cancellation_requested
|
||||
assert res.status_data.status == ModelIngestionStatus.CANCELLED
|
||||
|
||||
def test_error_non_existent_ingestion(
|
||||
self, client: SpeckleClient, project: Project
|
||||
):
|
||||
@@ -255,6 +265,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
|
||||
):
|
||||
@@ -262,7 +273,7 @@ class TestIngestionResource:
|
||||
ingestion_id=ingestion.id,
|
||||
root_object_id="asdfasdfasdfasfd",
|
||||
project_id=project.id,
|
||||
# version_message=None,
|
||||
version_message=None,
|
||||
)
|
||||
with pytest.raises(GraphQLException):
|
||||
_ = client.model_ingestion.complete(input)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.core.api.enums import ProjectVisibility
|
||||
from specklepy.core.api.inputs.model_inputs import (
|
||||
CreateModelInput,
|
||||
DeleteModelInput,
|
||||
@@ -12,11 +13,14 @@ from specklepy.core.api.inputs.project_inputs import (
|
||||
)
|
||||
from specklepy.core.api.models.current import (
|
||||
Model,
|
||||
ModelPermissionChecks,
|
||||
PermissionCheckResult,
|
||||
Project,
|
||||
ProjectWithModels,
|
||||
ResourceCollection,
|
||||
)
|
||||
from specklepy.logging.exceptions import GraphQLException
|
||||
from tests.integration.conftest import is_internal, is_public
|
||||
|
||||
|
||||
@pytest.mark.run()
|
||||
@@ -24,7 +28,9 @@ class TestModelResource:
|
||||
@pytest.fixture()
|
||||
def test_project(self, client: SpeckleClient) -> Project:
|
||||
project = client.project.create(
|
||||
ProjectCreateInput(name="Test project", description="", visibility=None)
|
||||
ProjectCreateInput(
|
||||
name="Test project", description="", visibility=ProjectVisibility.PUBLIC
|
||||
)
|
||||
)
|
||||
return project
|
||||
|
||||
@@ -149,3 +155,52 @@ class TestModelResource:
|
||||
|
||||
with pytest.raises(GraphQLException):
|
||||
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
|
||||
|
||||
@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(
|
||||
"name, description, visibility",
|
||||
[
|
||||
@@ -50,7 +61,7 @@ class TestProjectResource:
|
||||
assert result.id is not None
|
||||
assert result.name == name
|
||||
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:
|
||||
assert result.visibility == ProjectVisibility.PUBLIC
|
||||
else:
|
||||
@@ -67,13 +78,32 @@ class TestProjectResource:
|
||||
assert result.created_at == test_project.created_at
|
||||
|
||||
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 result.can_create_model.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):
|
||||
new_name = "MY new name"
|
||||
|
||||
@@ -34,6 +34,10 @@ def is_public() -> bool:
|
||||
return os.getenv("IS_PUBLIC", "false").lower() == "true"
|
||||
|
||||
|
||||
def is_internal() -> bool:
|
||||
return not is_public()
|
||||
|
||||
|
||||
def seed_user(host: str) -> Dict[str, str]:
|
||||
seed = uuid.uuid4().hex
|
||||
user_dict = {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from specklepy.core.api.client import SpeckleClient
|
||||
from specklepy.core.api.enums import ProjectVisibility
|
||||
from specklepy.core.api.inputs.model_ingestion_inputs import (
|
||||
ModelIngestionCreateInput,
|
||||
SourceDataInput,
|
||||
)
|
||||
from specklepy.core.api.inputs.model_inputs import CreateModelInput
|
||||
from specklepy.core.api.inputs.project_inputs import ProjectCreateInput
|
||||
from specklepy.core.api.models.current import Model, ModelIngestion, Project
|
||||
from specklepy.progress.ingestion_progress import IngestionProgressManager
|
||||
from tests.integration.conftest import is_public
|
||||
|
||||
|
||||
@pytest.mark.run()
|
||||
@pytest.mark.skipif(
|
||||
is_public(), reason="The public API does not support model ingestion api"
|
||||
)
|
||||
class TestIngestionProgressManager:
|
||||
@pytest.fixture
|
||||
def project(self, client: SpeckleClient) -> Project:
|
||||
return client.project.create(
|
||||
ProjectCreateInput(
|
||||
name="test", description=None, visibility=ProjectVisibility.PUBLIC
|
||||
)
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def model(self, client: SpeckleClient, project: Project) -> Model:
|
||||
return client.model.create(
|
||||
CreateModelInput(name="test", description=None, project_id=project.id)
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def model_ingestion(
|
||||
self, client: SpeckleClient, model: Model, project: Project
|
||||
) -> ModelIngestion:
|
||||
input = ModelIngestionCreateInput(
|
||||
model_id=model.id,
|
||||
project_id=project.id,
|
||||
progress_message="Starting processing",
|
||||
source_data=SourceDataInput(
|
||||
source_application_slug="pytest",
|
||||
source_application_version="0.0.0",
|
||||
file_name=None,
|
||||
file_size_bytes=None,
|
||||
),
|
||||
)
|
||||
|
||||
return client.model_ingestion.create(input)
|
||||
|
||||
def test_progress_respects_throttle(
|
||||
self,
|
||||
client: SpeckleClient,
|
||||
model_ingestion: ModelIngestion,
|
||||
) -> None:
|
||||
EXPECTED_MESSAGE = "This is a test ingestion message"
|
||||
EXPECTED_PROGRESS = 0.123123
|
||||
UPDATE_INTERVAL_SECONDS = 1
|
||||
|
||||
sut = IngestionProgressManager(
|
||||
speckle_client=client,
|
||||
ingestion=model_ingestion,
|
||||
update_interval_seconds=UPDATE_INTERVAL_SECONDS,
|
||||
)
|
||||
|
||||
assert sut.should_report_progress() is True
|
||||
|
||||
res = sut.report(EXPECTED_MESSAGE, EXPECTED_PROGRESS)
|
||||
|
||||
assert sut.should_report_progress() is False
|
||||
|
||||
time.sleep(UPDATE_INTERVAL_SECONDS + 0.5)
|
||||
|
||||
assert sut.should_report_progress() is True
|
||||
assert sut.should_report_progress() is True
|
||||
|
||||
assert res.status_data.progress_message == EXPECTED_MESSAGE
|
||||
|
||||
def test_progress_no_throttle(
|
||||
self,
|
||||
client: SpeckleClient,
|
||||
model_ingestion: ModelIngestion,
|
||||
) -> None:
|
||||
EXPECTED_MESSAGE = "This is a test ingestion message"
|
||||
EXPECTED_PROGRESS = 0.123123
|
||||
UPDATE_INTERVAL_SECONDS = 0
|
||||
|
||||
sut = IngestionProgressManager(
|
||||
speckle_client=client,
|
||||
ingestion=model_ingestion,
|
||||
update_interval_seconds=UPDATE_INTERVAL_SECONDS,
|
||||
)
|
||||
|
||||
assert sut.should_report_progress() is True
|
||||
|
||||
res = sut.report(EXPECTED_MESSAGE, EXPECTED_PROGRESS)
|
||||
|
||||
assert sut.should_report_progress() is True
|
||||
assert sut.should_report_progress() is True
|
||||
|
||||
assert res.status_data.progress_message == EXPECTED_MESSAGE
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == {}
|
||||
Reference in New Issue
Block a user