Compare commits

...

16 Commits

Author SHA1 Message Date
Jedd Morgan af42b09dd5 Map nan values to None (#458)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-10-02 14:21:04 +02:00
Jedd Morgan e4453f0b04 Fix geometry counter (#455) 2025-10-01 12:56:53 +02:00
Gergő Jedlicska c9a0e45171 fix: limit gql package version to not upgrade to latest major version (#456) 2025-10-01 12:54:17 +02:00
Jedd Morgan f20fc7edb3 Fix stream wrapper client call (#457)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-10-01 11:17:44 +01:00
Jedd Morgan 0cd0c3a1f6 correct macos user application data path (#454) 2025-09-19 15:01:25 +01:00
Jedd Morgan 2594ce0382 fix(specklepy): small tweaks to the url handling of accounts (#452)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* set url post query

* Use canonical url if available

* format

* revert canonical url changes

* quick tweak

* small tweak again

* Add test
2025-09-11 17:34:06 +01:00
Jedd Morgan ec67f5ba48 Add more exception wrapping to display more useful error messages (#451)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-09-11 14:49:16 +00:00
Jedd Morgan db61d2e99c feat(specklepy): Make Client.authenticate_with_token initialise user data (#450)
* easy solution

* Fixed tests
2025-09-11 15:37:31 +01:00
Jedd Morgan 69090f6eb1 Fix log warnings caused by mistake in args (#449)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-09-05 18:14:26 +02:00
Jedd Morgan 99f0b3516a Fix missing typing in metrics (#436)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-09-04 15:42:19 +00:00
Mucahit Bilal GOKER f69ee07a94 feat (speckleifc): Level Proxies (#444)
* add level proxies class

* level proxy manager

* importer updates

* add storey name to data objects

* formatting

* fix python syntax

---------

Co-authored-by: Jedd Morgan <45512892+JR-Morgan@users.noreply.github.com>
2025-09-04 16:35:30 +01:00
Gergő Jedlicska 1d246c921a chore: add py3.13 compatible ifcopenshell (#448) 2025-09-04 17:31:58 +02:00
Jedd Morgan 80b5982424 feat(fic): Add Metrics tracking (#447) 2025-09-04 17:17:41 +02:00
Jedd Morgan d06f0b5b4e feat(speckleifc): Add QTOS (#446)
* add qtos

* cleanup

* add units first pass

* module cache for project units

* quantity field unit cache

* quantity extraction py

* comm cleanup

* cache by field name

* simplify get quantities

* take only what you need

* move unit mapping to module level

* function call elimination

* early return ifc quantities

* final touches (hopefully)

* second pass

* fixed mistake

* fix

* little optimisation

* reset main back to before

---------

Co-authored-by: bimgeek <mucahitbgoker@gmail.com>
2025-08-27 13:43:35 +01:00
Chuck Driesler a6790c7c70 feat(automate): return blob id from file results (#445) 2025-08-26 15:27:13 +01:00
Gergő Jedlicska 7bc78b6bf9 feat: add file import resource with complete job handling support (#440)
* feat: add file import resource with complete job handling support

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

* feat: integrate with server side parser app

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

* chore: fix test compose file readiness probe
2025-08-26 14:25:01 +01:00
39 changed files with 2439 additions and 1467 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
run: uv run pytest --cov --cov-report xml:reports/coverage.xml --junitxml=reports/test-results.xml
- uses: codecov/codecov-action@v5
if: matrix.python-version == 3.13
if: matrix.python-version == 3.12
with:
fail_ci_if_error: true # optional (default = false)
files: ./reports/test-results.xml # optional
+11 -4
View File
@@ -13,7 +13,7 @@ services:
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- postgres-data:/var/lib/postgresql/data/
- ./.volumes/postgres-data:/var/lib/postgresql/data/
healthcheck:
# the -U user has to match the POSTGRES_USER value
test: ["CMD-SHELL", "pg_isready -U speckle"]
@@ -25,7 +25,7 @@ services:
image: "redis:6.0-alpine"
restart: always
volumes:
- redis-data:/data
- ./.volumes/redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
@@ -37,7 +37,7 @@ services:
command: server /data --console-address ":9001"
restart: always
volumes:
- minio-data:/data
- ./.volumes/minio-data:/data
healthcheck:
test:
[
@@ -48,6 +48,8 @@ services:
timeout: 30s
retries: 30
start_period: 10s
ports:
- "0.0.0.0:9000:9000"
speckle-server:
image: speckle/speckle-server:latest
@@ -57,7 +59,7 @@ services:
- CMD
- /nodejs/bin/node
- -e
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(res.statusCode != 200 || body.toLowerCase().includes('error'));}); }).end(); } catch { process.exit(1); }"
- "try { require('node:http').request({headers: {'Content-Type': 'application/json'}, port:3000, hostname:'127.0.0.1', path:'/readiness', method: 'GET', timeout: 2000 }, (res) => { body = ''; res.on('data', (chunk) => {body += chunk;}); res.on('end', () => {process.exit(Number(res.statusCode != 200 || body.toLowerCase().includes('error')));}); }).end(); } catch { process.exit(1); }"
interval: 10s
timeout: 10s
retries: 3
@@ -81,6 +83,7 @@ services:
REDIS_URL: "redis://redis"
S3_ENDPOINT: "http://minio:9000"
S3_PUBLIC_ENDPOINT: "http://127.0.0.1:9000"
S3_ACCESS_KEY: "minioadmin"
S3_SECRET_KEY: "minioadmin"
S3_BUCKET: "speckle-server"
@@ -101,6 +104,10 @@ services:
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
FF_BACKGROUND_JOBS_ENABLED: "true"
networks:
default:
name: speckle-server
+6
View File
@@ -0,0 +1,6 @@
[tools]
python = "3.13.7"
[settings]
experimental = true
python.uv_venv_auto = true
+2 -2
View File
@@ -11,7 +11,7 @@ dependencies = [
"appdirs>=1.4.4",
"attrs>=24.3.0",
"deprecated>=1.2.15",
"gql[requests,websockets]>=3.5.0",
"gql[requests,websockets]>=3.5.0,<4.0.0",
"httpx>=0.28.1",
"pydantic>=2.10.5",
"pydantic-settings>=2.7.1",
@@ -19,7 +19,7 @@ dependencies = [
]
[project.optional-dependencies]
speckleifc = ["ifcopenshell>=0.8.2"]
speckleifc = ["ifcopenshell>=0.8.3.post2"]
[dependency-groups]
dev = [
+3 -1
View File
@@ -268,7 +268,7 @@ class AutomationContext:
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]) -> None:
def store_file_result(self, file_path: Union[Path, str]) -> str:
"""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,6 +310,8 @@ 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) -> None:
"""Mark the current run a failure."""
self._mark_run(AutomationStatus.FAILED, status_message)
-9
View File
@@ -1,8 +1,5 @@
"""Some useful helpers for working with automation data."""
import secrets
import string
import pytest
from gql import gql
from pydantic import Field
@@ -140,12 +137,6 @@ def test_automation_run_data(
return create_test_automation_run_data(speckle_client, test_automation_environment)
def crypto_random_string(length: int) -> str:
"""Generate a semi crypto random string of a given length."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length)).lower()
__all__ = [
"test_automation_environment",
"test_automation_token",
+12 -56
View File
@@ -4,14 +4,9 @@ import traceback
from argparse import ArgumentParser
from os import getenv
from speckleifc.ifc_geometry_processing import open_ifc
from speckleifc.importer import ImportJob
from speckleifc.main import open_and_convert_file
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import Account
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.models.current import Version
from specklepy.core.api.operations import send
from specklepy.transports.server import ServerTransport
from specklepy.logging import metrics
def cmd_line_import() -> None:
@@ -32,15 +27,22 @@ def cmd_line_import() -> None:
TOKEN = getenv("USER_TOKEN")
assert TOKEN is not None
SERVER_URL = getenv("SPECKLE_SERVER_URL") or "http://127.0.0.1:3000"
account = Account.from_token(TOKEN, SERVER_URL)
metrics.set_host_app(
"ifc",
)
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,
args.project_id,
project,
args.version_message,
args.model_id,
account,
client,
)
with open(args.output_path, "w") as f:
json.dump({"success": True, "commitId": version.id}, f)
@@ -53,52 +55,6 @@ def cmd_line_import() -> None:
json.dump({"success": False, "error": str(e)}, f)
def open_and_convert_file(
file_path: str,
project_id: str,
version_message: str | None,
model_id: str,
account: Account,
) -> Version:
start = time.time()
very_start = start
ifc_file = open_ifc(file_path)
import_job = ImportJob(ifc_file)
data = import_job.convert()
print(f"File conversion complete after {(time.time() - start) * 1000}ms")
start = time.time()
remote_transport = ServerTransport(project_id, account=account)
root_id = send(data, transports=[remote_transport], use_default_cache=False)
print(f"Sending to speckle complete after: {(time.time() - start) * 1000}ms")
start = time.time()
server_url = account.serverInfo.url
assert server_url
client = SpeckleClient(host=server_url, use_ssl=server_url.startswith("https"))
client.authenticate_with_account(account)
create_version = CreateVersionInput(
object_id=root_id,
model_id=model_id,
project_id=project_id,
message=version_message,
source_application="IFC",
)
version = client.version.create(create_version)
end = time.time()
print(f"Version committed after: {(end - start) * 1000}ms")
print(f"Total time (to commit): {(end - very_start) * 1000}ms")
del ifc_file
return version
if __name__ == "__main__":
start = time.time()
cmd_line_import()
@@ -11,13 +11,20 @@ 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=extract_properties(step_element),
properties=properties,
name=name or guid,
displayValue=display_value,
)
@@ -12,8 +12,11 @@ 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)
direct_geometry = _convert_as_data_object(
display_value, step_element, current_storey
)
all_children = [direct_geometry] + relational_children
guid = cast(str, step_element.GlobalId)
@@ -26,13 +29,22 @@ def spatial_element_to_speckle(
def _convert_as_data_object(
display_value: list[Base], step_element: entity_instance
display_value: list[Base],
step_element: entity_instance,
current_storey: str | None = None,
) -> 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=extract_properties(step_element),
properties=properties,
name=name,
displayValue=display_value,
)
+9 -2
View File
@@ -1,6 +1,6 @@
import multiprocessing
from ifcopenshell import file, ifcopenshell_wrapper, open, sqlite
from ifcopenshell import SchemaError, file, ifcopenshell_wrapper, open, sqlite
from ifcopenshell.geom import iterator, settings
from specklepy.logging.exceptions import SpeckleException
@@ -33,7 +33,14 @@ def _create_iterator_settings() -> settings:
def open_ifc(file_path: str) -> file:
ifc_file = open(file_path)
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
if isinstance(ifc_file, file):
return ifc_file
+64 -12
View File
@@ -12,9 +12,11 @@ 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.level_proxy_manager import LevelProxyManager
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
@dataclass
@@ -24,22 +26,66 @@ class ImportJob:
_render_material_manager: RenderMaterialProxyManager = field(
default_factory=lambda: RenderMaterialProxyManager()
)
_level_proxy_manager: LevelProxyManager = field(
default_factory=lambda: LevelProxyManager()
)
geometries_count: int = 0
geometries_used: int = 0
_current_storey_data_object: DataObject | None = field(default=None, init=False)
def convert_element(self, step_element: entity_instance) -> Base:
children = self._convert_children(step_element)
display_value = self.cached_display_values.get(step_element.id(), [])
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
if display_value is not None:
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.cached_display_values.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.cached_display_values.get(id, [])
if display_value:
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"):
return project_to_speckle(step_element, children)
result = project_to_speckle(step_element, children)
elif step_element.is_a("IfcSpatialStructureElement"):
return spatial_element_to_speckle(display_value, step_element, children)
result = spatial_element_to_speckle(
display_value, step_element, children, current_storey_name
)
else:
return data_object_to_speckle(display_value, step_element, children)
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
def _convert_children(self, step_element: entity_instance) -> list[Base]:
return [
@@ -75,18 +121,22 @@ class ImportJob:
def pre_process_geometry(self) -> None:
iterator = create_geometry_iterator(self.ifc_file)
if not iterator.initialize():
raise SpeckleException(
"geometry iterator failed to initialize for the given file"
)
raise SpeckleException("Failed to find any geometry in file")
self.geometries_count = 0
while True:
shape = cast(TriangulationElement, iterator.get())
self.geometries_count += 1
id = cast(int, shape.id)
display_value = geometry_to_speckle(shape, self._render_material_manager)
self.cached_display_values[id] = display_value
try:
display_value = geometry_to_speckle(
shape, self._render_material_manager
)
self.cached_display_values[id] = display_value
except Exception as ex:
raise SpeckleException(
f"Failed to convert geometry with id: {id}"
) from ex
if not iterator.next():
break
@@ -100,5 +150,7 @@ class ImportJob:
tree["renderMaterialProxies"] = list(
self._render_material_manager.render_material_proxies.values()
)
tree["levelProxies"] = list(self._level_proxy_manager.level_proxies.values())
tree["version"] = 3
return tree
+27
View File
@@ -0,0 +1,27 @@
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,
)
+61
View File
@@ -0,0 +1,61 @@
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)
return version
+117 -12
View File
@@ -1,15 +1,31 @@
from typing import Any
import math
from typing import Any, Tuple
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",
}
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": _get_ifc_object_properties(element),
"Property Sets": psets,
}
if qtos:
properties["Quantities"] = qtos
if (ifc_type := get_type(element)) is not None:
properties["Element Type Property Sets"] = _get_ifc_element_type_properties(
ifc_type,
@@ -35,8 +51,11 @@ def _get_ifc_element_type_properties(element: entity_instance) -> dict[str, obje
return result
def _get_ifc_object_properties(element: entity_instance) -> dict[str, object]:
result: dict[str, object] = {}
def _get_ifc_object_properties(
element: entity_instance,
) -> Tuple[dict[str, object], dict[str, object]]:
psets: dict[str, object] = {}
qtos: dict[str, object] = {}
for rel in getattr(element, "IsDefinedBy", []):
if not rel.is_a("IfcRelDefinesByProperties"):
@@ -46,16 +65,27 @@ def _get_ifc_object_properties(element: entity_instance) -> dict[str, object]:
if not definition:
continue
if not definition.is_a("IfcPropertySet"):
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}")
continue
set_name = definition.Name
properties = _get_properties(definition.HasProperties)
if properties:
result[set_name] = properties
return result
return (psets, qtos)
def _get_properties(properties: entity_instance) -> dict[str, Any]:
@@ -90,3 +120,78 @@ 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}
+7
View File
@@ -3,6 +3,7 @@ import contextlib
from specklepy.api.credentials import Account
from specklepy.api.resources import (
ActiveUserResource,
FileImportResource,
ModelResource,
OtherUserResource,
ProjectInviteResource,
@@ -118,6 +119,12 @@ class SpeckleClient(CoreSpeckleClient):
client=self.httpclient,
server_version=server_version,
)
self.file_import = FileImportResource(
account=self.account,
basepath=self.url,
client=self.httpclient,
server_version=server_version,
)
self.subscription = SubscriptionResource(
account=self.account,
basepath=self.ws_url,
+3 -5
View File
@@ -1,5 +1,3 @@
from typing import List, Optional
# following imports seem to be unnecessary, but they need to stay
# to not break the scripts using these functions as non-core
from specklepy.core.api.credentials import ( # noqa: F401
@@ -14,7 +12,7 @@ from specklepy.core.api.credentials import get_local_accounts as core_get_local_
from specklepy.logging import metrics
def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
def get_local_accounts(base_path: str | None = None) -> list[Account]:
"""Gets all the accounts present in this environment
Arguments:
@@ -38,7 +36,7 @@ def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
return accounts
def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
def get_default_account(base_path: str | None = None) -> Account | None:
"""
Gets this environment's default account if any. If there is no default,
the first found will be returned and set as default.
@@ -61,7 +59,7 @@ def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
return default
def get_account_from_token(token: str, server_url: str = None) -> Account:
def get_account_from_token(token: str, server_url: str | None = None) -> Account:
"""Gets the local account for the token if it exists
Arguments:
token {str} -- the api token
+2
View File
@@ -1,4 +1,5 @@
from specklepy.api.resources.current.active_user_resource import ActiveUserResource
from specklepy.api.resources.current.file_import_resource import FileImportResource
from specklepy.api.resources.current.model_resource import ModelResource
from specklepy.api.resources.current.other_user_resource import OtherUserResource
from specklepy.api.resources.current.project_invite_resource import (
@@ -11,6 +12,7 @@ from specklepy.api.resources.current.version_resource import VersionResource
from specklepy.api.resources.current.workspace_resource import WorkspaceResource
__all__ = [
"FileImportResource",
"ActiveUserResource",
"ModelResource",
"OtherUserResource",
@@ -0,0 +1,87 @@
from pathlib import Path
from typing_extensions import override
from specklepy.core.api.inputs import (
FinishFileImportInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.models import FileImport, FileUploadUrl
from specklepy.core.api.models.current import ResourceCollection
from specklepy.core.api.resources import FileImportResource as CoreResource
from specklepy.core.api.resources.current.file_import_resource import UploadFileResponse
from specklepy.logging import metrics
class FileImportResource(CoreResource):
"""API Access class for projects"""
def __init__(self, account, basepath, client, server_version) -> None:
super().__init__(
account=account,
basepath=basepath,
client=client,
server_version=server_version,
)
@override
def start_file_import(self, input: StartFileImportInput) -> FileImport:
metrics.track(metrics.SDK, self.account, {"name": "File Import Start"})
return super().start_file_import(input)
@override
def generate_upload_url(self, input: GenerateFileUploadUrlInput) -> FileUploadUrl:
"""
Get a file upload url from the Speckle server.
This method asks the server to create a pre-signed S3 url,
which can be used as a short term authenticated route,
to put a file to the server.
"""
metrics.track(
metrics.SDK, self.account, {"name": "File Import Generate Upload Url"}
)
return super().generate_upload_url(input)
@override
def upload_file(self, file: Path, url: str) -> UploadFileResponse:
"""
Uploads a file to the given S3 url.
This method should be used together with the generate_upload_url method,
which generates a pre-signed S3 url, that can be used to upload the file to.
"""
metrics.track(metrics.SDK, self.account, {"name": "File Import Upload File"})
return super().upload_file(file, url)
@override
def download_file(self, project_id: str, file_id: str, target_file: Path) -> Path:
"""Download a file blob attached to the project, to the target path."""
metrics.track(metrics.SDK, self.account, {"name": "File Import Download File"})
return super().download_file(project_id, file_id, target_file)
@override
def finish_file_import_job(self, input: FinishFileImportInput) -> bool:
"""
This is mostly an internal api, that marks a file import job finished.
Only use this if you are writing a file importer, that is responsible for
processing file import jobs.
"""
metrics.track(metrics.SDK, self.account, {"name": "File Import Finish Job"})
return super().finish_file_import_job(input)
@override
def get_model_file_import_jobs(
self,
*,
project_id: str,
model_id: str,
limit: int = 25,
cursor: str | None = None,
) -> ResourceCollection[FileImport]:
metrics.track(metrics.SDK, self.account, {"name": "File Import Get Model Jobs"})
return super().get_model_file_import_jobs(
project_id=project_id, model_id=model_id, limit=limit, cursor=cursor
)
+35 -15
View File
@@ -11,6 +11,7 @@ from gql.transport.websockets import WebsocketsTransport
from specklepy.core.api.credentials import Account
from specklepy.core.api.resources import (
ActiveUserResource,
FileImportResource,
ModelResource,
OtherUserResource,
ProjectInviteResource,
@@ -130,6 +131,19 @@ 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
@@ -142,6 +156,21 @@ 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}",
@@ -161,21 +190,6 @@ 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)
@@ -230,6 +244,12 @@ class SpeckleClient:
client=self.httpclient,
server_version=server_version,
)
self.file_import = FileImportResource(
account=self.account,
basepath=self.url,
client=self.httpclient,
server_version=server_version,
)
self.subscription = SubscriptionResource(
account=self.account,
basepath=self.ws_url,
+13 -13
View File
@@ -1,6 +1,6 @@
import os
from pathlib import Path
from typing import List, Optional
from typing import List
from urllib.parse import urlparse
from pydantic import BaseModel, Field # pylint: disable=no-name-in-module
@@ -12,20 +12,20 @@ from specklepy.transports.sqlite import SQLiteTransport
class UserInfo(BaseModel):
id: Optional[str] = None
name: Optional[str] = None
email: Optional[str] = None
company: Optional[str] = None
avatar: Optional[str] = None
id: str | None = None
name: str | None = None
email: str | None = None
company: str | None = None
avatar: str | None = None
class Account(BaseModel):
isDefault: bool = False
token: Optional[str] = None
refreshToken: Optional[str] = None
token: str | None = None
refreshToken: str | None = None
serverInfo: ServerInfo = Field(default_factory=ServerInfo)
userInfo: UserInfo = Field(default_factory=UserInfo)
id: Optional[str] = None
id: str | None = None
def __repr__(self) -> str:
return (
@@ -37,13 +37,13 @@ class Account(BaseModel):
return self.__repr__()
@classmethod
def from_token(cls, token: str, server_url: str = None):
def from_token(cls, token: str, server_url: str | None = None):
acct = cls(token=token)
acct.serverInfo.url = server_url
return acct
def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
def get_local_accounts(base_path: str | None = None) -> List[Account]:
"""Gets all the accounts present in this environment
Arguments:
@@ -93,7 +93,7 @@ def get_local_accounts(base_path: Optional[str] = None) -> List[Account]:
return accounts
def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
def get_default_account(base_path: str | None = None) -> Account | None:
"""
Gets this environment's default account if any. If there is no default,
the first found will be returned and set as default.
@@ -116,7 +116,7 @@ def get_default_account(base_path: Optional[str] = None) -> Optional[Account]:
return default
def get_account_from_token(token: str, server_url: str = None) -> Account:
def get_account_from_token(token: str, server_url: str | None = None) -> Account:
"""Gets the local account for the token if it exists
Arguments:
token {str} -- the api token
+12
View File
@@ -1,3 +1,10 @@
from specklepy.core.api.inputs.file_import_inputs import (
FileImportErrorInput,
FileImportSuccessInput,
FinishFileImportInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.inputs.model_inputs import (
CreateModelInput,
DeleteModelInput,
@@ -22,6 +29,11 @@ from specklepy.core.api.inputs.version_inputs import (
)
__all__ = [
"FileImportErrorInput",
"FileImportSuccessInput",
"FinishFileImportInput",
"StartFileImportInput",
"GenerateFileUploadUrlInput",
"CreateModelInput",
"DeleteModelInput",
"UpdateModelInput",
@@ -0,0 +1,44 @@
from typing import Literal
from pydantic import Field
from specklepy.core.api.models.graphql_base_model import GraphQLBaseModel
class GenerateFileUploadUrlInput(GraphQLBaseModel):
project_id: str
file_name: str
class StartFileImportInput(GraphQLBaseModel):
project_id: str
model_id: str
file_id: str
etag: str
class FileImportResult(GraphQLBaseModel):
duration_seconds: float
download_duration_seconds: float
parse_duration_seconds: float
parser: str
version_id: str | None
class FileImportInputBase(GraphQLBaseModel):
project_id: str
job_id: str
warnings: list[str] = Field(default_factory=list)
result: FileImportResult
class FileImportSuccessInput(FileImportInputBase):
status: Literal["success"] = "success"
class FileImportErrorInput(FileImportInputBase):
status: Literal["error"] = "error"
reason: str
FinishFileImportInput = FileImportSuccessInput | FileImportErrorInput
@@ -1,5 +1,7 @@
from specklepy.core.api.models.current import (
AuthStrategy,
FileImport,
FileUploadUrl,
LimitedUser,
Model,
ModelWithVersions,
@@ -48,4 +50,6 @@ __all__ = [
"ProjectModelsUpdatedMessage",
"ProjectUpdatedMessage",
"ProjectVersionsUpdatedMessage",
"FileImport",
"FileUploadUrl",
]
+62 -46
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Generic, List, Optional, TypeVar
from typing import Generic, List, TypeVar
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.models.graphql_base_model import GraphQLBaseModel
@@ -10,13 +10,13 @@ T = TypeVar("T")
class User(GraphQLBaseModel):
id: str
email: Optional[str] = None
email: str | None = None
name: str
bio: Optional[str] = None
company: Optional[str] = None
avatar: Optional[str] = None
verified: Optional[bool] = None
role: Optional[str] = None
bio: str | None = None
company: str | None = None
avatar: str | None = None
verified: bool | None = None
role: str | None = None
def __repr__(self):
return (
@@ -31,16 +31,16 @@ class User(GraphQLBaseModel):
class ResourceCollection(GraphQLBaseModel, Generic[T]):
total_count: int
items: List[T]
cursor: Optional[str] = None
cursor: str | None = None
class ServerMigration(GraphQLBaseModel):
moved_from: Optional[str]
moved_to: Optional[str]
moved_from: str | None
moved_to: str | None
class AuthStrategy(GraphQLBaseModel):
color: Optional[str]
color: str | None
icon: str
id: str
name: str
@@ -60,17 +60,17 @@ class ServerWorkspacesInfo(GraphQLBaseModel):
# Keeping this one all Optionals at the minute,
# because its used both as a deserialization model for GQL and Account Management
class ServerInfo(GraphQLBaseModel):
name: Optional[str] = None
company: Optional[str] = None
url: Optional[str] = None
admin_contact: Optional[str] = None
description: Optional[str] = None
canonical_url: Optional[str] = None
scopes: Optional[List[dict]] = None
auth_strategies: Optional[List[dict]] = None
version: Optional[str] = None
migration: Optional[ServerMigration] = None
workspaces: Optional[ServerWorkspacesInfo] = None
name: str | None = None
company: str | None = None
url: str | None = None
admin_contact: str | None = None
description: str | None = None
canonical_url: str | None = None
scopes: List[dict] | None = None
auth_strategies: List[dict] | None = None
version: str | None = None
migration: ServerMigration | None = None
workspaces: ServerWorkspacesInfo | None = None
# TODO separate gql model from account management model
@@ -79,11 +79,11 @@ class LimitedUser(GraphQLBaseModel):
id: str
name: str
bio: Optional[str]
company: Optional[str]
avatar: Optional[str]
verified: Optional[bool]
role: Optional[str]
bio: str | None
company: str | None
avatar: str | None
verified: bool | None
role: str | None
def __repr__(self):
return (
@@ -99,15 +99,15 @@ class LimitedUser(GraphQLBaseModel):
class PendingStreamCollaborator(GraphQLBaseModel):
id: str
invite_id: str
stream_id: Optional[str] = None
stream_id: str | None = None
projectId: str
stream_name: Optional[str] = None
stream_name: str | None = None
project_name: str
title: str
role: str
invited_by: LimitedUser
user: Optional[LimitedUser] = None
token: Optional[str]
user: LimitedUser | None = None
token: str | None
def __repr__(self):
return (
@@ -127,24 +127,24 @@ class ProjectCollaborator(GraphQLBaseModel):
class Version(GraphQLBaseModel):
author_user: Optional[LimitedUser]
author_user: LimitedUser | None
created_at: datetime
id: str
message: Optional[str]
message: str | None
preview_url: str
referenced_object: Optional[str]
referenced_object: str | None
"""Maybe null if workspaces version history limit has been exceeded"""
source_application: Optional[str]
source_application: str | None
class Model(GraphQLBaseModel):
author: Optional[LimitedUser]
author: LimitedUser | None
created_at: datetime
description: Optional[str]
description: str | None
display_name: str
id: str
name: str
preview_url: Optional[str]
preview_url: str | None
updated_at: datetime
@@ -162,14 +162,14 @@ class ProjectPermissionChecks(GraphQLBaseModel):
class Project(GraphQLBaseModel):
allow_public_comments: bool
created_at: datetime
description: Optional[str]
description: str | None
id: str
name: str
role: Optional[str]
role: str | None
source_apps: List[str]
updated_at: datetime
visibility: ProjectVisibility
workspace_id: Optional[str]
workspace_id: str | None
class ProjectWithModels(Project):
@@ -191,7 +191,7 @@ class ProjectCommentCollection(ResourceCollection[T], Generic[T]):
class UserSearchResultCollection(GraphQLBaseModel):
items: List[LimitedUser]
cursor: Optional[str] = None
cursor: str | None = None
class PermissionCheckResult(GraphQLBaseModel):
@@ -216,15 +216,31 @@ class WorkspaceCreationState(GraphQLBaseModel):
class LimitedWorkspace(GraphQLBaseModel):
id: str
name: str
role: Optional[str]
role: str | None
slug: str
logo: Optional[str]
description: Optional[str]
logo: str | None
description: str | None
class Workspace(LimitedWorkspace):
created_at: datetime
updated_at: datetime
read_only: bool
creation_state: Optional[WorkspaceCreationState]
creation_state: WorkspaceCreationState | None
permissions: WorkspacePermissionChecks
class FileImport(GraphQLBaseModel):
id: str
project_id: str
converted_version_id: str | None
user_id: str
converted_status: int
converted_message: str | None
model_id: str | None
updated_at: datetime
class FileUploadUrl(GraphQLBaseModel):
url: str
file_id: str
@@ -1,4 +1,5 @@
from specklepy.core.api.resources.current.active_user_resource import ActiveUserResource
from specklepy.core.api.resources.current.file_import_resource import FileImportResource
from specklepy.core.api.resources.current.model_resource import ModelResource
from specklepy.core.api.resources.current.other_user_resource import OtherUserResource
from specklepy.core.api.resources.current.project_invite_resource import (
@@ -13,6 +14,7 @@ from specklepy.core.api.resources.current.version_resource import VersionResourc
from specklepy.core.api.resources.current.workspace_resource import WorkspaceResource
__all__ = [
"FileImportResource",
"ActiveUserResource",
"ModelResource",
"OtherUserResource",
@@ -0,0 +1,212 @@
from pathlib import Path
from typing import Any
import httpx
from gql import Client, gql
from specklepy.core.api.credentials import Account
from specklepy.core.api.inputs.file_import_inputs import (
FinishFileImportInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.models import FileImport, FileUploadUrl, ResourceCollection
from specklepy.core.api.models.graphql_base_model import GraphQLBaseModel
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
from specklepy.logging.exceptions import SpeckleException
class UploadFileResponse(GraphQLBaseModel):
etag: str
class FileImportResource(ResourceBase):
"""API Access class for project invites"""
def __init__(
self,
account: Account,
basepath: str,
client: Client,
server_version: tuple[Any, ...] | None, # pyright: ignore[reportExplicitAny]
) -> None:
super().__init__(
account=account,
basepath=basepath,
client=client,
server_version=server_version,
name="file-import",
)
def finish_file_import_job(self, input: FinishFileImportInput) -> bool:
"""
This is mostly an internal api, that marks a file import job finished.
Only use this if you are writing a file importer, that is responsible for
processing file import jobs.
"""
QUERY = gql(
"""
mutation FinishFileImport($input: FinishFileImportInput!) {
data:fileUploadMutations {
data:finishFileImport(input: $input)
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[bool]], QUERY, variables
).data.data
def start_file_import(self, input: StartFileImportInput) -> FileImport:
QUERY = gql(
"""
mutation StartFileImport($input: StartFileImportInput!) {
data:fileUploadMutations {
data:startFileImport(input: $input) {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[FileImport]], QUERY, variables
).data.data
def generate_upload_url(self, input: GenerateFileUploadUrlInput) -> FileUploadUrl:
"""
Get a file upload url from the Speckle server.
This method asks the server to create a pre-signed S3 url,
which can be used as a short term authenticated route,
to put a file to the server.
"""
QUERY = gql(
"""
mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {
data:fileUploadMutations {
data:generateUploadUrl(input: $input) {
fileId
url
}
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[FileUploadUrl]], QUERY, variables
).data.data
def upload_file(self, file: Path, url: str) -> UploadFileResponse:
"""
Uploads a file to the given S3 url.
This method should be used together with the generate_upload_url method,
which generates a pre-signed S3 url, that can be used to upload the file to.
"""
with open(file, "rb") as content:
response = httpx.put(
url,
content=content, # Pass file object directly for streaming
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file.stat().st_size),
},
).raise_for_status()
etag = response.headers.get("ETag", None) # pyright: ignore[reportAny]
if not etag:
raise SpeckleException(
"Response does not have an ETag attached to it,"
+ " cannot use this as an upload"
)
return UploadFileResponse(etag=str(etag)) # pyright: ignore[reportAny]
def download_file(self, project_id: str, file_id: str, target_file: Path) -> Path:
"""Download a file blob attached to the project, to the target path."""
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True)
url = f"{self.basepath}/api/stream/{project_id}/blob/{file_id}"
with httpx.stream(
"GET", url, headers={"Authorization": f"Bearer {self.account.token}"}
) as response:
_ = response.raise_for_status()
with target_file.open("wb") as f:
for chunk in response.iter_bytes(chunk_size=8192):
_ = f.write(chunk)
return target_file
def get_model_file_import_jobs(
self,
*,
project_id: str,
model_id: str,
limit: int = 25,
cursor: str | None = None,
) -> ResourceCollection[FileImport]:
QUERY = gql(
"""
query ModelFileImportJobs(
$projectId: String!,
$modelId: String!,
$input: GetModelUploadsInput
) {
data:project(id: $projectId) {
data:model(id: $modelId) {
data:uploads(input: $input) {
totalCount
cursor
items {
id
projectId
convertedVersionId
userId
convertedStatus
convertedMessage
modelId
updatedAt
}
}
}
}
}
"""
)
variables = {
"projectId": project_id,
"modelId": model_id,
"input": {
"limit": limit,
"cursor": cursor,
},
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[ResourceCollection[FileImport]]]],
QUERY,
variables,
).data.data.data
+8 -22
View File
@@ -1,8 +1,6 @@
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,
@@ -139,27 +137,11 @@ 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()
params = {"project_id": self.stream_id, "model_id": self.model_id}
project = self._client.httpclient.execute(query, params)
try:
self.branch_name = project["project"]["model"]["name"]
except KeyError as ke:
raise SpeckleException("Project model name is not found", ke) from ke
self._client = self.get_client()
model = self._client.model.get(self.model_id, self.stream_id)
self.branch_name = model.name
if not self.stream_id:
raise SpeckleException(
@@ -175,6 +157,10 @@ 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 +1,5 @@
"""Common helpers module for Core."""
from specklepy.core.helpers.random import crypto_random_string
__all__ = ["crypto_random_string"]
+8
View File
@@ -0,0 +1,8 @@
import secrets
import string
def crypto_random_string(length: int) -> str:
"""Generate a semi crypto random string of a given length."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length)).lower()
@@ -88,6 +88,8 @@ 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
@@ -98,7 +100,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.", exception=ex
message="Failed to initialize user application data path."
) from ex
+13 -11
View File
@@ -6,10 +6,11 @@ import platform
import queue
import sys
import threading
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!
@@ -54,7 +55,7 @@ def enable():
TRACK = True
def set_host_app(host_app: str, host_app_version: Optional[str] = None):
def set_host_app(host_app: str, host_app_version: str | None = None):
global HOST_APP, HOST_APP_VERSION
HOST_APP = host_app
HOST_APP_VERSION = host_app_version or HOST_APP_VERSION
@@ -62,8 +63,8 @@ def set_host_app(host_app: str, host_app_version: Optional[str] = None):
def track(
action: str,
account=None,
custom_props: Optional[dict] = None,
account: Account | None = None,
custom_props: dict | None = None,
):
if not TRACK:
return
@@ -91,15 +92,16 @@ def track(
LOG.debug(f"Error queueing metrics request: {str(ex)}")
def initialise_tracker(account=None):
def initialise_tracker(account: Account | None = None):
global METRICS_TRACKER
if not METRICS_TRACKER:
METRICS_TRACKER = MetricsTracker()
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)
if not account:
return
METRICS_TRACKER.set_last_user(account.userInfo.email)
METRICS_TRACKER.set_last_server(account.serverInfo.url)
class Singleton(type):
@@ -131,12 +133,12 @@ class MetricsTracker(metaclass=Singleton):
if node and user:
self.last_user = f"@{self.hash(f'{node}-{user}')}"
def set_last_user(self, email: str):
def set_last_user(self, email: str | None):
if not email:
return
self.last_user = f"@{self.hash(email)}"
def set_last_server(self, server: str):
def set_last_server(self, server: str | None):
if not server:
return
self.last_server = self.hash(server)
+20
View File
@@ -49,6 +49,26 @@ class InstanceDefinitionProxy(
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,
@@ -123,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),
@@ -0,0 +1,61 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION ((''), '2;1');
FILE_NAME ('', '2020-02-27T18:38:58', (''), (''), 'Processor version 5.1.0.0', 'Xbim.IO.MemoryModel', '');
FILE_SCHEMA (('IFC4'));
ENDSEC;
DATA;
#1=IFCPROJECT('3WoDmit2L9H8xguu5dNQPk',#2,'W\X\FCrfelEinfach',$,$,$,$,(#19,#22),#7);
#2=IFCOWNERHISTORY(#5,#6,$,.ADDED.,1582828739,$,$,0);
#3=IFCPERSON($,'Team','Finradon',$,$,$,$,$);
#4=IFCORGANIZATION($,'CMS',$,$,$);
#5=IFCPERSONANDORGANIZATION(#3,#4,$);
#6=IFCAPPLICATION(#4,'1.0','W\X\FCrfelEinfach','W\X\FCrfelEinfach.exe');
#7=IFCUNITASSIGNMENT((#8,#9,#10,#11,#12,#13,#14,#15,#16));
#8=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.);
#9=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.);
#10=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.);
#11=IFCSIUNIT(*,.SOLIDANGLEUNIT.,$,.STERADIAN.);
#12=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.);
#13=IFCSIUNIT(*,.MASSUNIT.,$,.GRAM.);
#14=IFCSIUNIT(*,.TIMEUNIT.,$,.SECOND.);
#15=IFCSIUNIT(*,.THERMODYNAMICTEMPERATUREUNIT.,$,.DEGREE_CELSIUS.);
#16=IFCSIUNIT(*,.LUMINOUSINTENSITYUNIT.,$,.LUMEN.);
#17=IFCCARTESIANPOINT((0.,0.,0.));
#18=IFCAXIS2PLACEMENT3D(#17,$,$);
#19=IFCGEOMETRICREPRESENTATIONCONTEXT('Building Model','Model',3,1.E-05,#18,$);
#20=IFCCARTESIANPOINT((0.,0.));
#21=IFCAXIS2PLACEMENT2D(#20,$);
#22=IFCGEOMETRICREPRESENTATIONCONTEXT('Building Plan View','Plan',2,1.E-05,#21,$);
#23=IFCBUILDING('1fOYmUWu5FGA6WZZJzE67P',#2,'Default Building',$,$,#24,$,$,.ELEMENT.,$,$,$);
#24=IFCLOCALPLACEMENT($,#25);
#25=IFCAXIS2PLACEMENT3D(#26,$,$);
#26=IFCCARTESIANPOINT((0.,0.,0.));
#27=IFCRELAGGREGATES('0yprV4hG98I8MgBrMkyNIg',#2,$,$,#1,(#23));
#28=IFCBUILDINGELEMENTPROXY('18CFESN5fCsuplarC$2Ulg',#2,'The cube in question',$,$,#38,#37,$,$);
#29=IFCRECTANGLEPROFILEDEF(.AREA.,$,#31,820.,820.);
#30=IFCCARTESIANPOINT((0.,40.));
#31=IFCAXIS2PLACEMENT2D(#30,$);
#32=IFCEXTRUDEDAREASOLID(#29,#35,#33,820.);
#33=IFCDIRECTION((0.,0.,1.));
#34=IFCCARTESIANPOINT((0.,0.,0.));
#35=IFCAXIS2PLACEMENT3D(#34,$,$);
#36=IFCSHAPEREPRESENTATION(#19,'Body','SweptSolid',(#32));
#37=IFCPRODUCTDEFINITIONSHAPE($,$,(#36,#51));
#38=IFCLOCALPLACEMENT($,#39);
#39=IFCAXIS2PLACEMENT3D(#34,#41,#40);
#40=IFCDIRECTION((0.,1.,0.));
#41=IFCDIRECTION((0.,0.,1.));
#42=IFCMATERIALLAYERSETUSAGE(#43,.AXIS2.,.NEGATIVE.,150.,$);
#43=IFCMATERIALLAYERSET((#44),$,$);
#44=IFCMATERIALLAYER($,10.,$,$,$,$,$);
#45=IFCMATERIAL('Metal + Glass',$,$);
#46=IFCRELASSOCIATESMATERIAL('2NhfPxMdr8_v0TKa3nx$N_',#2,$,$,(#28),#42);
#47=IFCPRESENTATIONLAYERASSIGNMENT('some ifcPresentationLayerAssignment',$,(#36),$);
#48=IFCPOLYLINE((#49,#50));
#49=IFCCARTESIANPOINT((0.,0.));
#50=IFCCARTESIANPOINT((4000.,0.));
#51=IFCSHAPEREPRESENTATION(#19,'Axis','Curve2D',(#48));
#52=IFCRELCONTAINEDINSPATIALSTRUCTURE('2KE_68CXDAFvue7XfUwVHI',#2,$,$,(#28),#23);
ENDSEC;
END-ISO-10303-21;
@@ -0,0 +1,251 @@
from pathlib import Path
import pytest
from specklepy.api import operations
from specklepy.api.client import SpeckleClient
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs.file_import_inputs import (
FileImportErrorInput,
FileImportResult,
FileImportSuccessInput,
GenerateFileUploadUrlInput,
StartFileImportInput,
)
from specklepy.core.api.inputs.model_inputs import CreateModelInput
from specklepy.core.api.inputs.project_inputs import ProjectCreateInput
from specklepy.core.api.inputs.version_inputs import CreateVersionInput
from specklepy.core.api.models import Project
from specklepy.core.api.models.current import FileUploadUrl
from specklepy.core.helpers import crypto_random_string
from specklepy.transports.server.server import ServerTransport
from tests.integration.fakemesh import FakeMesh
class TestFileImportResource:
@pytest.fixture
def file_path(self) -> Path:
path = Path("./tests/integration/client/current/test_file.ifc").absolute()
assert path.exists()
return path
@pytest.fixture
def project(self, client: SpeckleClient) -> Project:
return client.project.create(
ProjectCreateInput(
name="test", description=None, visibility=ProjectVisibility.PRIVATE
)
)
@pytest.fixture(scope="function")
def upload_url(
self, project: Project, file_path: Path, client: SpeckleClient
) -> FileUploadUrl:
upload_url_result = client.file_import.generate_upload_url(
GenerateFileUploadUrlInput(project_id=project.id, file_name=file_path.name)
)
return upload_url_result
def test_generate_upload_url(self, upload_url: FileUploadUrl) -> None:
assert upload_url.file_id
assert upload_url.url
def test_upload_file(
self, file_path: Path, client: SpeckleClient, upload_url: FileUploadUrl
) -> None:
response = client.file_import.upload_file(file=file_path, url=upload_url.url)
assert response.etag
def test_download_file(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
) -> None:
_ = client.file_import.upload_file(file=file_path, url=upload_url.url)
target_file = file_path.parent.joinpath("download.ifc")
downloaded_file = client.file_import.download_file(
project_id=project.id, file_id=upload_url.file_id, target_file=target_file
)
assert downloaded_file.exists()
assert file_path.stat().st_size == downloaded_file.stat().st_size
downloaded_file.unlink()
def test_start_file_import(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
) -> None:
model = client.model.create(
CreateModelInput(name=crypto_random_string(10), project_id=project.id)
)
upload_response = client.file_import.upload_file(
file=file_path, url=upload_url.url
)
response = client.file_import.start_file_import(
StartFileImportInput(
project_id=project.id,
model_id=model.id,
file_id=upload_url.file_id,
etag=upload_response.etag,
)
)
assert response.converted_status == 0
assert response.converted_version_id is None
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 0
assert job.converted_version_id is None
def test_finish_file_import_success(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
mesh: FakeMesh,
) -> None:
model = client.model.create(
CreateModelInput(name=crypto_random_string(10), project_id=project.id)
)
upload_response = client.file_import.upload_file(
file=file_path, url=upload_url.url
)
job_response = client.file_import.start_file_import(
StartFileImportInput(
project_id=project.id,
model_id=model.id,
file_id=upload_url.file_id,
etag=upload_response.etag,
)
)
assert job_response.converted_status == 0
assert job_response.converted_version_id is None
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 0
assert job.converted_version_id is None
transport = ServerTransport(client=client, stream_id=project.id)
hash = operations.send(mesh, transports=[transport])
version = client.version.create(
input=CreateVersionInput(
project_id=project.id, model_id=model.id, object_id=hash
)
)
finish_result = client.file_import.finish_file_import_job(
input=FileImportSuccessInput(
project_id=project.id,
job_id=job_response.id,
result=FileImportResult(
download_duration_seconds=0,
duration_seconds=0,
parse_duration_seconds=0,
parser="test",
version_id=version.id,
),
)
)
assert finish_result
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 2
assert job.converted_version_id == version.id
def test_finish_file_import_error(
self,
file_path: Path,
client: SpeckleClient,
project: Project,
upload_url: FileUploadUrl,
) -> None:
model = client.model.create(
CreateModelInput(name=crypto_random_string(10), project_id=project.id)
)
upload_response = client.file_import.upload_file(
file=file_path, url=upload_url.url
)
job_response = client.file_import.start_file_import(
StartFileImportInput(
project_id=project.id,
model_id=model.id,
file_id=upload_url.file_id,
etag=upload_response.etag,
)
)
assert job_response.converted_status == 0
assert job_response.converted_version_id is None
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 0
assert job.converted_version_id is None
finish_result = client.file_import.finish_file_import_job(
input=FileImportErrorInput(
project_id=project.id,
job_id=job_response.id,
reason="Test error",
result=FileImportResult(
download_duration_seconds=0,
duration_seconds=0,
parse_duration_seconds=0,
parser="test",
version_id=None,
),
)
)
assert finish_result
upload_jobs = client.file_import.get_model_file_import_jobs(
project_id=project.id,
model_id=model.id,
)
assert upload_jobs.total_count == 1
job = upload_jobs.items[0]
assert job
assert job.converted_status == 3
assert job.converted_version_id is None
@@ -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, SpeckleWarning
from specklepy.logging.exceptions import SpeckleException
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.warns(SpeckleWarning):
with pytest.raises(SpeckleException):
client.authenticate_with_token("fake token")
# remove path override
+11 -8
View File
@@ -8,11 +8,12 @@ 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
from specklepy.core.api.models.current import Project, ServerInfo
from specklepy.logging import metrics
from specklepy.objects.base import Base
from specklepy.objects.geometry import Point
@@ -89,13 +90,15 @@ 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)
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
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
return client
@@ -15,11 +15,11 @@ from speckle_automate import (
)
from speckle_automate.fixtures import (
create_test_automation_run_data,
crypto_random_string,
)
from speckle_automate.schema import AutomateBase
from specklepy.api.client import SpeckleClient
from specklepy.core.api.models.current import Model, Version
from specklepy.core.helpers import crypto_random_string
from specklepy.objects.base import Base
Generated
+1236 -1238
View File
File diff suppressed because it is too large Load Diff