@@ -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