Compare commits

..

14 Commits

Author SHA1 Message Date
bimgeek 650122e041 final touches (hopefully) 2025-08-19 20:20:15 +03:00
bimgeek 7c6e7c52c1 early return ifc quantities 2025-08-19 20:16:45 +03:00
bimgeek 6430e81995 function call elimination 2025-08-19 20:12:22 +03:00
bimgeek dc0eb24d9c move unit mapping to module level 2025-08-19 20:08:22 +03:00
bimgeek 484f31dbfa take only what you need 2025-08-19 19:33:25 +03:00
bimgeek 661c7c70a8 simplify get quantities 2025-08-18 22:30:49 +03:00
bimgeek 061ddf33fd cache by field name 2025-08-18 22:24:12 +03:00
bimgeek 94a2d86900 comm cleanup 2025-08-18 22:05:59 +03:00
bimgeek 8e44310f91 quantity extraction py 2025-08-18 22:04:17 +03:00
bimgeek a56085b1b4 quantity field unit cache 2025-08-18 21:55:30 +03:00
bimgeek 4f20315582 module cache for project units 2025-08-18 21:45:30 +03:00
bimgeek 452e764b6a add units first pass 2025-08-18 21:14:02 +03:00
bimgeek 86ac9a2b91 cleanup 2025-08-18 17:20:55 +03:00
bimgeek af39a52f42 add qtos 2025-08-18 17:10:12 +03:00
93 changed files with 1830 additions and 3418 deletions
+10 -50
View File
@@ -9,55 +9,8 @@ on:
- "main"
jobs:
test-internal: # Run integration tests against the internal server image
name: Test (internal)
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
steps:
- uses: actions/checkout@v4
- name: Install uv and set the python version
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install the project
run: uv sync --all-extras --dev
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: "ghcr.io"
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Run Speckle Server
run: docker compose --file docker-compose-internal.yml up --detach --wait
- name: Run tests
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.12
with:
fail_ci_if_error: true # optional (default = false)
files: ./reports/test-results.xml # optional
token: ${{ secrets.CODECOV_TOKEN }}
- name: Minimize uv cache
run: uv cache prune --ci
test-public: # Run integration tests against the public server image
name: Test (public)
test:
name: test
runs-on: ubuntu-latest
strategy:
matrix:
@@ -89,10 +42,17 @@ jobs:
run: uv run pre-commit run --all-files
- name: Run Speckle Server
run: docker compose --file docker-compose.yml up --detach --wait
run: docker compose up --detach --wait
- name: Run tests
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
with:
fail_ci_if_error: true # optional (default = false)
files: ./reports/test-results.xml # optional
token: ${{ secrets.CODECOV_TOKEN }}
- name: Minimize uv cache
run: uv cache prune --ci
-118
View File
@@ -1,118 +0,0 @@
name: "speckle-server"
services:
####
# Speckle Server dependencies
#######
postgres:
image: "postgres:16.4-alpine3.20@sha256:d898b0b78a2627cb4ee63464a14efc9d296884f1b28c841b0ab7d7c42f1fffdf"
restart: always
environment:
POSTGRES_DB: speckle
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- ./.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"]
interval: 5s
timeout: 5s
retries: 30
redis:
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
restart: always
volumes:
- ./.volumes/redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
timeout: 5s
retries: 30
minio:
image: "minio/minio:RELEASE.2023-10-25T06-33-25Z"
command: server /data --console-address ":9001"
restart: always
volumes:
- ./.volumes/minio-data:/data
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
healthcheck:
test:
[
"CMD-SHELL",
"curl -s -o /dev/null http://127.0.0.1:9000/minio/index.html",
]
interval: 5s
timeout: 30s
retries: 30
start_period: 10s
speckle-server:
image: ghcr.io/specklesystems/speckle-server:latest
restart: always
healthcheck:
test:
- 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(Number(res.statusCode != 200 || body.toLowerCase().includes('error')));}); }).end(); } catch { process.exit(1); }"
interval: 10s
timeout: 10s
retries: 3
start_period: 90s
ports:
- "0.0.0.0:3000:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
environment:
# TODO: Change this to the URL of the speckle server, as accessed from the network
CANONICAL_URL: "http://127.0.0.1:8080"
SPECKLE_AUTOMATE_URL: "http://127.0.0.1:3030"
FRONTEND_ORIGIN: "http://127.0.0.1:8081"
# TODO: Change thvolumes:
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"
S3_CREATE_BUCKET: "true"
FILE_SIZE_LIMIT_MB: 100
MAX_PROJECT_MODELS_PER_PAGE: 500
# TODO: Change this to a unique secret for this server
SESSION_SECRET: "TODO:ReplaceWithLongString"
STRATEGY_LOCAL: "true"
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
LOG_PRETTY: "true"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
networks:
default:
name: speckle-server
volumes:
postgres-data:
redis-data:
minio-data:
+7 -14
View File
@@ -1,3 +1,4 @@
version: "3.9"
name: "speckle-server"
services:
@@ -12,7 +13,7 @@ services:
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- ./.volumes/postgres-data:/var/lib/postgresql/data/
- postgres-data:/var/lib/postgresql/data/
healthcheck:
# the -U user has to match the POSTGRES_USER value
test: ["CMD-SHELL", "pg_isready -U speckle"]
@@ -21,10 +22,10 @@ services:
retries: 30
redis:
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
image: "redis:6.0-alpine"
restart: always
volumes:
- ./.volumes/redis-data:/data
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
@@ -36,10 +37,7 @@ services:
command: server /data --console-address ":9001"
restart: always
volumes:
- ./.volumes/minio-data:/data
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
- minio-data:/data
healthcheck:
test:
[
@@ -59,7 +57,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(Number(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(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }"
interval: 10s
timeout: 10s
retries: 3
@@ -83,7 +81,6 @@ 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"
@@ -96,6 +93,7 @@ services:
SESSION_SECRET: "TODO:ReplaceWithLongString"
STRATEGY_LOCAL: "true"
DEBUG: "speckle:*"
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
@@ -103,11 +101,6 @@ services:
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
LOG_PRETTY: "true"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
networks:
default:
name: speckle-server
-6
View File
@@ -1,6 +0,0 @@
[tools]
python = "3.13.7"
[settings]
experimental = true
python.uv_venv_auto = true
+2 -6
View File
@@ -11,19 +11,15 @@ dependencies = [
"appdirs>=1.4.4",
"attrs>=24.3.0",
"deprecated>=1.2.15",
"gql[requests,websockets]>=3.5.0,<4.0.0",
"gql[requests,websockets]>=3.5.0",
"httpx>=0.28.1",
"mkdocs>=1.6.1",
"mkdocs-material>=9.6.5",
"mkdocstrings>=0.28.1",
"mkdocstrings-python>=1.15.0",
"pydantic>=2.10.5",
"pydantic-settings>=2.7.1",
"ujson>=5.10.0",
]
[project.optional-dependencies]
speckleifc = ["ifcopenshell>=0.8.3.post2"]
speckleifc = ["ifcopenshell>=0.8.2"]
[dependency-groups]
dev = [
-1
View File
@@ -1 +0,0 @@
::: specklepy.api.client.SpeckleClient
@@ -1 +0,0 @@
::: specklepy.objects.data_objects.DataObject
@@ -1 +0,0 @@
::: specklepy.objects.data_objects.QgisObject
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IBlenderObject
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.ICurve
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IDataObject
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IDisplayValue
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IBlenderObject
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IHasArea
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IHasUnits
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IHasVolume
@@ -1 +0,0 @@
::: specklepy.objects.interfaces.IProperties
@@ -1 +0,0 @@
::: specklepy.objects.other.RenderMaterial
@@ -1 +0,0 @@
::: specklepy.objects.primitive.Interval
@@ -1 +0,0 @@
::: specklepy.objects.proxies.ColorProxy
@@ -1 +0,0 @@
::: specklepy.objects.proxies.GroupProxy
@@ -1 +0,0 @@
::: specklepy.objects.proxies.InstanceDefinitionProxy
@@ -1 +0,0 @@
::: specklepy.objects.proxies.InstanceProxy
@@ -1 +0,0 @@
::: specklepy.objects.proxies.RenderMaterialProxy
-1
View File
@@ -1 +0,0 @@
::: specklepy.objects.base.Base
@@ -1 +0,0 @@
::: specklepy.objects.geometry.arc.Arc
@@ -1 +0,0 @@
::: specklepy.objects.geometry.box.Box
@@ -1 +0,0 @@
::: specklepy.objects.geometry.circle.Circle
@@ -1 +0,0 @@
::: specklepy.objects.geometry.control_point.ControlPoint
@@ -1 +0,0 @@
::: specklepy.objects.geometry.ellipse.Ellipse
@@ -1 +0,0 @@
::: specklepy.objects.geometry.line.Line
@@ -1 +0,0 @@
::: specklepy.objects.geometry.mesh.Mesh
@@ -1 +0,0 @@
::: specklepy.objects.geometry.plane.Plane
@@ -1 +0,0 @@
::: specklepy.objects.geometry.point.Point
@@ -1 +0,0 @@
::: specklepy.objects.geometry.point_cloud.PointCloud
@@ -1 +0,0 @@
::: specklepy.objects.geometry.polycurve.Polycurve
@@ -1 +0,0 @@
::: specklepy.objects.geometry.polyline.Polyline
@@ -1 +0,0 @@
::: specklepy.objects.geometry.spiral.Spiral
@@ -1 +0,0 @@
::: specklepy.objects.geometry.surface.Surface
@@ -1 +0,0 @@
::: specklepy.objects.geometry.vector.Vector
Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

-29
View File
@@ -1,29 +0,0 @@
# Introduction
Welcome to the Specklepy Developer Docs - a single source of documentation on everything Specklepy! If you're looking for info on how to use Speckle, check our [user guide](https://speckle.guide/).
### Code Repository
The Python SDK can be found in our [repository](//github.com/specklesystems/specklepy), its readme contains instructions on how to build it.
### Installation
You can install it using pip
```
pip install specklepy
```
### Key Components
SpecklePy has three main parts:
1. a `SpeckleClient` which allows you to interact with the server API
2. `operations` and `transports` for sending and receiving large objects
3. a `Base` object and accompaniying serializer for creating and customizing your own Speckle objects
### Local Data Paths
It may be helpful to know where the local accounts and object cache dbs are stored. Depending on on your OS, you can find the dbs at:
- Windows: `APPDATA` or `<USER>\AppData\Roaming\Speckle`
- Linux: `$XDG_DATA_HOME` or by default `~/.local/share/Speckle`
- Mac: `~/.config/Speckle`
@@ -1 +0,0 @@
::: speckle_automate.automation_context.AutomationContext
-64
View File
@@ -1,64 +0,0 @@
site_name: Specklepy Docs
theme:
name: material
favicon: assets/speckle_logo.png
logo: assets/speckle_logo.png
features:
- navigation.tabs
palette:
# Palette toggle for light mode
- scheme: default
primary: white
toggle:
icon: material/weather-night
name: Switch to dark mode
# Palette toggle for dark mode
- scheme: slate
primary: black
logo: assets/logo_white.png
toggle:
icon: material/weather-sunny
name: Switch to light mode
markdown_extensions:
- pymdownx.highlight:
anchor_linenums: true
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- pymdownx.snippets
- pymdownx.superfences
extra_css:
- css/mkdocstrings.css
plugins:
- search
- mkdocstrings:
handlers:
python:
paths: [.]
options:
parameter_headings: false
members_order: source
separate_signature: true
filters: ["!^_"] #Ignore _ prefixed properties
docstring_options:
ignore_init_summary: true
merge_init_into_class: true
show_signature_annotations: true
signature_crossrefs: true
show_if_no_docstring: true
show_labels: true
show_source: true
show_symbol_type_heading: true
show_symbol_type_toc: true
show_bases: false
heading_level: 3
inventories:
- url: https://docs.python.org/3/objects.inv
domains: [py, std]
+16 -42
View File
@@ -245,30 +245,30 @@ class AutomationContext:
"""
)
if self.run_status in [AutomationStatus.SUCCEEDED, AutomationStatus.FAILED]:
results_dict = self._automation_result.model_dump(by_alias=True)
results = {
"version": 3,
object_results = {
"version": 2,
"values": {
"objectResults": results_dict["objectResults"],
"versionResult": results_dict["versionResult"],
"objectResults": self._automation_result.model_dump(by_alias=True)[
"objectResults"
],
"blobIds": self._automation_result.blobs,
},
}
else:
results = None
object_results = None
params = {
"projectId": self.automation_run_data.project_id,
"functionRunId": self.automation_run_data.function_run_id,
"status": self.run_status.value,
"statusMessage": self._automation_result.status_message,
"results": results,
"results": object_results,
"contextView": self._automation_result.result_view,
}
print(f"Reporting run status with content: {params}")
self.speckle_client.httpclient.execute(query, params)
def store_file_result(self, file_path: Union[Path, str]) -> str:
def store_file_result(self, file_path: Union[Path, str]) -> None:
"""Save a file attached to the project of this automation."""
path_obj = (
Path(file_path).resolve() if isinstance(file_path, str) else file_path
@@ -310,51 +310,25 @@ class AutomationContext:
[upload_result.blob_id for upload_result in upload_response.upload_results]
)
return upload_response.upload_results[0].blob_id
def mark_run_failed(
self, status_message: str, version_result: dict[str, Any] | None = None
) -> None:
"""
Mark the current run a failure.
Args:
status_message: Optional message to be displayed.
version_result: Optional data object,
that will be attached to the run results.
The dictionary should be JSON serializable
"""
self._mark_run(AutomationStatus.FAILED, status_message, version_result)
def mark_run_failed(self, status_message: str) -> None:
"""Mark the current run a failure."""
self._mark_run(AutomationStatus.FAILED, status_message)
def mark_run_exception(self, status_message: str) -> None:
"""Mark the current run a failure."""
self._mark_run(AutomationStatus.EXCEPTION, status_message, None)
self._mark_run(AutomationStatus.EXCEPTION, status_message)
def mark_run_success(
self, status_message: str | None, version_result: dict[str, Any] | None = None
) -> None:
"""
Mark the current run a success with an optional message.
Args:
status_message: Optional message to be displayed.
version_result: Optional data object,
that will be attached to the run results.
The dictionary should be JSON serializable
"""
self._mark_run(AutomationStatus.SUCCEEDED, status_message, version_result)
def mark_run_success(self, status_message: Optional[str]) -> None:
"""Mark the current run a success with an optional message."""
self._mark_run(AutomationStatus.SUCCEEDED, status_message)
def _mark_run(
self,
status: AutomationStatus,
status_message: str | None,
version_result: dict[str, Any] | None,
self, status: AutomationStatus, status_message: Optional[str]
) -> None:
duration = self.elapsed()
self._automation_result.status_message = status_message
self._automation_result.run_status = status
self._automation_result.elapsed = duration
self._automation_result.version_result = version_result
msg = f"Automation run {status.value} after {duration:.2f} seconds."
print("\n".join([msg, status_message]) if status_message else msg)
+16 -5
View File
@@ -1,5 +1,8 @@
"""Some useful helpers for working with automation data."""
import secrets
import string
import pytest
from gql import gql
from pydantic import Field
@@ -88,8 +91,10 @@ def create_test_automation_run(
print(result)
return TestAutomationRunData.model_validate(
result["projectMutations"]["automationMutations"]["createTestAutomationRun"]
return (
result.get("projectMutations")
.get("automationMutations")
.get("createTestAutomationRun")
)
@@ -121,9 +126,9 @@ def create_test_automation_run_data(
project_id=test_automation_environment.project_id,
speckle_server_url=test_automation_environment.server_url,
automation_id=test_automation_environment.automation_id,
automation_run_id=test_automation_run_data.automation_run_id,
function_run_id=test_automation_run_data.function_run_id,
triggers=test_automation_run_data.triggers,
automation_run_id=test_automation_run_data["automationRunId"],
function_run_id=test_automation_run_data["functionRunId"],
triggers=test_automation_run_data["triggers"],
)
@@ -135,6 +140,12 @@ 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",
+11 -12
View File
@@ -1,7 +1,7 @@
""""""
from enum import Enum
from typing import Any, Literal
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
@@ -36,7 +36,7 @@ class AutomationRunData(BaseModel):
automation_run_id: str
function_run_id: str
triggers: list[VersionCreationTrigger]
triggers: List[VersionCreationTrigger]
model_config = ConfigDict(
alias_generator=to_camel, populate_by_name=True, protected_namespaces=()
@@ -49,7 +49,7 @@ class TestAutomationRunData(BaseModel):
automation_run_id: str
function_run_id: str
triggers: list[VersionCreationTrigger]
triggers: List[VersionCreationTrigger]
model_config = ConfigDict(
alias_generator=to_camel, populate_by_name=True, protected_namespaces=()
@@ -80,20 +80,19 @@ class ResultCase(AutomateBase):
category: str
level: ObjectResultLevel
object_app_ids: dict[str, str | None]
message: str | None
metadata: dict[str, Any] | None
visual_overrides: dict[str, Any] | None
object_app_ids: Dict[str, Optional[str]]
message: Optional[str]
metadata: Optional[Dict[str, Any]]
visual_overrides: Optional[Dict[str, Any]]
class AutomationResult(AutomateBase):
"""Schema accepted by the Speckle server as a result for an automation run."""
elapsed: float = 0
result_view: str | None = None
result_versions: list[str] = Field(default_factory=list)
blobs: list[str] = Field(default_factory=list)
result_view: Optional[str] = None
result_versions: List[str] = Field(default_factory=list)
blobs: List[str] = Field(default_factory=list)
run_status: AutomationStatus = AutomationStatus.RUNNING
status_message: str | None = None
status_message: Optional[str] = None
object_results: list[ResultCase] = Field(default_factory=list)
version_result: dict[str, Any] | None = None
+56 -12
View File
@@ -4,9 +4,14 @@ import traceback
from argparse import ArgumentParser
from os import getenv
from speckleifc.main import open_and_convert_file
from speckleifc.ifc_geometry_processing import open_ifc
from speckleifc.importer import ImportJob
from specklepy.core.api.client import SpeckleClient
from specklepy.logging import metrics
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:
@@ -27,22 +32,15 @@ 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"
metrics.set_host_app(
"ifc",
)
account = Account.from_token(TOKEN, SERVER_URL)
try:
client = SpeckleClient(SERVER_URL, use_ssl=not SERVER_URL.startswith("http://"))
client.authenticate_with_token(TOKEN)
project = client.project.get(args.project_id)
version = open_and_convert_file(
args.file_path,
project,
args.project_id,
args.version_message,
args.model_id,
client,
account,
)
with open(args.output_path, "w") as f:
json.dump({"success": True, "commitId": version.id}, f)
@@ -55,6 +53,52 @@ 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()
@@ -11,20 +11,13 @@ def data_object_to_speckle(
display_value: list[Base],
step_element: entity_instance,
children: list[Base],
current_storey: str | None = None,
) -> DataObject:
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or guid)
properties = extract_properties(step_element)
# 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
data_object = DataObject(
applicationId=guid,
properties=properties,
properties=extract_properties(step_element),
name=name or guid,
displayValue=display_value,
)
@@ -4,21 +4,21 @@ from typing import cast
from ifcopenshell.ifcopenshell_wrapper import (
Triangulation,
TriangulationElement,
colour,
style,
)
from speckleifc.proxy_managers.render_material_proxy_manager import (
RenderMaterialProxyManager,
)
from speckleifc.render_material_proxy_manager import RenderMaterialProxyManager
from specklepy.objects.base import Base
from specklepy.objects.geometry import Mesh
from specklepy.objects.other import RenderMaterial
def geometry_to_speckle(
geometry: Triangulation, render_material_manager: RenderMaterialProxyManager
shape: TriangulationElement, render_material_manager: RenderMaterialProxyManager
) -> list[Base]:
geometry = cast(Triangulation, shape.geometry)
materials = cast(Sequence[style], geometry.materials)
MESH_COUNT = max(len(materials), 1)
@@ -33,7 +33,7 @@ def geometry_to_speckle(
# Not really expected, but occasionally some meshes fail to triangulate
return []
mapped_meshes = _pre_alloc_mesh_lists(geometry, material_ids, MESH_COUNT)
mapped_meshes = _pre_alloc_mesh_lists(shape, material_ids, MESH_COUNT)
for i, mesh in enumerate(mapped_meshes):
material = _material_to_speckle(materials[i])
render_material_manager.add_mesh_material_mapping(material, mesh)
@@ -103,14 +103,14 @@ def _color_to_argb(colour: colour) -> int:
def _pre_alloc_mesh_lists(
geometry: Triangulation, material_ids: Sequence[int], MESH_COUNT: int
shape: TriangulationElement, material_ids: Sequence[int], MESH_COUNT: int
) -> list[Mesh]:
"""
This is a performance optimisation to pre-size the lists
since we're expecting potential hundreds of thousands of verts in a single model
This is very much in the hot path, so worth the extra bit of convoluted logic
"""
appId = cast(str, geometry.id)
appId = cast(str, shape.guid)
material_face_counts = defaultdict(int)
for mat_id in material_ids:
@@ -12,11 +12,8 @@ def spatial_element_to_speckle(
display_value: list[Base],
step_element: entity_instance,
relational_children: list[Base],
current_storey: str | None = None,
) -> Collection:
direct_geometry = _convert_as_data_object(
display_value, step_element, current_storey
)
direct_geometry = _convert_as_data_object(display_value, step_element)
all_children = [direct_geometry] + relational_children
guid = cast(str, step_element.GlobalId)
@@ -29,22 +26,13 @@ def spatial_element_to_speckle(
def _convert_as_data_object(
display_value: list[Base],
step_element: entity_instance,
current_storey: str | None = None,
display_value: list[Base], step_element: entity_instance
) -> DataObject:
guid = cast(str, step_element.GlobalId)
name = cast(str, step_element.Name or step_element.LongName or guid)
properties = extract_properties(step_element)
# 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
data_object = DataObject(
applicationId=guid,
properties=properties,
properties=extract_properties(step_element),
name=name,
displayValue=display_value,
)
+4 -13
View File
@@ -1,6 +1,6 @@
import multiprocessing
from ifcopenshell import SchemaError, file, ifcopenshell_wrapper, open, sqlite
from ifcopenshell import file, ifcopenshell_wrapper, open, sqlite
from ifcopenshell.geom import iterator, settings
from specklepy.logging.exceptions import SpeckleException
@@ -12,10 +12,8 @@ def _create_iterator_settings() -> settings:
ifc_settings.set("triangulation-type", ifcopenshell_wrapper.TRIANGLE_MESH)
# no need to weld verts
ifc_settings.set("weld-vertices", False)
#
ifc_settings.set("use-world-coords", False)
ifc_settings.set("permissive-shape-reuse", True)
# Speckle meshes are all in world coords
ifc_settings.set("use-world-coords", True)
# Tiny performance improvement,
ifc_settings.set("no-wire-intersection-check", True)
# Rendermaterials inherit the material names instead of type + unique id
@@ -35,14 +33,7 @@ def _create_iterator_settings() -> settings:
def open_ifc(file_path: str) -> file:
try:
ifc_file = open(file_path)
except SchemaError:
raise
except FileNotFoundError:
raise
except Exception as ex:
raise SpeckleException("File could not be opened as an IFC file") from ex
ifc_file = open(file_path)
if isinstance(ifc_file, file):
return ifc_file
+16 -119
View File
@@ -1,10 +1,10 @@
import time
from dataclasses import dataclass, field
from typing import List, cast
from typing import cast
from ifcopenshell.entity_instance import entity_instance
from ifcopenshell.geom import file
from ifcopenshell.ifcopenshell_wrapper import Triangulation, TriangulationElement
from ifcopenshell.ifcopenshell_wrapper import TriangulationElement
from speckleifc.converter.data_object_converter import data_object_to_speckle
from speckleifc.converter.geometry_converter import geometry_to_speckle
@@ -12,91 +12,34 @@ from speckleifc.converter.project_converter import project_to_speckle
from speckleifc.converter.spatial_element_converter import spatial_element_to_speckle
from speckleifc.ifc_geometry_processing import create_geometry_iterator
from speckleifc.ifc_openshell_helpers import get_children
from speckleifc.proxy_managers.instance_proxy_manager import InstanceProxyManager
from speckleifc.proxy_managers.level_proxy_manager import LevelProxyManager
from speckleifc.proxy_managers.render_material_proxy_manager import (
RenderMaterialProxyManager,
)
from speckleifc.render_material_proxy_manager import RenderMaterialProxyManager
from specklepy.logging.exceptions import SpeckleException
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
@dataclass
class ImportJob:
ifc_file: file
cached_display_values: dict[int, list[Base]] = field(default_factory=dict) # noqa: F821
_render_material_manager: RenderMaterialProxyManager = field(
default_factory=lambda: RenderMaterialProxyManager()
)
_level_proxy_manager: LevelProxyManager = field(
default_factory=lambda: LevelProxyManager()
)
_instance_proxy_manager: InstanceProxyManager = field(
default_factory=lambda: InstanceProxyManager()
)
geometries_count: int = 0
geometries_used: 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:
try:
return self._convert_element(step_element)
except SpeckleException:
raise
except Exception as ex:
raise SpeckleException(
f"Failed to convert {step_element.is_a()} #{step_element.id()}"
) from ex
def _convert_element(self, step_element: entity_instance) -> 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, []
)
children = self._convert_children(step_element)
id = step_element.id()
display_value = self._display_value_cache.get(id, [])
display_value = self.cached_display_values.get(step_element.id(), [])
if display_value:
if display_value is not None:
self.geometries_used += 1
# Extract current storey name from DataObject if available
current_storey_name = (
self._current_storey_data_object.name
if self._current_storey_data_object
else None
)
if step_element.is_a("IfcProject"):
result = project_to_speckle(step_element, children)
return project_to_speckle(step_element, children)
elif step_element.is_a("IfcSpatialStructureElement"):
result = spatial_element_to_speckle(
display_value, step_element, children, current_storey_name
)
return spatial_element_to_speckle(display_value, step_element, children)
else:
result = data_object_to_speckle(
display_value, step_element, children, current_storey_name
)
# Associate non-spatial elements with current storey for level proxies
if self._current_storey_data_object is not None and result.applicationId:
self._level_proxy_manager.add_element_level_mapping(
self._current_storey_data_object, result.applicationId
)
# Restore previous storey context
self._current_storey_data_object = previous_storey_data_object
return result
return data_object_to_speckle(display_value, step_element, children)
def _convert_children(self, step_element: entity_instance) -> list[Base]:
return [
@@ -132,53 +75,21 @@ class ImportJob:
def pre_process_geometry(self) -> None:
iterator = create_geometry_iterator(self.ifc_file)
if not iterator.initialize():
raise SpeckleException("Failed to find any geometry in file")
raise SpeckleException(
"geometry iterator failed to initialize for the given file"
)
self.geometries_count = 0
while True:
shape = cast(TriangulationElement, iterator.get())
self.geometries_count += 1
id = cast(int, shape.id)
try:
display_value = self._create_display_value(shape)
self._display_value_cache[id] = display_value
except Exception as ex:
raise SpeckleException(
f"Failed to convert geometry with id: {id}"
) from ex
display_value = geometry_to_speckle(shape, self._render_material_manager)
self.cached_display_values[id] = display_value
if not iterator.next():
break
def _create_display_value(self, shape: TriangulationElement) -> List[Base]:
geometry = cast(Triangulation, shape.geometry)
display_value_geometry = geometry_to_speckle(
geometry, self._render_material_manager
)
definition_ids = self._instance_proxy_manager.add_display_value_definitions(
display_value_geometry
)
matrix = shape.transformation.matrix
transposed = [
matrix[0], matrix[4], matrix[8], matrix[12],
matrix[1], matrix[5], matrix[9], matrix[13],
matrix[2], matrix[6], matrix[10], matrix[14],
matrix[3], matrix[7], matrix[11], matrix[15],
] # fmt: skip
return [
cast(
Base,
InstanceProxy(
units="m",
definitionId=definition_id,
transform=transposed,
maxDepth=0,
applicationId=f"{shape.guid}:{definition_id}",
),
)
for definition_id in definition_ids
]
def _convert_project_tree(self) -> Base:
projects = self.ifc_file.by_type("IfcProject", False)
if len(projects) != 1:
@@ -186,22 +97,8 @@ class ImportJob:
project = projects[0]
tree = self.convert_element(project)
if not isinstance(tree, Collection):
raise TypeError("Expected root object to convert to a Collection")
tree["renderMaterialProxies"] = list(
self._render_material_manager.render_material_proxies.values()
)
tree["levelProxies"] = list(self._level_proxy_manager.level_proxies.values())
tree["instanceDefinitionProxies"] = list(
self._instance_proxy_manager.instance_definition_proxies.values()
)
tree.elements.append(
Collection(
name="definitionGeometry",
elements=list(self._instance_proxy_manager.instance_geometry.values()),
)
)
tree["version"] = 3
return tree
-61
View File
@@ -1,61 +0,0 @@
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 Project, Version
from specklepy.core.api.operations import send
from specklepy.logging import metrics
from specklepy.transports.server import ServerTransport
def open_and_convert_file(
file_path: str,
project: Project,
version_message: str | None,
model_id: str,
client: SpeckleClient,
) -> 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
custom_properties = {"ui": "dui3", "actionSource": "import"}
if project.workspace_id:
custom_properties["workspace_id"] = project.workspace_id
metrics.track(metrics.SEND, account, custom_properties, send_sync=True)
return version
+17 -115
View File
@@ -1,30 +1,21 @@
import math
from typing import Any, Tuple
from typing import Any
from ifcopenshell.entity_instance import entity_instance
from ifcopenshell.util.element import get_type
from ifcopenshell.util.unit import get_full_unit_name, get_project_unit
UNIT_MAPPING = {
"IfcQuantityLength": "LENGTHUNIT",
"IfcQuantityArea": "AREAUNIT",
"IfcQuantityVolume": "VOLUMEUNIT",
"IfcQuantityCount": None, # Count quantities have no units
"IfcQuantityWeight": "MASSUNIT",
"IfcQuantityTime": "TIMEUNIT",
}
from speckleifc.qtos_only import get_quantities
def extract_properties(element: entity_instance) -> dict[str, object]:
(psets, qtos) = _get_ifc_object_properties(element)
properties: dict[str, object] = {
"Attributes": _get_attributes(element),
"Property Sets": psets,
"Property Sets": _get_ifc_object_properties(element),
}
if qtos:
properties["Quantities"] = qtos
# Add quantities if they exist
quantities = get_quantities(element)
if quantities:
properties["Quantities"] = quantities
if (ifc_type := get_type(element)) is not None:
properties["Element Type Property Sets"] = _get_ifc_element_type_properties(
@@ -51,11 +42,8 @@ def _get_ifc_element_type_properties(element: entity_instance) -> dict[str, obje
return result
def _get_ifc_object_properties(
element: entity_instance,
) -> Tuple[dict[str, object], dict[str, object]]:
psets: dict[str, object] = {}
qtos: dict[str, object] = {}
def _get_ifc_object_properties(element: entity_instance) -> dict[str, object]:
result: dict[str, object] = {}
for rel in getattr(element, "IsDefinedBy", []):
if not rel.is_a("IfcRelDefinesByProperties"):
@@ -65,27 +53,16 @@ def _get_ifc_object_properties(
if not definition:
continue
try:
if definition.is_a("IfcPropertySet"):
set_name = definition.Name
properties = _get_properties(definition.HasProperties)
if properties:
psets[set_name] = properties
elif definition.is_a("IfcElementQuantity"):
quantities_data = _get_quantities(definition.Quantities, element)
if not quantities_data:
continue
quantities_data["id"] = definition.id()
qtos[definition.Name] = quantities_data
except (KeyError, AttributeError):
# If entity access fails, skip this quantity set
print(f"Skipping {definition}")
if not definition.is_a("IfcPropertySet"):
continue
return (psets, qtos)
set_name = definition.Name
properties = _get_properties(definition.HasProperties)
if properties:
result[set_name] = properties
return result
def _get_properties(properties: entity_instance) -> dict[str, Any]:
@@ -120,78 +97,3 @@ def _get_properties(properties: entity_instance) -> dict[str, Any]:
# elif prop.is_a("IfcPropertyTableValue"):
# properties[name] = #not sure if we want to support these...
return result
def _get_quantities(
quantities: list[entity_instance], element: entity_instance
) -> dict[str, Any]:
"""Extract quantity values from IfcPhysicalQuantity entities."""
results: dict[str, Any] = {}
for quantity in quantities or []:
quantity_name = quantity.Name
if quantity.is_a("IfcPhysicalSimpleQuantity"):
# Get the quantity value (3rd attribute for simple quantities)
value = getattr(quantity, quantity.attribute_name(3))
unit_info = _get_unit_info(element, quantity)
# Server does not consider `NaN` valid json
if math.isnan(value):
value = None
if unit_info:
# Create structured quantity object with units
results[quantity_name] = {
"name": quantity_name,
"value": value,
**unit_info,
}
else:
# No unit info available, keep as simple value with name
results[quantity_name] = {"name": quantity_name, "value": value}
elif quantity.is_a("IfcPhysicalComplexQuantity"):
# Handle complex quantities
data = {
k: v
for k, v in quantity.get_info().items()
if v is not None and k != "Name"
}
data["properties"] = _get_quantities(quantity.HasQuantities, element)
del data["HasQuantities"]
results[quantity_name] = data
return results
def _get_unit_info(
element: entity_instance, quantity: entity_instance
) -> dict[str, str]:
"""Get unit information for a quantity."""
# Early return for count quantities - they don't have units
quantity_type = quantity.is_a()
if quantity_type == "IfcQuantityCount":
return {}
unit = getattr(element, "Unit", None)
if unit:
# Quantity has its own unit
unit_name = get_full_unit_name(unit)
formatted_unit_name = unit_name.replace("_", " ").title() if unit_name else ""
return {"units": formatted_unit_name}
else:
# Fall back to project unit based on quantity type
unit_type = UNIT_MAPPING.get(quantity_type)
if not unit_type:
return {}
# Get the project unit for this unit type
project_unit = get_project_unit(element.file, unit_type, use_cache=True)
if not project_unit:
return {}
# Get unit name and format
unit_name = get_full_unit_name(project_unit)
formatted_unit_name = unit_name.replace("_", " ").title() if unit_name else ""
return {"units": formatted_unit_name}
@@ -1,43 +0,0 @@
from typing import Sequence
from specklepy.objects.base import Base
from specklepy.objects.proxies import InstanceDefinitionProxy
class InstanceProxyManager:
def __init__(self):
self._instance_definition_proxies: dict[str, InstanceDefinitionProxy] = {}
"""definition proxies to be added directly to the root"""
self._instance_geometry: dict[str, Base] = {}
"""The geometry that will be added in it's own collection under the root"""
@property
def instance_definition_proxies(self) -> dict[str, InstanceDefinitionProxy]:
return self._instance_definition_proxies
@property
def instance_geometry(self) -> dict[str, Base]:
return self._instance_geometry
def add_display_value_definitions(self, geometry: Sequence[Base]) -> list[str]:
result: list[str] = []
for m in geometry:
if not m.applicationId:
raise ValueError("geometry with no applicationId cannot be proxied ")
definition_id = f"DEFINITION:{m.applicationId}"
result.append(definition_id)
self._add_definition(definition_id, [m.applicationId], 0)
self._instance_geometry[m.applicationId] = m
return result
def _add_definition(
self, definition_id: str, objects: list[str], max_depth: int
) -> None:
proxy = InstanceDefinitionProxy(
applicationId=definition_id,
name=definition_id,
objects=objects,
maxDepth=max_depth,
)
self._instance_definition_proxies[definition_id] = proxy
@@ -1,27 +0,0 @@
from specklepy.objects.data_objects import DataObject
from specklepy.objects.proxies import LevelProxy
class LevelProxyManager:
def __init__(self):
self._level_proxies: dict[str, LevelProxy] = {}
@property
def level_proxies(self):
return self._level_proxies
def add_element_level_mapping(
self, level_data_object: DataObject, element_application_id: str
) -> None:
level_id = level_data_object.applicationId
assert level_id is not None
proxy = self._level_proxies.get(level_id, None)
if proxy is not None:
proxy.objects.append(element_application_id)
else:
self._level_proxies[level_id] = LevelProxy(
objects=[element_application_id],
value=level_data_object,
applicationId=level_id,
)
+106
View File
@@ -0,0 +1,106 @@
from typing import Any
from ifcopenshell.entity_instance import entity_instance
from ifcopenshell.util.unit import get_full_unit_name, get_project_unit
# Module-level constants for units
UNIT_MAPPING = {
"IfcQuantityLength": "LENGTHUNIT",
"IfcQuantityArea": "AREAUNIT",
"IfcQuantityVolume": "VOLUMEUNIT",
"IfcQuantityCount": None, # Count quantities have no units
"IfcQuantityWeight": "MASSUNIT",
"IfcQuantityTime": "TIMEUNIT"
}
def _get_unit_info(element: entity_instance, quantity) -> dict[str, str]:
"""Get unit information for a quantity."""
try:
# Early return for count quantities - they don't have units
quantity_type = quantity.is_a()
if quantity_type == "IfcQuantityCount":
return {}
if quantity.Unit is not None:
# Quantity has its own unit
try:
unit_name = get_full_unit_name(quantity.Unit)
formatted_unit_name = unit_name.replace("_", " ").title() if unit_name else ""
return {"units": formatted_unit_name}
except:
return {"units": str(quantity.Unit)}
else:
# Fall back to project unit based on quantity type
unit_type = UNIT_MAPPING.get(quantity_type)
if not unit_type:
return {}
# Get the project unit for this unit type
project_unit = get_project_unit(element.file, unit_type, use_cache=True)
if not project_unit:
return {}
# Get unit name and format
unit_name = get_full_unit_name(project_unit)
formatted_unit_name = unit_name.replace("_", " ").title() if unit_name else ""
return {"units": formatted_unit_name}
except Exception:
# If anything fails, return empty dict
return {}
def _get_quantities(quantities: list[entity_instance], element: entity_instance) -> dict[str, Any]:
"""Extract quantity values from IfcPhysicalQuantity entities."""
results = {}
for quantity in quantities or []:
quantity_name = quantity.Name
quantity_type = quantity.is_a() # Cache the type check
if quantity_type == "IfcPhysicalSimpleQuantity":
# Get the quantity value (3rd attribute for simple quantities)
value = getattr(quantity, quantity.attribute_name(3))
unit_info = _get_unit_info(element, quantity)
if unit_info:
# Create structured quantity object with units
results[quantity_name] = {
"name": quantity_name,
"value": value,
**unit_info,
}
else:
# No unit info available, keep as simple value with name
results[quantity_name] = {"name": quantity_name, "value": value}
elif quantity_type == "IfcPhysicalComplexQuantity":
# Handle complex quantities
data = {k: v for k, v in quantity.get_info().items() if v is not None and k != "Name"}
data["properties"] = _get_quantities(quantity.HasQuantities, element)
del data["HasQuantities"]
results[quantity_name] = data
return results
def get_quantities(element: entity_instance) -> dict[str, object]:
"""
Extract quantity takeoffs (QTOs) from an IFC element with unit information.
"""
qtos = {}
# Handle elements with IsDefinedBy relationship
if element.IsDefinedBy:
for relationship in element.IsDefinedBy:
if relationship.is_a("IfcRelDefinesByProperties"):
definition = relationship.RelatingPropertyDefinition
if definition.is_a("IfcElementQuantity"):
try:
quantities_data = _get_quantities(definition.Quantities, element)
quantities_data["id"] = definition.id()
qtos[definition.Name] = quantities_data
except (KeyError, AttributeError):
# If entity access fails, skip this quantity set
continue
return qtos
+110
View File
@@ -0,0 +1,110 @@
from typing import Any
from ifcopenshell.entity_instance import entity_instance
from ifcopenshell.util.element import get_psets
from ifcopenshell.util.unit import get_full_unit_name, get_project_unit
def _format_unit_name(unit_name: str) -> str:
"""
Convert IFC unit names to user-friendly format.
"""
if not unit_name:
return ""
# Convert underscore-separated words to space-separated and title case
return unit_name.replace("_", " ").title()
def _get_unit_info(element: entity_instance, quantity_type: str) -> dict[str, str]:
"""
Get unit information for a given quantity type from the IFC project.
"""
try:
# Map IFC quantity types to unit types
unit_type_mapping = {
"IfcQuantityLength": "LENGTHUNIT",
"IfcQuantityArea": "AREAUNIT",
"IfcQuantityVolume": "VOLUMEUNIT",
"IfcQuantityCount": None, # Count quantities typically have no units
"IfcQuantityWeight": "MASSUNIT",
"IfcQuantityTime": "TIMEUNIT",
}
unit_type = unit_type_mapping.get(quantity_type)
if not unit_type:
return {}
# Get the project unit for this unit type (with built-in caching)
project_unit = get_project_unit(element.file, unit_type, use_cache=True)
if not project_unit:
return {}
# Get unit name
unit_name = get_full_unit_name(project_unit)
# Format the unit name to be user-friendly
formatted_unit_name = _format_unit_name(unit_name)
return {"units": formatted_unit_name}
except Exception:
# If anything fails, return empty dict to maintain robustness
return {}
def get_quantities(element: entity_instance) -> dict[str, object]:
"""
Extract quantity takeoffs (QTOs) from an IFC element with unit information.
"""
# Get basic quantities using existing utility
quantities = get_psets(element, qtos_only=True, should_inherit=False)
if not quantities:
return {}
# Enhance each QTO pset with unit information
enhanced_quantities = {}
for pset_name, pset_data in quantities.items():
if not isinstance(pset_data, dict) or "id" not in pset_data:
# Fallback for unexpected data structure
enhanced_quantities[pset_name] = pset_data
continue
try:
# Get the actual IfcElementQuantity entity
pset_entity = element.file.by_id(pset_data["id"])
# Transform quantities to include unit information
enhanced_pset = {"id": pset_data["id"]}
# Create mapping of quantity names to their IFC entities for unit lookup
quantity_entities = {
q.Name: q for q in pset_entity.Quantities if hasattr(q, "Name")
}
for qty_name, qty_value in pset_data.items():
if qty_name == "id":
continue
# Get the IFC quantity entity for unit information
qty_entity = quantity_entities[qty_name]
unit_info = _get_unit_info(element, qty_entity.is_a())
if unit_info:
# Create structured quantity object with units
enhanced_pset[qty_name] = {
"name": qty_name,
"value": qty_value,
**unit_info,
}
else:
# No unit info available, keep as simple value with name
enhanced_pset[qty_name] = {"name": qty_name, "value": qty_value}
enhanced_quantities[pset_name] = enhanced_pset
except (KeyError, AttributeError):
# If entity access fails, use original data as fallback
enhanced_quantities[pset_name] = pset_data
return enhanced_quantities
-7
View File
@@ -3,7 +3,6 @@ import contextlib
from specklepy.api.credentials import Account
from specklepy.api.resources import (
ActiveUserResource,
FileImportResource,
ModelResource,
OtherUserResource,
ProjectInviteResource,
@@ -119,12 +118,6 @@ 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,
+5 -3
View File
@@ -1,3 +1,5 @@
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
@@ -12,7 +14,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: str | None = None) -> list[Account]:
def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
"""Gets all the accounts present in this environment
Arguments:
@@ -36,7 +38,7 @@ def get_local_accounts(base_path: str | None = None) -> list[Account]:
return accounts
def get_default_account(base_path: str | None = None) -> Account | None:
def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
"""
Gets this environment's default account if any. If there is no default,
the first found will be returned and set as default.
@@ -59,7 +61,7 @@ def get_default_account(base_path: str | None = None) -> Account | None:
return default
def get_account_from_token(token: str, server_url: str | None = None) -> Account:
def get_account_from_token(token: str, server_url: str = None) -> Account:
"""Gets the local account for the token if it exists
Arguments:
token {str} -- the api token
-2
View File
@@ -1,5 +1,4 @@
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 (
@@ -12,7 +11,6 @@ from specklepy.api.resources.current.version_resource import VersionResource
from specklepy.api.resources.current.workspace_resource import WorkspaceResource
__all__ = [
"FileImportResource",
"ActiveUserResource",
"ModelResource",
"OtherUserResource",
@@ -1,87 +0,0 @@
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
)
+15 -35
View File
@@ -11,7 +11,6 @@ from gql.transport.websockets import WebsocketsTransport
from specklepy.core.api.credentials import Account
from specklepy.core.api.resources import (
ActiveUserResource,
FileImportResource,
ModelResource,
OtherUserResource,
ProjectInviteResource,
@@ -131,19 +130,6 @@ class SpeckleClient:
self.account = Account.from_token(token, self.url)
self._set_up_client()
userData = self.active_user.get()
# None if the token lacked the profile:read scope or if it was None
if userData:
self.account.userInfo.id = userData.id
self.account.userInfo.email = userData.email
self.account.userInfo.name = userData.name
self.account.userInfo.company = userData.company
self.account.userInfo.avatar = userData.avatar
self.account.serverInfo = self.server.get()
self.account.serverInfo.url = self.url
def authenticate_with_account(self, account: Account) -> None:
"""Authenticate the client using an Account object
The account is saved in the client object and a synchronous GraphQL
@@ -156,21 +142,6 @@ class SpeckleClient:
self.account = account
self._set_up_client()
try:
_ = self.active_user.get()
except SpeckleException as ex:
if isinstance(ex.exception, TransportServerError):
if ex.exception.code == 403:
warn(
SpeckleWarning(
"Possibly invalid token - could not authenticate "
f"Speckle Client for server {self.url}"
),
stacklevel=2,
)
else:
raise ex
def _set_up_client(self) -> None:
headers = {
"Authorization": f"Bearer {self.account.token}",
@@ -190,6 +161,21 @@ class SpeckleClient:
self._init_resources()
try:
_ = self.active_user.get()
except SpeckleException as ex:
if isinstance(ex.exception, TransportServerError):
if ex.exception.code == 403:
warn(
SpeckleWarning(
"Possibly invalid token - could not authenticate "
f"Speckle Client for server {self.url}"
),
stacklevel=2,
)
else:
raise ex
def execute_query(self, query: str) -> Dict:
return self.httpclient.execute(query)
@@ -244,12 +230,6 @@ 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,
+13 -13
View File
@@ -1,6 +1,6 @@
import os
from pathlib import Path
from typing import List
from typing import List, Optional
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: str | None = None
name: str | None = None
email: str | None = None
company: str | None = None
avatar: str | None = None
id: Optional[str] = None
name: Optional[str] = None
email: Optional[str] = None
company: Optional[str] = None
avatar: Optional[str] = None
class Account(BaseModel):
isDefault: bool = False
token: str | None = None
refreshToken: str | None = None
token: Optional[str] = None
refreshToken: Optional[str] = None
serverInfo: ServerInfo = Field(default_factory=ServerInfo)
userInfo: UserInfo = Field(default_factory=UserInfo)
id: str | None = None
id: Optional[str] = None
def __repr__(self) -> str:
return (
@@ -37,13 +37,13 @@ class Account(BaseModel):
return self.__repr__()
@classmethod
def from_token(cls, token: str, server_url: str | None = None):
def from_token(cls, token: str, server_url: str = None):
acct = cls(token=token)
acct.serverInfo.url = server_url
return acct
def get_local_accounts(base_path: str | None = None) -> List[Account]:
def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
"""Gets all the accounts present in this environment
Arguments:
@@ -93,7 +93,7 @@ def get_local_accounts(base_path: str | None = None) -> List[Account]:
return accounts
def get_default_account(base_path: str | None = None) -> Account | None:
def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
"""
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: str | None = None) -> Account | None:
return default
def get_account_from_token(token: str, server_url: str | None = None) -> Account:
def get_account_from_token(token: str, server_url: str = None) -> Account:
"""Gets the local account for the token if it exists
Arguments:
token {str} -- the api token
-12
View File
@@ -1,10 +1,3 @@
from specklepy.core.api.inputs.file_import_inputs import (
FileImportErrorInput,
FileImportSuccessInput,
FinishFileImportInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.inputs.model_inputs import (
CreateModelInput,
DeleteModelInput,
@@ -29,11 +22,6 @@ from specklepy.core.api.inputs.version_inputs import (
)
__all__ = [
"FileImportErrorInput",
"FileImportSuccessInput",
"FinishFileImportInput",
"StartFileImportInput",
"GenerateFileUploadUrlInput",
"CreateModelInput",
"DeleteModelInput",
"UpdateModelInput",
@@ -1,44 +0,0 @@
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,7 +1,5 @@
from specklepy.core.api.models.current import (
AuthStrategy,
FileImport,
FileUploadUrl,
LimitedUser,
Model,
ModelWithVersions,
@@ -50,6 +48,4 @@ __all__ = [
"ProjectModelsUpdatedMessage",
"ProjectUpdatedMessage",
"ProjectVersionsUpdatedMessage",
"FileImport",
"FileUploadUrl",
]
+46 -62
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Generic, List, TypeVar
from typing import Generic, List, Optional, 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: str | None = None
email: Optional[str] = None
name: str
bio: str | None = None
company: str | None = None
avatar: str | None = None
verified: bool | None = None
role: str | None = None
bio: Optional[str] = None
company: Optional[str] = None
avatar: Optional[str] = None
verified: Optional[bool] = None
role: Optional[str] = None
def __repr__(self):
return (
@@ -31,16 +31,16 @@ class User(GraphQLBaseModel):
class ResourceCollection(GraphQLBaseModel, Generic[T]):
total_count: int
items: List[T]
cursor: str | None = None
cursor: Optional[str] = None
class ServerMigration(GraphQLBaseModel):
moved_from: str | None
moved_to: str | None
moved_from: Optional[str]
moved_to: Optional[str]
class AuthStrategy(GraphQLBaseModel):
color: str | None
color: Optional[str]
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: 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
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
# TODO separate gql model from account management model
@@ -79,11 +79,11 @@ class LimitedUser(GraphQLBaseModel):
id: str
name: str
bio: str | None
company: str | None
avatar: str | None
verified: bool | None
role: str | None
bio: Optional[str]
company: Optional[str]
avatar: Optional[str]
verified: Optional[bool]
role: Optional[str]
def __repr__(self):
return (
@@ -99,15 +99,15 @@ class LimitedUser(GraphQLBaseModel):
class PendingStreamCollaborator(GraphQLBaseModel):
id: str
invite_id: str
stream_id: str | None = None
stream_id: Optional[str] = None
projectId: str
stream_name: str | None = None
stream_name: Optional[str] = None
project_name: str
title: str
role: str
invited_by: LimitedUser
user: LimitedUser | None = None
token: str | None
user: Optional[LimitedUser] = None
token: Optional[str]
def __repr__(self):
return (
@@ -127,24 +127,24 @@ class ProjectCollaborator(GraphQLBaseModel):
class Version(GraphQLBaseModel):
author_user: LimitedUser | None
author_user: Optional[LimitedUser]
created_at: datetime
id: str
message: str | None
message: Optional[str]
preview_url: str
referenced_object: str | None
referenced_object: Optional[str]
"""Maybe null if workspaces version history limit has been exceeded"""
source_application: str | None
source_application: Optional[str]
class Model(GraphQLBaseModel):
author: LimitedUser | None
author: Optional[LimitedUser]
created_at: datetime
description: str | None
description: Optional[str]
display_name: str
id: str
name: str
preview_url: str | None
preview_url: Optional[str]
updated_at: datetime
@@ -162,14 +162,14 @@ class ProjectPermissionChecks(GraphQLBaseModel):
class Project(GraphQLBaseModel):
allow_public_comments: bool
created_at: datetime
description: str | None
description: Optional[str]
id: str
name: str
role: str | None
role: Optional[str]
source_apps: List[str]
updated_at: datetime
visibility: ProjectVisibility
workspace_id: str | None
workspace_id: Optional[str]
class ProjectWithModels(Project):
@@ -191,7 +191,7 @@ class ProjectCommentCollection(ResourceCollection[T], Generic[T]):
class UserSearchResultCollection(GraphQLBaseModel):
items: List[LimitedUser]
cursor: str | None = None
cursor: Optional[str] = None
class PermissionCheckResult(GraphQLBaseModel):
@@ -216,31 +216,15 @@ class WorkspaceCreationState(GraphQLBaseModel):
class LimitedWorkspace(GraphQLBaseModel):
id: str
name: str
role: str | None
role: Optional[str]
slug: str
logo: str | None
description: str | None
logo: Optional[str]
description: Optional[str]
class Workspace(LimitedWorkspace):
created_at: datetime
updated_at: datetime
read_only: bool
creation_state: WorkspaceCreationState | None
creation_state: Optional[WorkspaceCreationState]
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,5 +1,4 @@
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 (
@@ -14,7 +13,6 @@ 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",
@@ -1,212 +0,0 @@
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
+21 -7
View File
@@ -1,6 +1,8 @@
from urllib.parse import quote, unquote, urlparse
from warnings import warn
from gql import gql
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import (
Account,
@@ -137,11 +139,27 @@ class StreamWrapper:
if use_fe2 is True and self.branch_name is not None:
self.model_id = self.branch_name
# get branch name
query = gql(
"""
query Project($project_id: String!, $model_id: String!) {
project(id: $project_id) {
id
model(id: $model_id) {
name
}
}
}
"""
)
self._client = self.get_client()
model = self._client.model.get(self.model_id, self.stream_id)
params = {"project_id": self.stream_id, "model_id": self.model_id}
project = self._client.httpclient.execute(query, params)
self.branch_name = model.name
try:
self.branch_name = project["project"]["model"]["name"]
except KeyError as ke:
raise SpeckleException("Project model name is not found", ke) from ke
if not self.stream_id:
raise SpeckleException(
@@ -157,10 +175,6 @@ class StreamWrapper:
"""
Gets an account object for this server from the local accounts db
(added via Speckle Manager or a json file)
WARNING: this function will return ANY account for the server,
just because you pass a token in doesn't guarantee it will be used.
This whole class could do with a re-design...
"""
if self._account and self._account.token:
return self._account
-4
View File
@@ -1,5 +1 @@
"""Common helpers module for Core."""
from specklepy.core.helpers.random import crypto_random_string
__all__ = ["crypto_random_string"]
-8
View File
@@ -1,8 +0,0 @@
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()
@@ -88,8 +88,6 @@ def user_application_data_path() -> Path:
message="Cannot get appdata path from environment."
)
return Path(app_data_path)
if sys.platform.startswith("darwin"): # macOS
return _ensure_folder_exists(Path.home() / "Library", "Application Support")
else:
# try getting the standard XDG_DATA_HOME value
# as that is used as an override
@@ -100,7 +98,7 @@ def user_application_data_path() -> Path:
return _ensure_folder_exists(Path.home(), ".config")
except Exception as ex:
raise SpeckleException(
message="Failed to initialize user application data path."
message="Failed to initialize user application data path.", exception=ex
) from ex
+62 -64
View File
@@ -6,12 +6,10 @@ import platform
import queue
import sys
import threading
from typing import Any
from typing import Optional
import requests
from specklepy.core.api.credentials import Account
"""
Anonymous telemetry to help us understand how to make a better Speckle.
This really helps us to deliver a better open source project and product!
@@ -30,6 +28,21 @@ CONNECTOR = "Connector Action"
RECEIVE = "Receive"
SEND = "Send"
# not in use since 2.15
ACCOUNTS = "Get Local Accounts"
BRANCH = "Branch Action"
CLIENT = "Speckle Client"
COMMIT = "Commit Action"
DESERIALIZE = "serialization/deserialize"
INVITE = "Invite Action"
OTHER_USER = "Other User Action"
PERMISSION = "Permission Action"
SERIALIZE = "serialization/serialize"
SERVER = "Server Action"
STREAM = "Stream Action"
STREAM_WRAPPER = "Stream Wrapper"
USER = "User Action"
def disable():
global TRACK
@@ -41,7 +54,7 @@ def enable():
TRACK = True
def set_host_app(host_app: str, host_app_version: str | None = None):
def set_host_app(host_app: str, host_app_version: Optional[str] = None):
global HOST_APP, HOST_APP_VERSION
HOST_APP = host_app
HOST_APP_VERSION = host_app_version or HOST_APP_VERSION
@@ -49,46 +62,45 @@ def set_host_app(host_app: str, host_app_version: str | None = None):
def track(
action: str,
account: Account | None = None,
custom_props: dict | None = None,
send_sync: bool = False,
account=None,
custom_props: Optional[dict] = None,
):
if not TRACK:
return
try:
initialise_tracker(account)
event_params = {
"event": action,
"properties": {
"distinct_id": METRICS_TRACKER.last_user,
"server_id": METRICS_TRACKER.last_server,
"token": METRICS_TRACKER.analytics_token,
"hostApp": HOST_APP,
"hostAppVersion": HOST_APP_VERSION,
"$os": METRICS_TRACKER.platform,
"type": "action",
},
}
if custom_props:
event_params["properties"].update(custom_props)
tracker = initialise_tracker(account)
event_params: dict[str, Any] = {
"event": action,
"properties": {
"distinct_id": tracker.last_user,
"server_id": tracker.last_server,
"token": tracker.analytics_token,
"hostApp": HOST_APP,
"hostAppVersion": HOST_APP_VERSION,
"$os": tracker.platform,
"type": "action",
},
}
if custom_props:
event_params["properties"].update(custom_props)
if send_sync:
tracker.send_event(event_params)
else:
tracker.queue_event(event_params)
METRICS_TRACKER.queue.put_nowait(event_params)
except Exception as ex:
# wrapping this whole thing in a try except as we never want a failure here
# to annoy users!
LOG.debug(f"Error queueing metrics request: {str(ex)}")
def initialise_tracker(account: Account | None = None) -> "MetricsTracker":
def initialise_tracker(account=None):
global METRICS_TRACKER
if not METRICS_TRACKER:
METRICS_TRACKER = MetricsTracker()
if account:
if account and account.userInfo.email:
METRICS_TRACKER.set_last_user(account.userInfo.email)
if account and account.serverInfo.url:
METRICS_TRACKER.set_last_server(account.serverInfo.url)
return METRICS_TRACKER
class Singleton(type):
_instances = {}
@@ -100,62 +112,48 @@ class Singleton(type):
class MetricsTracker(metaclass=Singleton):
analytics_url: str = "https://analytics.speckle.systems/track?ip=1"
analytics_token: str = "acd87c5a50b56df91a795e999812a3a4"
last_user: str = ""
last_server: str | None = None
platform: str
_sending_thread: threading.Thread
_queue: queue.Queue[dict[str, Any]] = queue.Queue(1000)
_session = requests.Session()
analytics_url = "https://analytics.speckle.systems/track?ip=1"
analytics_token = "acd87c5a50b56df91a795e999812a3a4"
last_user = ""
last_server = None
platform = None
sending_thread = None
queue = queue.Queue(1000)
def __init__(self) -> None:
self._sending_thread = threading.Thread(
self.sending_thread = threading.Thread(
target=self._send_tracking_requests, daemon=True
)
self.platform = PLATFORMS.get(sys.platform, "linux")
self._sending_thread.start()
self.sending_thread.start()
with contextlib.suppress(Exception):
node, user = platform.node(), getpass.getuser()
if node and user:
self.last_user = f"@{self.hash(f'{node}-{user}')}"
def set_last_user(self, email: str | None) -> None:
def set_last_user(self, email: str):
if not email:
return
self.last_user = f"@{self.hash(email)}"
def set_last_server(self, server: str | None) -> None:
def set_last_server(self, server: str):
if not server:
return
self.last_server = self.hash(server)
def hash(self, value: str) -> str:
def hash(self, value: str):
inputList = value.lower().split("://")
input = inputList[len(inputList) - 1].split("/")[0].split("?")[0]
return hashlib.md5(input.encode("utf-8")).hexdigest().upper()
def queue_event(self, event_params: dict[str, Any]) -> None:
try:
self._queue.put_nowait(event_params)
except queue.Full:
LOG.warning(
"Metrics event was skipped because the metrics queue was was full",
exc_info=True,
)
def send_event(self, event_params: dict[str, Any]) -> None:
response = self._session.post(self.analytics_url, json=[event_params])
response.raise_for_status()
def _send_tracking_requests(self) -> None:
def _send_tracking_requests(self):
session = requests.Session()
while True:
event_params = self._queue.get()
event_params = [self.queue.get()]
try:
self.send_event(event_params)
except Exception:
LOG.warning("Error sending metrics request", exc_info=True)
session.post(self.analytics_url, json=event_params)
except Exception as ex:
LOG.debug(f"Error sending metrics request: {str(ex)}")
self._queue.task_done()
self.queue.task_done()
-24
View File
@@ -323,30 +323,6 @@ def _validate_type(t: Optional[type], value: Any) -> Tuple[bool, Any]:
@dataclass(kw_only=True)
class Base(_RegisteringBase, speckle_type="Base"):
"""Base class for all Speckle objects.
The base object class is the foundation of all data being
transferred with Speckle. Any custom data structure that you want to transfer via
Speckle should inherit from it.
Objects in Speckle are immutable for storage purposes. When any property changes,
the object gets a new identity (hash). This hash is stored in the `id` property
after serialization.
Attributes:
id: Unique identifier (hash) for the object. This is typically
set automatically during serialization and depends on the object's properties.
applicationId: Optional identifier for the application that created
this object, can store the host application's native object ID.
```py title="Example"
from specklepy.objects.base import Base
obj = Base(id="some-id", applicationId="my-app")
obj["custom_prop"] = 42 # Add a dynamic property
obj["@detached_prop"] = another_object # Add a detached property
```
"""
id: Union[str, None] = None
# totalChildrenCount: Union[int, None] = None
applicationId: Union[str, None] = None
-4
View File
@@ -18,10 +18,6 @@ class DataObject(
speckle_type="Objects.Data.DataObject",
detachable={"displayValue"},
):
"""
A generic data object that can hold arbitrary properties and display values.
"""
name: str
properties: Dict[str, object]
displayValue: List[Base]
-39
View File
@@ -9,30 +9,6 @@ from specklepy.objects.interfaces import ICurve, IHasUnits
@dataclass(kw_only=True)
class Arc(Base, IHasUnits, ICurve, speckle_type="Objects.Geometry.Arc"):
"""
An arc defined by a plane, start point, mid point and end point.
This class represents a circular arc in 3D space, defined by three points
and a plane. The arc is a portion of a circle that lies on the specified plane.
Attributes:
plane: The plane on which the arc lies
startPoint: The starting point of the arc
midPoint: A point on the arc between the start and end points
endPoint: The ending point of the arc.
```py title="Example"
from specklepy.objects.geometry.plane import Plane
from specklepy.objects.geometry.point import Point
plane = Plane(origin=Point(0, 0, 0), normal=Point(0, 0, 1))
start = Point(1, 0, 0)
mid = Point(0.7071, 0.7071, 0)
end = Point(0, 1, 0)
arc = Arc(plane=plane, startPoint=start, midPoint=mid, endPoint=end)
```
"""
plane: Plane
startPoint: Point
midPoint: Point
@@ -40,20 +16,10 @@ class Arc(Base, IHasUnits, ICurve, speckle_type="Objects.Geometry.Arc"):
@property
def radius(self) -> float:
"""Calculates the radius of the arc.
Returns:
The radius of the arc, as the distance from the start point to the origin.
"""
return self.startPoint.distance_to(self.plane.origin)
@property
def length(self) -> float:
"""Calculates the length of the arc.
Returns:
The length of the arc.
"""
start_to_mid = self.startPoint.distance_to(self.midPoint)
mid_to_end = self.midPoint.distance_to(self.endPoint)
r = self.radius
@@ -64,11 +30,6 @@ class Arc(Base, IHasUnits, ICurve, speckle_type="Objects.Geometry.Arc"):
@property
def measure(self) -> float:
"""Calculates the angular measure of the arc in radians.
Returns:
The angular measure of the arc in radians.
"""
start_to_mid = self.startPoint.distance_to(self.midPoint)
mid_to_end = self.midPoint.distance_to(self.endPoint)
r = self.radius
+1 -33
View File
@@ -9,29 +9,7 @@ from specklepy.objects.primitive import Interval
@dataclass(kw_only=True)
class Box(Base, IHasUnits, IHasArea, IHasVolume, speckle_type="Objects.Geometry.Box"):
"""
A 3-dimensional box oriented on a plane.
This class represents a rectangular prism in 3D space, defined by a base plane and
three intervals specifying its dimensions along the x, y, and z axes.
Attributes:
basePlane: The plane on which the box is oriented
xSize: The interval defining the box's size along the x-axis
ySize: The interval defining the box's size along the y-axis
zSize: The interval defining the box's size along the z-axis
```py title="Example"
from specklepy.objects.geometry.plane import Plane
from specklepy.objects.geometry.point import Point
from specklepy.objects.primitive import Interval
base_plane = Plane(origin=Point(0, 0, 0), normal=Point(0, 0, 1))
x_size = Interval(start=0, end=10)
y_size = Interval(start=0, end=5)
z_size = Interval(start=0, end=3)
box = Box(basePlane=base_plane, xSize=x_size, ySize=y_size, zSize=z_size)
```
a 3-dimensional box oriented on a plane
"""
basePlane: Plane
@@ -51,11 +29,6 @@ class Box(Base, IHasUnits, IHasArea, IHasVolume, speckle_type="Objects.Geometry.
@property
def area(self) -> float:
"""Calculates the surface area of the box.
Returns:
The total surface area of the box.
"""
return 2 * (
self.xSize.length * self.ySize.length
+ self.xSize.length * self.zSize.length
@@ -64,9 +37,4 @@ class Box(Base, IHasUnits, IHasArea, IHasVolume, speckle_type="Objects.Geometry.
@property
def volume(self) -> float:
"""Calculates the volume of the box.
Returns:
The volume of the box.
"""
return self.xSize.length * self.ySize.length * self.zSize.length
+3 -23
View File
@@ -33,9 +33,9 @@ class InstanceProxy(
IHasUnits,
speckle_type="Speckle.Core.Models.Instances.InstanceProxy",
):
definitionId: str
definition_id: str
transform: List[float]
maxDepth: int
max_depth: int
@dataclass(kw_only=True)
@@ -45,30 +45,10 @@ class InstanceDefinitionProxy(
detachable={"objects"},
):
objects: List[str]
maxDepth: int
max_depth: int
name: str
@dataclass(kw_only=True)
class LevelProxy(
Base,
speckle_type="Objects.Other.LevelProxy",
detachable={"objects"},
):
"""
used to store building storey to object relationships in root collections
Args:
objects (list): the list of application ids of objects in this building storey
value (DataObject): the building storey data object with all properties
applicationId (str): the GUID of the building storey
"""
objects: List[str]
value: Base
applicationId: str
@dataclass(kw_only=True)
class RenderMaterialProxy(
Base,
@@ -7,7 +7,6 @@ import threading
import requests
from specklepy.logging.exceptions import SpeckleException
from specklepy.transports.server.retry_policy import setup_session
LOG = logging.getLogger(__name__)
@@ -73,7 +72,10 @@ class BatchSender:
def _sending_thread_main(self):
try:
session = setup_session(self._token)
session = requests.Session()
session.headers.update(
{"Authorization": f"Bearer {self._token}", "Accept": "text/plain"}
)
while True:
batch = self._batches.get()
@@ -121,8 +123,8 @@ class BatchSender:
upload_data = "[" + ",".join(new_objects) + "]"
upload_data_gzip = gzip.compress(upload_data.encode())
LOG.info(
"Uploading batch of {batch_size} objects {new_object_count}: "
+ "(size: {upload_size}, compressed size: {upload_data_size})",
"Uploading batch of {batch_size} objects {new_object_count}: ",
"(size: {upload_size}, compressed size: {upload_data_size})",
{
"batch_size": len(batch),
"new_object_count": len(new_objects),
@@ -1,46 +0,0 @@
import requests
from requests.adapters import HTTPAdapter
from urllib3 import Retry
def setup_session(auth_token: str | None) -> requests.Session:
"""
Sets up a requests.Session with a basic retry policy
to retry on all the usual retryable status codes, with a back off policy:
1st: 0ms,
2nd: 500ms,
3rd: 1500ms.
Also sets "Accept": "text/plain" header (because this is what ServerTransport needs)
and (if a auth_token is provided) the Authorization header
"""
session = requests.Session()
retry_policy = Retry(
total=3,
read=3,
connect=3,
backoff_factor=0.5,
status_forcelist=(500, 502, 503, 504, 520, 408, 429),
allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"],
raise_on_status=False,
)
adapter = HTTPAdapter(max_retries=retry_policy)
session.mount("https://", adapter)
session.mount("http://", adapter)
session.headers.update(
{
"Accept": "text/plain",
}
)
if auth_token is not None:
session.headers.update(
{
"Authorization": f"Bearer {auth_token}",
}
)
return session
+15 -4
View File
@@ -2,11 +2,12 @@ import json
from typing import Dict, List, Optional
from warnings import warn
import requests
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account, get_account_from_token
from specklepy.logging.exceptions import SpeckleException, SpeckleWarning
from specklepy.transports.abstract_transport import AbstractTransport
from specklepy.transports.server.retry_policy import setup_session
from .batch_sender import BatchSender
@@ -91,13 +92,23 @@ class ServerTransport(AbstractTransport):
self.stream_id = stream_id
self.url = url
self.session = requests.Session()
self.session.headers.update(
{
"Accept": "text/plain",
}
)
if self.account is not None:
self._batch_sender = BatchSender(
self.url, self.stream_id, self.account.token, max_batch_size_mb=1
)
self.session = setup_session(
self.account.token if self.account is not None else None
)
self.session.headers.update(
{
"Authorization": f"Bearer {self.account.token}",
}
)
@property
def name(self) -> str:
@@ -1,61 +0,0 @@
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;
@@ -1,251 +0,0 @@
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
@@ -6,7 +6,7 @@ from specklepy.api import operations
from specklepy.api.client import SpeckleClient
from specklepy.api.credentials import Account, get_account_from_token
from specklepy.core.helpers import speckle_path_provider
from specklepy.logging.exceptions import SpeckleException
from specklepy.logging.exceptions import SpeckleException, SpeckleWarning
from specklepy.objects.base import Base
from specklepy.transports.server import ServerTransport
@@ -17,7 +17,7 @@ def test_invalid_authentication():
speckle_path_provider.override_application_data_path(gettempdir())
client = SpeckleClient()
with pytest.raises(SpeckleException):
with pytest.warns(SpeckleWarning):
client.authenticate_with_token("fake token")
# remove path override
+8 -11
View File
@@ -8,12 +8,11 @@ import requests
from specklepy.api.client import SpeckleClient
from specklepy.core.api import operations
from specklepy.core.api.credentials import Account, UserInfo
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs.project_inputs import ProjectCreateInput
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.models import Version
from specklepy.core.api.models.current import Project, ServerInfo
from specklepy.core.api.models.current import Project
from specklepy.logging import metrics
from specklepy.objects.base import Base
from specklepy.objects.geometry import Point
@@ -90,15 +89,13 @@ def second_user_dict(host: str) -> Dict[str, str]:
def create_client(host: str, token: str) -> SpeckleClient:
client = SpeckleClient(host=host, use_ssl=False)
client.authenticate_with_token(token)
assert isinstance(client.account, Account)
assert isinstance(client.account.userInfo, UserInfo)
assert client.account.userInfo.id
assert client.account.userInfo.name
assert isinstance(client.account.serverInfo, ServerInfo)
assert client.account.serverInfo.url
assert client.account.serverInfo.name
user = client.active_user.get()
assert user
client.account.userInfo.id = user.id
client.account.userInfo.email = user.email
client.account.userInfo.name = user.name
client.account.userInfo.company = user.company
client.account.userInfo.avatar = user.avatar
return client
@@ -14,16 +14,12 @@ from speckle_automate import (
run_function,
)
from speckle_automate.fixtures import (
TestAutomationEnvironment,
create_test_automation_run_data,
crypto_random_string,
)
from speckle_automate.schema import AutomateBase
from specklepy.api.client import SpeckleClient
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs import ProjectCreateInput
from specklepy.core.api.models import Project
from specklepy.core.api.models.current import Model, Version
from specklepy.core.helpers import crypto_random_string
from specklepy.objects.base import Base
@@ -47,32 +43,17 @@ def test_client(speckle_server_url: str, speckle_token: str) -> SpeckleClient:
return test_client
@pytest.fixture
def project(test_client: SpeckleClient) -> Project:
return test_client.project.create(
ProjectCreateInput(
name="test", description=None, visibility=ProjectVisibility.PRIVATE
)
)
@pytest.fixture
def automation_run_data(
test_client: SpeckleClient,
speckle_server_url: str,
speckle_token: str,
project: Project,
test_client: SpeckleClient, speckle_server_url: str
) -> AutomationRunData:
"""TODO: Set up a test automation for integration testing"""
project_id = crypto_random_string(10)
test_automation_id = crypto_random_string(10)
environment = TestAutomationEnvironment(
token=speckle_token,
server_url=speckle_server_url,
project_id=project.id,
automation_id=test_automation_id,
)
return create_test_automation_run_data(test_client, environment)
return create_test_automation_run_data(
test_client, speckle_server_url, project_id, test_automation_id
)
@pytest.fixture
@@ -152,7 +133,7 @@ def automate_function(
raise ValueError("Cannot operate on objects without their id's.")
automation_context.attach_error_to_objects(
"Forbidden speckle_type",
version_root_object,
version_root_object.id,
"This project should not contain the type: "
f"{function_inputs.forbidden_speckle_type}",
)
@@ -183,7 +164,7 @@ def test_function_run(automation_context: AutomationContext) -> None:
assert automation_context.run_status == AutomationStatus.FAILED
status = get_automation_status(
automation_context.automation_run_data.project_id,
automation_context.automation_run_data.triggers[0].payload.model_id,
automation_context.automation_run_data.model_id,
automation_context.speckle_client,
)
assert status["status"] == automation_context.run_status
@@ -224,7 +205,7 @@ def test_create_version_in_project_raises_error_for_same_model(
) -> None:
with pytest.raises(ValueError):
automation_context.create_new_version_in_project(
Base(), automation_context.automation_run_data.triggers[0].payload.model_id
Base(), automation_context.automation_run_data.branch_name
)
@@ -239,8 +220,8 @@ def test_create_version_in_project(
model, version = automation_context.create_new_version_in_project(
root_object, "foobar"
)
assert isinstance(model, Model)
assert isinstance(version, Version)
isinstance(model, Model)
isinstance(version, Version)
@pytest.mark.skip(
@@ -249,11 +230,9 @@ def test_create_version_in_project(
def test_set_context_view(automation_context: AutomationContext) -> None:
automation_context.set_context_view()
trigger = automation_context.automation_run_data.triggers[0].payload
assert automation_context.context_view is not None
assert automation_context.context_view.endswith(
f"models/{trigger.model_id}@{trigger.version_id}"
f"models/{automation_context.automation_run_data.model_id}@{automation_context.automation_run_data.version_id}"
)
automation_context.report_run_status()
@@ -265,7 +244,7 @@ def test_set_context_view(automation_context: AutomationContext) -> None:
assert automation_context.context_view is not None
assert automation_context.context_view.endswith(
f"models/{trigger.model_id}@{trigger.version_id},{dummy_context}"
f"models/{automation_context.automation_run_data.model_id}@{automation_context.automation_run_data.version_id},{dummy_context}"
)
automation_context.report_run_status()
-20
View File
@@ -1,20 +0,0 @@
import requests
from specklepy.transports.server.retry_policy import setup_session
def test_session_headers_without_auth():
"""Check that Accept header is set and Authorization is not."""
session = setup_session(None)
assert isinstance(session, requests.Session)
assert session.headers["Accept"] == "text/plain"
assert "Authorization" not in session.headers
def test_session_headers_with_auth():
"""Check that Authorization header is properly added."""
token = "abc123"
session = setup_session(token)
assert isinstance(session, requests.Session)
assert session.headers["Authorization"] == f"Bearer {token}"
assert session.headers["Accept"] == "text/plain"
Generated
+1237 -1491
View File
File diff suppressed because it is too large Load Diff