From af74e3207f8b0400eb37daa4c2c186e924e0c3fc Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 24 Jun 2026 16:26:51 +0530 Subject: [PATCH 1/3] chore: added new fields is_active and is_bot, and filters in workspace and project members --- plane/api/projects.py | 13 +++++++++---- plane/api/workspaces.py | 13 +++++++++---- plane/models/__init__.py | 2 ++ plane/models/projects.py | 4 ++-- plane/models/query_params.py | 32 ++++++++++++++++++++++++++++++++ plane/models/workspaces.py | 18 ++++++++++-------- tests/unit/test_projects.py | 24 +++++++++++++++++++++++- tests/unit/test_workspaces.py | 30 ++++++++++++++++++++++++++++++ 8 files changed, 117 insertions(+), 19 deletions(-) diff --git a/plane/api/projects.py b/plane/api/projects.py index 411114d..9c5f757 100644 --- a/plane/api/projects.py +++ b/plane/api/projects.py @@ -12,7 +12,7 @@ ProjectWorklogSummary, UpdateProject, ) -from ..models.query_params import PaginatedQueryParams +from ..models.query_params import MemberQueryParams, PaginatedQueryParams from .base_resource import BaseResource @@ -86,7 +86,10 @@ def get_worklog_summary(self, workspace_slug: str, project_id: str) -> [ProjectW return [ProjectWorklogSummary.model_validate(item) for item in response] def get_members( - self, workspace_slug: str, project_id: str, params: Mapping[str, Any] | None = None + self, + workspace_slug: str, + project_id: str, + params: MemberQueryParams | Mapping[str, Any] | None = None, ) -> list[ProjectMember]: """Get all members of a project. @@ -96,8 +99,11 @@ def get_members( Args: workspace_slug: The workspace slug identifier project_id: UUID of the project - params: Optional query parameters + params: Optional query parameters. Accepts a ``MemberQueryParams`` + instance for typed filtering/ordering, or a raw mapping. """ + if isinstance(params, MemberQueryParams): + params = params.model_dump(exclude_none=True) response = self._get(f"{workspace_slug}/projects/{project_id}/members", params=params) return [ProjectMember.model_validate(item) for item in response or []] @@ -154,4 +160,3 @@ def unarchive(self, workspace_slug: str, project_id: str) -> None: None (HTTP 204 No Content) """ self._delete(f"{workspace_slug}/projects/{project_id}/archive") - diff --git a/plane/api/workspaces.py b/plane/api/workspaces.py index 9ca5b3a..771e161 100644 --- a/plane/api/workspaces.py +++ b/plane/api/workspaces.py @@ -2,6 +2,7 @@ from typing import Any +from ..models.query_params import MemberQueryParams from ..models.workspaces import WorkspaceFeature, WorkspaceMember from .base_resource import BaseResource @@ -11,7 +12,7 @@ def __init__(self, config: Any) -> None: super().__init__(config, "/workspaces/") def get_members( - self, workspace_slug: str + self, workspace_slug: str, params: MemberQueryParams | None = None ) -> list[WorkspaceMember]: """Get all members of a workspace. @@ -20,8 +21,12 @@ def get_members( Args: workspace_slug: The workspace slug identifier + params: Optional query parameters for filtering and ordering members """ - response = self._get(f"{workspace_slug}/members") + response = self._get( + f"{workspace_slug}/members", + params=params.model_dump(exclude_none=True) if params else None, + ) return [WorkspaceMember.model_validate(item) for item in response or []] def get_features(self, workspace_slug: str) -> WorkspaceFeature: @@ -32,7 +37,7 @@ def get_features(self, workspace_slug: str) -> WorkspaceFeature: """ response = self._get(f"{workspace_slug}/features") return WorkspaceFeature.model_validate(response) - + def update_features(self, workspace_slug: str, data: WorkspaceFeature) -> WorkspaceFeature: """Update features of a workspace. @@ -41,4 +46,4 @@ def update_features(self, workspace_slug: str, data: WorkspaceFeature) -> Worksp data: Updated workspace features """ response = self._patch(f"{workspace_slug}/features", data.model_dump(exclude_none=True)) - return WorkspaceFeature.model_validate(response) \ No newline at end of file + return WorkspaceFeature.model_validate(response) diff --git a/plane/models/__init__.py b/plane/models/__init__.py index dfa2830..23eeccf 100644 --- a/plane/models/__init__.py +++ b/plane/models/__init__.py @@ -15,6 +15,7 @@ ) from .query_params import ( BaseQueryParams, + MemberQueryParams, PaginatedQueryParams, RetrieveQueryParams, WorkItemQueryParams, @@ -37,6 +38,7 @@ "IntakeWorkItemStatusEnum", # query params "BaseQueryParams", + "MemberQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", "WorkItemQueryParams", diff --git a/plane/models/projects.py b/plane/models/projects.py index f9e6197..ee50ba0 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -148,6 +148,8 @@ class ProjectMember(UserLite): role: int | None = None role_slug: str | None = None + is_active: bool | None = None + is_bot: bool | None = None class ProjectFeature(BaseModel): @@ -165,5 +167,3 @@ class ProjectFeature(BaseModel): workflows: bool | None = None parallel_cycles: bool | None = None project_updates: bool | None = None - - diff --git a/plane/models/query_params.py b/plane/models/query_params.py index f029142..7732cac 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -90,6 +90,37 @@ class RetrieveQueryParams(BaseQueryParams): model_config = ConfigDict(extra="ignore", populate_by_name=True) +class MemberQueryParams(BaseQueryParams): + """Query parameters for workspace/project member list endpoints. + + Inherits the documented query parameters from BaseQueryParams (expand, + fields, external_id, external_source, order_by) and adds member-specific + filters. Text filters match case-insensitively on a substring; ``role_slug`` + matches exactly. Boolean filters narrow by membership/account flags. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + # text filters + first_name: str | None = Field( + None, description="Filter by member first name (case-insensitive contains)" + ) + last_name: str | None = Field( + None, description="Filter by member last name (case-insensitive contains)" + ) + email: str | None = Field( + None, description="Filter by member email (case-insensitive contains)" + ) + display_name: str | None = Field( + None, description="Filter by member display name (case-insensitive contains)" + ) + role_slug: str | None = Field(None, description="Filter by role slug (exact match)") + + # boolean filters + is_active: bool | None = Field(None, description="Filter by active membership status") + is_bot: bool | None = Field(None, description="Filter by bot accounts") + + WorkItemCountGroupBy = Literal[ "state_id", "state__group", @@ -155,6 +186,7 @@ class WorkItemCountQueryParams(BaseModel): __all__ = [ "BaseQueryParams", + "MemberQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", "WorkItemCountGroupBy", diff --git a/plane/models/workspaces.py b/plane/models/workspaces.py index 2f8c321..dd52e3a 100644 --- a/plane/models/workspaces.py +++ b/plane/models/workspaces.py @@ -13,16 +13,18 @@ class WorkspaceMember(UserLite): role: int | None = None role_slug: str | None = None + is_active: bool | None = None + is_bot: bool | None = None class WorkspaceFeature(BaseModel): - """Workspace feature model.""" + """Workspace feature model.""" - model_config = ConfigDict(extra="allow", populate_by_name=True) + model_config = ConfigDict(extra="allow", populate_by_name=True) - project_grouping: bool - initiatives: bool - teams: bool - customers: bool - wiki: bool - pi: bool \ No newline at end of file + project_grouping: bool + initiatives: bool + teams: bool + customers: bool + wiki: bool + pi: bool diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index 2eb2cb9..2c06607 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -6,7 +6,7 @@ from plane.client import PlaneClient from plane.models.projects import CreateProject, Project, ProjectMember, UpdateProject -from plane.models.query_params import PaginatedQueryParams +from plane.models.query_params import MemberQueryParams, PaginatedQueryParams class TestProjectsAPI: @@ -103,6 +103,28 @@ def test_get_members(self, client: PlaneClient, workspace_slug: str, project: Pr assert hasattr(member, "id") assert hasattr(member, "email") + def test_get_members_typed_filter( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """get_members accepts a typed MemberQueryParams and filters by is_active.""" + members = client.projects.get_members( + workspace_slug, project.id, params=MemberQueryParams(is_active=True) + ) + assert isinstance(members, list) + for member in members: + assert isinstance(member, ProjectMember) + + def test_get_members_dict_filter_backcompat( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """get_members still accepts a raw mapping for backward compatibility.""" + members = client.projects.get_members( + workspace_slug, project.id, params={"is_active": True} + ) + assert isinstance(members, list) + for member in members: + assert isinstance(member, ProjectMember) + def test_get_features(self, client: PlaneClient, workspace_slug: str, project: Project) -> None: """Test getting project features.""" features = client.projects.get_features(workspace_slug, project.id) diff --git a/tests/unit/test_workspaces.py b/tests/unit/test_workspaces.py index 349d1c4..3f4dbec 100644 --- a/tests/unit/test_workspaces.py +++ b/tests/unit/test_workspaces.py @@ -1,6 +1,7 @@ """Unit tests for Workspaces API resource (smoke tests with real HTTP requests).""" from plane.client import PlaneClient +from plane.models.query_params import MemberQueryParams from plane.models.workspaces import WorkspaceMember @@ -18,6 +19,35 @@ def test_get_members(self, client: PlaneClient, workspace_slug: str) -> None: assert hasattr(member, "role") assert hasattr(member, "role_slug") + def test_get_members_filter_is_active( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """Filtering by is_active should only return active members.""" + members = client.workspaces.get_members( + workspace_slug, params=MemberQueryParams(is_active=True) + ) + assert isinstance(members, list) + for member in members: + assert isinstance(member, WorkspaceMember) + # is_active may be omitted by older servers; when present it must match + if member.is_active is not None: + assert member.is_active is True + + def test_get_members_filter_display_name( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """Filtering by a substring of an existing member's display name returns that member.""" + all_members = client.workspaces.get_members(workspace_slug) + named = next((m for m in all_members if m.display_name), None) + if named is None: + return + fragment = named.display_name[: max(1, len(named.display_name) // 2)] + filtered = client.workspaces.get_members( + workspace_slug, params=MemberQueryParams(display_name=fragment) + ) + assert isinstance(filtered, list) + assert any(m.id == named.id for m in filtered) + def test_get_features(self, client: PlaneClient, workspace_slug: str) -> None: """Test getting workspace features.""" features = client.workspaces.get_features(workspace_slug) From ae4b7e618a85a6d0cc4f2dcdca42fabb6c4696c3 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 24 Jun 2026 18:55:11 +0530 Subject: [PATCH 2/3] chore: updated workspace and project memebers and included the filters in the existing and handled the pagination for new lite endpoints --- README.md | 36 +++++++++++++++++++++++++++ plane/api/projects.py | 47 +++++++++++++++++++++++++++++++---- plane/api/workspaces.py | 35 ++++++++++++++++++++++---- plane/models/__init__.py | 2 ++ plane/models/projects.py | 8 ++++++ plane/models/query_params.py | 37 ++++++++++++++++++++++++++- plane/models/workspaces.py | 9 +++++++ pyproject.toml | 2 +- tests/unit/test_projects.py | 23 ++++++++++++++--- tests/unit/test_workspaces.py | 20 ++++++++++----- 10 files changed, 198 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ecadafb..7d37e73 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,28 @@ users = client.users.list() ```python # Get workspace members members = client.workspaces.get_members(workspace_slug) + +# Filter members (all filters combine with AND; role_slug is exact, text fields +# match case-insensitive contains) +from plane.models.query_params import MemberQueryParams, MemberListQueryParams + +admins = client.workspaces.get_members( + workspace_slug, + params=MemberQueryParams(role_slug="admin", is_active=True), +) + +# Paginated "lite" list — follow next_cursor until next_page_results is False +page = client.workspaces.get_members_lite( + workspace_slug, + params=MemberListQueryParams(per_page=1000), +) +all_members = list(page.results) +while page.next_page_results: + page = client.workspaces.get_members_lite( + workspace_slug, + params=MemberListQueryParams(per_page=1000, cursor=page.next_cursor), + ) + all_members.extend(page.results) ``` ### Project Management @@ -321,6 +343,20 @@ worklog_summary = client.projects.get_worklog_summary(workspace_slug, project_id # Get project members members = client.projects.get_members(workspace_slug, project_id) + +# Filter project members (same filters as workspace members) +from plane.models.query_params import MemberQueryParams, MemberListQueryParams + +members = client.projects.get_members( + workspace_slug, project_id, + params=MemberQueryParams(display_name="ana", is_bot=False), +) + +# Paginated "lite" list +page = client.projects.get_members_lite( + workspace_slug, project_id, + params=MemberListQueryParams(per_page=1000), +) ``` #### Work Items diff --git a/plane/api/projects.py b/plane/api/projects.py index 9c5f757..252fb64 100644 --- a/plane/api/projects.py +++ b/plane/api/projects.py @@ -5,6 +5,7 @@ from ..models.projects import ( CreateProject, + PaginatedProjectMemberResponse, PaginatedProjectResponse, Project, ProjectFeature, @@ -12,7 +13,11 @@ ProjectWorklogSummary, UpdateProject, ) -from ..models.query_params import MemberQueryParams, PaginatedQueryParams +from ..models.query_params import ( + MemberListQueryParams, + MemberQueryParams, + PaginatedQueryParams, +) from .base_resource import BaseResource @@ -91,8 +96,9 @@ def get_members( project_id: str, params: MemberQueryParams | Mapping[str, Any] | None = None, ) -> list[ProjectMember]: - """Get all members of a project. + """Get all members of a project with optional filtering (unpaginated). + Calls the filterable ``/projects/{id}/project-members/`` endpoint. Returns a list of ProjectMember objects that include role (int) and role_slug (str) fields in addition to basic identity fields. @@ -100,13 +106,44 @@ def get_members( workspace_slug: The workspace slug identifier project_id: UUID of the project params: Optional query parameters. Accepts a ``MemberQueryParams`` - instance for typed filtering/ordering, or a raw mapping. + instance for typed filtering (first_name, last_name, email, + display_name, role_slug, is_active, is_bot), or a raw mapping. """ if isinstance(params, MemberQueryParams): - params = params.model_dump(exclude_none=True) - response = self._get(f"{workspace_slug}/projects/{project_id}/members", params=params) + params = params.to_query_params() + elif params is not None: + # Lowercase bools so the typed filter backend accepts them + # (requests would otherwise encode True as "True" -> HTTP 400). + params = {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in params.items()} + response = self._get( + f"{workspace_slug}/projects/{project_id}/project-members", params=params + ) return [ProjectMember.model_validate(item) for item in response or []] + def get_members_lite( + self, + workspace_slug: str, + project_id: str, + params: MemberListQueryParams | None = None, + ) -> PaginatedProjectMemberResponse: + """List project members as a paginated "lite" response. + + Unlike :meth:`get_members` (which returns a bare list), this returns a + cursor-paginated envelope. To page through every member, follow + ``response.next_cursor`` while ``response.next_page_results`` is True. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + params: Optional filter + pagination query parameters (the + :meth:`get_members` filters plus ``cursor`` and ``per_page``) + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/project-members-lite", + params=params.to_query_params() if params else None, + ) + return PaginatedProjectMemberResponse.model_validate(response) + def get_features(self, workspace_slug: str, project_id: str) -> ProjectFeature: """Get features of a project. diff --git a/plane/api/workspaces.py b/plane/api/workspaces.py index 771e161..3a4855e 100644 --- a/plane/api/workspaces.py +++ b/plane/api/workspaces.py @@ -2,8 +2,12 @@ from typing import Any -from ..models.query_params import MemberQueryParams -from ..models.workspaces import WorkspaceFeature, WorkspaceMember +from ..models.query_params import MemberListQueryParams, MemberQueryParams +from ..models.workspaces import ( + PaginatedWorkspaceMemberResponse, + WorkspaceFeature, + WorkspaceMember, +) from .base_resource import BaseResource @@ -14,21 +18,42 @@ def __init__(self, config: Any) -> None: def get_members( self, workspace_slug: str, params: MemberQueryParams | None = None ) -> list[WorkspaceMember]: - """Get all members of a workspace. + """Get all members of a workspace (unpaginated). Returns a list of WorkspaceMember objects that include role (int) and role_slug (str) fields in addition to basic identity fields. Args: workspace_slug: The workspace slug identifier - params: Optional query parameters for filtering and ordering members + params: Optional filter query parameters (first_name, last_name, + email, display_name, role_slug, is_active, is_bot) """ response = self._get( f"{workspace_slug}/members", - params=params.model_dump(exclude_none=True) if params else None, + params=params.to_query_params() if params else None, ) return [WorkspaceMember.model_validate(item) for item in response or []] + def get_members_lite( + self, workspace_slug: str, params: MemberListQueryParams | None = None + ) -> PaginatedWorkspaceMemberResponse: + """List workspace members as a paginated "lite" response. + + Unlike :meth:`get_members` (which returns a bare list), this returns a + cursor-paginated envelope. To page through every member, follow + ``response.next_cursor`` while ``response.next_page_results`` is True. + + Args: + workspace_slug: The workspace slug identifier + params: Optional filter + pagination query parameters (the + :meth:`get_members` filters plus ``cursor`` and ``per_page``) + """ + response = self._get( + f"{workspace_slug}/members-lite", + params=params.to_query_params() if params else None, + ) + return PaginatedWorkspaceMemberResponse.model_validate(response) + def get_features(self, workspace_slug: str) -> WorkspaceFeature: """Get features of a workspace. diff --git a/plane/models/__init__.py b/plane/models/__init__.py index 23eeccf..5121534 100644 --- a/plane/models/__init__.py +++ b/plane/models/__init__.py @@ -15,6 +15,7 @@ ) from .query_params import ( BaseQueryParams, + MemberListQueryParams, MemberQueryParams, PaginatedQueryParams, RetrieveQueryParams, @@ -38,6 +39,7 @@ "IntakeWorkItemStatusEnum", # query params "BaseQueryParams", + "MemberListQueryParams", "MemberQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", diff --git a/plane/models/projects.py b/plane/models/projects.py index ee50ba0..48ab1c1 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -152,6 +152,14 @@ class ProjectMember(UserLite): is_bot: bool | None = None +class PaginatedProjectMemberResponse(PaginatedResponse): + """Paginated response for the project members-lite endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ProjectMember] + + class ProjectFeature(BaseModel): """Project feature model.""" diff --git a/plane/models/query_params.py b/plane/models/query_params.py index 7732cac..6ce0c24 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -120,6 +120,39 @@ class MemberQueryParams(BaseQueryParams): is_active: bool | None = Field(None, description="Filter by active membership status") is_bot: bool | None = Field(None, description="Filter by bot accounts") + def to_query_params(self) -> dict[str, Any]: + """Serialize to a query-param dict the member endpoints accept. + + Booleans are rendered as lowercase ``"true"``/``"false"`` strings so the + backend's typed filter backend parses them (a Python ``True`` would be + encoded as ``"True"`` and rejected with HTTP 400). Unset fields are + dropped so they never reach the query string. + """ + raw = self.model_dump(exclude_none=True) + return {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in raw.items()} + + +class MemberListQueryParams(MemberQueryParams): + """Query parameters for the paginated member-lite list endpoints. + + Adds cursor pagination to the :class:`MemberQueryParams` filters. The lite + endpoints default to and cap ``per_page`` at 1000. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + cursor: str | None = Field( + None, + description='Pagination cursor of the form "{per_page}:{page}:{offset}", ' + "e.g. 100:0:0. Use the response's next_cursor to fetch the next page.", + ) + per_page: int | None = Field( + None, + description="Number of results per page (default and max 1000)", + ge=1, + le=1000, + ) + WorkItemCountGroupBy = Literal[ "state_id", @@ -147,7 +180,8 @@ class WorkItemCountQueryParams(BaseModel): Always returns a grouped envelope matching :class:`~plane.models.work_items.WorkItemGroupedCountResponse`. When ``group_by`` is omitted, ``grouped_counts`` is empty and ``total_count`` holds the overall count. - When ``group_by`` is provided, ``grouped_counts`` contains per-group counts, optionally nested when ``sub_group_by`` is also provided.""" + When ``group_by`` is provided, ``grouped_counts`` contains per-group counts, optionally nested when ``sub_group_by`` is also provided. + """ model_config = ConfigDict(extra="ignore", populate_by_name=True) @@ -186,6 +220,7 @@ class WorkItemCountQueryParams(BaseModel): __all__ = [ "BaseQueryParams", + "MemberListQueryParams", "MemberQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", diff --git a/plane/models/workspaces.py b/plane/models/workspaces.py index dd52e3a..94b554b 100644 --- a/plane/models/workspaces.py +++ b/plane/models/workspaces.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, ConfigDict +from .pagination import PaginatedResponse from .users import UserLite @@ -17,6 +18,14 @@ class WorkspaceMember(UserLite): is_bot: bool | None = None +class PaginatedWorkspaceMemberResponse(PaginatedResponse): + """Paginated response for the workspace members-lite endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[WorkspaceMember] + + class WorkspaceFeature(BaseModel): """Workspace feature model.""" diff --git a/pyproject.toml b/pyproject.toml index b75483f..0178cbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.17" +version = "0.2.18" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index 2c06607..e5b4acd 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -6,7 +6,11 @@ from plane.client import PlaneClient from plane.models.projects import CreateProject, Project, ProjectMember, UpdateProject -from plane.models.query_params import MemberQueryParams, PaginatedQueryParams +from plane.models.query_params import ( + MemberListQueryParams, + MemberQueryParams, + PaginatedQueryParams, +) class TestProjectsAPI: @@ -117,14 +121,27 @@ def test_get_members_typed_filter( def test_get_members_dict_filter_backcompat( self, client: PlaneClient, workspace_slug: str, project: Project ) -> None: - """get_members still accepts a raw mapping for backward compatibility.""" + """get_members accepts a raw mapping; bool values are normalized to true/false.""" members = client.projects.get_members( - workspace_slug, project.id, params={"is_active": True} + workspace_slug, project.id, params={"is_active": True, "role_slug": "admin"} ) assert isinstance(members, list) for member in members: assert isinstance(member, ProjectMember) + def test_get_members_lite_paginated( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """get_members_lite returns a paginated envelope of ProjectMember items.""" + page = client.projects.get_members_lite( + workspace_slug, project.id, params=MemberListQueryParams(per_page=100) + ) + assert isinstance(page.results, list) + assert isinstance(page.total_count, int) + assert isinstance(page.next_page_results, bool) + for member in page.results: + assert isinstance(member, ProjectMember) + def test_get_features(self, client: PlaneClient, workspace_slug: str, project: Project) -> None: """Test getting project features.""" features = client.projects.get_features(workspace_slug, project.id) diff --git a/tests/unit/test_workspaces.py b/tests/unit/test_workspaces.py index 3f4dbec..bc7133b 100644 --- a/tests/unit/test_workspaces.py +++ b/tests/unit/test_workspaces.py @@ -1,7 +1,7 @@ """Unit tests for Workspaces API resource (smoke tests with real HTTP requests).""" from plane.client import PlaneClient -from plane.models.query_params import MemberQueryParams +from plane.models.query_params import MemberListQueryParams, MemberQueryParams from plane.models.workspaces import WorkspaceMember @@ -19,9 +19,7 @@ def test_get_members(self, client: PlaneClient, workspace_slug: str) -> None: assert hasattr(member, "role") assert hasattr(member, "role_slug") - def test_get_members_filter_is_active( - self, client: PlaneClient, workspace_slug: str - ) -> None: + def test_get_members_filter_is_active(self, client: PlaneClient, workspace_slug: str) -> None: """Filtering by is_active should only return active members.""" members = client.workspaces.get_members( workspace_slug, params=MemberQueryParams(is_active=True) @@ -48,6 +46,17 @@ def test_get_members_filter_display_name( assert isinstance(filtered, list) assert any(m.id == named.id for m in filtered) + def test_get_members_lite_paginated(self, client: PlaneClient, workspace_slug: str) -> None: + """get_members_lite returns a paginated envelope of WorkspaceMember items.""" + page = client.workspaces.get_members_lite( + workspace_slug, params=MemberListQueryParams(per_page=100) + ) + assert isinstance(page.results, list) + assert isinstance(page.total_count, int) + assert isinstance(page.next_page_results, bool) + for member in page.results: + assert isinstance(member, WorkspaceMember) + def test_get_features(self, client: PlaneClient, workspace_slug: str) -> None: """Test getting workspace features.""" features = client.workspaces.get_features(workspace_slug) @@ -63,11 +72,10 @@ def test_update_features(self, client: PlaneClient, workspace_slug: str) -> None """Test updating workspace features.""" # Get current features first features = client.workspaces.get_features(workspace_slug) - + # Update features features.initiatives = True updated = client.workspaces.update_features(workspace_slug, features) assert updated is not None assert hasattr(updated, "initiatives") assert updated.initiatives is True - From 5085f43f3de4cf976815e77d4a55abd61d09725b Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 24 Jun 2026 20:22:59 +0530 Subject: [PATCH 3/3] dev: typo and reverted the version bump --- README.md | 23 +++++++---------------- pyproject.toml | 2 +- tests/unit/test_projects.py | 10 +++++----- tests/unit/test_workspaces.py | 10 +++++----- 4 files changed, 18 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 7d37e73..10ca4b2 100644 --- a/README.md +++ b/README.md @@ -17,48 +17,39 @@ A comprehensive, type-annotated Python SDK for interacting with the Plane API. T This SDK (v0.2.0) replaces the v0.1.x OpenAPI-generated client and introduces intentional breaking changes for a cleaner, type-safe developer experience. - Authentication and client - - New `PlaneClient(base_url, api_key | access_token)` replaces OpenAPI `Configuration`/`ApiClient` usage - Exactly one of `api_key` or `access_token` is required; providing both raises a `ConfigurationError` - `base_url` should NOT include `/api/v1`; the SDK appends `/api/v1` automatically - HTTP headers - - API key header standardized to `X-Api-Key`; access tokens use `Authorization: Bearer ` - Resource paths and naming - - All paths use `work-items` instead of v0.1.x `issues` - Sub-resources are grouped under `client.work_items.` - Method names - - Methods are standardized across resources: `list`, `create`, `retrieve`, `update`, `delete` - Replaces verbose, OpenAPI-generated method names - Models and DTOs - - Uses Pydantic v2 with: response models `extra="allow"`; Create*/Update* DTOs `extra="ignore"` - Separate DTOs for create/update: `Create*` and `Update*` - Field naming is normalized - Pagination shape - - Paginated responses now expose: `results`, `total_count`, `next_page_number`, `prev_page_number` - This replaces v0.1.x shapes that included different field names - Query parameters - - Typed query params via models like `WorkItemQueryParams` and `RetrieveQueryParams` - Common fields include `per_page`, `page`, `order_by`, `expand` - Errors - - Raises `HttpError(message, status_code, response)` on non-2xx responses - Configuration validation errors raise `ConfigurationError` - Imports and organization - - Import models from `plane.models.` - No OpenAPI `*Api` classes; use resource objects from `PlaneClient` @@ -291,17 +282,17 @@ admins = client.workspaces.get_members( ) # Paginated "lite" list — follow next_cursor until next_page_results is False -page = client.workspaces.get_members_lite( +paginated_members = client.workspaces.get_members_lite( workspace_slug, params=MemberListQueryParams(per_page=1000), ) -all_members = list(page.results) -while page.next_page_results: - page = client.workspaces.get_members_lite( +all_members = list(paginated_members.results) +while paginated_members.next_page_results: + paginated_members = client.workspaces.get_members_lite( workspace_slug, - params=MemberListQueryParams(per_page=1000, cursor=page.next_cursor), + params=MemberListQueryParams(per_page=1000, cursor=paginated_members.next_cursor), ) - all_members.extend(page.results) + all_members.extend(paginated_members.results) ``` ### Project Management @@ -353,7 +344,7 @@ members = client.projects.get_members( ) # Paginated "lite" list -page = client.projects.get_members_lite( +members = client.projects.get_members_lite( workspace_slug, project_id, params=MemberListQueryParams(per_page=1000), ) diff --git a/pyproject.toml b/pyproject.toml index 0178cbc..b75483f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.18" +version = "0.2.17" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index e5b4acd..e4d05b7 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -133,13 +133,13 @@ def test_get_members_lite_paginated( self, client: PlaneClient, workspace_slug: str, project: Project ) -> None: """get_members_lite returns a paginated envelope of ProjectMember items.""" - page = client.projects.get_members_lite( + paginated_members = client.projects.get_members_lite( workspace_slug, project.id, params=MemberListQueryParams(per_page=100) ) - assert isinstance(page.results, list) - assert isinstance(page.total_count, int) - assert isinstance(page.next_page_results, bool) - for member in page.results: + assert isinstance(paginated_members.results, list) + assert isinstance(paginated_members.total_count, int) + assert isinstance(paginated_members.next_page_results, bool) + for member in paginated_members.results: assert isinstance(member, ProjectMember) def test_get_features(self, client: PlaneClient, workspace_slug: str, project: Project) -> None: diff --git a/tests/unit/test_workspaces.py b/tests/unit/test_workspaces.py index bc7133b..dd4d41d 100644 --- a/tests/unit/test_workspaces.py +++ b/tests/unit/test_workspaces.py @@ -48,13 +48,13 @@ def test_get_members_filter_display_name( def test_get_members_lite_paginated(self, client: PlaneClient, workspace_slug: str) -> None: """get_members_lite returns a paginated envelope of WorkspaceMember items.""" - page = client.workspaces.get_members_lite( + paginated_members = client.workspaces.get_members_lite( workspace_slug, params=MemberListQueryParams(per_page=100) ) - assert isinstance(page.results, list) - assert isinstance(page.total_count, int) - assert isinstance(page.next_page_results, bool) - for member in page.results: + assert isinstance(paginated_members.results, list) + assert isinstance(paginated_members.total_count, int) + assert isinstance(paginated_members.next_page_results, bool) + for member in paginated_members.results: assert isinstance(member, WorkspaceMember) def test_get_features(self, client: PlaneClient, workspace_slug: str) -> None: