Compare commits

...

14 Commits

Author SHA1 Message Date
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
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 12b9602577 Merge pull request #397 from specklesystems/gergo/nostringcase
chore: remove stringcase as a dependency
2025-03-27 15:27:06 +01:00
16 changed files with 428 additions and 99 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
)
-3
View File
@@ -10,9 +10,6 @@ class ProjectVisibility(str, Enum):
WORKSPACE = "WORKSPACE"
foo = ProjectVisibility.PRIVATE
class UserProjectsUpdatedMessageType(str, Enum):
ADDED = "ADDED"
REMOVED = "REMOVED"
@@ -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",
+4
View File
@@ -176,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
@@ -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
@@ -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)
@@ -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)