Compare commits

...

21 Commits

Author SHA1 Message Date
Jedd Morgan 4f82c0f43d feat(api): Added functions for fetching the connector version feeds. (#435)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* Added connector feed utility functions

* Check ex code
2025-07-18 13:00:09 +01:00
Jedd Morgan f5e024c8ce perf(serializer): Avoid unnecessary serialization of detached objects (#431)
* Avoid unnecessary serialization of detached objects

* camel case variable namings
2025-06-16 16:24:41 +01:00
Dogukan Karatas 3bcdf723b0 feat (api): projects with permissions (#430)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
* adds project with permissions

* removes the project resource with permissions

* fix the tests
2025-06-06 16:07:48 +02:00
Jedd Morgan adc1105b3a Forward secret to publish job (#428)
Publish Python Package / test (push) Has been cancelled
Publish Python Package / Build and Publish Python Package (push) Has been cancelled
2025-06-02 17:00:00 +01:00
Gergő Jedlicska fa9877b6da Gergo/ci upgrade (#427)
* feat(ci): refactor ci jobs to remove duplication

* chore(ci): just some comment fix
2025-06-02 16:45:41 +02:00
Gergő Jedlicska 2929e2f93b Merge pull request #426 from specklesystems/v3-dev
V3 mainline
2025-06-02 15:10:27 +01:00
Gergő Jedlicska 6636950705 Merge branch 'main' of github.com:specklesystems/specklepy into v3-dev 2025-06-02 12:52:31 +02:00
Gergő Jedlicska 79c0106f57 Merge pull request #425 from specklesystems/gergo/fix_wheel_build
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
fix: specify what packages to include in the wheel
2025-05-29 14:33:31 +02:00
Gergő Jedlicska f4d73ff1ae fix: specify what packages to include in the wheel 2025-05-29 14:31:39 +02:00
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
Jedd Morgan 8aba21de01 Fix(v2): Fix Workspace Visibility enum for Project queries (#422)
* V2 workspaces updated

* Update hooks

* Updated docker file

* Pre-commit passing

* Skipped failing test

* commented out test

* Fixed tests
2025-05-19 11:52:47 +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
Gergő Jedlicska 12b9602577 Merge pull request #397 from specklesystems/gergo/nostringcase
chore: remove stringcase as a dependency
2025-03-27 15:27:06 +01:00
28 changed files with 999 additions and 111 deletions
+8 -4
View File
@@ -1,12 +1,16 @@
name: "Specklepy test and build"
name: "Specklepy test"
on:
workflow_call:
secrets:
CODECOV_TOKEN:
required: true
pull_request:
branches:
- "v3-dev"
- "main"
jobs:
build-and-test:
name: build-and-test
test:
name: test
runs-on: ubuntu-latest
strategy:
matrix:
+7 -50
View File
@@ -2,63 +2,20 @@ name: "Publish Python Package"
on:
push:
branches:
- "v3-dev"
- "main"
tags:
- "3.*.*"
jobs:
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
test:
uses: "./.github/workflows/pr.yml"
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
publish-package:
name: "Build and Publish Python Package"
runs-on: ubuntu-latest
needs: build-and-test
needs: test
# set the environment based on what triggered the workflow
environment:
@@ -79,7 +36,7 @@ jobs:
- name: "Build package artifacts"
run: uv build
# Logic for TestPyPI (on v3-dev branch push)
# Logic for TestPyPI (on main branch push)
- name: "Publish to TestPyPI"
if: ${{ github.ref_type == 'branch' }}
run: uv publish --index test
+4
View File
@@ -47,6 +47,10 @@ build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.wheel]
only-include = ["src"]
sources = ["src"]
[tool.hatch.version.raw-options]
local_scheme = "no-local-version"
+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]]
@@ -11,7 +11,11 @@ from specklepy.core.api.models import (
ResourceCollection,
User,
)
from specklepy.core.api.models.current import PermissionCheckResult, Workspace
from specklepy.core.api.models.current import (
PermissionCheckResult,
ProjectWithPermissions,
Workspace,
)
from specklepy.core.api.resources import ActiveUserResource as CoreResource
from specklepy.logging import metrics
@@ -51,6 +55,22 @@ class ActiveUserResource(CoreResource):
metrics.track(metrics.SDK, self.account, {"name": "Active User Get Projects"})
return super().get_projects(limit=limit, cursor=cursor, filter=filter)
def get_projects_with_permissions(
self,
*,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[UserProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
metrics.track(
metrics.SDK,
self.account,
{"name": "Active User Get Projects With Permissions"},
)
return super().get_projects_with_permissions(
limit=limit, cursor=cursor, filter=filter
)
def get_project_invites(self) -> List[PendingStreamCollaborator]:
metrics.track(
metrics.SDK, self.account, {"name": "Active User Get Project Invites"}
@@ -7,7 +7,11 @@ from specklepy.core.api.inputs.project_inputs import (
ProjectUpdateRoleInput,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.models import Project, ProjectWithModels, ProjectWithTeam
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
@@ -1,7 +1,12 @@
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.models.current import (
Project,
ProjectWithPermissions,
ResourceCollection,
Workspace,
)
from specklepy.core.api.resources import WorkspaceResource as CoreResource
from specklepy.logging import metrics
@@ -30,3 +35,19 @@ class WorkspaceResource(CoreResource):
) -> ResourceCollection[Project]:
metrics.track(metrics.SDK, self.account, {"name": "Workspace Get Projects"})
return super().get_projects(workspace_id, limit, cursor, filter)
def get_projects_with_permissions(
self,
workspace_id: str,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[WorksaceProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
metrics.track(
metrics.SDK,
self.account,
{"name": "Workspace Get Projects With Permissions"},
)
return super().get_projects_with_permissions(
workspace_id, limit, cursor, filter
)
@@ -0,0 +1,70 @@
from datetime import datetime
from typing import List
import httpx
from pydantic import AliasGenerator, BaseModel, ConfigDict, HttpUrl
from pydantic.alias_generators import to_pascal
class ConnectorFeedBaseModel(BaseModel):
"""
Parent class for all Connector Feed Object Model classes
Sets-up a pydantic config to serialize properties using a pascal case alias
"""
model_config = ConfigDict(
alias_generator=AliasGenerator(
validation_alias=to_pascal,
),
populate_by_name=True,
)
class ConnectorVersion(ConnectorFeedBaseModel):
number: str
url: HttpUrl
os: int # this is an enum, it's properly defined in the old v2 SDK (used by Speckle.Manager.Feed) # noqa: E501
architecture: int # These are enums, they are properly defined in the old v2 SDK (used by Speckle.Manager.Feed) # noqa: E501
date: datetime
prerelease: bool
class ConnectorVersions(ConnectorFeedBaseModel):
versions: List[ConnectorVersion]
def get_latest_version(host_app_slug: str, allow_pre_release: bool) -> ConnectorVersion:
"""
Fetches the JSON feed for the given connector slug and
Returns the latest version by date - Note, it does not consider semvers!
Arguments:
host_app_slug {str} -- the host app slug to query for
allow_pre_release {bool} -- if false, only stable releases will be considered
Raises:
HTTPStatusError: if http request failed
ValidationError: response was not valid json
ValueError: The feed contained no connector versions
"""
connector_versions = get_connector_versions(host_app_slug).versions
filtered_versions = [
v for v in connector_versions if allow_pre_release or not v.prerelease
]
return max(filtered_versions, key=lambda x: x.date)
def get_connector_versions(host_app_slug: str) -> ConnectorVersions:
"""
Fetches the JSON feed for the given slug (v3 feeds only)
Raises:
HTTPStatusError: if http request failed
ValidationError: response was not valid json
"""
url = f"https://releases.speckle.dev/manager2/feeds/{host_app_slug.lower()}-v3.json"
res = httpx.get(url).raise_for_status()
feed_data = ConnectorVersions.model_validate_json(res.text)
return feed_data
+3
View File
@@ -2,9 +2,12 @@ from enum import Enum
class ProjectVisibility(str, Enum):
"""Supported project visibility types"""
PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC"
UNLISTED = "UNLISTED"
WORKSPACE = "WORKSPACE"
class UserProjectsUpdatedMessageType(str, Enum):
@@ -8,6 +8,7 @@ from specklepy.core.api.models.current import (
ProjectCollaborator,
ProjectCommentCollection,
ProjectWithModels,
ProjectWithPermissions,
ProjectWithTeam,
ResourceCollection,
ServerConfiguration,
@@ -39,6 +40,7 @@ __all__ = [
"ModelWithVersions",
"Project",
"ProjectWithModels",
"ProjectWithPermissions",
"ProjectWithTeam",
"ProjectCommentCollection",
"UserSearchResultCollection",
+6
View File
@@ -155,6 +155,8 @@ class ModelWithVersions(Model):
class ProjectPermissionChecks(GraphQLBaseModel):
can_create_model: "PermissionCheckResult"
can_delete: "PermissionCheckResult"
can_load: "PermissionCheckResult"
can_publish: "PermissionCheckResult"
class Project(GraphQLBaseModel):
@@ -174,6 +176,10 @@ class ProjectWithModels(Project):
models: ResourceCollection[Model]
class ProjectWithPermissions(Project):
permissions: ProjectPermissionChecks
class ProjectWithTeam(Project):
invited_team: List[PendingStreamCollaborator]
team: List[ProjectCollaborator]
@@ -13,7 +13,11 @@ from specklepy.core.api.models import (
ResourceCollection,
User,
)
from specklepy.core.api.models.current import PermissionCheckResult, Workspace
from specklepy.core.api.models.current import (
PermissionCheckResult,
ProjectWithPermissions,
Workspace,
)
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
from specklepy.logging.exceptions import GraphQLException
@@ -338,3 +342,84 @@ class ActiveUserResource(ResourceBase):
)
return response.data.data
def get_projects_with_permissions(
self,
*,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[UserProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
"""
Gets the currently active user's projects with their permissions.
This is useful for checking what actions can be performed on each project.
"""
QUERY = gql(
"""
query User($limit : Int!, $cursor: String, $filter: UserProjectsFilter) {
data:activeUser {
data:projects(limit: $limit, cursor: $cursor, filter: $filter) {
totalCount
cursor
items {
id
name
description
visibility
allowPublicComments
role
createdAt
updatedAt
sourceApps
workspaceId
permissions {
canCreateModel {
code
authorized
message
}
canDelete {
code
authorized
message
}
canLoad {
code
authorized
message
}
canPublish {
code
authorized
message
}
}
}
}
}
}
"""
)
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[ProjectWithPermissions]]]
],
QUERY,
variables,
)
if response.data is None:
raise GraphQLException(
"GraphQL response indicated that the ActiveUser could not be found"
)
return response.data.data
@@ -9,7 +9,11 @@ from specklepy.core.api.inputs.project_inputs import (
ProjectUpdateRoleInput,
WorkspaceProjectCreateInput,
)
from specklepy.core.api.models import Project, ProjectWithModels, ProjectWithTeam
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
@@ -73,6 +77,16 @@ class ProjectResource(ResourceBase):
code
message
}
canLoad {
authorized
code
message
}
canPublish {
authorized
code
message
}
}
}
}
@@ -3,7 +3,12 @@ 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.models.current import (
Project,
ProjectWithPermissions,
ResourceCollection,
Workspace,
)
from specklepy.core.api.resource import ResourceBase
from specklepy.core.api.responses import DataResponse
@@ -104,3 +109,72 @@ class WorkspaceResource(ResourceBase):
return self.make_request_and_parse_response(
DataResponse[DataResponse[ResourceCollection[Project]]], QUERY, variables
).data.data
def get_projects_with_permissions(
self,
workspace_id: str,
limit: int = 25,
cursor: Optional[str] = None,
filter: Optional[WorksaceProjectsFilter] = None,
) -> ResourceCollection[ProjectWithPermissions]:
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
permissions {
canCreateModel {
code
authorized
message
}
canDelete {
code
authorized
message
}
canLoad {
code
authorized
message
}
canPublish {
code
authorized
message
}
}
}
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[ProjectWithPermissions]]],
QUERY,
variables,
).data.data
+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)}"
)
-1
View File
@@ -47,7 +47,6 @@ class Region(
@property
def displayValue(self) -> List[Mesh]:
print(self._displayValue)
return self._displayValue
@displayValue.setter
@@ -199,8 +199,9 @@ class BaseObjectSerializer:
# write detached or root objects to transports
if detached and self.write_transports:
serialized_data = ujson.dumps(object_builder)
for t in self.write_transports:
t.save_object(id=obj_id, serialized_object=ujson.dumps(object_builder))
t.save_object(id=obj_id, serialized_object=serialized_data)
del self.lineage[-1]
@@ -0,0 +1,85 @@
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.core.api.inputs.project_inputs import ProjectCreateInput
from specklepy.core.api.inputs.user_inputs import UserProjectsFilter
from specklepy.core.api.models.current import (
Project,
ProjectWithPermissions,
ResourceCollection,
)
@pytest.mark.run()
class TestActiveUserResourcePermissions:
@pytest.fixture()
def test_project(self, client: SpeckleClient) -> Project:
project = client.project.create(
ProjectCreateInput(
name="test project for active user permissions",
description="test description",
visibility=None,
)
)
return project
def test_active_user_get_projects_with_permissions(
self, client: SpeckleClient, test_project: Project
):
result = client.active_user.get_projects_with_permissions()
assert isinstance(result, ResourceCollection)
assert len(result.items) >= 1
test_project_with_permissions = None
for project in result.items:
if project.id == test_project.id:
test_project_with_permissions = project
break
assert test_project_with_permissions is not None
assert isinstance(test_project_with_permissions, ProjectWithPermissions)
assert hasattr(test_project_with_permissions, "permissions")
assert test_project_with_permissions.permissions is not None
assert test_project_with_permissions.id == test_project.id
assert test_project_with_permissions.name == test_project.name
permissions = test_project_with_permissions.permissions
assert hasattr(permissions, "can_create_model")
assert hasattr(permissions, "can_delete")
assert hasattr(permissions, "can_load")
assert hasattr(permissions, "can_publish")
assert permissions.can_create_model.authorized is True
assert permissions.can_delete.authorized is True
assert permissions.can_load.authorized is True
assert permissions.can_publish.authorized is True
def test_active_user_get_projects_with_permissions_with_filter(
self, client: SpeckleClient, test_project: Project
):
"""test getting active user's projects with permissions using a filter."""
filter = UserProjectsFilter(search=test_project.name)
result = client.active_user.get_projects_with_permissions(filter=filter)
assert isinstance(result, ResourceCollection)
assert len(result.items) >= 1
assert result.total_count >= 1
project_with_permissions = result.items[0]
assert isinstance(project_with_permissions, ProjectWithPermissions)
assert project_with_permissions.id == test_project.id
assert hasattr(project_with_permissions, "permissions")
assert project_with_permissions.permissions is not None
def test_active_user_projects_with_permissions_method_exists(
self, client: SpeckleClient
):
"""test that the method exists and is callable on active user resource."""
assert hasattr(client.active_user, "get_projects_with_permissions")
method = client.active_user.get_projects_with_permissions
assert callable(method)
@@ -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
@@ -51,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
@@ -78,7 +78,7 @@ class TestProjectResource:
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,
@@ -94,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,19 @@
import pytest
from specklepy.api.client import SpeckleClient
from specklepy.logging.exceptions import GraphQLException
@pytest.mark.run()
class TestWorkspaceResourcePermissions:
def test_get_projects_with_permissions(self, client: SpeckleClient):
with pytest.raises(GraphQLException):
client.workspace.get_projects_with_permissions("not a real id")
def test_get_projects_with_permissions_method_exists(self, client: SpeckleClient):
"""
test that the method exists with the correct signature.
"""
assert hasattr(client.workspace, "get_projects_with_permissions")
method = client.workspace.get_projects_with_permissions
assert callable(method)
@@ -0,0 +1,32 @@
import pytest
from httpx import HTTPStatusError
from specklepy.core.api.connector_versions import (
ConnectorVersion,
ConnectorVersions,
get_connector_versions,
get_latest_version,
)
# NOTE: the tests in this file are testing against the live releases.speckle.dev server
# url defined in get_connector_versions.
def test_connector_versions():
res = get_connector_versions("blender")
assert isinstance(res, ConnectorVersions)
assert res.versions # Assuming the feed is not empty
def test_get_latest_version_throws_no_slug():
with pytest.raises(HTTPStatusError) as ex:
get_latest_version("non-existent-connector!", True)
assert "404" in str(ex.value)
def test_get_latest_version():
res = get_latest_version("blender", False)
assert isinstance(res, ConnectorVersion)
+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"
+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