Compare commits

..

12 Commits

Author SHA1 Message Date
Gergő Jedlicska 309c78da37 feat(automate_sdk): support version result reporting (#468)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* feat(automate_sdk): support version result reporting

* fix(automate_tests): skip test, its not working in CI

* docs(automate_sdk): describe new args
2025-11-05 21:50:40 +01:00
Jedd Morgan ff812d5ad9 add 520 status to retry policy (#467) 2025-11-05 10:49:36 +00:00
Jedd Morgan 8edc0d5d78 feat(ifc)!: Display Value proxies for IFC importer (#466)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* First pass

* Add applicationId for proxyInstance

* renamed revit instances

* renamed collection again

* again

* reverted main changes for manual testing

* small refactor of function def

* format matix
2025-10-31 13:54:46 +00:00
Jedd Morgan 78b3e99475 Remove unused server envars (#464) 2025-10-28 16:49:32 +00:00
Jedd Morgan ac9e081d49 feat(metrics): Introduce sync metrics (#462)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* sync metrics tracking

* fix
2025-10-27 13:40:40 +01:00
Jedd Morgan 4bc95441b9 feat(serverTransport): Add urlib3 retry policy to requests.Session clients used by ServerTransport (#461)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* Pre-commit on the public image

* change names

* first pass using urllib3 retry policy

* add some basic unit test

* correct the doc string
2025-10-15 21:43:45 +01:00
Jedd Morgan 0d74848b68 chore(ci): Pre-commit on the public image (#460)
* Pre-commit on the public image

* change names
2025-10-15 21:16:21 +01:00
Jedd Morgan 8a76006f9e feat(ci): Integration tests against internal image (#459)
* Integration tests against internal image

* fixed docker compose up

* auth

* add auth again!

* Update pr.yml
2025-10-14 11:37:15 +00:00
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
26 changed files with 1652 additions and 1334 deletions
+50 -10
View File
@@ -9,8 +9,55 @@ on:
- "main"
jobs:
test:
name: test
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)
runs-on: ubuntu-latest
strategy:
matrix:
@@ -42,17 +89,10 @@ jobs:
run: uv run pre-commit run --all-files
- name: Run Speckle Server
run: docker compose up --detach --wait
run: docker compose --file docker-compose.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
+118
View File
@@ -0,0 +1,118 @@
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:
+6 -6
View File
@@ -1,4 +1,3 @@
version: "3.9"
name: "speckle-server"
services:
@@ -22,7 +21,7 @@ services:
retries: 30
redis:
image: "redis:6.0-alpine"
image: "valkey/valkey:8.1-alpine@sha256:0d27f0bca0249f61d060029a6aaf2e16b2c417d68d02a508e1dfb763fa2948b4"
restart: always
volumes:
- ./.volumes/redis-data:/data
@@ -38,6 +37,9 @@ services:
restart: always
volumes:
- ./.volumes/minio-data:/data
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
healthcheck:
test:
[
@@ -48,8 +50,6 @@ services:
timeout: 30s
retries: 30
start_period: 10s
ports:
- "0.0.0.0:9000:9000"
speckle-server:
image: speckle/speckle-server:latest
@@ -96,7 +96,6 @@ services:
SESSION_SECRET: "TODO:ReplaceWithLongString"
STRATEGY_LOCAL: "true"
DEBUG: "speckle:*"
POSTGRES_URL: "postgres"
POSTGRES_USER: "speckle"
@@ -104,9 +103,10 @@ services:
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
LOG_PRETTY: "true"
FF_NEXT_GEN_FILE_IMPORTER_ENABLED: "true"
FF_LARGE_FILE_IMPORTS_ENABLED: "true"
FF_BACKGROUND_JOBS_ENABLED: "true"
networks:
default:
+1 -1
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",
+39 -15
View File
@@ -245,24 +245,24 @@ class AutomationContext:
"""
)
if self.run_status in [AutomationStatus.SUCCEEDED, AutomationStatus.FAILED]:
object_results = {
"version": 2,
results_dict = self._automation_result.model_dump(by_alias=True)
results = {
"version": 3,
"values": {
"objectResults": self._automation_result.model_dump(by_alias=True)[
"objectResults"
],
"objectResults": results_dict["objectResults"],
"versionResult": results_dict["versionResult"],
"blobIds": self._automation_result.blobs,
},
}
else:
object_results = None
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": object_results,
"results": results,
"contextView": self._automation_result.result_view,
}
print(f"Reporting run status with content: {params}")
@@ -312,25 +312,49 @@ class AutomationContext:
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)
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_exception(self, status_message: str) -> None:
"""Mark the current run a failure."""
self._mark_run(AutomationStatus.EXCEPTION, status_message)
self._mark_run(AutomationStatus.EXCEPTION, status_message, None)
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_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(
self, status: AutomationStatus, status_message: Optional[str]
self,
status: AutomationStatus,
status_message: str | None,
version_result: dict[str, Any] | None,
) -> 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)
+5 -7
View File
@@ -88,10 +88,8 @@ def create_test_automation_run(
print(result)
return (
result.get("projectMutations")
.get("automationMutations")
.get("createTestAutomationRun")
return TestAutomationRunData.model_validate(
result["projectMutations"]["automationMutations"]["createTestAutomationRun"]
)
@@ -123,9 +121,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["automationRunId"],
function_run_id=test_automation_run_data["functionRunId"],
triggers=test_automation_run_data["triggers"],
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,
)
+12 -11
View File
@@ -1,7 +1,7 @@
""""""
from enum import Enum
from typing import Any, Dict, List, Literal, Optional
from typing import Any, Literal
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,19 +80,20 @@ class ResultCase(AutomateBase):
category: str
level: ObjectResultLevel
object_app_ids: Dict[str, Optional[str]]
message: Optional[str]
metadata: Optional[Dict[str, Any]]
visual_overrides: Optional[Dict[str, Any]]
object_app_ids: dict[str, str | None]
message: str | None
metadata: dict[str, Any] | None
visual_overrides: dict[str, Any] | None
class AutomationResult(AutomateBase):
"""Schema accepted by the Speckle server as a result for an automation run."""
elapsed: float = 0
result_view: Optional[str] = None
result_versions: List[str] = Field(default_factory=list)
blobs: List[str] = Field(default_factory=list)
result_view: str | None = None
result_versions: list[str] = Field(default_factory=list)
blobs: list[str] = Field(default_factory=list)
run_status: AutomationStatus = AutomationStatus.RUNNING
status_message: Optional[str] = None
status_message: str | None = None
object_results: list[ResultCase] = Field(default_factory=list)
version_result: dict[str, Any] | None = None
+1 -21
View File
@@ -6,7 +6,6 @@ from os import getenv
from speckleifc.main import open_and_convert_file
from specklepy.core.api.client import SpeckleClient
from specklepy.core.api.credentials import get_accounts_for_server
from specklepy.logging import metrics
@@ -56,26 +55,7 @@ def cmd_line_import() -> None:
json.dump({"success": False, "error": str(e)}, f)
def manual_import() -> None:
PROJECT_ID = "f3a42bdf24"
MODEL_ID = "0e23cfdea3"
SERVER_URL = "app.speckle.systems"
metrics.set_host_app(
"ifc",
)
account = get_accounts_for_server(SERVER_URL)[0]
client = SpeckleClient(SERVER_URL, use_ssl=not SERVER_URL.startswith("http://"))
client.authenticate_with_account(account)
project = client.project.get(PROJECT_ID)
open_and_convert_file(FILE_PATH, project, None, MODEL_ID, client)
if __name__ == "__main__":
start = time.time()
# cmd_line_import()
manual_import()
cmd_line_import()
print(f"Total time (including cleanup): {(time.time() - start) * 1000}ms")
@@ -4,21 +4,21 @@ from typing import cast
from ifcopenshell.ifcopenshell_wrapper import (
Triangulation,
TriangulationElement,
colour,
style,
)
from speckleifc.render_material_proxy_manager import RenderMaterialProxyManager
from speckleifc.proxy_managers.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(
shape: TriangulationElement, render_material_manager: RenderMaterialProxyManager
geometry: Triangulation, 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(shape, material_ids, MESH_COUNT)
mapped_meshes = _pre_alloc_mesh_lists(geometry, 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(
shape: TriangulationElement, material_ids: Sequence[int], MESH_COUNT: int
geometry: Triangulation, 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, shape.guid)
appId = cast(str, geometry.id)
material_face_counts = defaultdict(int)
for mat_id in material_ids:
+3 -2
View File
@@ -12,9 +12,10 @@ 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)
# Speckle meshes are all in world coords
#
ifc_settings.set("use-world-coords", False)
ifc_settings.set("permissive-shape-reuse", False)
ifc_settings.set("permissive-shape-reuse", True)
# Tiny performance improvement,
ifc_settings.set("no-wire-intersection-check", True)
# Rendermaterials inherit the material names instead of type + unique id
+63 -13
View File
@@ -1,10 +1,10 @@
import time
from dataclasses import dataclass, field
from typing import cast
from typing import List, cast
from ifcopenshell.entity_instance import entity_instance
from ifcopenshell.geom import file
from ifcopenshell.ifcopenshell_wrapper import TriangulationElement
from ifcopenshell.ifcopenshell_wrapper import Triangulation, TriangulationElement
from speckleifc.converter.data_object_converter import data_object_to_speckle
from speckleifc.converter.geometry_converter import geometry_to_speckle
@@ -12,27 +12,38 @@ 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 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 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)
@@ -48,14 +59,14 @@ class ImportJob:
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(), [])
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.cached_display_values.get(id, [])
display_value = self._display_value_cache.get(id, [])
if display_value:
self.geometries_used += 1
@@ -127,13 +138,9 @@ class ImportJob:
shape = cast(TriangulationElement, iterator.get())
self.geometries_count += 1
id = cast(int, shape.id)
print(f"converted {id}")
try:
display_value = geometry_to_speckle(
shape, self._render_material_manager
)
self.cached_display_values[id] = display_value
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}"
@@ -141,6 +148,37 @@ class ImportJob:
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:
@@ -148,10 +186,22 @@ 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
+1 -1
View File
@@ -56,6 +56,6 @@ def open_and_convert_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)
metrics.track(metrics.SEND, account, custom_properties, send_sync=True)
return version
+5
View File
@@ -1,3 +1,4 @@
import math
from typing import Any, Tuple
from ifcopenshell.entity_instance import entity_instance
@@ -134,6 +135,10 @@ def _get_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] = {
@@ -0,0 +1,43 @@
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
+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
+60 -60
View File
@@ -6,6 +6,7 @@ import platform
import queue
import sys
import threading
from typing import Any
import requests
@@ -29,21 +30,6 @@ 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
@@ -65,43 +51,43 @@ def track(
action: str,
account: Account | None = None,
custom_props: dict | None = None,
send_sync: bool = False,
):
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)
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)}")
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)
def initialise_tracker(account: Account | None = None):
def initialise_tracker(account: Account | None = None) -> "MetricsTracker":
global METRICS_TRACKER
if not METRICS_TRACKER:
METRICS_TRACKER = MetricsTracker()
if not account:
return
if account:
METRICS_TRACKER.set_last_user(account.userInfo.email)
METRICS_TRACKER.set_last_server(account.serverInfo.url)
METRICS_TRACKER.set_last_user(account.userInfo.email)
METRICS_TRACKER.set_last_server(account.serverInfo.url)
return METRICS_TRACKER
class Singleton(type):
@@ -114,48 +100,62 @@ class Singleton(type):
class MetricsTracker(metaclass=Singleton):
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)
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()
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):
def set_last_user(self, email: str | None) -> None:
if not email:
return
self.last_user = f"@{self.hash(email)}"
def set_last_server(self, server: str | None):
def set_last_server(self, server: str | None) -> None:
if not server:
return
self.last_server = self.hash(server)
def hash(self, value: str):
def hash(self, value: str) -> str:
inputList = value.lower().split("://")
input = inputList[len(inputList) - 1].split("/")[0].split("?")[0]
return hashlib.md5(input.encode("utf-8")).hexdigest().upper()
def _send_tracking_requests(self):
session = requests.Session()
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:
while True:
event_params = [self.queue.get()]
event_params = self._queue.get()
try:
session.post(self.analytics_url, json=event_params)
except Exception as ex:
LOG.debug(f"Error sending metrics request: {str(ex)}")
self.send_event(event_params)
except Exception:
LOG.warning("Error sending metrics request", exc_info=True)
self.queue.task_done()
self._queue.task_done()
+3 -3
View File
@@ -33,9 +33,9 @@ class InstanceProxy(
IHasUnits,
speckle_type="Speckle.Core.Models.Instances.InstanceProxy",
):
definition_id: str
definitionId: str
transform: List[float]
max_depth: int
maxDepth: int
@dataclass(kw_only=True)
@@ -45,7 +45,7 @@ class InstanceDefinitionProxy(
detachable={"objects"},
):
objects: List[str]
max_depth: int
maxDepth: int
name: str
@@ -7,6 +7,7 @@ import threading
import requests
from specklepy.logging.exceptions import SpeckleException
from specklepy.transports.server.retry_policy import setup_session
LOG = logging.getLogger(__name__)
@@ -72,10 +73,7 @@ class BatchSender:
def _sending_thread_main(self):
try:
session = requests.Session()
session.headers.update(
{"Authorization": f"Bearer {self._token}", "Accept": "text/plain"}
)
session = setup_session(self._token)
while True:
batch = self._batches.get()
@@ -0,0 +1,46 @@
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
+4 -15
View File
@@ -2,12 +2,11 @@ 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
@@ -92,23 +91,13 @@ 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.headers.update(
{
"Authorization": f"Bearer {self.account.token}",
}
)
self.session = setup_session(
self.account.token if self.account is not None else None
)
@property
def name(self) -> str:
@@ -14,10 +14,14 @@ from speckle_automate import (
run_function,
)
from speckle_automate.fixtures import (
TestAutomationEnvironment,
create_test_automation_run_data,
)
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
@@ -43,18 +47,33 @@ 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
test_client: SpeckleClient,
speckle_server_url: str,
speckle_token: str,
project: Project,
) -> AutomationRunData:
"""TODO: Set up a test automation for integration testing"""
project_id = crypto_random_string(10)
test_automation_id = crypto_random_string(10)
return create_test_automation_run_data(
test_client, speckle_server_url, project_id, test_automation_id
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)
@pytest.fixture
def automation_context(
@@ -133,7 +152,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.id,
version_root_object,
"This project should not contain the type: "
f"{function_inputs.forbidden_speckle_type}",
)
@@ -164,7 +183,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.model_id,
automation_context.automation_run_data.triggers[0].payload.model_id,
automation_context.speckle_client,
)
assert status["status"] == automation_context.run_status
@@ -205,7 +224,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.branch_name
Base(), automation_context.automation_run_data.triggers[0].payload.model_id
)
@@ -220,8 +239,8 @@ def test_create_version_in_project(
model, version = automation_context.create_new_version_in_project(
root_object, "foobar"
)
isinstance(model, Model)
isinstance(version, Version)
assert isinstance(model, Model)
assert isinstance(version, Version)
@pytest.mark.skip(
@@ -230,9 +249,11 @@ 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/{automation_context.automation_run_data.model_id}@{automation_context.automation_run_data.version_id}"
f"models/{trigger.model_id}@{trigger.version_id}"
)
automation_context.report_run_status()
@@ -244,7 +265,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/{automation_context.automation_run_data.model_id}@{automation_context.automation_run_data.version_id},{dummy_context}"
f"models/{trigger.model_id}@{trigger.version_id},{dummy_context}"
)
automation_context.report_run_status()
+20
View File
@@ -0,0 +1,20 @@
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
+1122 -1124
View File
File diff suppressed because it is too large Load Diff