Compare commits

...

11 Commits

Author SHA1 Message Date
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
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
20 changed files with 1468 additions and 1224 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
+119
View File
@@ -0,0 +1,119 @@
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"
DEBUG: "speckle:*"
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 -5
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
@@ -104,9 +104,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",
+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
+23 -8
View File
@@ -34,6 +34,16 @@ class ImportJob:
_current_storey_data_object: DataObject | None = field(default=None, init=False)
def convert_element(self, step_element: entity_instance) -> Base:
try:
return self._convert_element(step_element)
except SpeckleException:
raise
except Exception as ex:
raise SpeckleException(
f"Failed to convert {step_element.is_a()} #{step_element.id()}"
) from ex
def _convert_element(self, step_element: entity_instance) -> Base:
# Track current storey context and store for level proxies
previous_storey_data_object = self._current_storey_data_object
if step_element.is_a("IfcBuildingStorey"):
@@ -44,9 +54,10 @@ class ImportJob:
)
children = self._convert_children(step_element)
display_value = self.cached_display_values.get(step_element.id(), [])
id = step_element.id()
display_value = self.cached_display_values.get(id, [])
if display_value is not None:
if display_value:
self.geometries_used += 1
# Extract current storey name from DataObject if available
@@ -110,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
+1
View File
@@ -26,6 +26,7 @@ def open_and_convert_file(
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()
+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] = {
+28 -15
View File
@@ -131,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
@@ -143,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}",
@@ -162,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)
+1 -1
View File
@@ -37,7 +37,7 @@ 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
+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
@@ -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
+7 -6
View File
@@ -97,10 +97,11 @@ def initialise_tracker(account: Account | None = None):
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):
@@ -132,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)
@@ -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, 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:
@@ -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
+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