Compare commits

...

15 Commits

Author SHA1 Message Date
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
42 changed files with 686 additions and 192 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:
+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,
@@ -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]
+46 -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,11 @@ class ModelWithVersions(Model):
versions: ResourceCollection[Version]
class ProjectPermissionChecks(GraphQLBaseModel):
can_create_model: "PermissionCheckResult"
can_delete: "PermissionCheckResult"
class Project(GraphQLBaseModel):
allow_public_comments: bool
created_at: datetime
@@ -177,3 +186,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,36 @@ 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
}
}
}
}
"""
)
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 +230,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 +265,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[DataResponse[Workspace]], QUERY, variables
).data.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)
+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 (
@@ -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
@@ -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
@@ -65,6 +66,15 @@ 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"
@@ -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
@@ -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
@@ -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