Skip to content

Commit 2446dc5

Browse files
[SILO-1466] feat: add new v2 client for v2 apis (#70)
1 parent 4fdaded commit 2446dc5

300 files changed

Lines changed: 51052 additions & 4 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/test.yml‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
name: Test Python SDK
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.10", "3.12"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Set up Python ${{ matrix.python-version }}
19+
uses: actions/setup-python@v5
20+
with:
21+
python-version: ${{ matrix.python-version }}
22+
23+
- name: Install dependencies
24+
run: |
25+
pip install -e .
26+
pip install -r requirements.txt
27+
28+
- name: Lint (scoped to api_v2)
29+
# Scoped to v2: the v1 tree has 28 pre-existing ruff errors and is not gated yet.
30+
run: ruff check plane/api/v2 plane/models/v2 tests/v2
31+
32+
- name: Unit tests (tests/v2, excluding integration)
33+
run: pytest tests/v2 --ignore=tests/v2/integration -q
34+
35+
- name: Integration tests (tests/v2/integration)
36+
# No PLANE_* env vars are set in CI, so every test here must skip via the
37+
# repo's env-var skip gate rather than run against a live API. A run that
38+
# isn't all-skips here means the skip gate itself is broken.
39+
run: pytest tests/v2/integration -q
40+
41+
# mypy is intentionally NOT run in this workflow: the codebase currently has 56
42+
# pre-existing mypy errors under `--strict` (see pyproject.toml's [tool.mypy]).
43+
# Add a mypy step once that baseline is cleaned up.
44+
45+
v2-golden-drift:
46+
needs: check-secrets
47+
if: ${{ needs.check-secrets.outputs.has_token == 'true' }}
48+
runs-on: ubuntu-latest
49+
# Runs inside plane-python-sdk/ with plane-ee as a sibling dir: the generator records
50+
# its golden path verbatim in the output header, so the relative spelling must match
51+
# the committed one (`../plane-ee/apps/api/plane/api_v2/core/schema/openapi`).
52+
defaults:
53+
run:
54+
working-directory: plane-python-sdk
55+
steps:
56+
- uses: actions/checkout@v4
57+
with:
58+
path: plane-python-sdk
59+
60+
- name: Set up Python
61+
uses: actions/setup-python@v5
62+
with:
63+
python-version: "3.12"
64+
65+
- name: Install dependencies
66+
# black is required by scripts/generate_v2_constants.py to format its output
67+
# so the regenerated file matches the committed one byte-for-byte.
68+
run: pip install -r requirements.txt
69+
70+
- name: Checkout plane-ee (api_v2 OpenAPI golden)
71+
# PLANE_EE_CHECKOUT_TOKEN: fine-grained PAT with read access to makeplane/plane-ee.
72+
# Without it this job is skipped (visibly), not failed.
73+
uses: actions/checkout@v4
74+
with:
75+
repository: makeplane/plane-ee
76+
ref: preview
77+
token: ${{ secrets.PLANE_EE_CHECKOUT_TOKEN }}
78+
sparse-checkout: apps/api/plane/api_v2/core/schema/openapi
79+
sparse-checkout-cone-mode: false
80+
path: plane-ee
81+
82+
- name: Check generated v2 constants against the api_v2 golden
83+
run: |
84+
python scripts/generate_v2_constants.py ../plane-ee/apps/api/plane/api_v2/core/schema/openapi
85+
git diff --exit-code plane/api/v2/_generated/constants.py
86+
87+
check-secrets:
88+
runs-on: ubuntu-latest
89+
outputs:
90+
has_token: ${{ steps.check.outputs.has_token }}
91+
steps:
92+
- name: Check whether PLANE_EE_CHECKOUT_TOKEN is configured
93+
# `secrets` isn't reliably available in a job-level `if:` on every runner
94+
# context, so export the check as a step output here instead and gate the
95+
# v2-golden-drift job on that output.
96+
id: check
97+
run: echo "has_token=${{ secrets.PLANE_EE_CHECKOUT_TOKEN != '' }}" >> "$GITHUB_OUTPUT"

‎.gitignore‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,6 @@ target/
7373
.history
7474
tsconfig.tsbuildinfo
7575

76-
test.py
76+
test.py
77+
.superpowers/
78+

‎CLAUDE.md‎

Lines changed: 318 additions & 1 deletion
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,292 @@ work_items = client.work_items.list(
196196
)
197197
```
198198

199+
## API v2
200+
201+
`client.v2` reaches the v2 surface. v1 resources on the client are unchanged.
202+
203+
The v2 surface is complete: every one of the 90 `V2Resource` subclasses in the
204+
package (`tests/v2/tree_walk.py`'s `all_resource_classes()`, the enumeration the
205+
test suite itself sweeps) is on the flat shape described below and reachable
206+
through `client.v2`. Counting resources means not counting grouping nodes:
207+
`wiki` and `group_sync` hold no `V2Resource` base, `path` or `operations` of
208+
their own — they only group children (`wiki.pages`, `wiki.collections`,
209+
`group_sync.config`) — and are outside the 90.
210+
211+
Wired directly on `client.v2.workspaces`: `artifacts`, `assets`, `audit_logs`,
212+
`automations`, `customer_properties`, `customers`, `features`, `initiatives`,
213+
`invitations`, `members`, `permission_schemes`, `permissions`, `projects`,
214+
`releases` (`.labels`, `.tags`, `.comments`, `.links`, `.changelog`,
215+
`.work_items`), `roles`, `stickies`, `teamspaces`, `views`, `webhooks` (with
216+
`.logs`), `work_item_properties`, `work_item_relation_definitions`,
217+
`work_item_templates`, `work_item_types`, and `work_items` (a distinct,
218+
workspace-wide, list-only resource, not to be confused with the project-scoped
219+
`client.v2.workspaces.projects.work_items` below), plus the grouping nodes
220+
`wiki` (`.pages`, `.collections`) and `group_sync` (`.config`,
221+
`.project_mappings`, `.workspace_mappings`). Each takes the workspace slug as
222+
its leading argument, e.g. `client.v2.workspaces.roles.list("acme")` or
223+
`client.v2.workspaces.group_sync.config.retrieve("acme")`.
224+
225+
The whole project band is wired onto `client.v2.workspaces.projects`: `states`,
226+
`labels`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`,
227+
`intakes`, `members`, `views`, `features`, `permissions`, `work_item_templates`,
228+
`worklogs`, `pages`, `automations`, `work_item_properties`, `work_item_types`
229+
and `workflows`.
230+
231+
Two of these are worth calling out because they surprise people:
232+
233+
- `client.v2.workspaces.roles.list("acme", role_slug="admin")` — the workspace
234+
slug is the positional argument; the *role's* own slug filter is `role_slug`,
235+
spelled out rather than folded into `**filters`, because the two would
236+
otherwise collide.
237+
- `client.v2.workspaces.group_sync.project_mappings` is workspace-level despite
238+
the name — it takes only the workspace slug, no project.
239+
240+
The bound-locator chain from earlier releases (`client.v2.workspace(slug).project(key)`)
241+
is **gone**. There are two ways to reach a resource now:
242+
243+
### 1. The flat path
244+
245+
A static tree, reached by plain attribute access. Read it left to right: every
246+
segment that names an actual resource consumes one URL path id (a workspace slug,
247+
a project key, a work item identifier, ...); a segment that only *groups* children
248+
(`wiki`) consumes none.
249+
250+
```python
251+
from plane import PlaneClient
252+
from plane.models.v2 import CreateState
253+
254+
client = PlaneClient(base_url="https://api.plane.so", api_key="...")
255+
256+
client.v2.users.me()
257+
client.v2.workspaces.retrieve("acme")
258+
client.v2.workspaces.projects.states.list("acme", "ENG", fields=["id", "name"])
259+
client.v2.workspaces.projects.work_items.comments.list("acme", "ENG", "ENG-12")
260+
client.v2.workspaces.wiki.pages.list("acme") # `wiki` groups, consumes no id
261+
client.v2.workspaces.features.retrieve("acme") # singleton: no primary key at all
262+
263+
client.v2.workspaces.projects.states.create(
264+
"acme", "ENG", CreateState(name="In Review", color="#4ECDC4")
265+
)
266+
```
267+
268+
Path ids are plain positional-or-keyword parameters, so they can be passed by
269+
keyword too — handy when a call's own arguments would otherwise read ambiguously:
270+
271+
```python
272+
client.v2.workspaces.projects.states.list(slug="acme", project="ENG")
273+
```
274+
275+
### 2. Loaded rows
276+
277+
A resource with children (`projects`, `work_items`, `cycles`, `milestones`,
278+
`modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`,
279+
`releases`, `work_item_types`, `work_item_properties`, `automations` and
280+
`workflows` — 18 of the 90 classes, some families having a separate
281+
project-scoped and workspace-scoped resource, each independently navigable)
282+
doesn't just hand back a bare pydantic model from `retrieve`/`list`/`iterate` —
283+
it hands back a row that carries its own data *and* already knows where it
284+
lives, so the row's own children are reached with none of the ids repeated:
285+
286+
```python
287+
p = client.v2.workspaces.projects.retrieve("acme", "ENG")
288+
p.name
289+
p.states.list() # no "acme", "ENG" to repeat
290+
item = p.work_items.retrieve("ENG-12")
291+
item.comments.list() # same, one level deeper
292+
```
293+
294+
Membership bridges hang off a loaded row the same way. A fetched cycle reaches
295+
its own work-item membership without repeating `"acme"`, `"ENG"` or the cycle's
296+
own id:
297+
298+
```python
299+
cycle = client.v2.workspaces.projects.cycles.retrieve("acme", "ENG", "c1")
300+
cycle.name
301+
cycle.work_items.add(["w1"]) # moves work item "w1" into this cycle
302+
```
303+
304+
The same navigable shape holds throughout: `milestones`/`modules` via their own
305+
`.work_items` bridge; `estimates` via `.estimate_points` (not `.points`, which
306+
is the row's own inline-expand field); `webhooks` via `.logs`, its delivery
307+
log; `collections` via `.members`/`.pages`; `customers` via `.requests`,
308+
`.property_values`, `.work_items`; `initiatives` via `.labels`, `.projects`,
309+
`.work_items`; `releases` via `.labels`, `.tags`, `.comments`, `.links`,
310+
`.changelog`, `.work_items`; `work_item_types` via `.properties`;
311+
`work_item_properties` via `.property_options` (plus `.contexts` on the
312+
workspace-scoped resource only); `automations` via `.edges`, `.nodes`,
313+
`.activities`; and `workflows` via `.states`, `.transitions`. A work item
314+
itself reaches all seven of its own children this way — `comments`,
315+
`attachments`, `links`, `worklogs`, `activities`, `relations`, `dependencies`.
316+
317+
`list` and `iterate` yield these same navigable rows, not bare pydantic models —
318+
`for project in client.v2.workspaces.projects.iterate("acme"): project.states.list()`
319+
works with no extra plumbing. Resources without children (`states`, `labels`,
320+
`workspaces`, `wiki.pages`, `features`, `releases.labels`, `intakes`, and most
321+
other leaf resources) still return plain pydantic models — the `Loaded` mixin
322+
(`plane/api/v2/_kernel/loaded.py`) is generic and every resource with children
323+
picks it up the same way. `tests/v2/test_loaded_navigation.py` sweeps every
324+
class that declares a `loaded_model` and fails if its row's navigation
325+
properties don't match its resource's own children exactly — see
326+
[the four rules the tests enforce](#the-four-rules-the-tests-enforce) below.
327+
328+
### Sparse responses raise, they don't lie
329+
330+
Every read field except `id` is optional at the model level, because `?fields=`
331+
and collection deferral can both omit any field the server would otherwise send.
332+
On a Loaded row, *reading* a field the response didn't carry raises
333+
`FieldNotRequested` instead of silently returning `None` — a `None` you get back is
334+
a real null, not a sign the data was never fetched:
335+
336+
```python
337+
from plane.api.v2 import FieldNotRequested
338+
339+
p = client.v2.workspaces.projects.retrieve("acme", "ENG", fields=["id"])
340+
p.name # raises FieldNotRequested -- "name" was not requested
341+
342+
# The same holds with no `fields=` at all: presence follows what the server
343+
# actually returned, so a row the collection route deferred fields on still
344+
# raises rather than handing back a `None` that looks like real data.
345+
row = client.v2.workspaces.projects.list("acme").data[0]
346+
row.description # raises FieldNotRequested if the list route omitted it
347+
```
348+
349+
### Typing is not decorative
350+
351+
Field names, `order_by` values and filter keyword names are all generated
352+
`Literal`/`TypedDict` types (from `plane/api/v2/_generated/constants.py`, produced
353+
from the api_v2 OpenAPI golden), and the package ships a `py.typed` marker so a
354+
type checker actually reads them. A typo in a filter keyword is a `mypy` error,
355+
not a runtime surprise:
356+
357+
```python
358+
# mypy rejects this: "not_a_filter" isn't in StatesListFilters
359+
client.v2.workspaces.projects.states.list("acme", "ENG", not_a_filter="x")
360+
```
361+
362+
Navigation off a loaded row is typed the same way, not `Any`: `project.states.list()`
363+
resolves to `Page[State]`, a misspelled child (`project.states.lst()`) or an unknown
364+
keyword on it is still a `mypy` error, and this holds several hops deep — a fetched
365+
work item reached through a fetched project still resolves its `.comments.list()` to
366+
a real model, not a collapsed `Any`. `tests/v2/test_typing.py` runs mypy over probe
367+
scripts to prove it, rather than trusting it by inspection.
368+
369+
### The five rules the tests enforce
370+
371+
Five properties of the surface are each enforced by a sweep in `tests/v2/`, over
372+
every one of the 90 resource classes (`tests/v2/tree_walk.py`'s
373+
`all_resource_classes()`) rather than a hand-picked subset — so a newly added
374+
resource is covered the moment it exists, with nothing to remember to add it to:
375+
376+
- **Path-id naming** (`tests/v2/test_path_id_naming.py`). Every path-id parameter
377+
is named after the resource it identifies, singular, with no `_id` suffix —
378+
`slug`, `project`, `work_item`, `state`, `label`, `page`, `comment`, `release`,
379+
and so on, the same name whether it's a method's own primary key or an
380+
ancestor's. This isn't cosmetic: `Owned` (the mechanism behind loaded-row
381+
navigation) matches a child method's leading parameter names against its
382+
parent's literally, so a resource that suffixed its own id would silently break
383+
navigation from its parent. URL templates and model field names keep their own
384+
golden-derived names (`{project_id}`, `WorkItem.state_id`) — this rule is about
385+
method parameters only.
386+
- **`expand` exposure** (`tests/v2/test_expand_coverage.py`). Wherever the api_v2
387+
OpenAPI golden declares an operation can expand a relation, the SDK method
388+
exposes an `expand` parameter for it. A method that just omits the parameter
389+
makes that capability unreachable from the SDK with no error to notice it by —
390+
which is exactly how eleven methods shipped without it before this sweep
391+
existed.
392+
- **`fields` exposure** (`tests/v2/test_fields_coverage.py`). The same shape for
393+
`?fields=`: wherever the golden declares it for an operation, the method exposes
394+
it. The one deliberate exception is a response that cannot be re-fetched — a
395+
secret shown once (`Webhooks.regenerate`) or a presigned-upload envelope whose
396+
extra data exists only in that one reply (`WorkItemAttachments.create`,
397+
`WorkspaceAssets.create`, `UserAssets.create`) — where projecting fields could
398+
silently and irrecoverably drop data the caller has no second chance at. Those
399+
are named, with their reason, in the test's own `ONE_TIME_RESPONSES` set, and
400+
the reason is repeated in the method's docstring so the next reader doesn't
401+
"fix" the omission back.
402+
- **Pagination exposure** (`tests/v2/test_pagination_coverage.py`). The same
403+
shape again, for the parameters that pick the *envelope* rather than shape the
404+
rows: `per_page`, `offset`, `paginate` and `count`. `list` exposes every one its
405+
operation declares; `iterate` exposes `per_page` and `paginate` (page size and
406+
envelope choice are the caller's) but not `offset` or `count`, which belong to
407+
the auto-pager's own walk. This sweep is the newest, and it was added because
408+
its absence was expensive: `paginate` and `count` were *reserved* by the
409+
constants generator — kept out of every `*Filters` TypedDict on the grounds that
410+
each belonged on the method as an explicit parameter — and then never added to a
411+
single one of the 68 list methods. Nothing could see it, because `FIELDS` and
412+
`EXPAND` were the only golden tables the generator emitted. The cost:
413+
`client.v2.workspaces.audit_logs` could not be listed at all (the server refuses
414+
the offset envelope there), the `CursorPage` branch of `parse_page` was
415+
unreachable from any public method, and `count=false` was unsendable while the
416+
kernel's own `_find_one` had been sending it all along.
417+
- **Loaded-row navigation completeness** (`tests/v2/test_loaded_navigation.py`).
418+
For every resource that declares a `loaded_model`, its `Loaded` row type's
419+
navigation properties must be exactly the child resources the resource class
420+
itself attaches — no more, no fewer, and each must wrap its own child rather
421+
than a copy-pasted sibling's. This is what closes the gap a name-only
422+
comparison would miss: a resource can attach fifteen children while its row
423+
exposes three, with every other test still green, unless something checks the
424+
two sides against each other.
425+
426+
### What else is wired
427+
428+
`releases.labels` sits on `workspaces` too (`client.v2.workspaces.releases.labels`)
429+
and, being both a catalog *and* a membership bridge, additionally exposes `add`/
430+
`remove` to attach/detach existing labels on a specific release. Both take every
431+
path id the bridge's own URL needs — the workspace slug, then the release id —
432+
ahead of 1..100 label ids to add/remove:
433+
434+
```python
435+
client.v2.workspaces.releases.labels.add("acme", release.id, [label.id])
436+
client.v2.workspaces.releases.labels.remove("acme", release.id, [label.id])
437+
```
438+
439+
An empty list, or more than 100 ids, raises `ValueError` before any request is
440+
sent.
441+
442+
`workspaces.permissions` is a singleton like `features`, reached with just the
443+
slug and no primary key:
444+
445+
```python
446+
client.v2.workspaces.permissions.me("acme")
447+
```
448+
449+
`group_sync` groups three resources under one namespace without consuming a path
450+
id itself — each child still takes its own leading `slug`:
451+
452+
```python
453+
client.v2.workspaces.group_sync.config.retrieve("acme")
454+
client.v2.workspaces.group_sync.project_mappings.list("acme")
455+
client.v2.workspaces.group_sync.workspace_mappings.list("acme")
456+
```
457+
458+
### Errors
459+
460+
Errors from `client.v2` calls raise `PlaneAPIError` (RFC 9457 problem detail —
461+
`.status`, `.type`, `.code`, `.detail`, `.errors`), and `find_by_name` raises
462+
`NoMatchFound` or `MultipleMatchesFound` when it can't resolve to exactly one row.
463+
All three, plus `FieldError` (the shape of one entry in `.errors`), are re-exported
464+
from both `plane.api.v2` and the top-level `plane` package:
465+
466+
```python
467+
from plane.api.v2 import MultipleMatchesFound, NoMatchFound, PlaneAPIError
468+
469+
# or, equivalently:
470+
# from plane import MultipleMatchesFound, NoMatchFound, PlaneAPIError
471+
472+
try:
473+
todo = client.v2.workspaces.projects.states.find_by_name("acme", "ENG", "Todo")
474+
except NoMatchFound:
475+
...
476+
except MultipleMatchesFound:
477+
...
478+
479+
try:
480+
client.v2.workspaces.projects.states.create("acme", "ENG", CreateState(name="", color="#fff"))
481+
except PlaneAPIError as e:
482+
print(e.status, e.code, e.detail)
483+
```
484+
199485
## Architecture
200486

201487
### Client Structure

0 commit comments

Comments
 (0)