feat: add file import resource with complete job handling support (#440)

* feat: add file import resource with complete job handling support

* fix: include the file import resource in the core client too

* feat: integrate with server side parser app

* chore: fix pr comments and make docker compose work with new object
storage

* chore: fix test compose file readiness probe
This commit is contained in:
Gergő Jedlicska
2025-08-26 14:25:01 +01:00
committed by GitHub
parent f584ad84ed
commit 7bc78b6bf9
22 changed files with 850 additions and 133 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
run: uv run pytest --cov --cov-report xml:reports/coverage.xml --junitxml=reports/test-results.xml
- uses: codecov/codecov-action@v5
if: matrix.python-version == 3.13
if: matrix.python-version == 3.12
with:
fail_ci_if_error: true # optional (default = false)
files: ./reports/test-results.xml # optional
+11 -4
View File
@@ -13,7 +13,7 @@ services:
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- postgres-data:/var/lib/postgresql/data/
- ./.volumes/postgres-data:/var/lib/postgresql/data/
healthcheck:
# the -U user has to match the POSTGRES_USER value
test: ["CMD-SHELL", "pg_isready -U speckle"]
@@ -25,7 +25,7 @@ services:
image: "redis:6.0-alpine"
restart: always
volumes:
- redis-data:/data
- ./.volumes/redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
@@ -37,7 +37,7 @@ services:
command: server /data --console-address ":9001"
restart: always
volumes:
- minio-data:/data
- ./.volumes/minio-data:/data
healthcheck:
test:
[
@@ -48,6 +48,8 @@ services:
timeout: 30s
retries: 30
start_period: 10s
ports:
- "0.0.0.0:9000:9000"
speckle-server:
image: speckle/speckle-server:latest
@@ -57,7 +59,7 @@ services:
- CMD
- /nodejs/bin/node
- -e
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }"
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(Number(res.statusCode != 200 || body.toLowerCase().includes('error')));}); }).end(); } catch { process.exit(1); }"
interval: 10s
timeout: 10s
retries: 3
@@ -81,6 +83,7 @@ services:
REDIS_URL: "redis://redis"
S3_ENDPOINT: "http://minio:9000"
S3_PUBLIC_ENDPOINT: "http://127.0.0.1:9000"
S3_ACCESS_KEY: "minioadmin"
S3_SECRET_KEY: "minioadmin"
S3_BUCKET: "speckle-server"
@@ -101,6 +104,10 @@ services:
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
FF_BACKGROUND_JOBS_ENABLED: "true"
networks:
default:
name: speckle-server
-9
View File
@@ -1,8 +1,5 @@
"""Some useful helpers for working with automation data."""
import secrets
import string
import pytest
from gql import gql
from pydantic import Field
@@ -140,12 +137,6 @@ def test_automation_run_data(
return create_test_automation_run_data(speckle_client, test_automation_environment)
def crypto_random_string(length: int) -> str:
"""Generate a semi crypto random string of a given length."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length)).lower()
__all__ = [
"test_automation_environment",
"test_automation_token",
+4 -55
View File
@@ -4,14 +4,8 @@ import traceback
from argparse import ArgumentParser
from os import getenv
from speckleifc.ifc_geometry_processing import open_ifc
from speckleifc.importer import ImportJob
from speckleifc.main import open_and_convert_file
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.models.current import Version
from specklepy.core.api.operations import send
from specklepy.transports.server import ServerTransport
def cmd_line_import() -> None:
@@ -32,15 +26,16 @@ def cmd_line_import() -> None:
TOKEN = getenv("USER_TOKEN")
assert TOKEN is not None
SERVER_URL = getenv("SPECKLE_SERVER_URL") or "http://127.0.0.1:3000"
account = Account.from_token(TOKEN, SERVER_URL)
try:
client = SpeckleClient(SERVER_URL, use_ssl=not SERVER_URL.startswith("http://"))
client.authenticate_with_token(TOKEN)
version = open_and_convert_file(
args.file_path,
args.project_id,
args.version_message,
args.model_id,
account,
client,
)
with open(args.output_path, "w") as f:
json.dump({"success": True, "commitId": version.id}, f)
@@ -53,52 +48,6 @@ def cmd_line_import() -> None:
json.dump({"success": False, "error": str(e)}, f)
def open_and_convert_file(
file_path: str,
project_id: str,
version_message: str | None,
model_id: str,
account: Account,
) -> Version:
start = time.time()
very_start = start
ifc_file = open_ifc(file_path)
import_job = ImportJob(ifc_file)
data = import_job.convert()
print(f"File conversion complete after {(time.time() - start) * 1000}ms")
start = time.time()
remote_transport = ServerTransport(project_id, account=account)
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()
server_url = account.serverInfo.url
assert server_url
client = SpeckleClient(host=server_url, use_ssl=server_url.startswith("https"))
client.authenticate_with_account(account)
create_version = CreateVersionInput(
object_id=root_id,
model_id=model_id,
project_id=project_id,
message=version_message,
source_application="IFC",
)
version = client.version.create(create_version)
end = time.time()
print(f"Version committed after: {(end - start) * 1000}ms")
print(f"Total time (to commit): {(end - very_start) * 1000}ms")
del ifc_file
return version
if __name__ == "__main__":
start = time.time()
cmd_line_import()
+55
View File
@@ -0,0 +1,55 @@
import time
from speckleifc.ifc_geometry_processing import open_ifc
from speckleifc.importer import ImportJob
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.models.current import Version
from specklepy.core.api.operations import send
from specklepy.transports.server import ServerTransport
def open_and_convert_file(
file_path: str,
project_id: str,
version_message: str | None,
model_id: str,
client: SpeckleClient,
# account: Account,
) -> Version:
start = time.time()
very_start = start
account = client.account
server_url = account.serverInfo.url
assert server_url
remote_transport = ServerTransport(project_id, account=account)
ifc_file = open_ifc(file_path) # pyright: ignore[reportUnknownVariableType]
import_job = ImportJob(ifc_file) # pyright: ignore[reportUnknownArgumentType]
data = import_job.convert()
print(f"File conversion complete after {(time.time() - start) * 1000}ms")
start = time.time()
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()
create_version = CreateVersionInput(
object_id=root_id,
model_id=model_id,
project_id=project_id,
message=version_message,
source_application="IFC",
)
version = client.version.create(create_version)
end = time.time()
print(f"Version committed after: {(end - start) * 1000}ms")
print(f"Total time (to commit): {(end - very_start) * 1000}ms")
del ifc_file
return version
+7
View File
@@ -3,6 +3,7 @@ import contextlib
from specklepy.api.credentials import Account
from specklepy.api.resources import (
ActiveUserResource,
FileImportResource,
ModelResource,
OtherUserResource,
ProjectInviteResource,
@@ -118,6 +119,12 @@ class SpeckleClient(CoreSpeckleClient):
client=self.httpclient,
server_version=server_version,
)
self.file_import = FileImportResource(
account=self.account,
basepath=self.url,
client=self.httpclient,
server_version=server_version,
)
self.subscription = SubscriptionResource(
account=self.account,
basepath=self.ws_url,
+3 -5
View File
@@ -1,5 +1,3 @@
from typing import List, Optional
# following imports seem to be unnecessary, but they need to stay
# to not break the scripts using these functions as non-core
from specklepy.core.api.credentials import ( # noqa: F401
@@ -14,7 +12,7 @@ from specklepy.core.api.credentials import get_local_accounts as core_get_local_
from specklepy.logging import metrics
def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
def get_local_accounts(base_path: str | None = None) -> list[Account]:
"""Gets all the accounts present in this environment
Arguments:
@@ -38,7 +36,7 @@ def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
return accounts
def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
def get_default_account(base_path: str | None = None) -> Account | None:
"""
Gets this environment's default account if any. If there is no default,
the first found will be returned and set as default.
@@ -61,7 +59,7 @@ def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
return default
def get_account_from_token(token: str, server_url: str = None) -> Account:
def get_account_from_token(token: str, server_url: str | None = None) -> Account:
"""Gets the local account for the token if it exists
Arguments:
token {str} -- the api token
+2
View File
@@ -1,4 +1,5 @@
from specklepy.api.resources.current.active_user_resource import ActiveUserResource
from specklepy.api.resources.current.file_import_resource import FileImportResource
from specklepy.api.resources.current.model_resource import ModelResource
from specklepy.api.resources.current.other_user_resource import OtherUserResource
from specklepy.api.resources.current.project_invite_resource import (
@@ -11,6 +12,7 @@ from specklepy.api.resources.current.version_resource import VersionResource
from specklepy.api.resources.current.workspace_resource import WorkspaceResource
__all__ = [
"FileImportResource",
"ActiveUserResource",
"ModelResource",
"OtherUserResource",
@@ -0,0 +1,87 @@
from pathlib import Path
from typing_extensions import override
from specklepy.core.api.inputs import (
FinishFileImportInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.models import FileImport, FileUploadUrl
from specklepy.core.api.models.current import ResourceCollection
from specklepy.core.api.resources import FileImportResource as CoreResource
from specklepy.core.api.resources.current.file_import_resource import UploadFileResponse
from specklepy.logging import metrics
class FileImportResource(CoreResource):
"""API Access class for projects"""
def __init__(self, account, basepath, client, server_version) -> None:
super().__init__(
account=account,
basepath=basepath,
client=client,
server_version=server_version,
)
@override
def start_file_import(self, input: StartFileImportInput) -> FileImport:
metrics.track(metrics.SDK, self.account, {"name": "File Import Start"})
return super().start_file_import(input)
@override
def generate_upload_url(self, input: GenerateFileUploadUrlInput) -> FileUploadUrl:
"""
Get a file upload url from the Speckle server.
This method asks the server to create a pre-signed S3 url,
which can be used as a short term authenticated route,
to put a file to the server.
"""
metrics.track(
metrics.SDK, self.account, {"name": "File Import Generate Upload Url"}
)
return super().generate_upload_url(input)
@override
def upload_file(self, file: Path, url: str) -> UploadFileResponse:
"""
Uploads a file to the given S3 url.
This method should be used together with the generate_upload_url method,
which generates a pre-signed S3 url, that can be used to upload the file to.
"""
metrics.track(metrics.SDK, self.account, {"name": "File Import Upload File"})
return super().upload_file(file, url)
@override
def download_file(self, project_id: str, file_id: str, target_file: Path) -> Path:
"""Download a file blob attached to the project, to the target path."""
metrics.track(metrics.SDK, self.account, {"name": "File Import Download File"})
return super().download_file(project_id, file_id, target_file)
@override
def finish_file_import_job(self, input: FinishFileImportInput) -> bool:
"""
This is mostly an internal api, that marks a file import job finished.
Only use this if you are writing a file importer, that is responsible for
processing file import jobs.
"""
metrics.track(metrics.SDK, self.account, {"name": "File Import Finish Job"})
return super().finish_file_import_job(input)
@override
def get_model_file_import_jobs(
self,
*,
project_id: str,
model_id: str,
limit: int = 25,
cursor: str | None = None,
) -> ResourceCollection[FileImport]:
metrics.track(metrics.SDK, self.account, {"name": "File Import Get Model Jobs"})
return super().get_model_file_import_jobs(
project_id=project_id, model_id=model_id, limit=limit, cursor=cursor
)
+7
View File
@@ -11,6 +11,7 @@ from gql.transport.websockets import WebsocketsTransport
from specklepy.core.api.credentials import Account
from specklepy.core.api.resources import (
ActiveUserResource,
FileImportResource,
ModelResource,
OtherUserResource,
ProjectInviteResource,
@@ -230,6 +231,12 @@ class SpeckleClient:
client=self.httpclient,
server_version=server_version,
)
self.file_import = FileImportResource(
account=self.account,
basepath=self.url,
client=self.httpclient,
server_version=server_version,
)
self.subscription = SubscriptionResource(
account=self.account,
basepath=self.ws_url,
+12 -12
View File
@@ -1,6 +1,6 @@
import os
from pathlib import Path
from typing import List, Optional
from typing import List
from urllib.parse import urlparse
from pydantic import BaseModel, Field # pylint: disable=no-name-in-module
@@ -12,20 +12,20 @@ from specklepy.transports.sqlite import SQLiteTransport
class UserInfo(BaseModel):
id: Optional[str] = None
name: Optional[str] = None
email: Optional[str] = None
company: Optional[str] = None
avatar: Optional[str] = None
id: str | None = None
name: str | None = None
email: str | None = None
company: str | None = None
avatar: str | None = None
class Account(BaseModel):
isDefault: bool = False
token: Optional[str] = None
refreshToken: Optional[str] = None
token: str | None = None
refreshToken: str | None = None
serverInfo: ServerInfo = Field(default_factory=ServerInfo)
userInfo: UserInfo = Field(default_factory=UserInfo)
id: Optional[str] = None
id: str | None = None
def __repr__(self) -> str:
return (
@@ -43,7 +43,7 @@ class Account(BaseModel):
return acct
def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
def get_local_accounts(base_path: str | None = None) -> List[Account]:
"""Gets all the accounts present in this environment
Arguments:
@@ -93,7 +93,7 @@ def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
return accounts
def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
def get_default_account(base_path: str | None = None) -> Account | None:
"""
Gets this environment's default account if any. If there is no default,
the first found will be returned and set as default.
@@ -116,7 +116,7 @@ def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
return default
def get_account_from_token(token: str, server_url: str = None) -> Account:
def get_account_from_token(token: str, server_url: str | None = None) -> Account:
"""Gets the local account for the token if it exists
Arguments:
token {str} -- the api token
+12
View File
@@ -1,3 +1,10 @@
from specklepy.core.api.inputs.file_import_inputs import (
FileImportErrorInput,
FileImportSuccessInput,
FinishFileImportInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.inputs.model_inputs import (
CreateModelInput,
DeleteModelInput,
@@ -22,6 +29,11 @@ from specklepy.core.api.inputs.version_inputs import (
)
__all__ = [
"FileImportErrorInput",
"FileImportSuccessInput",
"FinishFileImportInput",
"StartFileImportInput",
"GenerateFileUploadUrlInput",
"CreateModelInput",
"DeleteModelInput",
"UpdateModelInput",
@@ -0,0 +1,44 @@
from typing import Literal
from pydantic import Field
from specklepy.core.api.models.graphql_base_model import GraphQLBaseModel
class GenerateFileUploadUrlInput(GraphQLBaseModel):
project_id: str
file_name: str
class StartFileImportInput(GraphQLBaseModel):
project_id: str
model_id: str
file_id: str
etag: str
class FileImportResult(GraphQLBaseModel):
duration_seconds: float
download_duration_seconds: float
parse_duration_seconds: float
parser: str
version_id: str | None
class FileImportInputBase(GraphQLBaseModel):
project_id: str
job_id: str
warnings: list[str] = Field(default_factory=list)
result: FileImportResult
class FileImportSuccessInput(FileImportInputBase):
status: Literal["success"] = "success"
class FileImportErrorInput(FileImportInputBase):
status: Literal["error"] = "error"
reason: str
FinishFileImportInput = FileImportSuccessInput | FileImportErrorInput
@@ -1,5 +1,7 @@
from specklepy.core.api.models.current import (
AuthStrategy,
FileImport,
FileUploadUrl,
LimitedUser,
Model,
ModelWithVersions,
@@ -48,4 +50,6 @@ __all__ = [
"ProjectModelsUpdatedMessage",
"ProjectUpdatedMessage",
"ProjectVersionsUpdatedMessage",
"FileImport",
"FileUploadUrl",
]
+62 -46
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Generic, List, Optional, TypeVar
from typing import Generic, List, TypeVar
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.models.graphql_base_model import GraphQLBaseModel
@@ -10,13 +10,13 @@ T = TypeVar("T")
class User(GraphQLBaseModel):
id: str
email: Optional[str] = None
email: str | None = None
name: str
bio: Optional[str] = None
company: Optional[str] = None
avatar: Optional[str] = None
verified: Optional[bool] = None
role: Optional[str] = None
bio: str | None = None
company: str | None = None
avatar: str | None = None
verified: bool | None = None
role: str | None = None
def __repr__(self):
return (
@@ -31,16 +31,16 @@ class User(GraphQLBaseModel):
class ResourceCollection(GraphQLBaseModel, Generic[T]):
total_count: int
items: List[T]
cursor: Optional[str] = None
cursor: str | None = None
class ServerMigration(GraphQLBaseModel):
moved_from: Optional[str]
moved_to: Optional[str]
moved_from: str | None
moved_to: str | None
class AuthStrategy(GraphQLBaseModel):
color: Optional[str]
color: str | None
icon: str
id: str
name: str
@@ -60,17 +60,17 @@ class ServerWorkspacesInfo(GraphQLBaseModel):
# Keeping this one all Optionals at the minute,
# because its used both as a deserialization model for GQL and Account Management
class ServerInfo(GraphQLBaseModel):
name: Optional[str] = None
company: Optional[str] = None
url: Optional[str] = None
admin_contact: Optional[str] = None
description: Optional[str] = None
canonical_url: Optional[str] = None
scopes: Optional[List[dict]] = None
auth_strategies: Optional[List[dict]] = None
version: Optional[str] = None
migration: Optional[ServerMigration] = None
workspaces: Optional[ServerWorkspacesInfo] = None
name: str | None = None
company: str | None = None
url: str | None = None
admin_contact: str | None = None
description: str | None = None
canonical_url: str | None = None
scopes: List[dict] | None = None
auth_strategies: List[dict] | None = None
version: str | None = None
migration: ServerMigration | None = None
workspaces: ServerWorkspacesInfo | None = None
# TODO separate gql model from account management model
@@ -79,11 +79,11 @@ class LimitedUser(GraphQLBaseModel):
id: str
name: str
bio: Optional[str]
company: Optional[str]
avatar: Optional[str]
verified: Optional[bool]
role: Optional[str]
bio: str | None
company: str | None
avatar: str | None
verified: bool | None
role: str | None
def __repr__(self):
return (
@@ -99,15 +99,15 @@ class LimitedUser(GraphQLBaseModel):
class PendingStreamCollaborator(GraphQLBaseModel):
id: str
invite_id: str
stream_id: Optional[str] = None
stream_id: str | None = None
projectId: str
stream_name: Optional[str] = None
stream_name: str | None = None
project_name: str
title: str
role: str
invited_by: LimitedUser
user: Optional[LimitedUser] = None
token: Optional[str]
user: LimitedUser | None = None
token: str | None
def __repr__(self):
return (
@@ -127,24 +127,24 @@ class ProjectCollaborator(GraphQLBaseModel):
class Version(GraphQLBaseModel):
author_user: Optional[LimitedUser]
author_user: LimitedUser | None
created_at: datetime
id: str
message: Optional[str]
message: str | None
preview_url: str
referenced_object: Optional[str]
referenced_object: str | None
"""Maybe null if workspaces version history limit has been exceeded"""
source_application: Optional[str]
source_application: str | None
class Model(GraphQLBaseModel):
author: Optional[LimitedUser]
author: LimitedUser | None
created_at: datetime
description: Optional[str]
description: str | None
display_name: str
id: str
name: str
preview_url: Optional[str]
preview_url: str | None
updated_at: datetime
@@ -162,14 +162,14 @@ class ProjectPermissionChecks(GraphQLBaseModel):
class Project(GraphQLBaseModel):
allow_public_comments: bool
created_at: datetime
description: Optional[str]
description: str | None
id: str
name: str
role: Optional[str]
role: str | None
source_apps: List[str]
updated_at: datetime
visibility: ProjectVisibility
workspace_id: Optional[str]
workspace_id: str | None
class ProjectWithModels(Project):
@@ -191,7 +191,7 @@ class ProjectCommentCollection(ResourceCollection[T], Generic[T]):
class UserSearchResultCollection(GraphQLBaseModel):
items: List[LimitedUser]
cursor: Optional[str] = None
cursor: str | None = None
class PermissionCheckResult(GraphQLBaseModel):
@@ -216,15 +216,31 @@ class WorkspaceCreationState(GraphQLBaseModel):
class LimitedWorkspace(GraphQLBaseModel):
id: str
name: str
role: Optional[str]
role: str | None
slug: str
logo: Optional[str]
description: Optional[str]
logo: str | None
description: str | None
class Workspace(LimitedWorkspace):
created_at: datetime
updated_at: datetime
read_only: bool
creation_state: Optional[WorkspaceCreationState]
creation_state: WorkspaceCreationState | None
permissions: WorkspacePermissionChecks
class FileImport(GraphQLBaseModel):
id: str
project_id: str
converted_version_id: str | None
user_id: str
converted_status: int
converted_message: str | None
model_id: str | None
updated_at: datetime
class FileUploadUrl(GraphQLBaseModel):
url: str
file_id: str
@@ -1,4 +1,5 @@
from specklepy.core.api.resources.current.active_user_resource import ActiveUserResource
from specklepy.core.api.resources.current.file_import_resource import FileImportResource
from specklepy.core.api.resources.current.model_resource import ModelResource
from specklepy.core.api.resources.current.other_user_resource import OtherUserResource
from specklepy.core.api.resources.current.project_invite_resource import (
@@ -13,6 +14,7 @@ from specklepy.core.api.resources.current.version_resource import VersionResourc
from specklepy.core.api.resources.current.workspace_resource import WorkspaceResource
__all__ = [
"FileImportResource",
"ActiveUserResource",
"ModelResource",
"OtherUserResource",
@@ -0,0 +1,212 @@
from pathlib import Path
from typing import Any
import httpx
from gql import Client, gql
from specklepy.core.api.credentials import Account
from specklepy.core.api.inputs.file_import_inputs import (
FinishFileImportInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.models import FileImport, FileUploadUrl, ResourceCollection
from specklepy.core.api.models.graphql_base_model import GraphQLBaseModel
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
from specklepy.logging.exceptions import SpeckleException
class UploadFileResponse(GraphQLBaseModel):
etag: str
class FileImportResource(ResourceBase):
"""API Access class for project invites"""
def __init__(
self,
account: Account,
basepath: str,
client: Client,
server_version: tuple[Any, ...] | None, # pyright: ignore[reportExplicitAny]
) -> None:
super().__init__(
account=account,
basepath=basepath,
client=client,
server_version=server_version,
name="file-import",
)
def finish_file_import_job(self, input: FinishFileImportInput) -> bool:
"""
This is mostly an internal api, that marks a file import job finished.
Only use this if you are writing a file importer, that is responsible for
processing file import jobs.
"""
QUERY = gql(
"""
mutation FinishFileImport($input: FinishFileImportInput!) {
data:fileUploadMutations {
data:finishFileImport(input: $input)
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[bool]], QUERY, variables
).data.data
def start_file_import(self, input: StartFileImportInput) -> FileImport:
QUERY = gql(
"""
mutation StartFileImport($input: StartFileImportInput!) {
data:fileUploadMutations {
data:startFileImport(input: $input) {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[FileImport]], QUERY, variables
).data.data
def generate_upload_url(self, input: GenerateFileUploadUrlInput) -> FileUploadUrl:
"""
Get a file upload url from the Speckle server.
This method asks the server to create a pre-signed S3 url,
which can be used as a short term authenticated route,
to put a file to the server.
"""
QUERY = gql(
"""
mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {
data:fileUploadMutations {
data:generateUploadUrl(input: $input) {
fileId
url
}
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[FileUploadUrl]], QUERY, variables
).data.data
def upload_file(self, file: Path, url: str) -> UploadFileResponse:
"""
Uploads a file to the given S3 url.
This method should be used together with the generate_upload_url method,
which generates a pre-signed S3 url, that can be used to upload the file to.
"""
with open(file, "rb") as content:
response = httpx.put(
url,
content=content, # Pass file object directly for streaming
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file.stat().st_size),
},
).raise_for_status()
etag = response.headers.get("ETag", None) # pyright: ignore[reportAny]
if not etag:
raise SpeckleException(
"Response does not have an ETag attached to it,"
+ " cannot use this as an upload"
)
return UploadFileResponse(etag=str(etag)) # pyright: ignore[reportAny]
def download_file(self, project_id: str, file_id: str, target_file: Path) -> Path:
"""Download a file blob attached to the project, to the target path."""
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True)
url = f"{self.basepath}/api/stream/{project_id}/blob/{file_id}"
with httpx.stream(
"GET", url, headers={"Authorization": f"Bearer {self.account.token}"}
) as response:
_ = response.raise_for_status()
with target_file.open("wb") as f:
for chunk in response.iter_bytes(chunk_size=8192):
_ = f.write(chunk)
return target_file
def get_model_file_import_jobs(
self,
*,
project_id: str,
model_id: str,
limit: int = 25,
cursor: str | None = None,
) -> ResourceCollection[FileImport]:
QUERY = gql(
"""
query ModelFileImportJobs(
$projectId: String!,
$modelId: String!,
$input: GetModelUploadsInput
) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:uploads(input: $input) {
totalCount
cursor
items {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
}
}
"""
)
variables = {
"projectId": project_id,
"modelId": model_id,
"input": {
"limit": limit,
"cursor": cursor,
},
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[ResourceCollection[FileImport]]]],
QUERY,
variables,
).data.data.data
+4
View File
@@ -1 +1,5 @@
"""Common helpers module for Core."""
from specklepy.core.helpers.random import crypto_random_string
__all__ = ["crypto_random_string"]
+8
View File
@@ -0,0 +1,8 @@
import secrets
import string
def crypto_random_string(length: int) -> str:
"""Generate a semi crypto random string of a given length."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length)).lower()
@@ -0,0 +1,61 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION ((''), '2;1');
FILE_NAME ('', '2020-02-27T18:38:58', (''), (''), 'Processor version 5.1.0.0', 'Xbim.IO.MemoryModel', '');
FILE_SCHEMA (('IFC4'));
ENDSEC;
DATA;
#1=IFCPROJECT('3WoDmit2L9H8xguu5dNQPk',#2,'W\X\FCrfelEinfach',$,$,$,$,(#19,#22),#7);
#2=IFCOWNERHISTORY(#5,#6,$,.ADDED.,1582828739,$,$,0);
#3=IFCPERSON($,'Team','Finradon',$,$,$,$,$);
#4=IFCORGANIZATION($,'CMS',$,$,$);
#5=IFCPERSONANDORGANIZATION(#3,#4,$);
#6=IFCAPPLICATION(#4,'1.0','W\X\FCrfelEinfach','W\X\FCrfelEinfach.exe');
#7=IFCUNITASSIGNMENT((#8,#9,#10,#11,#12,#13,#14,#15,#16));
#8=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
#9=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
#10=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
#11=IFCSIUNIT(*,.SOLIDANGLEUNIT.,$,.STERADIAN.);
#12=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);
#13=IFCSIUNIT(*,.MASSUNIT.,$,.GRAM.);
#14=IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.);
#15=IFCSIUNIT(*,.THERMODYNAMICTEMPERATUREUNIT.,$,.DEGREE_CELSIUS.);
#16=IFCSIUNIT(*,.LUMINOUSINTENSITYUNIT.,$,.LUMEN.);
#17=IFCCARTESIANPOINT((0.,0.,0.));
#18=IFCAXIS2PLACEMENT3D(#17,$,$);
#19=IFCGEOMETRICREPRESENTATIONCONTEXT('Building Model','Model',3,1.E-05,#18,$);
#20=IFCCARTESIANPOINT((0.,0.));
#21=IFCAXIS2PLACEMENT2D(#20,$);
#22=IFCGEOMETRICREPRESENTATIONCONTEXT('Building Plan View','Plan',2,1.E-05,#21,$);
#23=IFCBUILDING('1fOYmUWu5FGA6WZZJzE67P',#2,'Default Building',$,$,#24,$,$,.ELEMENT.,$,$,$);
#24=IFCLOCALPLACEMENT($,#25);
#25=IFCAXIS2PLACEMENT3D(#26,$,$);
#26=IFCCARTESIANPOINT((0.,0.,0.));
#27=IFCRELAGGREGATES('0yprV4hG98I8MgBrMkyNIg',#2,$,$,#1,(#23));
#28=IFCBUILDINGELEMENTPROXY('18CFESN5fCsuplarC$2Ulg',#2,'The cube in question',$,$,#38,#37,$,$);
#29=IFCRECTANGLEPROFILEDEF(.AREA.,$,#31,820.,820.);
#30=IFCCARTESIANPOINT((0.,40.));
#31=IFCAXIS2PLACEMENT2D(#30,$);
#32=IFCEXTRUDEDAREASOLID(#29,#35,#33,820.);
#33=IFCDIRECTION((0.,0.,1.));
#34=IFCCARTESIANPOINT((0.,0.,0.));
#35=IFCAXIS2PLACEMENT3D(#34,$,$);
#36=IFCSHAPEREPRESENTATION(#19,'Body','SweptSolid',(#32));
#37=IFCPRODUCTDEFINITIONSHAPE($,$,(#36,#51));
#38=IFCLOCALPLACEMENT($,#39);
#39=IFCAXIS2PLACEMENT3D(#34,#41,#40);
#40=IFCDIRECTION((0.,1.,0.));
#41=IFCDIRECTION((0.,0.,1.));
#42=IFCMATERIALLAYERSETUSAGE(#43,.AXIS2.,.NEGATIVE.,150.,$);
#43=IFCMATERIALLAYERSET((#44),$,$);
#44=IFCMATERIALLAYER($,10.,$,$,$,$,$);
#45=IFCMATERIAL('Metal + Glass',$,$);
#46=IFCRELASSOCIATESMATERIAL('2NhfPxMdr8_v0TKa3nx$N_',#2,$,$,(#28),#42);
#47=IFCPRESENTATIONLAYERASSIGNMENT('some ifcPresentationLayerAssignment',$,(#36),$);
#48=IFCPOLYLINE((#49,#50));
#49=IFCCARTESIANPOINT((0.,0.));
#50=IFCCARTESIANPOINT((4000.,0.));
#51=IFCSHAPEREPRESENTATION(#19,'Axis','Curve2D',(#48));
#52=IFCRELCONTAINEDINSPATIALSTRUCTURE('2KE_68CXDAFvue7XfUwVHI',#2,$,$,(#28),#23);
ENDSEC;
END-ISO-10303-21;
@@ -0,0 +1,251 @@
from pathlib import Path
import pytest
from specklepy.api import operations
from specklepy.api.client import SpeckleClient
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs.file_import_inputs import (
FileImportErrorInput,
FileImportResult,
FileImportSuccessInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.inputs.model_inputs import CreateModelInput
from specklepy.core.api.inputs.project_inputs import ProjectCreateInput
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.models import Project
from specklepy.core.api.models.current import FileUploadUrl
from specklepy.core.helpers import crypto_random_string
from specklepy.transports.server.server import ServerTransport
from tests.integration.fakemesh import FakeMesh
class TestFileImportResource:
@pytest.fixture
def file_path(self) -> Path:
path = Path("./tests/integration/client/current/test_file.ifc").absolute()
assert path.exists()
return path
@pytest.fixture
def project(self, client: SpeckleClient) -> Project:
return client.project.create(
ProjectCreateInput(
name="test", description=None, visibility=ProjectVisibility.PRIVATE
)
)
@pytest.fixture(scope="function")
def upload_url(
self, project: Project, file_path: Path, client: SpeckleClient
) -> FileUploadUrl:
upload_url_result = client.file_import.generate_upload_url(
GenerateFileUploadUrlInput(project_id=project.id, file_name=file_path.name)
)
return upload_url_result
def test_generate_upload_url(self, upload_url: FileUploadUrl) -> None:
assert upload_url.file_id
assert upload_url.url
def test_upload_file(
self, file_path: Path, client: SpeckleClient, upload_url: FileUploadUrl
) -> None:
response = client.file_import.upload_file(file=file_path, url=upload_url.url)
assert response.etag
def test_download_file(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
) -> None:
_ = client.file_import.upload_file(file=file_path, url=upload_url.url)
target_file = file_path.parent.joinpath("download.ifc")
downloaded_file = client.file_import.download_file(
project_id=project.id, file_id=upload_url.file_id, target_file=target_file
)
assert downloaded_file.exists()
assert file_path.stat().st_size == downloaded_file.stat().st_size
downloaded_file.unlink()
def test_start_file_import(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
) -> None:
model = client.model.create(
CreateModelInput(name=crypto_random_string(10), project_id=project.id)
)
upload_response = client.file_import.upload_file(
file=file_path, url=upload_url.url
)
response = client.file_import.start_file_import(
StartFileImportInput(
project_id=project.id,
model_id=model.id,
file_id=upload_url.file_id,
etag=upload_response.etag,
)
)
assert response.converted_status == 0
assert response.converted_version_id is None
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 0
assert job.converted_version_id is None
def test_finish_file_import_success(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
mesh: FakeMesh,
) -> None:
model = client.model.create(
CreateModelInput(name=crypto_random_string(10), project_id=project.id)
)
upload_response = client.file_import.upload_file(
file=file_path, url=upload_url.url
)
job_response = client.file_import.start_file_import(
StartFileImportInput(
project_id=project.id,
model_id=model.id,
file_id=upload_url.file_id,
etag=upload_response.etag,
)
)
assert job_response.converted_status == 0
assert job_response.converted_version_id is None
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 0
assert job.converted_version_id is None
transport = ServerTransport(client=client, stream_id=project.id)
hash = operations.send(mesh, transports=[transport])
version = client.version.create(
input=CreateVersionInput(
project_id=project.id, model_id=model.id, object_id=hash
)
)
finish_result = client.file_import.finish_file_import_job(
input=FileImportSuccessInput(
project_id=project.id,
job_id=job_response.id,
result=FileImportResult(
download_duration_seconds=0,
duration_seconds=0,
parse_duration_seconds=0,
parser="test",
version_id=version.id,
),
)
)
assert finish_result
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 2
assert job.converted_version_id == version.id
def test_finish_file_import_error(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
) -> None:
model = client.model.create(
CreateModelInput(name=crypto_random_string(10), project_id=project.id)
)
upload_response = client.file_import.upload_file(
file=file_path, url=upload_url.url
)
job_response = client.file_import.start_file_import(
StartFileImportInput(
project_id=project.id,
model_id=model.id,
file_id=upload_url.file_id,
etag=upload_response.etag,
)
)
assert job_response.converted_status == 0
assert job_response.converted_version_id is None
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 0
assert job.converted_version_id is None
finish_result = client.file_import.finish_file_import_job(
input=FileImportErrorInput(
project_id=project.id,
job_id=job_response.id,
reason="Test error",
result=FileImportResult(
download_duration_seconds=0,
duration_seconds=0,
parse_duration_seconds=0,
parser="test",
version_id=None,
),
)
)
assert finish_result
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 3
assert job.converted_version_id is None
@@ -15,11 +15,11 @@ from speckle_automate import (
)
from speckle_automate.fixtures import (
create_test_automation_run_data,
crypto_random_string,
)
from speckle_automate.schema import AutomateBase
from specklepy.api.client import SpeckleClient
from specklepy.core.api.models.current import Model, Version
from specklepy.core.helpers import crypto_random_string
from specklepy.objects.base import Base