diff --git a/README.md b/README.md index ecadafb..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` @@ -280,6 +271,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 +paginated_members = client.workspaces.get_members_lite( + workspace_slug, + params=MemberListQueryParams(per_page=1000), +) +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=paginated_members.next_cursor), + ) + all_members.extend(paginated_members.results) ``` ### Project Management @@ -321,6 +334,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 +members = 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 411114d..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 PaginatedQueryParams +from ..models.query_params import ( + MemberListQueryParams, + MemberQueryParams, + PaginatedQueryParams, +) from .base_resource import BaseResource @@ -86,21 +91,59 @@ 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. + """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. 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 (first_name, last_name, email, + display_name, role_slug, is_active, is_bot), or a raw mapping. """ - response = self._get(f"{workspace_slug}/projects/{project_id}/members", params=params) + if isinstance(params, MemberQueryParams): + 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. @@ -154,4 +197,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..3a4855e 100644 --- a/plane/api/workspaces.py +++ b/plane/api/workspaces.py @@ -2,7 +2,12 @@ from typing import Any -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 @@ -11,19 +16,44 @@ 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. + """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 filter query parameters (first_name, last_name, + email, display_name, role_slug, is_active, is_bot) """ - response = self._get(f"{workspace_slug}/members") + response = self._get( + f"{workspace_slug}/members", + 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. @@ -32,7 +62,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 +71,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..5121534 100644 --- a/plane/models/__init__.py +++ b/plane/models/__init__.py @@ -15,6 +15,8 @@ ) from .query_params import ( BaseQueryParams, + MemberListQueryParams, + MemberQueryParams, PaginatedQueryParams, RetrieveQueryParams, WorkItemQueryParams, @@ -37,6 +39,8 @@ "IntakeWorkItemStatusEnum", # query params "BaseQueryParams", + "MemberListQueryParams", + "MemberQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", "WorkItemQueryParams", diff --git a/plane/models/projects.py b/plane/models/projects.py index f9e6197..48ab1c1 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -148,6 +148,16 @@ class ProjectMember(UserLite): role: int | None = None role_slug: str | None = None + is_active: bool | None = None + 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): @@ -165,5 +175,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..6ce0c24 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -90,6 +90,70 @@ 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") + + 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", "state__group", @@ -116,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) @@ -155,6 +220,8 @@ class WorkItemCountQueryParams(BaseModel): __all__ = [ "BaseQueryParams", + "MemberListQueryParams", + "MemberQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", "WorkItemCountGroupBy", diff --git a/plane/models/workspaces.py b/plane/models/workspaces.py index 2f8c321..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 @@ -13,16 +14,26 @@ class WorkspaceMember(UserLite): role: int | None = None role_slug: str | None = None + is_active: bool | None = None + 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.""" + """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..e4d05b7 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 PaginatedQueryParams +from plane.models.query_params import ( + MemberListQueryParams, + MemberQueryParams, + PaginatedQueryParams, +) class TestProjectsAPI: @@ -103,6 +107,41 @@ 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 accepts a raw mapping; bool values are normalized to true/false.""" + members = client.projects.get_members( + 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.""" + paginated_members = client.projects.get_members_lite( + workspace_slug, project.id, params=MemberListQueryParams(per_page=100) + ) + 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: """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..dd4d41d 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 MemberListQueryParams, MemberQueryParams from plane.models.workspaces import WorkspaceMember @@ -18,6 +19,44 @@ 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_members_lite_paginated(self, client: PlaneClient, workspace_slug: str) -> None: + """get_members_lite returns a paginated envelope of WorkspaceMember items.""" + paginated_members = client.workspaces.get_members_lite( + workspace_slug, params=MemberListQueryParams(per_page=100) + ) + 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: """Test getting workspace features.""" features = client.workspaces.get_features(workspace_slug) @@ -33,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 -