workwire 0.3.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,19 @@
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
+
10
+ ## [0.4.0](https://github.com/workwire/workwire-python/compare/v0.3.0...v0.4.0) (2026-10-18)
11
+
12
+
13
+ ### Features
14
+
15
+ * **WOR-471:** party jobs carry location_name, location_kind and location_path (backend v1.54.0) ([#20](https://github.com/workwire/workwire-python/issues/20)) ([14f1c08](https://github.com/workwire/workwire-python/commit/14f1c083a600747521087a3b4b572d44c69934ef))
16
+
3
17
  ## [0.3.0](https://github.com/workwire/workwire-python/compare/v0.2.0...v0.3.0) (2026-10-16)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: workwire
3
- Version: 0.3.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,19 +107,48 @@ 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
  ```
122
135
 
136
+ A party Job — each of `client.wires.retrieve(...).parties[i].jobs`, and the
137
+ one `client.jobs.update` returns — carries `location_name`, `location_kind` and
138
+ `location_path` (the ancestors, root first), so a sub reads its scope without
139
+ the GC's tree; `client.jobs.list` rows had them already.
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
+
123
152
  Leave `scope_location_ids` out to scope a party to the whole project.
124
153
  `client.jobs.update` sends only the fields you pass: leave one out to keep
125
154
  it, pass `None` to clear it. Add to a tree later with
@@ -84,19 +84,48 @@ 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
  ```
99
112
 
113
+ A party Job — each of `client.wires.retrieve(...).parties[i].jobs`, and the
114
+ one `client.jobs.update` returns — carries `location_name`, `location_kind` and
115
+ `location_path` (the ancestors, root first), so a sub reads its scope without
116
+ the GC's tree; `client.jobs.list` rows had them already.
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
+
100
129
  Leave `scope_location_ids` out to scope a party to the whole project.
101
130
  `client.jobs.update` sends only the fields you pass: leave one out to keep
102
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.3.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__ = [
@@ -171,14 +171,33 @@ class PO(WorkwireObject):
171
171
  created_at: str
172
172
 
173
173
 
174
+ class JobPathLocation(WorkwireObject):
175
+ """One ancestor on a Job's ``location_path`` (a :class:`PartyJob`'s or a
176
+ :class:`JobRow`'s)."""
177
+
178
+ id: str
179
+ name: str
180
+ kind: str
181
+
182
+
174
183
  class PartyJob(WorkwireObject):
175
184
  """One Job on a wire party (``JOB-…``) — the party's work at one location
176
- of its scope. ``your_reference``, ``quantity`` and ``checked_at`` are
177
- **your org's own** fields, never the other side's."""
185
+ of its scope. ``location_name``, ``location_kind`` and ``location_path``
186
+ (the ancestors, root first; empty at the site root) are the server's
187
+ description of where the Job sits in the GC's tree; ``your_reference``,
188
+ ``quantity`` and ``checked_at`` are **your org's own** fields, never the
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."""
191
+
192
+ _nested = {"location_path": JobPathLocation}
178
193
 
179
194
  id: str
180
195
  display_id: str
181
196
  location_id: str
197
+ location_name: str
198
+ location_kind: str
199
+ location_unit_type: str | None
200
+ location_path: list[JobPathLocation]
182
201
  your_reference: str | None
183
202
  quantity: str | None
184
203
  checked_at: str | None
@@ -296,13 +315,16 @@ class PartnershipListItem(WorkwireObject):
296
315
 
297
316
  class Location(WorkwireObject):
298
317
  """One location of a project's tree. ``path`` is the ancestor ids, root
299
- 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."""
300
321
 
301
322
  id: str
302
323
  project_id: str
303
324
  parent_id: str | None
304
325
  kind: str
305
326
  name: str
327
+ unit_type: str | None
306
328
  path: list[str]
307
329
  archived_at: str | None
308
330
  created_at: str
@@ -334,14 +356,6 @@ class LocationBatch(list[Location]):
334
356
  return copy.deepcopy(self._data)
335
357
 
336
358
 
337
- class JobPathLocation(WorkwireObject):
338
- """One ancestor on a Job row's ``location_path``."""
339
-
340
- id: str
341
- name: str
342
- kind: str
343
-
344
-
345
359
  class JobMilestones(WorkwireObject):
346
360
  """A Job row's party milestone counts (repeated across the party's Jobs)."""
347
361
 
@@ -352,7 +366,9 @@ class JobMilestones(WorkwireObject):
352
366
  class JobRow(WorkwireObject):
353
367
  """One row of :meth:`Jobs.list`: the Job with its party, wire, project,
354
368
  location and counterparty context. ``your_reference``, ``quantity`` and
355
- ``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."""
356
372
 
357
373
  _nested = {"location_path": JobPathLocation, "milestones": JobMilestones}
358
374
 
@@ -371,6 +387,7 @@ class JobRow(WorkwireObject):
371
387
  location_id: str
372
388
  location_name: str
373
389
  location_kind: str
390
+ location_unit_type: str | None
374
391
  location_path: list[JobPathLocation]
375
392
  counterparty_org_id: str | None
376
393
  counterparty_name: str | None
@@ -444,13 +461,20 @@ class ColdInvite:
444
461
 
445
462
  @dataclass(frozen=True)
446
463
  class LocationNode:
447
- """One location of a tree being created — its ``kind``, its ``name``, and
448
- the ``children`` nested beneath it. Any nesting is allowed (a building
449
- 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,
450
472
  counted over every level."""
451
473
 
452
474
  kind: LocationKind
453
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)
454
478
  children: Sequence[LocationNode] | None = None
455
479
 
456
480
  def __post_init__(self) -> None:
@@ -466,6 +490,8 @@ class LocationNode:
466
490
 
467
491
  def to_payload(self) -> dict[str, Any]:
468
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
469
495
  if self.children is not None:
470
496
  payload["children"] = [child.to_payload() for child in self.children]
471
497
  return payload
@@ -515,9 +541,7 @@ class Party:
515
541
 
516
542
  def __init__(self, payload: dict[str, Any], *, _guard: object = None) -> None:
517
543
  if _guard is not _PARTY_GUARD:
518
- raise TypeError(
519
- "Construct with Party.partnered(...) or Party.cold_invite(...)"
520
- )
544
+ raise TypeError("Construct with Party.partnered(...) or Party.cold_invite(...)")
521
545
  self._payload = payload
522
546
 
523
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