Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`

- Resource paths and naming

- All paths use `work-items` instead of v0.1.x `issues`
- Sub-resources are grouped under `client.work_items.<subresource>`

- 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.<resource>`
- No OpenAPI `*Api` classes; use resource objects from `PlaneClient`

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 48 additions & 6 deletions plane/api/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@

from ..models.projects import (
CreateProject,
PaginatedProjectMemberResponse,
PaginatedProjectResponse,
Project,
ProjectFeature,
ProjectMember,
ProjectWorklogSummary,
UpdateProject,
)
from ..models.query_params import PaginatedQueryParams
from ..models.query_params import (
MemberListQueryParams,
MemberQueryParams,
PaginatedQueryParams,
)
from .base_resource import BaseResource


Expand Down Expand Up @@ -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
Comment on lines +118 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

These member endpoints should include the trailing slash.

The docstring already describes /project-members/, but both requests still hit slashless paths. That violates the API endpoint convention for plane/api/**/*.py and risks route mismatches on stricter servers. As per path instructions, All API endpoints should end with a trailing / and follow URL convention: {base_path}/api/v1{resource_base_path}/{endpoint}/.

Also applies to: 141-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/api/projects.py` around lines 118 - 119, The project member endpoints
in the `Projects` API are missing the required trailing slash, which can cause
route mismatches on stricter servers. Update the `_get` calls for the
`project-members` and related member endpoint in `plane/api/projects.py` to use
the conventionally formatted path ending in `/`, matching the docstring and the
API URL pattern used throughout `plane/api/**/*.py`.

Source: Path instructions

)
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.

Expand Down Expand Up @@ -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")

42 changes: 36 additions & 6 deletions plane/api/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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 []]
Comment on lines 18 to 35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Workspaces.get_members() lost the raw-mapping backcompat path.

The PR contract says workspace member listing still accepts raw dictionaries, but this signature only accepts MemberQueryParams and immediately calls to_query_params(). Existing callers that pass {"is_active": True} will now fail before the request is made.

Suggested fix
+from collections.abc import Mapping
+
     def get_members(
-        self, workspace_slug: str, params: MemberQueryParams | None = None
+        self,
+        workspace_slug: str,
+        params: MemberQueryParams | Mapping[str, Any] | None = None,
     ) -> list[WorkspaceMember]:
@@
-        response = self._get(
-            f"{workspace_slug}/members",
-            params=params.to_query_params() if params else None,
-        )
+        if isinstance(params, MemberQueryParams):
+            params = params.to_query_params()
+        elif params is not None:
+            params = {
+                k: (str(v).lower() if isinstance(v, bool) else v)
+                for k, v in params.items()
+            }
+        response = self._get(f"{workspace_slug}/members", params=params)
         return [WorkspaceMember.model_validate(item) for item in response or []]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 []]
from collections.abc import Mapping
def get_members(
self,
workspace_slug: str,
params: MemberQueryParams | Mapping[str, Any] | None = None,
) -> list[WorkspaceMember]:
"""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)
"""
if isinstance(params, MemberQueryParams):
params = params.to_query_params()
elif params is not None:
params = {
k: (str(v).lower() if isinstance(v, bool) else v)
for k, v in params.items()
}
response = self._get(
f"{workspace_slug}/members",
params=params,
)
return [WorkspaceMember.model_validate(item) for item in response or []]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/api/workspaces.py` around lines 18 - 35, Workspaces.get_members()
should preserve the raw-dictionary backcompat path instead of only handling
MemberQueryParams. Update the get_members method in Workspaces so callers can
still pass a plain mapping like {"is_active": True} without hitting
params.to_query_params(); keep the existing MemberQueryParams behavior while
adding a branch that forwards raw dict-like params directly to _get. Ensure the
response parsing into WorkspaceMember stays unchanged.


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.

Expand All @@ -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.

Expand All @@ -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)
return WorkspaceFeature.model_validate(response)
4 changes: 4 additions & 0 deletions plane/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
)
from .query_params import (
BaseQueryParams,
MemberListQueryParams,
MemberQueryParams,
PaginatedQueryParams,
RetrieveQueryParams,
WorkItemQueryParams,
Expand All @@ -37,6 +39,8 @@
"IntakeWorkItemStatusEnum",
# query params
"BaseQueryParams",
"MemberListQueryParams",
"MemberQueryParams",
"PaginatedQueryParams",
"RetrieveQueryParams",
"WorkItemQueryParams",
Expand Down
12 changes: 10 additions & 2 deletions plane/models/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -165,5 +175,3 @@ class ProjectFeature(BaseModel):
workflows: bool | None = None
parallel_cycles: bool | None = None
project_updates: bool | None = None


69 changes: 68 additions & 1 deletion plane/models/query_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Comment on lines +93 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Blank string filters will still be sent.

model_dump(exclude_none=True) only drops None, so MemberQueryParams(display_name="") still serializes as display_name=. That contradicts the PR contract that empty filters are omitted and will leak blank filters into both typed member-list endpoints.

Suggested fix
+from pydantic import ConfigDict, Field, field_validator
+
 class MemberQueryParams(BaseQueryParams):
@@
     role_slug: str | None = Field(None, description="Filter by role slug (exact match)")
@@
     is_active: bool | None = Field(None, description="Filter by active membership status")
     is_bot: bool | None = Field(None, description="Filter by bot accounts")
+
+    `@field_validator`("first_name", "last_name", "email", "display_name", "role_slug", mode="before")
+    `@classmethod`
+    def _empty_strings_to_none(cls, value: str | None) -> str | None:
+        if isinstance(value, str) and not value.strip():
+            return None
+        return value
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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")
from pydantic import ConfigDict, Field, field_validator
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")
`@field_validator`("first_name", "last_name", "email", "display_name", "role_slug", mode="before")
`@classmethod`
def _empty_strings_to_none(cls, value: str | None) -> str | None:
if isinstance(value, str) and not value.strip():
return None
return value
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/models/query_params.py` around lines 93 - 123, The blank-string filters
in MemberQueryParams are still being serialized because only None is excluded,
so empty values like display_name="" leak into member-list requests. Update the
MemberQueryParams handling so empty strings are treated as unset and omitted
during serialization, likely by normalizing blank text fields to None in the
model or by adding a field-level serializer/validator on the text filter fields
(first_name, last_name, email, display_name). Ensure the behavior matches the
BaseQueryParams/member-list contract that empty filters are not sent.

WorkItemCountGroupBy = Literal[
"state_id",
"state__group",
Expand All @@ -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)

Expand Down Expand Up @@ -155,6 +220,8 @@ class WorkItemCountQueryParams(BaseModel):

__all__ = [
"BaseQueryParams",
"MemberListQueryParams",
"MemberQueryParams",
"PaginatedQueryParams",
"RetrieveQueryParams",
"WorkItemCountGroupBy",
Expand Down
Loading