Skip to content
Open
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
97 changes: 97 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Test Python SDK

on:
pull_request:
push:
branches:
- main

jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
pip install -e .
pip install -r requirements.txt

- name: Lint (scoped to api_v2)
# Scoped to v2: the v1 tree has 28 pre-existing ruff errors and is not gated yet.
run: ruff check plane/api/v2 plane/models/v2 tests/v2

- name: Unit tests (tests/v2, excluding integration)
run: pytest tests/v2 --ignore=tests/v2/integration -q

- name: Integration tests (tests/v2/integration)
# No PLANE_* env vars are set in CI, so every test here must skip via the
# repo's env-var skip gate rather than run against a live API. A run that
# isn't all-skips here means the skip gate itself is broken.
run: pytest tests/v2/integration -q

# mypy is intentionally NOT run in this workflow: the codebase currently has 56
# pre-existing mypy errors under `--strict` (see pyproject.toml's [tool.mypy]).
# Add a mypy step once that baseline is cleaned up.

v2-golden-drift:
needs: check-secrets
if: ${{ needs.check-secrets.outputs.has_token == 'true' }}
runs-on: ubuntu-latest
# Runs inside plane-python-sdk/ with plane-ee as a sibling dir: the generator records
# its golden path verbatim in the output header, so the relative spelling must match
# the committed one (`../plane-ee/apps/api/plane/api_v2/core/schema/openapi`).
defaults:
run:
working-directory: plane-python-sdk
steps:
- uses: actions/checkout@v4
with:
path: plane-python-sdk

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
# black is required by scripts/generate_v2_constants.py to format its output
# so the regenerated file matches the committed one byte-for-byte.
run: pip install -r requirements.txt

- name: Checkout plane-ee (api_v2 OpenAPI golden)
# PLANE_EE_CHECKOUT_TOKEN: fine-grained PAT with read access to makeplane/plane-ee.
# Without it this job is skipped (visibly), not failed.
uses: actions/checkout@v4
with:
repository: makeplane/plane-ee
ref: preview
token: ${{ secrets.PLANE_EE_CHECKOUT_TOKEN }}
sparse-checkout: apps/api/plane/api_v2/core/schema/openapi
sparse-checkout-cone-mode: false
path: plane-ee

- name: Check generated v2 constants against the api_v2 golden
run: |
python scripts/generate_v2_constants.py ../plane-ee/apps/api/plane/api_v2/core/schema/openapi
git diff --exit-code plane/api/v2/_generated/constants.py

check-secrets:
runs-on: ubuntu-latest
outputs:
has_token: ${{ steps.check.outputs.has_token }}
steps:
- name: Check whether PLANE_EE_CHECKOUT_TOKEN is configured
# `secrets` isn't reliably available in a job-level `if:` on every runner
# context, so export the check as a step output here instead and gate the
# v2-golden-drift job on that output.
id: check
run: echo "has_token=${{ secrets.PLANE_EE_CHECKOUT_TOKEN != '' }}" >> "$GITHUB_OUTPUT"
22 changes: 21 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Plane Python SDK (`plane-sdk` on PyPI, v0.2.4) — a synchronous, type-annotated Python client for the Plane API. Built on `requests` + `pydantic` v2, targeting Python 3.10+.
Plane Python SDK (`plane-sdk` on PyPI, v0.3.0) — a synchronous, type-annotated Python client for the Plane API. Built on `requests` + `pydantic` v2, targeting Python 3.10+.

## Common Commands

Expand Down Expand Up @@ -69,6 +69,26 @@ PlaneClient
- `plane/client/` — `PlaneClient` (API key / access token auth) and `OAuthClient` (OAuth 2.0 flows).
- `plane/errors/` — `PlaneError` → `HttpError`, `ConfigurationError`.
- `plane/config.py` — `Configuration` and `RetryConfig` dataclasses.
- `plane/api/v2/` — the v2 surface. The chain is the only public form:
`client.v2.workspace(slug)` (`Workspace`, `plane/api/v2/workspace.py`) and
`.project(project)` (`Project`, `plane/api/v2/project.py`) are zero-I/O locators
that bind `slug`/`project_id` once; every v2 resource hangs off one of them as a
plain attribute (`.wiki` on `Workspace` is itself a small locator, `Wiki` in
`plane/api/v2/wiki.py`, holding `.pages`/`.collections`). `client.v2.users` /
`.user_assets` are the only resources kept directly on `V2Namespace` (the 6
operations with no workspace in their path). `_kernel/` holds the shared
machinery: `V2Resource.__init__(transport, **scope)` stores the bound scope,
and `_collection_url`/`_detail_url` merge it with any explicitly passed path
params (explicit wins) — a resource constructed with no scope (most offline
tests do this) behaves exactly as if every path param were passed per call, so
a resource's methods work identically whether or not it was reached through
the chain. No public v2 method takes `workspace_slug`/`project` parameters —
the locator supplies both; leaf ids (`work_item_id`, `release_id`, ...) stay as
the first positional argument. `_generated/constants.py` is produced by
`scripts/generate_v2_constants.py` from the api_v2 OpenAPI golden and must
never be hand-edited.
- `plane/models/v2/` — v2 pydantic models. Read models mark every field except `id`
optional, because `?fields=` and collection deferral can omit any of them.

### Sub-resource pattern

Expand Down
115 changes: 115 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,121 @@ work_items = client.work_items.list(
)
```

## API v2

`client.v2` reaches the v2 surface. v1 resources on the client are unchanged.

The chain is the **only** public form: bind a workspace once
(`client.v2.workspace(slug)`), then a project inside it once more
(`.project(project)`) — both are zero-I/O locators, not requests. Every v2
resource hangs off one of the two as a plain attribute; nothing takes a
`workspace_slug`/`project` parameter directly, because the scope you bound already
supplies it. `client.v2.users` and `client.v2.user_assets` are the only exceptions
— the 6 v2 operations with no workspace in their path stay directly on `client.v2`.

```python
from plane import PlaneClient
from plane.models.v2 import CreateState

client = PlaneClient(base_url="https://api.plane.so", api_key="...")

# Bind once -- "acme" is a workspace slug, "ENG" a project key (a UUID works too)
eng = client.v2.workspace("acme").project("ENG")

# Projects address by key; states resolve by name
todo = eng.states.find_by_name("Todo")

# Ask for only the fields you need
for state in eng.states.iterate(fields=["id", "name"]):
print(state.id, state.name)

eng.states.create(CreateState(name="In Review", color="#4ECDC4"))

# Batches report per row; partial success is the default
result = eng.states.bulk_create([CreateState(name="QA", color="#fff")])
result.raise_for_failures()
```

Sparse responses mean every read field except `id` is optional — check for `None`
rather than assuming a field is present.

`eng.labels` follows the same shape as `eng.states`: `list`, `iterate`, `retrieve`,
`find_by_name`, `create`, `update`, `delete`, `upsert`, `bulk_create`, `bulk_update`,
`bulk_delete`. Models: `State`, `CreateState`, `UpdateState`, `Label`, `CreateLabel`,
`UpdateLabel`, `BulkWriteResponse`, `OffsetPage`, `CursorPage`, all importable from
`plane.models.v2`.

`eng.cycles`, `eng.modules` and `eng.milestones` offer the same CRUD/upsert/bulk
surface. Milestones' identifying field is `title`, not `name`, but `find_by_name`
still takes a `name` argument — the API's own list filter aliases `?name=` to the
`title` column. Models: `Cycle`, `CreateCycle`, `UpdateCycle`, `Module`, `CreateModule`,
`UpdateModule`, `ModuleStatus`, `Milestone`, `CreateMilestone`, `UpdateMilestone`, all
importable from `plane.models.v2`.

Wiki resources are workspace-scoped, reached under `.wiki`:

```python
from plane.models.v2 import CreatePage

ws = client.v2.workspace("acme")

# A public page created without `collection_id` lands in the workspace's default
# ("General") collection server-side; private pages need an explicit private one.
handbook = ws.wiki.collections.find_by_name("Engineering handbook")
ws.wiki.pages.create(CreatePage(name="Runbook", collection_id=handbook.id))
ws.wiki.collections.default() # the default collection, resolved by `is_default`
```

Work items follow the same pattern, and readable identifiers come first:

```python
from plane.models.v2 import CreateWorkItem

eng = client.v2.workspace("acme").project("ENG")
item = eng.work_items.create(CreateWorkItem(name="Fix login bug", state="Todo", labels=["bug"]))
eng.work_items.comments.list(item.id)

# By human key, with no project needed -- `ws.work_items` spans every project
ws.work_items.retrieve_by_identifier("ENG-12")
```

Every other resource hangs off the same two locators with the same shape --
`ws.members`, `ws.releases.comments`, `eng.cycles`, `eng.work_item_types.properties`,
... -- and none of them take `workspace_slug`/`project` arguments: the locator
supplies both.

Errors from `client.v2` calls raise `PlaneAPIError` (RFC 9457 problem detail —
`.status`, `.type`, `.code`, `.detail`, `.errors`), and `find_by_name` raises
`NoMatchFound` or `MultipleMatchesFound` when it can't resolve to exactly one row.
All three, plus `FieldError` (the shape of one entry in `.errors`), are re-exported
from both `plane.api.v2` and the top-level `plane` package:

```python
from plane.api.v2 import MultipleMatchesFound, NoMatchFound, PlaneAPIError

# or, equivalently:
# from plane import MultipleMatchesFound, NoMatchFound, PlaneAPIError

try:
state = eng.states.find_by_name("Todo")
except NoMatchFound:
...
except MultipleMatchesFound:
...

try:
eng.states.create(CreateState(name="", color="#fff"))
except PlaneAPIError as e:
print(e.status, e.code, e.detail)
```

Bulk writes (`bulk_create`, `bulk_update`, `bulk_delete`) cap at 50 rows per call and
answer HTTP 200 even when some rows fail — call `result.raise_for_failures()` to turn
partial failure into an exception, or inspect `result.failures` yourself. An empty
batch is rejected client-side with a `ValueError` before any request is sent — the API
itself 400s on `[]` (every bulk schema requires at least one row), so the client mirrors
that instead of round-tripping a request guaranteed to fail.

## Architecture

### Client Structure
Expand Down
6 changes: 6 additions & 0 deletions plane/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .api.stickies import Stickies
from .api.teamspaces import Teamspaces
from .api.users import Users
from .api.v2 import FieldError, MultipleMatchesFound, NoMatchFound, PlaneAPIError, V2Namespace
from .api.work_item_properties import WorkItemProperties
from .api.work_item_relation_definitions import WorkItemRelationDefinitions
from .api.work_item_type_governance import WorkItemTypeGovernance
Expand Down Expand Up @@ -102,6 +103,11 @@
"PlaneClient",
"OAuthClient",
"Configuration",
"V2Namespace",
"PlaneAPIError",
"NoMatchFound",
"MultipleMatchesFound",
"FieldError",
"AgentRuns",
"WorkItems",
"WorkItemTypes",
Expand Down
Loading
Loading