workwire 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0](https://github.com/workwire/workwire-python/compare/v0.4.0...v0.5.0) (2026-10-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * **WOR-511:** backend v1.62.0's spec — unit_type on locations and jobs, the kind_rank reason ([#24](https://github.com/workwire/workwire-python/issues/24)) ([c97d486](https://github.com/workwire/workwire-python/commit/c97d4860acfe7a73598c6c3407ccc9dc5d666be2))
9
+
3
10
  ## [0.4.0](https://github.com/workwire/workwire-python/compare/v0.3.0...v0.4.0) (2026-10-18)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: workwire
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Official Python SDK for the Workwire API
5
5
  Project-URL: Homepage, https://github.com/workwire/workwire-python
6
6
  Project-URL: Changelog, https://github.com/workwire/workwire-python/blob/main/CHANGELOG.md
@@ -107,15 +107,28 @@ shown to the other side.
107
107
  ```python
108
108
  from workwire import LocationNode, Party
109
109
 
110
- project = client.projects.create(name="Elm St. Townhomes", template="development", locations=[
111
- LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
112
- ])
110
+ project = client.projects.create(
111
+ name="Elm St. Townhomes",
112
+ template="development",
113
+ locations=[
114
+ LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
115
+ ],
116
+ )
113
117
  tree = client.projects.locations.list(project.id).data # the site root first
114
118
  level_1 = next(loc for loc in tree if loc.name == "Level 1")
115
- client.wires.create(project_id=project.id, title="Drywall — Level 1", parties=[Party.partnered(
116
- partnership_id=partnership.id, trade="Drywall", scope_text="Level 1.",
117
- contract_value="40000.00", scope_location_ids=[level_1.id], # one Job per scope location
118
- )])
119
+ client.wires.create(
120
+ project_id=project.id,
121
+ title="Drywall — Level 1",
122
+ parties=[
123
+ Party.partnered(
124
+ partnership_id=partnership.id,
125
+ trade="Drywall",
126
+ scope_text="Level 1.",
127
+ contract_value="40000.00",
128
+ scope_location_ids=[level_1.id], # one Job per scope location
129
+ )
130
+ ],
131
+ )
119
132
  page = client.jobs.list(project=project.id) # page.quantity_unit is your org's unit
120
133
  client.jobs.update(page.data[0].id, your_reference="PO-4471-A", quantity="12.5", checked=True)
121
134
  ```
@@ -125,6 +138,17 @@ one `client.jobs.update` returns — carries `location_name`, `location_kind` an
125
138
  `location_path` (the ancestors, root first), so a sub reads its scope without
126
139
  the GC's tree; `client.jobs.list` rows had them already.
127
140
 
141
+ A tree only descends: `site` › `building`/`house` › `floor` ›
142
+ `unit`/`common_area` › `room`, levels skippable but never repeated or
143
+ reversed. A node that breaks the rank raises `InvalidRequestError` with code
144
+ `invalid_parent` and `details` `{"reason": "kind_rank", "child_kind": ...,
145
+ "parent_kind": ...}`.
146
+
147
+ `LocationNode(..., unit_type="C1.1 (2 Bedroom)")` (or
148
+ `locations.create(..., unit_type=...)`) labels a unit's plan; every
149
+ `Location` reads it back as `unit_type`, and a Job as `location_unit_type` —
150
+ `None` when unset.
151
+
128
152
  Leave `scope_location_ids` out to scope a party to the whole project.
129
153
  `client.jobs.update` sends only the fields you pass: leave one out to keep
130
154
  it, pass `None` to clear it. Add to a tree later with
@@ -84,15 +84,28 @@ shown to the other side.
84
84
  ```python
85
85
  from workwire import LocationNode, Party
86
86
 
87
- project = client.projects.create(name="Elm St. Townhomes", template="development", locations=[
88
- LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
89
- ])
87
+ project = client.projects.create(
88
+ name="Elm St. Townhomes",
89
+ template="development",
90
+ locations=[
91
+ LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
92
+ ],
93
+ )
90
94
  tree = client.projects.locations.list(project.id).data # the site root first
91
95
  level_1 = next(loc for loc in tree if loc.name == "Level 1")
92
- client.wires.create(project_id=project.id, title="Drywall — Level 1", parties=[Party.partnered(
93
- partnership_id=partnership.id, trade="Drywall", scope_text="Level 1.",
94
- contract_value="40000.00", scope_location_ids=[level_1.id], # one Job per scope location
95
- )])
96
+ client.wires.create(
97
+ project_id=project.id,
98
+ title="Drywall — Level 1",
99
+ parties=[
100
+ Party.partnered(
101
+ partnership_id=partnership.id,
102
+ trade="Drywall",
103
+ scope_text="Level 1.",
104
+ contract_value="40000.00",
105
+ scope_location_ids=[level_1.id], # one Job per scope location
106
+ )
107
+ ],
108
+ )
96
109
  page = client.jobs.list(project=project.id) # page.quantity_unit is your org's unit
97
110
  client.jobs.update(page.data[0].id, your_reference="PO-4471-A", quantity="12.5", checked=True)
98
111
  ```
@@ -102,6 +115,17 @@ one `client.jobs.update` returns — carries `location_name`, `location_kind` an
102
115
  `location_path` (the ancestors, root first), so a sub reads its scope without
103
116
  the GC's tree; `client.jobs.list` rows had them already.
104
117
 
118
+ A tree only descends: `site` › `building`/`house` › `floor` ›
119
+ `unit`/`common_area` › `room`, levels skippable but never repeated or
120
+ reversed. A node that breaks the rank raises `InvalidRequestError` with code
121
+ `invalid_parent` and `details` `{"reason": "kind_rank", "child_kind": ...,
122
+ "parent_kind": ...}`.
123
+
124
+ `LocationNode(..., unit_type="C1.1 (2 Bedroom)")` (or
125
+ `locations.create(..., unit_type=...)`) labels a unit's plan; every
126
+ `Location` reads it back as `unit_type`, and a Job as `location_unit_type` —
127
+ `None` when unset.
128
+
105
129
  Leave `scope_location_ids` out to scope a party to the whole project.
106
130
  `client.jobs.update` sends only the fields you pass: leave one out to keep
107
131
  it, pass `None` to clear it. Add to a tree later with
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "workwire"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Official Python SDK for the Workwire API"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -96,9 +96,7 @@ class BaseClient:
96
96
  return {
97
97
  "Authorization": f"Bearer {self._api_key}",
98
98
  "Accept": "application/json",
99
- "User-Agent": (
100
- f"workwire-python/{__version__} python/{platform.python_version()}"
101
- ),
99
+ "User-Agent": (f"workwire-python/{__version__} python/{platform.python_version()}"),
102
100
  "X-Workwire-Client-User-Agent": telemetry,
103
101
  }
104
102
 
@@ -248,5 +246,3 @@ class BaseClient:
248
246
  headers={k.lower(): v for k, v in response.headers.items()},
249
247
  body=body,
250
248
  )
251
-
252
-
@@ -66,7 +66,7 @@ OPERATIONS: dict[str, dict[str, object]] = {
66
66
  "createProjectLocation": {
67
67
  "method": "POST",
68
68
  "path": "/v1/projects/{id}/locations",
69
- "request_fields": {"parent_id", "kind", "name"},
69
+ "request_fields": {"parent_id", "kind", "name", "unit_type"},
70
70
  "query_params": set(),
71
71
  "response_model": _models.Location,
72
72
  },
@@ -19,7 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  from collections.abc import Iterator, Mapping, Sequence
21
21
  from collections.abc import Set as AbstractSet
22
- from dataclasses import dataclass
22
+ from dataclasses import dataclass, field
23
23
  from typing import Any, Literal
24
24
 
25
25
  __all__ = [
@@ -186,7 +186,8 @@ class PartyJob(WorkwireObject):
186
186
  (the ancestors, root first; empty at the site root) are the server's
187
187
  description of where the Job sits in the GC's tree; ``your_reference``,
188
188
  ``quantity`` and ``checked_at`` are **your org's own** fields, never the
189
- other side's."""
189
+ other side's. ``location_unit_type`` is the location's ``unit_type`` — the
190
+ label a builder gives a unit's plan, ``None`` when unset."""
190
191
 
191
192
  _nested = {"location_path": JobPathLocation}
192
193
 
@@ -195,6 +196,7 @@ class PartyJob(WorkwireObject):
195
196
  location_id: str
196
197
  location_name: str
197
198
  location_kind: str
199
+ location_unit_type: str | None
198
200
  location_path: list[JobPathLocation]
199
201
  your_reference: str | None
200
202
  quantity: str | None
@@ -313,13 +315,16 @@ class PartnershipListItem(WorkwireObject):
313
315
 
314
316
  class Location(WorkwireObject):
315
317
  """One location of a project's tree. ``path`` is the ancestor ids, root
316
- first (empty on the root, whose ``parent_id`` is ``None``)."""
318
+ first (empty on the root, whose ``parent_id`` is ``None``). ``unit_type``
319
+ is the label a builder gives a unit's plan — ``None`` when unset; always
320
+ ``None`` on a split."""
317
321
 
318
322
  id: str
319
323
  project_id: str
320
324
  parent_id: str | None
321
325
  kind: str
322
326
  name: str
327
+ unit_type: str | None
323
328
  path: list[str]
324
329
  archived_at: str | None
325
330
  created_at: str
@@ -361,7 +366,9 @@ class JobMilestones(WorkwireObject):
361
366
  class JobRow(WorkwireObject):
362
367
  """One row of :meth:`Jobs.list`: the Job with its party, wire, project,
363
368
  location and counterparty context. ``your_reference``, ``quantity`` and
364
- ``checked_at`` are your org's own fields, never the other side's."""
369
+ ``checked_at`` are your org's own fields, never the other side's.
370
+ ``location_unit_type`` is the location's ``unit_type`` — the label a
371
+ builder gives a unit's plan, ``None`` when unset."""
365
372
 
366
373
  _nested = {"location_path": JobPathLocation, "milestones": JobMilestones}
367
374
 
@@ -380,6 +387,7 @@ class JobRow(WorkwireObject):
380
387
  location_id: str
381
388
  location_name: str
382
389
  location_kind: str
390
+ location_unit_type: str | None
383
391
  location_path: list[JobPathLocation]
384
392
  counterparty_org_id: str | None
385
393
  counterparty_name: str | None
@@ -453,13 +461,20 @@ class ColdInvite:
453
461
 
454
462
  @dataclass(frozen=True)
455
463
  class LocationNode:
456
- """One location of a tree being created — its ``kind``, its ``name``, and
457
- the ``children`` nested beneath it. Any nesting is allowed (a building
458
- may hold floors or units directly). At most 500 locations per request,
464
+ """One location of a tree being created — its ``kind``, its ``name``, its
465
+ optional ``unit_type`` (the label a builder gives a unit's plan, "C1.1
466
+ (2 Bedroom)"), and the ``children`` nested beneath it. A tree only
467
+ descends — each kind ranks strictly below its parent's, ``site`` ›
468
+ ``building``/``house`` › ``floor`` › ``unit``/``common_area`` › ``room``,
469
+ levels skippable but never repeated or reversed, else
470
+ ``InvalidRequestError`` code ``invalid_parent`` with
471
+ ``details["reason"] == "kind_rank"``. At most 500 locations per request,
459
472
  counted over every level."""
460
473
 
461
474
  kind: LocationKind
462
475
  name: str
476
+ # Keyword-only, so a positional third argument is still ``children``.
477
+ unit_type: str | None = field(default=None, kw_only=True)
463
478
  children: Sequence[LocationNode] | None = None
464
479
 
465
480
  def __post_init__(self) -> None:
@@ -475,6 +490,8 @@ class LocationNode:
475
490
 
476
491
  def to_payload(self) -> dict[str, Any]:
477
492
  payload: dict[str, Any] = {"kind": self.kind, "name": self.name}
493
+ if self.unit_type is not None: # absent, never null: the bytes stay as before
494
+ payload["unit_type"] = self.unit_type
478
495
  if self.children is not None:
479
496
  payload["children"] = [child.to_payload() for child in self.children]
480
497
  return payload
@@ -524,9 +541,7 @@ class Party:
524
541
 
525
542
  def __init__(self, payload: dict[str, Any], *, _guard: object = None) -> None:
526
543
  if _guard is not _PARTY_GUARD:
527
- raise TypeError(
528
- "Construct with Party.partnered(...) or Party.cold_invite(...)"
529
- )
544
+ raise TypeError("Construct with Party.partnered(...) or Party.cold_invite(...)")
530
545
  self._payload = payload
531
546
 
532
547
  @classmethod
@@ -43,11 +43,11 @@ def _quantity(value: str | Decimal | None) -> str | None:
43
43
  return format(value, "f")
44
44
  if isinstance(value, float):
45
45
  raise TypeError(
46
- "quantity must be a decimal string (\"12.5\") or a decimal.Decimal, "
46
+ 'quantity must be a decimal string ("12.5") or a decimal.Decimal, '
47
47
  "never a float: a binary float is not an exact quantity"
48
48
  )
49
49
  raise TypeError(
50
- "quantity must be a decimal string (\"12.5\") or a decimal.Decimal, "
50
+ 'quantity must be a decimal string ("12.5") or a decimal.Decimal, '
51
51
  f"not {type(value).__name__} — pass str(n) or Decimal(n)"
52
52
  )
53
53
 
@@ -3,7 +3,8 @@
3
3
 
4
4
  Every project has one ``site`` root, created with the project
5
5
  (``Project.root_location_id``), and beneath it houses, buildings, floors,
6
- units, rooms and common areas, nested any way. Locations have stable ids —
6
+ units, rooms and common areas, each kind ranked below its parent's (see
7
+ :class:`~workwire.LocationNode`). Locations have stable ids —
7
8
  nothing references one by name or path. Seed a tree when you create the
8
9
  project (``projects.create(locations=...)``) or add to it here; renames,
9
10
  moves and archives happen in the Workwire app.
@@ -76,6 +77,7 @@ class ProjectLocations:
76
77
  *,
77
78
  kind: LocationKind,
78
79
  name: str,
80
+ unit_type: str | None = None,
79
81
  parent_id: str | None = None,
80
82
  idempotency_key: str | None = None,
81
83
  timeout: float | None = None,
@@ -83,13 +85,22 @@ class ProjectLocations:
83
85
  """Add one location beneath ``parent_id`` — absent, the project's
84
86
  root. Requires ``projects:write``.
85
87
 
88
+ ``unit_type`` is the label a builder gives a unit's plan ("C1.1
89
+ (2 Bedroom)"); left ``None``, it is not sent.
90
+
86
91
  A parent that is not an unarchived location of this project's tree
87
- raises ``InvalidRequestError`` (code ``invalid_parent``); an unknown
88
- ``kind`` is checked with the body here — ``InvalidRequestError``
89
- code ``validation_error``, ``fields["kind"]``. An archived project's
90
- tree is read-only (``ConflictError``, code ``project_archived``).
92
+ raises ``InvalidRequestError`` (code ``invalid_parent``), and so does
93
+ a ``kind`` that does not rank below the parent's (``site`` ›
94
+ ``building``/``house`` › ``floor`` › ``unit``/``common_area`` ›
95
+ ``room``) — that refusal's ``details`` are ``{"reason": "kind_rank",
96
+ "child_kind": ..., "parent_kind": ...}``. An unknown ``kind`` is
97
+ checked with the body here — ``InvalidRequestError`` code
98
+ ``validation_error``, ``fields["kind"]``. An archived project's tree
99
+ is read-only (``ConflictError``, code ``project_archived``).
91
100
  """
92
101
  body: dict[str, Any] = {"kind": kind, "name": name}
102
+ if unit_type is not None:
103
+ body["unit_type"] = unit_type
93
104
  if parent_id is not None:
94
105
  body["parent_id"] = parent_id
95
106
  data, request_id = self._client._request(
@@ -117,7 +128,10 @@ class ProjectLocations:
117
128
  At most 500 locations, counted over every level: more raises
118
129
  ``InvalidRequestError`` (code ``too_many_locations``, with
119
130
  ``details["limit"]``), as does an unknown ``kind``
120
- (``invalid_location_kind``); a refused node creates nothing. Returns
131
+ (``invalid_location_kind``) and a node whose kind does not rank below
132
+ its own parent's (``invalid_parent``, with ``details`` ``{"reason":
133
+ "kind_rank", "child_kind": ..., "parent_kind": ...}``); a refused
134
+ node creates nothing. Returns
121
135
  a :class:`LocationBatch` — a plain list of the created locations,
122
136
  flat, every parent before its children, so you have every id; each
123
137
  carries the response's ``request_id``.
@@ -45,10 +45,13 @@ class Projects:
45
45
  (``root_location_id``). ``locations`` seeds a tree beneath the root
46
46
  in the same transaction — at most 500 locations counted over every
47
47
  level (``too_many_locations``), each of a known kind
48
- (``invalid_location_kind``); a refused node creates nothing, the
49
- project included. ``template`` (``development``, ``single_home``,
50
- ``renovation``) records the Project Type your tree follows in the
51
- project's history only — the server seeds nothing from it.
48
+ (``invalid_location_kind``) ranked below its parent's
49
+ (``invalid_parent``, with ``details`` ``{"reason": "kind_rank",
50
+ "child_kind": ..., "parent_kind": ...}`` — see :class:`LocationNode`);
51
+ a refused node creates nothing, the project included. ``template``
52
+ (``development``, ``single_home``, ``renovation``) records the Project
53
+ Type your tree follows in the project's history only — the server
54
+ seeds nothing from it.
52
55
  """
53
56
  body: dict[str, Any] = {"name": name}
54
57
  if address is not None:
@@ -46,8 +46,7 @@ class Wires:
46
46
  for party in parties:
47
47
  if not isinstance(party, Party):
48
48
  raise TypeError(
49
- "parties must be built with Party.partnered(...) or "
50
- "Party.cold_invite(...)"
49
+ "parties must be built with Party.partnered(...) or Party.cold_invite(...)"
51
50
  )
52
51
  body: dict[str, Any] = {
53
52
  "project_id": project_id,
@@ -70,9 +69,7 @@ class Wires:
70
69
  def retrieve(self, wire_id: str, *, timeout: float | None = None) -> Wire:
71
70
  """Fetch one wire with embedded parties, milestones, and POs.
72
71
  Requires ``wires:read``."""
73
- data, request_id = self._client._request(
74
- "GET", f"/v1/wires/{wire_id}", timeout=timeout
75
- )
72
+ data, request_id = self._client._request("GET", f"/v1/wires/{wire_id}", timeout=timeout)
76
73
  return Wire(data, request_id=request_id)
77
74
 
78
75
  def go_live(
File without changes
File without changes
File without changes
File without changes