Compare commits

...

28 Commits

Author SHA1 Message Date
Gergő Jedlicska 7ea719141f Merge pull request #424 from specklesystems/gergo/objectResultsWithApplicationIds
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
feat(automate): attach application id-s to automate result cases
2025-05-28 10:43:10 +02:00
Gergő Jedlicska a47f568f69 chore: comment cleanup 2025-05-27 15:34:59 +02:00
Gergő Jedlicska b174802451 fix(automate): remove last ref to object_id 2025-05-27 14:30:19 +02:00
Gergő Jedlicska 87a7e7482d Merge branch 'v3-dev' of github.com:specklesystems/specklepy into gergo/objectResultsWithApplicationIds 2025-05-22 20:36:38 +02:00
Gergő Jedlicska e888339dda feat(automate): attach application id-s to automate result cases 2025-05-22 20:35:35 +02:00
Dogukan Karatas 3417557405 feat: BlenderObject (#423)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* adds blenderobject

* exports the classes

* tests added
2025-05-21 18:18:36 +02:00
Gergő Jedlicska 4ce61f4e89 feat: add WORKSPACE visibility for projects (#421)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* feat: add WORKSPACE visibility for projects

* tests: projects are now private by default, follow that in tests
2025-05-15 14:35:54 +02:00
Dogukan Karatas 6d6e1e7650 adds can_load and can_publish (#420)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-05-08 12:32:47 +02:00
KatKatKateryna 95de5cbb30 Introducing Text class (#419)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* add text class and tests

* formatting

* fix default values

* comments

* comment

* sort imports

* import alignments

* compare properties, not Base objects

* revert irrelevant changes

* tests

* use correct fixture

* fix tests property
2025-05-06 10:12:29 +01:00
KatKatKateryna 5f56818d63 remove print statement (#418) 2025-05-05 19:03:33 +01:00
Jedd Morgan 825097e1a6 Oops (#417)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-05-01 22:04:46 +02:00
Jedd Morgan d3ab26240a fix(ap): fix mistake in workspace get response handling (#416)
* Corrected broken workspace query

* And one more!

* Fixed mistake in workspace get
2025-05-01 19:57:44 +00:00
Jedd Morgan ce6be1a98e fic(api): Fix mistake in workspace queries (#415)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* Corrected broken workspace query

* And one more!
2025-05-01 07:06:33 +00:00
Jedd Morgan 213e73dfdd Corrected broken workspace query (#414)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-04-30 17:10:17 +00:00
Jedd Morgan 15129df7ce More tweaks (#413)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* More tweaks

* WIP on v3-dev

* Add creation state

* format
2025-04-30 18:16:17 +02:00
Jedd Morgan 88519ce8b0 fix schema (#412)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-04-29 13:08:26 +00:00
Jedd Morgan d4f94450a5 Correct filter serialization (#411) 2025-04-29 09:50:07 +00:00
Jedd Morgan 4c46201526 Jedd/cnx 1660 add workspace resources to specklepy (#409)
* Added workspace client queries

* Enable tests
2025-04-29 11:46:13 +02:00
Jedd Morgan 75b064b3c7 Allow null version id (#410) 2025-04-28 19:57:09 +02:00
Jedd Morgan 1198f2e2ad Feat(objects): Added Vertex Normals to Mesh (#404)
* Mesh vertex normals

* Moved tests

* test curve
2025-04-25 14:39:04 +00:00
Jedd Morgan 7ab787bfb1 fic(ci): Change trigger to use branhc (#408)
Publish Python Package / continuous-integration (3.10) (push) Has been cancelled
Publish Python Package / continuous-integration (3.11) (push) Has been cancelled
Publish Python Package / continuous-integration (3.12) (push) Has been cancelled
Publish Python Package / continuous-integration (3.13) (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* Invert check

* empty
2025-04-23 17:03:42 +01:00
Jedd Morgan bbbf373b50 replaced env with correct boolean check (#407) 2025-04-23 16:51:09 +01:00
Dogukan Karatas f34e4a2874 updates publish.yml (#406) 2025-04-23 17:28:02 +02:00
Dogukan Karatas 45ebc375ad feat(specklepy): update github actions (#405)
* updates publish.yml

* added the secrets

* update publish.yml

* updates workflow

* updates workflows

* Update github-action.yml

* updates github action

* updates docker-compose and deletes .devcontainer

* disables pytests

* changes the localhost

* rollback - comment out the tests

* updates the ci pipeline

---------

Co-authored-by: KatKatKateryna <89912278+KatKatKateryna@users.noreply.github.com>
2025-04-23 17:11:31 +02:00
Dogukan Karatas 4c41fa79fc feat(specklepy): publish to pypi (#396)
* updates publish.yml

* added the secrets

* update publish.yml

* updates workflow

* updates workflows

* Update github-action.yml

* updates github action

* updates docker-compose and deletes .devcontainer

* disables pytests

* changes the localhost

* rollback - comment out the tests

---------

Co-authored-by: KatKatKateryna <89912278+KatKatKateryna@users.noreply.github.com>
2025-04-23 16:51:39 +02:00
Jedd Morgan 0aa14ca077 Publish to testpypi every push (#403) 2025-04-22 14:31:18 +01:00
Jedd Morgan 6bfdf8850c Update publish.yml (#402) 2025-04-22 14:25:41 +01:00
KatKatKateryna 22ecd2c2b3 dont ignore props (#401) 2025-04-22 13:43:58 +01:00
54 changed files with 1241 additions and 243 deletions
-27
View File
@@ -1,27 +0,0 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/blob/main/containers/python-3/.devcontainer/base.Dockerfile
# [Choice] Python version: 3, 3.9, 3.8, 3.7, 3.6
ARG VARIANT="3.10"
FROM mcr.microsoft.com/vscode/devcontainers/python:${VARIANT}
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
ARG NODE_VERSION="16"
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image.
# COPY requirements.txt /tmp/pip-tmp/
# RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \
# && rm -rf /tmp/pip-tmp
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
USER vscode
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH=$PATH:$HOME/.poetry/env
-55
View File
@@ -1,55 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/python-3
{
"name": "Python 3",
// "build": {
// "dockerfile": "Dockerfile",
// "context": "..",
// "args": {
// // Update 'VARIANT' to pick a Python version: 3, 3.6, 3.7, 3.8, 3.9
// "VARIANT": "3.6",
// // Options
// "NODE_VERSION": "lts/*"
// }
// },
"dockerComposeFile": "./docker-compose.yaml",
"service": "specklepy",
"workspaceFolder": "/workspaces/specklepy",
"shutdownAction": "stopCompose",
// Set *default* container specific settings.json values on container create.
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.languageServer": "Pylance",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.linting.pylintArgs": [
"--max-line-length=120"
],
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint",
"python.testing.pytestArgs": [
"tests/",
"-s"
],
"python.testing.pytestEnabled": true,
"editor.formatOnSave": true,
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "poetry config virtualenvs.create false && poetry install",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
}
-44
View File
@@ -1,44 +0,0 @@
version: "3.3" # optional since v1.27.0
services:
postgres:
image: cimg/postgres:14.2
environment:
POSTGRES_DB: speckle2_test
POSTGRES_PASSWORD: speckle
POSTGRES_USER: speckle
network_mode: host
redis:
image: cimg/redis:6.2
network_mode: host
speckle-server:
image: speckle/speckle-server:latest
command: ["bash", "-c", "/wait && node bin/www"]
environment:
POSTGRES_URL: "localhost"
POSTGRES_USER: "speckle"
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle2_test"
REDIS_URL: "redis://localhost"
SESSION_SECRET: "keyboard cat"
STRATEGY_LOCAL: "true"
CANONICAL_URL: "http://localhost:3000"
WAIT_HOSTS: localhost:5432, localhost:6379
DISABLE_FILE_UPLOADS: "true"
network_mode: host
specklepy:
build:
dockerfile: Dockerfile
context: .
args:
VARIANT: 3.9
NODE_VERSION: lts/*
volumes:
# Mounts the project folder to '/workspace'. While this file is in .devcontainer,
# mounts are relative to the first file in the list, which is a level up.
- ..:/workspaces/specklepy:cached
# Overrides default command so things don't shut down after the process ends.
command: /bin/sh -c "while sleep 1000; do :; done"
network_mode: host
# networks:
# default:
@@ -3,12 +3,10 @@ on:
pull_request:
branches:
- "v3-dev"
push:
branches:
- "v3-dev"
jobs:
ci:
name: continuous-integration
build-and-test:
name: build-and-test
runs-on: ubuntu-latest
strategy:
matrix:
@@ -39,18 +37,18 @@ jobs:
- name: Run pre-commit
run: uv run pre-commit run --all-files
# - name: Run Speckle Server
# run: docker compose up -d
- name: Run Speckle Server
run: docker compose up --detach --wait
# - name: Run tests
# run: uv run pytest --cov --cov-report xml:reports/coverage.xml --junitxml=reports/test-results.xml
- name: Run tests
run: uv run pytest --cov --cov-report xml:reports/coverage.xml --junitxml=reports/test-results.xml
# - uses: codecov/codecov-action@v5
# if: matrix.python-version == 3.13
# with:
# fail_ci_if_error: true # optional (default = false)
# files: ./reports/test-results.xml # optional
# token: ${{ secrets.CODECOV_TOKEN }}
- uses: codecov/codecov-action@v5
if: matrix.python-version == 3.13
with:
fail_ci_if_error: true # optional (default = false)
files: ./reports/test-results.xml # optional
token: ${{ secrets.CODECOV_TOKEN }}
- name: Minimize uv cache
run: uv cache prune --ci
+82 -17
View File
@@ -1,33 +1,98 @@
# Publish a release to PyPI.
name: "Publish to PyPI"
name: "Publish Python Package"
on:
workflow_run:
workflows: ["Specklepy test and build"]
branches: [v3-dev]
types:
- completed
push:
branches:
- "v3-dev"
tags:
- "3.*.*"
jobs:
pypi-publish:
name: Upload to PyPI
build-and-test:
name: continuous-integration
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
- uses: actions/cache@v3
with:
path: ~/.cache/pre-commit/
key: ${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
run: uv run pre-commit run --all-files
- name: Run Speckle Server
run: docker compose up -d
# - name: Run tests
# run: uv run pytest --cov --cov-report xml:reports/coverage.xml --junitxml=reports/test-results.xml
# - uses: codecov/codecov-action@v5
# if: matrix.python-version == 3.13
# with:
# fail_ci_if_error: true # optional (default = false)
# files: ./reports/test-results.xml # optional
# token: ${{ secrets.CODECOV_TOKEN }}
- name: Minimize uv cache
run: uv cache prune --ci
publish-package:
name: "Build and Publish Python Package"
runs-on: ubuntu-latest
needs: build-and-test
# set the environment based on what triggered the workflow
environment:
name: testpypi
name: ${{ github.ref_type == 'tag' && 'pypi' || 'testpypi' }}
permissions:
# For PyPI's trusted publishing.
id-token: write
steps:
- name: "Install uv"
uses: astral-sh/setup-uv@v5
- uses: actions/checkout@v4
- name: "Checkout code"
uses: actions/checkout@v4
with:
# This is necessary so that we have the tags.
fetch-depth: 0
- name: "Build artifacts"
- name: "Build package artifacts"
run: uv build
- name: Publish to PyPi
# Logic for TestPyPI (on v3-dev branch push)
- name: "Publish to TestPyPI"
if: ${{ github.ref_type == 'branch' }}
run: uv publish --index test
- name: Test package install
- name: "Verify TestPyPI package installation"
if: ${{ github.ref_type == 'branch' }}
run: uv run --index test --with specklepy --no-project -- python -c "import specklepy"
# Logic for PyPI (on v3* tag creation)
- name: "Publish to PyPI"
if: ${{ github.ref_type == 'tag' }}
run: uv publish
- name: "Verify PyPI package installation"
if: ${{ github.ref_type == 'tag' }}
run: uv run --with specklepy --no-project -- python -c "import specklepy"
+3 -4
View File
@@ -13,7 +13,7 @@ services:
POSTGRES_USER: speckle
POSTGRES_PASSWORD: speckle
volumes:
- ./.volumes/postgres-data:/var/lib/postgresql/data/
- postgres-data:/var/lib/postgresql/data/
healthcheck:
# the -U user has to match the POSTGRES_USER value
test: ["CMD-SHELL", "pg_isready -U speckle"]
@@ -25,7 +25,7 @@ services:
image: "redis:6.0-alpine"
restart: always
volumes:
- ./.volumes/redis-data:/data
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 5s
@@ -37,7 +37,7 @@ services:
command: server /data --console-address ":9001"
restart: always
volumes:
- ./.volumes/minio-data:/data
- minio-data:/data
healthcheck:
test:
[
@@ -100,7 +100,6 @@ services:
POSTGRES_PASSWORD: "speckle"
POSTGRES_DB: "speckle"
ENABLE_MP: "false"
FRONTEND_ORIGIN: "http://127.0.0.1:8080"
networks:
default:
+84 -35
View File
@@ -1,5 +1,3 @@
# ignoring "line too long" check from linter
# ruff: noqa: E501
"""This module provides an abstraction layer above the Speckle Automate runtime."""
import time
@@ -75,7 +73,7 @@ class AutomationContext:
speckle_client.authenticate_with_token(speckle_token)
if not speckle_client.account:
msg = (
f"Could not autenticate to {automation_run_data.speckle_server_url}",
f"Could not authenticate to {automation_run_data.speckle_server_url}",
"with the provided token",
)
raise ValueError(msg)
@@ -109,18 +107,24 @@ class AutomationContext:
)
except SpeckleException as err:
raise ValueError(
f"""\
Could not receive specified version.
Is your environment configured correctly?
project_id: {self.automation_run_data.project_id}
model_id: {self.automation_run_data.triggers[0].payload.model_id}
version_id: {self.automation_run_data.triggers[0].payload.version_id}
"""
f"""Could not receive specified version.
Is your environment configured correctly?
project_id: {self.automation_run_data.project_id}
model_id: {self.automation_run_data.triggers[0].payload.model_id}
version_id: {self.automation_run_data.triggers[0].payload.version_id}
"""
) from err
if not version.referenced_object:
raise Exception(
"This version is past the version history limit,",
" cannot execute an automation on it",
)
base = operations.receive(
version.referenced_object, self._server_transport, self._memory_transport
)
# self._closure_tree = base["__closure"]
print(
f"It took {self.elapsed():.2f} seconds to receive",
f" the speckle version {version_id}",
@@ -242,7 +246,7 @@ class AutomationContext:
)
if self.run_status in [AutomationStatus.SUCCEEDED, AutomationStatus.FAILED]:
object_results = {
"version": 1,
"version": 2,
"values": {
"objectResults": self._automation_result.model_dump(by_alias=True)[
"objectResults"
@@ -332,26 +336,24 @@ class AutomationContext:
def attach_error_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new error case to the run results.
If the error cause has already created an error case,
the error will be extended with a new case refering to the causing objects.
Args:
error_tag (str): A short tag for the error type.
causing_object_ids (str[]): A list of object_id-s that are causing the error
error_messagge (Optional[str]): Optional error message.
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the error case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.ERROR,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -360,16 +362,25 @@ class AutomationContext:
def attach_warning_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new warning case to the run results."""
"""Add a new warning case to the run results.
Args:
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the warning case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.WARNING,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -378,16 +389,25 @@ class AutomationContext:
def attach_success_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new success case to the run results."""
"""Add a new success case to the run results.
Args:
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the success case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.SUCCESS,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -396,16 +416,25 @@ class AutomationContext:
def attach_info_to_objects(
self,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
"""Add a new info case to the run results."""
"""Add a new info case to the run results.
Args:
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the info case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
self.attach_result_to_objects(
ObjectResultLevel.INFO,
category,
object_ids,
affected_objects,
message,
metadata,
visual_overrides,
@@ -415,19 +444,39 @@ class AutomationContext:
self,
level: ObjectResultLevel,
category: str,
object_ids: Union[str, List[str]],
affected_objects: Union[Base, List[Base]],
message: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
visual_overrides: Optional[Dict[str, Any]] = None,
) -> None:
if isinstance(object_ids, list):
if len(object_ids) < 1:
"""Add a new result case to the run results.
Args:
level: Result level.
category (str): A short tag for the event type.
affected_objects (Union[Base, List[Base]]): A single object or a list of
objects that are causing the info case.
message (Optional[str]): Optional message.
metadata: User provided metadata key value pairs
visual_overrides: Case specific 3D visual overrides.
"""
if isinstance(affected_objects, list):
if len(affected_objects) < 1:
raise ValueError(
f"Need atleast one object_id to report a(n) {level.value.upper()}"
f"Need atleast one object to report a(n) {level.value.upper()}"
)
id_list = object_ids
object_list = affected_objects
else:
id_list = [object_ids]
object_list = [affected_objects]
ids: Dict[str, Optional[str]] = {}
for o in object_list:
# validate that the Base.id is not None. If its a None, throw an Exception
if not o.id:
raise Exception(
f"You can only attach {level} results to objects with an id."
)
ids[o.id] = o.applicationId
print(
f"Created new {level.value.upper()}"
f" category: {category} caused by: {message}"
@@ -436,7 +485,7 @@ class AutomationContext:
ResultCase(
category=category,
level=level,
object_ids=id_list,
object_app_ids=ids,
message=message,
metadata=metadata,
visual_overrides=visual_overrides,
+1 -1
View File
@@ -80,7 +80,7 @@ class ResultCase(AutomateBase):
category: str
level: ObjectResultLevel
object_ids: List[str]
object_app_ids: Dict[str, Optional[str]]
message: Optional[str]
metadata: Optional[Dict[str, Any]]
visual_overrides: Optional[Dict[str, Any]]
+7
View File
@@ -10,6 +10,7 @@ from specklepy.api.resources import (
ServerResource,
SubscriptionResource,
VersionResource,
WorkspaceResource,
)
from specklepy.core.api.client import SpeckleClient as CoreSpeckleClient
from specklepy.logging import metrics
@@ -111,6 +112,12 @@ class SpeckleClient(CoreSpeckleClient):
client=self.httpclient,
server_version=server_version,
)
self.workspace = WorkspaceResource(
account=self.account,
basepath=self.url,
client=self.httpclient,
server_version=server_version,
)
self.subscription = SubscriptionResource(
account=self.account,
basepath=self.ws_url,
+2
View File
@@ -8,6 +8,7 @@ from specklepy.api.resources.current.project_resource import ProjectResource
from specklepy.api.resources.current.server_resource import ServerResource
from specklepy.api.resources.current.subscription_resource import SubscriptionResource
from specklepy.api.resources.current.version_resource import VersionResource
from specklepy.api.resources.current.workspace_resource import WorkspaceResource
__all__ = [
"ActiveUserResource",
@@ -18,4 +19,5 @@ __all__ = [
"ServerResource",
"SubscriptionResource",
"VersionResource",
"WorkspaceResource",
]
@@ -1,12 +1,17 @@
from typing import List, Optional
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter, UserUpdateInput
from specklepy.core.api.inputs.user_inputs import (
UserProjectsFilter,
UserUpdateInput,
UserWorkspacesFilter,
)
from specklepy.core.api.models import (
PendingStreamCollaborator,
Project,
ResourceCollection,
User,
)
from specklepy.core.api.models.current import PermissionCheckResult, Workspace
from specklepy.core.api.resources import ActiveUserResource as CoreResource
from specklepy.logging import metrics
@@ -51,3 +56,26 @@ class ActiveUserResource(CoreResource):
metrics.SDK, self.account, {"name": "Active User Get Project Invites"}
)
return super().get_project_invites()
def can_create_personal_projects(self) -> PermissionCheckResult:
metrics.track(
metrics.SDK,
self.account,
{"name": "Active User Can Create Personal Projects Check"},
)
return super().can_create_personal_projects()
def get_workspaces(
self,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[UserWorkspacesFilter] = None,
) -> ResourceCollection[Workspace]:
metrics.track(metrics.SDK, self.account, {"name": "Active User Get Workspaces"})
return super().get_workspaces(limit, cursor, filter)
def get_active_workspace(self) -> Optional[Workspace]:
metrics.track(
metrics.SDK, self.account, {"name": "Active User Get Active Workspace"}
)
return super().get_active_workspace()
@@ -5,8 +5,10 @@ from specklepy.core.api.inputs.project_inputs import (
ProjectModelsFilter,
ProjectUpdateInput,
ProjectUpdateRoleInput,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.models import Project, ProjectWithModels, ProjectWithTeam
from specklepy.core.api.models.current import ProjectPermissionChecks
from specklepy.core.api.resources import ProjectResource as CoreResource
from specklepy.logging import metrics
@@ -26,6 +28,12 @@ class ProjectResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Project Get "})
return super().get(project_id)
def get_permissions(self, project_id: str) -> ProjectPermissionChecks:
metrics.track(
metrics.SDK, self.account, {"name": "Project Project Permissions "}
)
return super().get_permissions(project_id)
def get_with_models(
self,
project_id: str,
@@ -50,6 +58,10 @@ class ProjectResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Project Create"})
return super().create(input)
def create_in_workspace(self, input: WorkspaceProjectCreateInput) -> Project:
metrics.track(metrics.SDK, self.account, {"name": "Workspace Project Create"})
return super().create_in_workspace(input)
def update(self, input: ProjectUpdateInput) -> Project:
metrics.track(metrics.SDK, self.account, {"name": "Project Update"})
return super().update(input)
@@ -0,0 +1,32 @@
from typing import Optional
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from specklepy.core.api.models.current import Project, ResourceCollection, Workspace
from specklepy.core.api.resources import WorkspaceResource as CoreResource
from specklepy.logging import metrics
class WorkspaceResource(CoreResource):
"""API Access class for workspace"""
def __init__(self, account, basepath, client, server_version) -> None:
super().__init__(
account=account,
basepath=basepath,
client=client,
server_version=server_version,
)
def get(self, workspace_id: str) -> Workspace:
metrics.track(metrics.SDK, self.account, {"name": "Workspace Get"})
return super().get(workspace_id)
def get_projects(
self,
workspace_id: str,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[WorksaceProjectsFilter] = None,
) -> ResourceCollection[Project]:
metrics.track(metrics.SDK, self.account, {"name": "Workspace Get Projects"})
return super().get_projects(workspace_id, limit, cursor, filter)
+7
View File
@@ -18,6 +18,7 @@ from specklepy.core.api.resources import (
ServerResource,
SubscriptionResource,
VersionResource,
WorkspaceResource,
)
from specklepy.logging import metrics
from specklepy.logging.exceptions import SpeckleException, SpeckleWarning
@@ -223,6 +224,12 @@ class SpeckleClient:
client=self.httpclient,
server_version=server_version,
)
self.workspace = WorkspaceResource(
account=self.account,
basepath=self.url,
client=self.httpclient,
server_version=server_version,
)
self.subscription = SubscriptionResource(
account=self.account,
basepath=self.ws_url,
+6
View File
@@ -2,9 +2,15 @@ from enum import Enum
class ProjectVisibility(str, Enum):
"""Supported project visibility types"""
PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC"
UNLISTED = "UNLISTED"
WORKSPACE = "WORKSPACE"
foo = ProjectVisibility.PRIVATE
class UserProjectsUpdatedMessageType(str, Enum):
@@ -10,6 +10,13 @@ class ProjectCreateInput(GraphQLBaseModel):
visibility: Optional[ProjectVisibility]
class WorkspaceProjectCreateInput(GraphQLBaseModel):
name: Optional[str]
description: Optional[str]
visibility: Optional[ProjectVisibility]
workspaceId: str
class ProjectInviteCreateInput(GraphQLBaseModel):
email: Optional[str]
role: Optional[str]
@@ -44,3 +51,12 @@ class ProjectUpdateRoleInput(GraphQLBaseModel):
user_id: str
project_id: str
role: Optional[str]
class WorksaceProjectsFilter(GraphQLBaseModel):
search: Optional[str]
"""Filter out projects by name"""
with_project_role_only: Optional[bool]
"""
Only return workspace projects that the active user has an explicit project role in
"""
+8 -1
View File
@@ -11,5 +11,12 @@ class UserUpdateInput(GraphQLBaseModel):
class UserProjectsFilter(GraphQLBaseModel):
search: str
search: Optional[str] = None
only_with_roles: Optional[Sequence[str]] = None
workspace_id: Optional[str] = None
personal_only: Optional[bool] = None
include_implicit_access: Optional[bool] = None
class UserWorkspacesFilter(GraphQLBaseModel):
search: Optional[str]
+48 -4
View File
@@ -3,6 +3,7 @@ from typing import Generic, List, Optional, TypeVar
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.models.graphql_base_model import GraphQLBaseModel
from specklepy.logging.exceptions import WorkspacePermissionException
T = TypeVar("T")
@@ -52,6 +53,10 @@ class ServerConfiguration(GraphQLBaseModel):
object_size_limit_bytes: int
class ServerWorkspacesInfo(GraphQLBaseModel):
workspaces_enabled: bool
# Keeping this one all Optionals at the minute,
# because its used both as a deserialization model for GQL and Account Management
class ServerInfo(GraphQLBaseModel):
@@ -61,13 +66,11 @@ class ServerInfo(GraphQLBaseModel):
admin_contact: Optional[str] = None
description: Optional[str] = None
canonical_url: Optional[str] = None
roles: Optional[List[dict]] = None
scopes: Optional[List[dict]] = None
auth_strategies: Optional[List[dict]] = None
version: Optional[str] = None
frontend2: Optional[bool] = None
migration: Optional[ServerMigration] = None
workspaces: Optional[ServerWorkspacesInfo] = None
# TODO separate gql model from account management model
@@ -129,7 +132,8 @@ class Version(GraphQLBaseModel):
id: str
message: Optional[str]
preview_url: str
referenced_object: str
referenced_object: Optional[str]
"""Maybe null if workspaces version history limit has been exceeded"""
source_application: Optional[str]
@@ -148,6 +152,13 @@ class ModelWithVersions(Model):
versions: ResourceCollection[Version]
class ProjectPermissionChecks(GraphQLBaseModel):
can_create_model: "PermissionCheckResult"
can_delete: "PermissionCheckResult"
can_load: "PermissionCheckResult"
can_publish: "PermissionCheckResult"
class Project(GraphQLBaseModel):
allow_public_comments: bool
created_at: datetime
@@ -177,3 +188,36 @@ class ProjectCommentCollection(ResourceCollection[T], Generic[T]):
class UserSearchResultCollection(GraphQLBaseModel):
items: List[LimitedUser]
cursor: Optional[str] = None
class PermissionCheckResult(GraphQLBaseModel):
authorized: bool
code: str
message: str
def ensure_authorised(self) -> None:
"""Raises WorkspacePermissionException if not authorized"""
if not self.authorized:
raise WorkspacePermissionException(self.message)
class WorkspacePermissionChecks(GraphQLBaseModel):
can_create_project: PermissionCheckResult
class WorkspaceCreationState(GraphQLBaseModel):
completed: bool
class Workspace(GraphQLBaseModel):
id: str
name: str
role: Optional[str]
slug: str
logo: Optional[str]
created_at: datetime
updated_at: datetime
read_only: bool
description: Optional[str]
creation_state: Optional[WorkspaceCreationState]
permissions: WorkspacePermissionChecks
@@ -10,6 +10,7 @@ from specklepy.core.api.resources.current.subscription_resource import (
SubscriptionResource,
)
from specklepy.core.api.resources.current.version_resource import VersionResource
from specklepy.core.api.resources.current.workspace_resource import WorkspaceResource
__all__ = [
"ActiveUserResource",
@@ -20,4 +21,5 @@ __all__ = [
"ServerResource",
"SubscriptionResource",
"VersionResource",
"WorkspaceResource",
]
@@ -2,13 +2,18 @@ from typing import List, Optional
from gql import gql
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter, UserUpdateInput
from specklepy.core.api.inputs.user_inputs import (
UserProjectsFilter,
UserUpdateInput,
UserWorkspacesFilter,
)
from specklepy.core.api.models import (
PendingStreamCollaborator,
Project,
ResourceCollection,
User,
)
from specklepy.core.api.models.current import PermissionCheckResult, Workspace
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
from specklepy.logging.exceptions import GraphQLException
@@ -190,3 +195,146 @@ class ActiveUserResource(ResourceBase):
)
return response.data.data
def can_create_personal_projects(self) -> PermissionCheckResult:
QUERY = gql(
"""
query CanCreatePersonalProject {
data:activeUser {
data:permissions {
data:canCreatePersonalProject {
authorized
code
message
}
}
}
}
"""
)
response = self.make_request_and_parse_response(
DataResponse[Optional[DataResponse[DataResponse[PermissionCheckResult]]]],
QUERY,
)
if response.data is None:
raise GraphQLException(
"GraphQL response indicated that the ActiveUser could not be found"
)
return response.data.data.data
def get_workspaces(
self,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[UserWorkspacesFilter] = None,
) -> ResourceCollection[Workspace]:
"""
This feature is only available on Workspace enabled servers (server versions
>=2.23.17) e.g. app.speckle.systems
"""
QUERY = gql(
"""
query ActiveUser($limit: Int!, $cursor: String, $filter: UserWorkspacesFilter) {
data:activeUser {
data:workspaces(limit: $limit, cursor: $cursor, filter: $filter) {
cursor
totalCount
items {
id
name
role
slug
logo
createdAt
updatedAt
readOnly
description
creationState
{
completed
}
permissions {
canCreateProject {
authorized
code
message
}
}
}
}
}
}
""" # noqa: E501
)
variables = {
"limit": limit,
"cursor": cursor,
"filter": filter.model_dump(warnings="error", by_alias=True)
if filter
else None,
}
response = self.make_request_and_parse_response(
DataResponse[Optional[DataResponse[ResourceCollection[Workspace]]]],
QUERY,
variables,
)
if response.data is None:
raise GraphQLException(
"GraphQL response indicated that the ActiveUser could not be found"
)
return response.data.data
def get_active_workspace(self) -> Optional[Workspace]:
"""
This feature is only available on Workspace enabled servers (server versions
>=2.23.17) e.g. app.speckle.systems
"""
QUERY = gql(
"""
query ActiveUser {
data:activeUser {
data:activeWorkspace {
id
name
role
slug
logo
createdAt
updatedAt
readOnly
description
creationState
{
completed
}
permissions {
canCreateProject {
authorized
code
message
}
}
}
}
}
""" # noqa: E501
)
response = self.make_request_and_parse_response(
DataResponse[Optional[DataResponse[Optional[Workspace]]]],
QUERY,
)
if response.data is None:
raise GraphQLException(
"GraphQL response indicated that the ActiveUser could not be found"
)
return response.data.data
@@ -7,8 +7,10 @@ from specklepy.core.api.inputs.project_inputs import (
ProjectModelsFilter,
ProjectUpdateInput,
ProjectUpdateRoleInput,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.models import Project, ProjectWithModels, ProjectWithTeam
from specklepy.core.api.models.current import ProjectPermissionChecks
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
@@ -55,6 +57,46 @@ class ProjectResource(ResourceBase):
DataResponse[Project], QUERY, variables
).data
def get_permissions(self, project_id: str) -> ProjectPermissionChecks:
QUERY = gql(
"""
query Project($projectId: String!) {
data:project(id: $projectId) {
data:permissions {
canCreateModel {
authorized
code
message
}
canDelete {
authorized
code
message
}
canLoad {
authorized
code
message
}
canPublish {
authorized
code
message
}
}
}
}
"""
)
variables = {
"projectId": project_id,
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[ProjectPermissionChecks]], QUERY, variables
).data.data
def get_with_models(
self,
project_id: str,
@@ -198,6 +240,12 @@ class ProjectResource(ResourceBase):
).data
def create(self, input: ProjectCreateInput) -> Project:
"""
Creates a non-workspace project (aka Personal Project)
see client.active_user.can_create_personal_projects to see if the user has
permission
"""
QUERY = gql(
"""
mutation ProjectCreate($input: ProjectCreateInput) {
@@ -227,6 +275,45 @@ class ProjectResource(ResourceBase):
DataResponse[DataResponse[Project]], QUERY, variables
).data.data
def create_in_workspace(self, input: WorkspaceProjectCreateInput) -> Project:
"""
Creates a workspace project
This feature is only supported by Workspace Enabled Servers
(e.g. app.speckle.systems)
see `workspace.permissions.can_create_project` to see if the user has permission
"""
QUERY = gql(
"""
mutation WorkspaceProjectCreate($input: WorkspaceProjectCreateInput!) {
data:workspaceMutations {
data:projects {
data:create(input: $input) {
id
name
description
visibility
allowPublicComments
role
createdAt
updatedAt
sourceApps
workspaceId
}
}
}
}
"""
)
variables = {
"input": input.model_dump(warnings="error", by_alias=True),
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[DataResponse[Project]]], QUERY, variables
).data.data.data
def update(self, input: ProjectUpdateInput) -> Project:
QUERY = gql(
"""
@@ -1,7 +1,6 @@
import re
from typing import Any, Dict, List, Tuple
import requests
from gql import gql
from specklepy.core.api.models import ServerInfo
@@ -38,11 +37,6 @@ class ServerResource(ResourceBase):
adminContact
canonicalUrl
version
roles {
name
description
resourceTarget
}
scopes {
name
description
@@ -52,6 +46,9 @@ class ServerResource(ResourceBase):
name
icon
}
workspaces {
workspacesEnabled
}
}
}
"""
@@ -60,16 +57,6 @@ class ServerResource(ResourceBase):
server_info = self.make_request(
query=query, return_type="serverInfo", schema=ServerInfo
)
if isinstance(server_info, ServerInfo) and isinstance(
server_info.canonical_url, str
):
r = requests.get(
server_info.canonical_url, headers={"User-Agent": "specklepy SDK"}
)
if "x-speckle-frontend-2" in r.headers:
server_info.frontend2 = True
else:
server_info.frontend2 = False
return server_info
@@ -0,0 +1,106 @@
from typing import Optional
from gql import gql
from specklepy.core.api.inputs.project_inputs import WorksaceProjectsFilter
from specklepy.core.api.models.current import Project, ResourceCollection, Workspace
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
NAME = "workspace"
class WorkspaceResource(ResourceBase):
"""API Access class for models"""
def __init__(self, account, basepath, client, server_version) -> None:
super().__init__(
account=account,
basepath=basepath,
client=client,
name=NAME,
server_version=server_version,
)
def get(self, workspace_id: str) -> Workspace:
QUERY = gql(
"""
query WorkspaceGet($workspaceId: String!) {
data:workspace(id: $workspaceId) {
id
name
role
slug
logo
createdAt
updatedAt
readOnly
description
creationState
{
completed
}
permissions {
canCreateProject {
authorized
code
message
}
}
}
}
"""
)
variables = {
"workspaceId": workspace_id,
}
return self.make_request_and_parse_response(
DataResponse[Workspace], QUERY, variables
).data
def get_projects(
self,
workspace_id: str,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[WorksaceProjectsFilter] = None,
) -> ResourceCollection[Project]:
QUERY = gql(
"""
query Workspace($workspaceId: String!, $limit: Int!, $cursor: String, $filter: WorkspaceProjectsFilter) {
data:workspace(id: $workspaceId) {
data:projects(limit: $limit, cursor: $cursor, filter: $filter) {
cursor
items {
allowPublicComments
createdAt
description
id
name
role
sourceApps
updatedAt
visibility
workspaceId
}
totalCount
}
}
}
""" # noqa: E501
)
variables = {
"workspaceId": workspace_id,
"limit": limit,
"cursor": cursor,
"filter": filter.model_dump(warnings="error", by_alias=True)
if filter
else None,
}
return self.make_request_and_parse_response(
DataResponse[DataResponse[ResourceCollection[Project]]], QUERY, variables
).data.data
+5
View File
@@ -58,3 +58,8 @@ class UnsupportedException(SpeckleException):
class SpeckleWarning(Warning):
def __init__(self, *args: object) -> None:
super().__init__(*args)
class WorkspacePermissionException(SpeckleException):
def __init__(self, message: str) -> None:
super().__init__(message=message)
+2 -6
View File
@@ -1,7 +1,3 @@
from .data_objects import Base, DataObject, QgisObject
from .data_objects import Base, DataObject, QgisObject, BlenderObject # noqa: I001
__all__ = [
"Base",
"DataObject",
"QgisObject",
]
__all__ = ["Base", "DataObject", "QgisObject", "BlenderObject"]
@@ -0,0 +1,8 @@
from .text import AlignmentHorizontal, AlignmentVertical, Text
# re-export them at the geometry package level
__all__ = [
"Text",
"AlignmentHorizontal",
"AlignmentVertical",
]
+54
View File
@@ -0,0 +1,54 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from specklepy.objects.base import Base
from specklepy.objects.geometry import Plane, Point
from specklepy.objects.interfaces import IHasUnits
class AlignmentHorizontal(Enum):
Left = 0
Center = 1
Right = 2
class AlignmentVertical(Enum):
Top = 0
Center = 1
Bottom = 2
@dataclass(kw_only=True)
class Text(Base, IHasUnits, speckle_type="Objects.Annotation.Text"):
"""
Text class for representation in the viewer.
Units will be 'Units.None' if the text size is defined in pixels.
"""
value: str # Plain text, without formatting
origin: Point # Relation to the text is defined by AlignmentH and AlignmentV
height: float # Font height in linear units or pixels (if Units.None)
alignmentH: AlignmentHorizontal = field(
default_factory=lambda: AlignmentHorizontal.Left
)
alignmentV: AlignmentVertical = field(default_factory=lambda: AlignmentVertical.Top)
plane: Optional[Plane] = field(
default_factory=lambda: None
) # None if the text object orientation follows camera view
maxWidth: Optional[float] = field(
default_factory=lambda: None
) # Maximum width of the text field. None, if don't split into lines
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}("
f"value: {self.value}, "
f"origin: {self.origin}, "
f"height: {self.height}, "
f"alignmentH: {self.alignmentH}, "
f"alignmentV: {self.alignmentV}, "
f"plane: {self.plane}, "
f"maxWidth: {self.maxWidth}, "
f"units: {self.units})"
)
+27 -1
View File
@@ -3,7 +3,12 @@ from typing import Dict, List
from specklepy.logging.exceptions import SpeckleException
from specklepy.objects.base import Base
from specklepy.objects.interfaces import IDataObject, IGisObject, IHasUnits
from specklepy.objects.interfaces import (
IBlenderObject,
IDataObject,
IGisObject,
IHasUnits,
)
@dataclass(kw_only=True)
@@ -79,3 +84,24 @@ class QgisObject(
raise SpeckleException(
f"'type' value should be string, received {type(value)}"
)
@dataclass(kw_only=True)
class BlenderObject(
DataObject, IBlenderObject, IHasUnits, speckle_type="Objects.Data.BlenderObject"
):
type: str
_type: str = field(repr=False, init=False)
@property
def type(self) -> str:
return self._type
@type.setter
def type(self, value: str):
if isinstance(value, str):
self._type = value
else:
raise SpeckleException(
f"'type' value should be string, received {type(value)}"
)
+3 -1
View File
@@ -13,12 +13,13 @@ class Mesh(
IHasVolume,
IHasUnits,
speckle_type="Objects.Geometry.Mesh",
detachable={"vertices", "faces", "colors", "textureCoordinates"},
detachable={"vertices", "faces", "colors", "textureCoordinates", "vertexNormals"},
chunkable={
"vertices": 31250,
"faces": 62500,
"colors": 62500,
"textureCoordinates": 31250,
"vertexNormals": 31250,
},
serialize_ignore={"vertices_count", "texture_coordinates_count"},
):
@@ -31,6 +32,7 @@ class Mesh(
faces: List[int]
colors: List[int] = field(default_factory=list)
textureCoordinates: List[float] = field(default_factory=list)
vertexNormals: List[float] = field(default_factory=list)
def __repr__(self) -> str:
return (
-1
View File
@@ -47,7 +47,6 @@ class Region(
@property
def displayValue(self) -> List[Mesh]:
print(self._displayValue)
return self._displayValue
@displayValue.setter
-1
View File
@@ -7,7 +7,6 @@ from specklepy.objects.base import Base
class RenderMaterial(
Base,
speckle_type="Objects.Other.RenderMaterial",
serialize_ignore={"diffuse", "emissive"},
):
"""
Minimal physically based material DTO class. Based on references from
@@ -4,6 +4,7 @@ from specklepy.api.client import SpeckleClient
from specklepy.core.api.inputs.project_inputs import ProjectCreateInput
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter, UserUpdateInput
from specklepy.core.api.models import ResourceCollection, User
from specklepy.logging.exceptions import GraphQLException
@pytest.mark.run()
@@ -61,3 +62,24 @@ class TestActiveUserResource:
assert len(res.items) == 1
assert res.total_count == 1
assert res.items[0].id == p1.id
def test_can_create_personal_projects(self, client: SpeckleClient):
res = client.active_user.can_create_personal_projects()
res.ensure_authorised()
assert res.authorized is True
def test_get_workspaces(self, client: SpeckleClient):
"""
Test server is not workspace enabled, so we can't really test everything here
We'll just test client's error handling
"""
with pytest.raises(GraphQLException):
_ = client.active_user.get_workspaces()
def test_get_active_workspace(self, client: SpeckleClient):
"""
Test server is not workspace enabled, so we can't really test everything here
"""
res = client.active_user.get_active_workspace()
assert res is None
@@ -3,6 +3,7 @@ from typing import Optional
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.core.api.enums import ProjectVisibility
from specklepy.core.api.inputs.project_inputs import (
ProjectCreateInput,
ProjectInviteCreateInput,
@@ -22,7 +23,9 @@ class TestProjectInviteResource:
@pytest.fixture
def project(self, client: SpeckleClient):
return client.project.create(
ProjectCreateInput(name="test", description=None, visibility=None)
ProjectCreateInput(
name="test", description=None, visibility=ProjectVisibility.PUBLIC
)
)
@pytest.fixture
@@ -7,6 +7,7 @@ from specklepy.core.api.inputs.project_inputs import (
ProjectUpdateInput,
)
from specklepy.core.api.models import Project
from specklepy.core.api.models.current import ProjectPermissionChecks
from specklepy.logging.exceptions import GraphQLException
@@ -50,8 +51,8 @@ class TestProjectResource:
assert result.name == name
assert result.description == (description or "")
# we've disabled creation of public projects for now, they fall back to unlisted
if visibility == ProjectVisibility.PUBLIC:
assert result.visibility == ProjectVisibility.UNLISTED
if visibility == ProjectVisibility.UNLISTED:
assert result.visibility == ProjectVisibility.PUBLIC
else:
assert result.visibility == visibility
@@ -65,10 +66,19 @@ class TestProjectResource:
assert result.visibility == test_project.visibility
assert result.created_at == test_project.created_at
def test_project_get_permissions(
self, client: SpeckleClient, test_project: Project
):
result = client.project.get_permissions(test_project.id)
assert isinstance(result, ProjectPermissionChecks)
assert result.can_create_model.authorized is True
assert result.can_delete.authorized is True
def test_project_update(self, client: SpeckleClient, test_project: Project):
new_name = "MY new name"
new_description = "MY new desc"
new_visibility = ProjectVisibility.PUBLIC
new_visibility = ProjectVisibility.UNLISTED
update_data = ProjectUpdateInput(
id=test_project.id,
@@ -84,8 +94,8 @@ class TestProjectResource:
assert updated_project.name == new_name
assert updated_project.description == new_description
# we've disabled creation of public projects for now, they fall back to unlisted
if new_visibility == ProjectVisibility.PUBLIC:
assert updated_project.visibility == ProjectVisibility.UNLISTED
if new_visibility == ProjectVisibility.UNLISTED:
assert updated_project.visibility == ProjectVisibility.PUBLIC
else:
assert updated_project.visibility == new_visibility
@@ -0,0 +1,23 @@
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.logging.exceptions import GraphQLException
@pytest.mark.run()
class TestWorkspaceResource:
def test_get_workspace(self, client: SpeckleClient):
"""
Test server is not workspace enabled, so we can't really test everything here
We'll just test client's error handling
"""
with pytest.raises(GraphQLException):
client.workspace.get("not a real id")
def test_get_projects(self, client: SpeckleClient):
"""
Test server is not workspace enabled, so we can't really test everything here
We'll just test client's error handling
"""
with pytest.raises(GraphQLException):
client.workspace.get_projects("not a real id")
@@ -151,6 +151,7 @@ def test_arc_serialization(sample_arc):
serialized = serialize(sample_arc)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Arc)
assert deserialized.startPoint.x == sample_arc.startPoint.x
assert deserialized.startPoint.y == sample_arc.startPoint.y
assert deserialized.startPoint.z == sample_arc.startPoint.z
@@ -116,6 +116,7 @@ def test_box_serialization(sample_box):
serialized = serialize(sample_box)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Box)
assert deserialized.basePlane.origin.x == sample_box.basePlane.origin.x
assert deserialized.basePlane.origin.y == sample_box.basePlane.origin.y
assert deserialized.basePlane.origin.z == sample_box.basePlane.origin.z
@@ -107,6 +107,7 @@ def test_circle_serialization(sample_circle):
serialized = serialize(sample_circle)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Circle)
assert deserialized.plane.origin.x == sample_circle.plane.origin.x
assert deserialized.plane.origin.y == sample_circle.plane.origin.y
assert deserialized.plane.origin.z == sample_circle.plane.origin.z
@@ -70,6 +70,7 @@ def test_control_point_serialization(sample_control_point):
serialized = serialize(sample_control_point)
deserialized = deserialize(serialized)
assert isinstance(deserialized, ControlPoint)
assert deserialized.x == sample_control_point.x
assert deserialized.y == sample_control_point.y
assert deserialized.z == sample_control_point.z
@@ -125,6 +125,7 @@ def test_curve_serialization(sample_curve):
serialized = serialize(sample_curve)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Curve)
assert deserialized.degree == sample_curve.degree
assert deserialized.periodic == sample_curve.periodic
assert deserialized.rational == sample_curve.rational
+252
View File
@@ -0,0 +1,252 @@
import pytest
from specklepy.core.api.operations import deserialize, serialize
from specklepy.logging.exceptions import SpeckleException
from specklepy.objects.base import Base
from specklepy.objects.data_objects import BlenderObject, DataObject, QgisObject
from specklepy.objects.interfaces import (
IBlenderObject,
IDataObject,
IGisObject,
IHasUnits,
)
from specklepy.objects.models.units import Units
def test_data_object_creation():
display_value = [Base()]
data_obj = DataObject(
name="Test Data Object",
properties={"key1": "value1", "key2": 2},
displayValue=display_value,
)
assert data_obj.name == "Test Data Object"
assert data_obj.properties == {"key1": "value1", "key2": 2}
assert data_obj.displayValue == display_value
assert data_obj.speckle_type == "Objects.Data.DataObject"
def test_inheritance_relationships():
data_obj = DataObject(
name="Test Data Object",
properties={"key": "value"},
displayValue=[Base()],
)
assert isinstance(data_obj, DataObject)
assert isinstance(data_obj, Base)
assert isinstance(data_obj, IDataObject)
qgis_obj = QgisObject(
name="Test QGIS Object",
properties={"key": "value"},
displayValue=[Base()],
type="Feature",
units=Units.m,
)
assert isinstance(qgis_obj, QgisObject)
assert isinstance(qgis_obj, DataObject)
assert isinstance(qgis_obj, Base)
assert isinstance(qgis_obj, IDataObject)
assert isinstance(qgis_obj, IGisObject)
assert isinstance(qgis_obj, IHasUnits)
blender_obj = BlenderObject(
name="Test Blender Object",
properties={"key": "value"},
displayValue=[Base()],
type="Mesh",
units=Units.m,
)
assert isinstance(blender_obj, BlenderObject)
assert isinstance(blender_obj, DataObject)
assert isinstance(blender_obj, Base)
assert isinstance(blender_obj, IDataObject)
assert isinstance(blender_obj, IBlenderObject)
assert isinstance(blender_obj, IHasUnits)
def test_data_object_invalid_types():
data_obj = DataObject(
name="Test Object",
properties={"key": "value"},
displayValue=[Base()],
)
class ComplexObject:
def __str__(self):
raise ValueError("Can't convert to string")
complex_obj = ComplexObject()
with pytest.raises((ValueError, SpeckleException)):
data_obj.name = complex_obj # should be string
with pytest.raises(SpeckleException):
data_obj.properties = [1, 2, 3] # should be dict, not list
with pytest.raises(SpeckleException):
data_obj.displayValue = {"key": "value"} # should be list, not dict
def test_data_object_serialization():
display_value = [Base()]
data_obj = DataObject(
name="Test Data Object",
properties={"key1": "value1", "key2": 2},
displayValue=display_value,
)
serialized = serialize(data_obj)
deserialized = deserialize(serialized)
assert isinstance(deserialized, DataObject)
assert deserialized.name == data_obj.name
assert deserialized.properties == data_obj.properties
assert len(deserialized.displayValue) == len(data_obj.displayValue)
assert deserialized.speckle_type == data_obj.speckle_type
def test_qgis_object_creation():
display_value = [Base()]
qgis_obj = QgisObject(
name="Test QGIS Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Feature",
units=Units.m,
)
assert qgis_obj.name == "Test QGIS Object"
assert qgis_obj.properties == {"key1": "value1"}
assert qgis_obj.displayValue == display_value
assert qgis_obj.type == "Feature"
assert qgis_obj.units == Units.m.value
assert "Objects.Data.QgisObject" in qgis_obj.speckle_type
def test_qgis_object_serialization():
display_value = [Base()]
qgis_obj = QgisObject(
name="Test QGIS Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Feature",
units=Units.m,
)
serialized = serialize(qgis_obj)
deserialized = deserialize(serialized)
assert isinstance(deserialized, QgisObject)
assert deserialized.name == qgis_obj.name
assert deserialized.properties == qgis_obj.properties
assert len(deserialized.displayValue) == len(qgis_obj.displayValue)
assert deserialized.type == qgis_obj.type
assert deserialized.units == qgis_obj.units
assert "Objects.Data.QgisObject" in deserialized.speckle_type
def test_blender_object_creation():
display_value = [Base()]
blender_obj = BlenderObject(
name="Test Blender Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Mesh",
units=Units.m,
)
assert blender_obj.name == "Test Blender Object"
assert blender_obj.properties == {"key1": "value1"}
assert blender_obj.displayValue == display_value
assert blender_obj.type == "Mesh"
assert blender_obj.units == Units.m.value
assert "Objects.Data.BlenderObject" in blender_obj.speckle_type
def test_blender_object_invalid_types():
blender_obj = BlenderObject(
name="Test Object",
properties={"key": "value"},
displayValue=[Base()],
type="Mesh",
units=Units.m,
)
class ComplexObject:
def __str__(self):
raise ValueError("Can't convert to string")
complex_obj = ComplexObject()
with pytest.raises((ValueError, SpeckleException)):
blender_obj.type = complex_obj # should be string
def test_blender_object_serialization():
display_value = [Base()]
blender_obj = BlenderObject(
name="Test Blender Object",
properties={"key1": "value1"},
displayValue=display_value,
type="Mesh",
units=Units.m,
)
serialized = serialize(blender_obj)
deserialized = deserialize(serialized)
assert isinstance(deserialized, BlenderObject)
assert deserialized.name == blender_obj.name
assert deserialized.properties == blender_obj.properties
assert len(deserialized.displayValue) == len(blender_obj.displayValue)
assert deserialized.type == blender_obj.type
assert deserialized.units == blender_obj.units
assert "Objects.Data.BlenderObject" in deserialized.speckle_type
def test_data_object_property_modification():
data_obj = DataObject(
name="Original Name",
properties={"original": "value"},
displayValue=[Base()],
)
data_obj.name = "Updated Name"
data_obj.properties = {"updated": "property"}
new_display_value = [Base(), Base()]
data_obj.displayValue = new_display_value
assert data_obj.name == "Updated Name"
assert data_obj.properties == {"updated": "property"}
assert data_obj.displayValue == new_display_value
def test_qgis_object_property_modification():
"""Test modification of QgisObject properties after creation."""
qgis_obj = QgisObject(
name="Original Name",
properties={"original": "value"},
displayValue=[Base()],
type="OriginalType",
units=Units.m,
)
qgis_obj.type = "UpdatedType"
assert qgis_obj.type == "UpdatedType"
def test_blender_object_property_modification():
blender_obj = BlenderObject(
name="Original Name",
properties={"original": "value"},
displayValue=[Base()],
type="OriginalType",
units=Units.m,
)
blender_obj.type = "UpdatedType"
assert blender_obj.type == "UpdatedType"
@@ -104,6 +104,7 @@ def test_ellipse_serialization(sample_ellipse):
serialized = serialize(sample_ellipse)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Ellipse)
assert deserialized.plane.origin.x == sample_ellipse.plane.origin.x
assert deserialized.plane.origin.y == sample_ellipse.plane.origin.y
assert deserialized.plane.origin.z == sample_ellipse.plane.origin.z
@@ -83,6 +83,7 @@ def test_line_serialization(sample_line):
serialized = serialize(sample_line)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Line)
assert deserialized.start.x == sample_line.start.x
assert deserialized.start.y == sample_line.start.y
assert deserialized.start.z == sample_line.start.z
@@ -155,6 +155,7 @@ def test_mesh_creation(cube_vertices, cube_faces):
assert mesh.faces == cube_faces
assert mesh.colors == []
assert mesh.textureCoordinates == []
assert mesh.vertexNormals == []
assert mesh.units == Units.m.value
@@ -239,6 +240,7 @@ def test_mesh_serialization(full_mesh):
serialized = serialize(full_mesh)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Mesh)
assert deserialized.vertices == full_mesh.vertices
assert deserialized.faces == full_mesh.faces
assert deserialized.colors == full_mesh.colors
@@ -111,6 +111,7 @@ def test_spiral_serialization(sample_spiral: Spiral):
serialized = serialize(sample_spiral)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Spiral)
assert deserialized.start_point.x == sample_spiral.start_point.x
assert deserialized.start_point.y == sample_spiral.start_point.y
assert deserialized.start_point.z == sample_spiral.start_point.z
@@ -30,6 +30,7 @@ def test_point_serialization():
serialized = serialize(p1)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Point)
assert deserialized.x == p1.x
assert deserialized.y == p1.y
assert deserialized.z == p1.z
@@ -47,6 +47,7 @@ def test_point_cloud_serialization(sample_point_cloud):
serialized = serialize(sample_point_cloud)
deserialized = deserialize(serialized)
assert isinstance(deserialized, PointCloud)
assert len(deserialized.points) == len(sample_point_cloud.points)
for orig_point, deserial_point in zip(
@@ -86,15 +86,21 @@ def test_polycurve_serialization(sample_polycurve: Polycurve):
serialized = serialize(sample_polycurve)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Polycurve)
assert len(deserialized.segments) == len(sample_polycurve.segments)
assert deserialized.units == sample_polycurve.units
assert deserialized.segments[0].start.x == sample_polycurve.segments[0].start.x
assert deserialized.segments[0].start.y == sample_polycurve.segments[0].start.y
assert deserialized.segments[0].start.z == sample_polycurve.segments[0].start.z
assert deserialized.segments[0].end.x == sample_polycurve.segments[0].end.x
assert deserialized.segments[0].end.y == sample_polycurve.segments[0].end.y
assert deserialized.segments[0].end.z == sample_polycurve.segments[0].end.z
expectedSegment = sample_polycurve.segments[0]
segment = deserialized.segments[0]
assert isinstance(expectedSegment, Line)
assert isinstance(segment, Line)
assert segment.start.x == expectedSegment.start.x
assert segment.start.y == expectedSegment.start.y
assert segment.start.z == expectedSegment.start.z
assert segment.end.x == expectedSegment.end.x
assert segment.end.y == expectedSegment.end.y
assert segment.end.z == expectedSegment.end.z
def test_polycurve_empty():
@@ -134,6 +134,7 @@ def test_polyline_serialization(sample_polyline):
serialized = serialize(sample_polyline)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Polyline)
assert deserialized.value == sample_polyline.value
assert deserialized.units == sample_polyline.units
assert deserialized.domain.start == sample_polyline.domain.start
@@ -69,6 +69,7 @@ def test_region_serialization(sample_region):
serialized = serialize(sample_region)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Region)
assert deserialized.hasHatchPattern == sample_region.hasHatchPattern
assert deserialized.units == sample_region.units
@@ -113,6 +113,7 @@ def test_spiral_serialization(sample_spiral: Spiral):
serialized = serialize(sample_spiral)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Spiral)
assert deserialized.start_point.x == sample_spiral.start_point.x
assert deserialized.start_point.y == sample_spiral.start_point.y
assert deserialized.start_point.z == sample_spiral.start_point.z
@@ -140,6 +140,7 @@ def test_surface_serialization(sample_surface: Surface):
serialized = serialize(sample_surface)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Surface)
assert deserialized.degreeU == sample_surface.degreeU
assert deserialized.degreeV == sample_surface.degreeV
assert deserialized.rational == sample_surface.rational
+100
View File
@@ -0,0 +1,100 @@
import pytest
from specklepy.core.api.operations import deserialize, serialize
from specklepy.objects.annotation import AlignmentHorizontal, AlignmentVertical, Text
from specklepy.objects.geometry import Plane, Point, Vector
from specklepy.objects.models.units import Units
@pytest.fixture
def sample_point() -> Point:
return Point(x=0.0, y=0.0, z=0.0, units=Units.m)
@pytest.fixture
def sample_plane(sample_point: Point) -> Plane:
normal = Vector(x=0.0, y=0.0, z=1.0, units=Units.m)
xdir = Vector(x=1.0, y=0.0, z=0.0, units=Units.m)
ydir = Vector(x=0.0, y=1.0, z=0.0, units=Units.m)
return Plane(
origin=sample_point, normal=normal, xdir=xdir, ydir=ydir, units=Units.m
)
@pytest.fixture
def sample_text(sample_point: Point) -> Text:
return Text(value="text", origin=sample_point, height=0.5, units=Units.m)
@pytest.fixture
def sample_text_all_properties(sample_point: Point, sample_plane: Plane) -> Text:
return Text(
value="text",
origin=sample_point,
height=0.5,
alignmentH=AlignmentHorizontal.Center,
alignmentV=AlignmentVertical.Center,
plane=sample_plane,
maxWidth=20,
units=Units.m,
)
def test_text_creation_minimal(sample_point: Point):
text_value = "text"
text_obj = Text(value=text_value, origin=sample_point, height=0.5, units=Units.m)
assert text_obj.value == text_value
assert text_obj.origin == sample_point
assert text_obj.height == 0.5
assert text_obj.alignmentH == AlignmentHorizontal.Left
assert text_obj.alignmentV == AlignmentVertical.Top
assert text_obj.plane is None
assert text_obj.maxWidth is None
assert text_obj.units == Units.m.value
def test_text_creation_extended(sample_point: Point, sample_plane: Plane):
text_value = "text"
max_width = 20
text_obj = Text(
value=text_value,
origin=sample_point,
height=0.5,
alignmentH=AlignmentHorizontal.Center,
alignmentV=AlignmentVertical.Center,
plane=sample_plane,
maxWidth=max_width,
units=Units.m,
)
assert text_obj.value == text_value
assert text_obj.origin == sample_point
assert text_obj.height == 0.5
assert text_obj.alignmentH == AlignmentHorizontal.Center
assert text_obj.alignmentV == AlignmentVertical.Center
assert text_obj.plane == sample_plane
assert text_obj.maxWidth == max_width
assert text_obj.units == Units.m.value
def test_point_serialization(sample_text_all_properties: Text):
serialized = serialize(sample_text_all_properties)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Text)
assert deserialized.value == sample_text_all_properties.value
assert deserialized.origin.x == sample_text_all_properties.origin.x
assert deserialized.origin.y == sample_text_all_properties.origin.y
assert deserialized.origin.z == sample_text_all_properties.origin.z
assert deserialized.height == sample_text_all_properties.height
assert deserialized.alignmentH == sample_text_all_properties.alignmentH
assert deserialized.alignmentV == sample_text_all_properties.alignmentV
assert deserialized.plane.origin.x == sample_text_all_properties.plane.origin.x
assert deserialized.plane.origin.y == sample_text_all_properties.plane.origin.y
assert deserialized.plane.origin.z == sample_text_all_properties.plane.origin.z
assert deserialized.plane.normal.x == sample_text_all_properties.plane.normal.x
assert deserialized.plane.normal.y == sample_text_all_properties.plane.normal.y
assert deserialized.plane.normal.z == sample_text_all_properties.plane.normal.z
assert deserialized.maxWidth == sample_text_all_properties.maxWidth
assert deserialized.units == sample_text_all_properties.units
@@ -36,6 +36,7 @@ def test_vector_serialization():
serialized = serialize(v)
deserialized = deserialize(serialized)
assert isinstance(deserialized, Vector)
assert deserialized.x == v.x
assert deserialized.y == v.y
assert deserialized.z == v.z