workwire 0.2.0__tar.gz → 0.4.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.4.0](https://github.com/workwire/workwire-python/compare/v0.3.0...v0.4.0) (2026-10-18)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+ ## [0.3.0](https://github.com/workwire/workwire-python/compare/v0.2.0...v0.3.0) (2026-10-16)
11
+
12
+
13
+ ### Features
14
+
15
+ * **WOR-446:** wrap the v1.53.0 surface — project locations, party scope, Jobs lens and org-local fields ([#17](https://github.com/workwire/workwire-python/issues/17)) ([1072284](https://github.com/workwire/workwire-python/commit/107228458633e5f79266684055d3405d6c69dc91))
16
+
3
17
  ## [0.2.0](https://github.com/workwire/workwire-python/compare/v0.1.0...v0.2.0) (2026-09-04)
4
18
 
5
19
 
@@ -1,36 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: workwire
3
- Version: 0.2.0
3
+ Version: 0.4.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
7
7
  Project-URL: Issues, https://github.com/workwire/workwire-python/issues
8
8
  Author-email: Workwire <support@workwire.com>
9
- License: MIT License
10
-
11
- Copyright (c) 2026 Workwire Inc.
12
-
13
- Permission is hereby granted, free of charge, to any person obtaining a copy
14
- of this software and associated documentation files (the "Software"), to deal
15
- in the Software without restriction, including without limitation the rights
16
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
- copies of the Software, and to permit persons to whom the Software is
18
- furnished to do so, subject to the following conditions:
19
-
20
- The above copyright notice and this permission notice shall be included in all
21
- copies or substantial portions of the Software.
22
-
23
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
- SOFTWARE.
9
+ License-Expression: MIT
30
10
  License-File: LICENSE
31
11
  Classifier: Development Status :: 3 - Alpha
32
12
  Classifier: Intended Audience :: Developers
33
- Classifier: License :: OSI Approved :: MIT License
34
13
  Classifier: Operating System :: OS Independent
35
14
  Classifier: Programming Language :: Python :: 3
36
15
  Classifier: Programming Language :: Python :: 3.10
@@ -117,6 +96,40 @@ The quickstart is runnable as a script:
117
96
  drafts the wire above against a partnership you name, and only sends it
118
97
  with `--go-live`.
119
98
 
99
+ ## Locations and Jobs
100
+
101
+ Every project has a location tree under one `site` root
102
+ (`project.root_location_id`). A party's scope is the locations its work
103
+ covers, and it gets one Job (`JOB-…`) per scope location. A Job's
104
+ `your_reference`, `quantity` and `checked_at` are your org's own, never
105
+ shown to the other side.
106
+
107
+ ```python
108
+ from workwire import LocationNode, Party
109
+
110
+ project = client.projects.create(name="Elm St. Townhomes", template="development", locations=[
111
+ LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
112
+ ])
113
+ tree = client.projects.locations.list(project.id).data # the site root first
114
+ 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
+ page = client.jobs.list(project=project.id) # page.quantity_unit is your org's unit
120
+ client.jobs.update(page.data[0].id, your_reference="PO-4471-A", quantity="12.5", checked=True)
121
+ ```
122
+
123
+ A party Job — each of `client.wires.retrieve(...).parties[i].jobs`, and the
124
+ one `client.jobs.update` returns — carries `location_name`, `location_kind` and
125
+ `location_path` (the ancestors, root first), so a sub reads its scope without
126
+ the GC's tree; `client.jobs.list` rows had them already.
127
+
128
+ Leave `scope_location_ids` out to scope a party to the whole project.
129
+ `client.jobs.update` sends only the fields you pass: leave one out to keep
130
+ it, pass `None` to clear it. Add to a tree later with
131
+ `client.projects.locations.create(...)` or `.create_batch(...)`.
132
+
120
133
  ## Authentication
121
134
 
122
135
  An org admin issues an API key in the Workwire dashboard under **Settings →
@@ -176,8 +189,9 @@ idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
176
189
 
177
190
  ## Retries and idempotency
178
191
 
179
- Every `POST` automatically carries an `Idempotency-Key` (a fresh UUID, or
180
- pass `idempotency_key=` to control replay across your own process restarts).
192
+ Every `POST` and `PATCH` automatically carries an `Idempotency-Key` (a fresh
193
+ UUID, or pass `idempotency_key=` to control replay across your own process
194
+ restarts).
181
195
  That makes retries safe, so the SDK retries for you: connection errors,
182
196
  timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
183
197
  retryable 409 — `idempotency_in_flight`, where the API asks you to retry
@@ -202,6 +216,9 @@ for wire in client.wires.list(state="live").auto_paging_iter():
202
216
  ...
203
217
  ```
204
218
 
219
+ A list's other envelope fields ride on the page: `client.jobs.list()`
220
+ returns a `JobPage` with `.quantity_unit`.
221
+
205
222
  ## Responses
206
223
 
207
224
  Responses are typed objects with attribute access, `.to_dict()`, and
@@ -73,6 +73,40 @@ The quickstart is runnable as a script:
73
73
  drafts the wire above against a partnership you name, and only sends it
74
74
  with `--go-live`.
75
75
 
76
+ ## Locations and Jobs
77
+
78
+ Every project has a location tree under one `site` root
79
+ (`project.root_location_id`). A party's scope is the locations its work
80
+ covers, and it gets one Job (`JOB-…`) per scope location. A Job's
81
+ `your_reference`, `quantity` and `checked_at` are your org's own, never
82
+ shown to the other side.
83
+
84
+ ```python
85
+ from workwire import LocationNode, Party
86
+
87
+ project = client.projects.create(name="Elm St. Townhomes", template="development", locations=[
88
+ LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
89
+ ])
90
+ tree = client.projects.locations.list(project.id).data # the site root first
91
+ 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
+ page = client.jobs.list(project=project.id) # page.quantity_unit is your org's unit
97
+ client.jobs.update(page.data[0].id, your_reference="PO-4471-A", quantity="12.5", checked=True)
98
+ ```
99
+
100
+ A party Job — each of `client.wires.retrieve(...).parties[i].jobs`, and the
101
+ one `client.jobs.update` returns — carries `location_name`, `location_kind` and
102
+ `location_path` (the ancestors, root first), so a sub reads its scope without
103
+ the GC's tree; `client.jobs.list` rows had them already.
104
+
105
+ Leave `scope_location_ids` out to scope a party to the whole project.
106
+ `client.jobs.update` sends only the fields you pass: leave one out to keep
107
+ it, pass `None` to clear it. Add to a tree later with
108
+ `client.projects.locations.create(...)` or `.create_batch(...)`.
109
+
76
110
  ## Authentication
77
111
 
78
112
  An org admin issues an API key in the Workwire dashboard under **Settings →
@@ -132,8 +166,9 @@ idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
132
166
 
133
167
  ## Retries and idempotency
134
168
 
135
- Every `POST` automatically carries an `Idempotency-Key` (a fresh UUID, or
136
- pass `idempotency_key=` to control replay across your own process restarts).
169
+ Every `POST` and `PATCH` automatically carries an `Idempotency-Key` (a fresh
170
+ UUID, or pass `idempotency_key=` to control replay across your own process
171
+ restarts).
137
172
  That makes retries safe, so the SDK retries for you: connection errors,
138
173
  timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
139
174
  retryable 409 — `idempotency_in_flight`, where the API asks you to retry
@@ -158,6 +193,9 @@ for wire in client.wires.list(state="live").auto_paging_iter():
158
193
  ...
159
194
  ```
160
195
 
196
+ A list's other envelope fields ride on the page: `client.jobs.list()`
197
+ returns a `JobPage` with `.quantity_unit`.
198
+
161
199
  ## Responses
162
200
 
163
201
  Responses are typed objects with attribute access, `.to_dict()`, and
@@ -4,17 +4,17 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "workwire"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "Official Python SDK for the Workwire API"
9
9
  readme = "README.md"
10
- license = { file = "LICENSE" }
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
11
12
  authors = [{ name = "Workwire", email = "support@workwire.com" }]
12
13
  requires-python = ">=3.10"
13
14
  dependencies = ["httpx>=0.24,<1"]
14
15
  classifiers = [
15
16
  "Development Status :: 3 - Alpha",
16
17
  "Intended Audience :: Developers",
17
- "License :: OSI Approved :: MIT License",
18
18
  "Operating System :: OS Independent",
19
19
  "Programming Language :: Python :: 3",
20
20
  "Programming Language :: Python :: 3.10",
@@ -40,23 +40,35 @@ from ._client import (
40
40
  BaseClient,
41
41
  )
42
42
  from ._models import (
43
+ NOT_GIVEN,
43
44
  PO,
44
45
  ColdInvite,
46
+ JobMilestones,
47
+ JobPage,
48
+ JobPathLocation,
49
+ JobRow,
50
+ Location,
51
+ LocationBatch,
52
+ LocationKind,
53
+ LocationNode,
45
54
  Milestone,
46
55
  MilestoneSpec,
56
+ NotGiven,
47
57
  Page,
48
58
  Partnership,
49
59
  PartnershipDetail,
50
60
  PartnershipListItem,
51
61
  Party,
62
+ PartyJob,
52
63
  Project,
64
+ ProjectTemplate,
53
65
  Wire,
54
66
  WireListItem,
55
67
  WireParty,
56
68
  WorkwireObject,
57
69
  )
58
70
  from ._version import __version__
59
- from .resources import Partnerships, Projects, Wires
71
+ from .resources import Jobs, Partnerships, Projects, Wires
60
72
 
61
73
  __all__ = [
62
74
  "Workwire",
@@ -66,18 +78,30 @@ __all__ = [
66
78
  "Party",
67
79
  "MilestoneSpec",
68
80
  "ColdInvite",
81
+ "LocationNode",
82
+ "LocationKind",
83
+ "ProjectTemplate",
84
+ "NOT_GIVEN",
85
+ "NotGiven",
69
86
  # response types
70
87
  "WorkwireObject",
71
88
  "Page",
89
+ "JobPage",
72
90
  "Project",
91
+ "Location",
92
+ "LocationBatch",
73
93
  "Wire",
74
94
  "WireListItem",
75
95
  "WireParty",
96
+ "PartyJob",
76
97
  "Milestone",
77
98
  "PO",
78
99
  "Partnership",
79
100
  "PartnershipDetail",
80
101
  "PartnershipListItem",
102
+ "JobRow",
103
+ "JobPathLocation",
104
+ "JobMilestones",
81
105
  ]
82
106
 
83
107
 
@@ -112,6 +136,7 @@ class Workwire(BaseClient):
112
136
  self.projects = Projects(self)
113
137
  self.wires = Wires(self)
114
138
  self.partnerships = Partnerships(self)
139
+ self.jobs = Jobs(self)
115
140
 
116
141
  def request(
117
142
  self,
@@ -2,9 +2,9 @@
2
2
 
3
3
  The transport invariants live here, and the test suite holds them:
4
4
 
5
- - Every POST carries an ``Idempotency-Key`` — caller-supplied or a generated
6
- UUIDv4 — and every retry of that POST resends the **same key and the same
7
- serialized bytes**. The server's replay fingerprint is a raw-byte hash of
5
+ - Every POST and PATCH carries an ``Idempotency-Key`` — caller-supplied or a
6
+ generated UUIDv4 — and every retry of that request resends the **same key
7
+ and the same serialized bytes**. The server's replay fingerprint is a raw-byte hash of
8
8
  the body, so the body is serialized exactly once per logical request and
9
9
  the bytes are reused verbatim across attempts.
10
10
  - Retries: connection errors, timeouts, 429 (honoring ``Retry-After``),
@@ -46,6 +46,10 @@ _RETRY_AFTER_CAP = 30.0
46
46
 
47
47
  _RETRYABLE_STATUSES = frozenset({429}) # plus every 5xx, handled by range
48
48
 
49
+ # The methods the served idempotency layer guards ("Every POST and PATCH
50
+ # accepts an optional Idempotency-Key header").
51
+ _KEYED_METHODS = frozenset({"POST", "PATCH"})
52
+
49
53
 
50
54
  class BaseClient:
51
55
  """HTTP plumbing shared by every resource. Not public API — customers
@@ -150,7 +154,7 @@ class BaseClient:
150
154
  content = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
151
155
  headers["Content-Type"] = "application/json"
152
156
 
153
- if method == "POST":
157
+ if method in _KEYED_METHODS:
154
158
  headers["Idempotency-Key"] = idempotency_key or str(uuid.uuid4())
155
159
 
156
160
  attempt = 0
@@ -19,14 +19,24 @@ from . import _models
19
19
  # operationId → coverage entry.
20
20
  # request_fields: top-level request-body properties the SDK can send.
21
21
  # query_params: query parameters the SDK can send.
22
- # response_schema → the model whose annotations mirror it (for list
23
- # operations: the page envelope fields are items_key + next_cursor and the
24
- # item schema maps to item_model).
22
+ # response_model: the model the operation's response schema maps to.
23
+ # For paginated lists: the envelope is items_key + next_cursor, plus any
24
+ # extra field page_model annotates (default Page, which annotates none), and
25
+ # the item schema maps to list_item_model. For a one-shot list envelope
26
+ # (no cursor), envelope_key names its one field and envelope_item_model the
27
+ # item schema's model.
25
28
  OPERATIONS: dict[str, dict[str, object]] = {
26
29
  "createProject": {
27
30
  "method": "POST",
28
31
  "path": "/v1/projects",
29
- "request_fields": {"name", "address", "project_code", "external_metadata"},
32
+ "request_fields": {
33
+ "name",
34
+ "address",
35
+ "project_code",
36
+ "external_metadata",
37
+ "template",
38
+ "locations",
39
+ },
30
40
  "query_params": set(),
31
41
  "response_model": _models.Project,
32
42
  },
@@ -45,6 +55,29 @@ OPERATIONS: dict[str, dict[str, object]] = {
45
55
  "query_params": set(),
46
56
  "response_model": _models.Project,
47
57
  },
58
+ "listProjectLocations": {
59
+ "method": "GET",
60
+ "path": "/v1/projects/{id}/locations",
61
+ "request_fields": set(),
62
+ "query_params": {"cursor", "limit"},
63
+ "list_item_model": _models.Location,
64
+ "items_key": "locations",
65
+ },
66
+ "createProjectLocation": {
67
+ "method": "POST",
68
+ "path": "/v1/projects/{id}/locations",
69
+ "request_fields": {"parent_id", "kind", "name"},
70
+ "query_params": set(),
71
+ "response_model": _models.Location,
72
+ },
73
+ "createProjectLocationsBatch": {
74
+ "method": "POST",
75
+ "path": "/v1/projects/{id}/locations/batch",
76
+ "request_fields": {"parent_id", "locations"},
77
+ "query_params": set(),
78
+ "envelope_item_model": _models.Location,
79
+ "envelope_key": "locations",
80
+ },
48
81
  "createWire": {
49
82
  "method": "POST",
50
83
  "path": "/v1/wires",
@@ -102,6 +135,22 @@ OPERATIONS: dict[str, dict[str, object]] = {
102
135
  "query_params": set(),
103
136
  "response_model": _models.PartnershipDetail,
104
137
  },
138
+ "listJobs": {
139
+ "method": "GET",
140
+ "path": "/v1/jobs",
141
+ "request_fields": set(),
142
+ "query_params": {"status", "project", "counterparty", "checked", "cursor", "limit"},
143
+ "list_item_model": _models.JobRow,
144
+ "items_key": "jobs",
145
+ "page_model": _models.JobPage,
146
+ },
147
+ "updateJob": {
148
+ "method": "PATCH",
149
+ "path": "/v1/jobs/{id}",
150
+ "request_fields": {"your_reference", "quantity", "checked"},
151
+ "query_params": set(),
152
+ "response_model": _models.PartyJob,
153
+ },
105
154
  }
106
155
 
107
156
  # Component schema → the SDK type whose fields mirror it. Request-side types
@@ -109,8 +158,11 @@ OPERATIONS: dict[str, dict[str, object]] = {
109
158
  REQUEST_SCHEMA_TYPES: dict[str, type] = {
110
159
  "ComposeMilestone": _models.MilestoneSpec,
111
160
  "ColdInvitePartnership": _models.ColdInvite,
161
+ "LocationNode": _models.LocationNode,
112
162
  }
113
163
 
164
+ # A dotted key names an inline object schema: "JobRow.milestones" is the
165
+ # object JobRow's milestones property declares in place.
114
166
  RESPONSE_SCHEMA_MODELS: dict[str, type[_models.WorkwireObject]] = {
115
167
  "Project": _models.Project,
116
168
  "Wire": _models.Wire,
@@ -121,6 +173,11 @@ RESPONSE_SCHEMA_MODELS: dict[str, type[_models.WorkwireObject]] = {
121
173
  "Partnership": _models.Partnership,
122
174
  "PartnershipDetail": _models.PartnershipDetail,
123
175
  "PartnershipListItem": _models.PartnershipListItem,
176
+ "Location": _models.Location,
177
+ "PartyJob": _models.PartyJob,
178
+ "JobPathLocation": _models.JobPathLocation,
179
+ "JobRow": _models.JobRow,
180
+ "JobRow.milestones": _models.JobMilestones,
124
181
  }
125
182
 
126
183
  # ComposeParty is special-cased in the drift check: its fields are split
@@ -133,4 +190,5 @@ COMPOSE_PARTY_FIELDS = {
133
190
  "scope_text",
134
191
  "contract_value",
135
192
  "milestones",
193
+ "scope_location_ids",
136
194
  }
@@ -8,16 +8,19 @@ annotations on each subclass mirror the served response schema field-for-field
8
8
  they are documentation and typing aids, not a filter.
9
9
 
10
10
  Request-side constructors (:class:`Party`, :class:`MilestoneSpec`,
11
- :class:`ColdInvite`) make the composer contract structural: required fields
12
- are required arguments, and the partnered-XOR-cold-invite choice is which
13
- constructor you called.
11
+ :class:`ColdInvite`, :class:`LocationNode`) make the composer contract
12
+ structural: required fields are required arguments, the partnered-XOR-cold-
13
+ invite choice is which constructor you called, and a location tree's nesting
14
+ is the nesting of the objects you built. :data:`NOT_GIVEN` marks an argument
15
+ as *not passed* where the API tells absent apart from ``null``.
14
16
  """
15
17
 
16
18
  from __future__ import annotations
17
19
 
18
20
  from collections.abc import Iterator, Mapping, Sequence
21
+ from collections.abc import Set as AbstractSet
19
22
  from dataclasses import dataclass
20
- from typing import Any
23
+ from typing import Any, Literal
21
24
 
22
25
  __all__ = [
23
26
  "WorkwireObject",
@@ -30,9 +33,22 @@ __all__ = [
30
33
  "Partnership",
31
34
  "PartnershipDetail",
32
35
  "PartnershipListItem",
36
+ "Location",
37
+ "LocationBatch",
38
+ "PartyJob",
39
+ "JobPathLocation",
40
+ "JobMilestones",
41
+ "JobRow",
42
+ "Page",
43
+ "JobPage",
33
44
  "Party",
34
45
  "MilestoneSpec",
35
46
  "ColdInvite",
47
+ "LocationNode",
48
+ "LocationKind",
49
+ "ProjectTemplate",
50
+ "NotGiven",
51
+ "NOT_GIVEN",
36
52
  ]
37
53
 
38
54
 
@@ -51,7 +67,8 @@ class WorkwireObject:
51
67
  served ``null``.
52
68
  """
53
69
 
54
- # Nested field name → WorkwireObject subclass to wrap list items with.
70
+ # Nested field name → WorkwireObject subclass to wrap the field's object
71
+ # (or, for a list, each of its items) with.
55
72
  _nested: Mapping[str, type[WorkwireObject]] = {}
56
73
 
57
74
  def __init__(self, data: Mapping[str, Any], *, request_id: str | None = None) -> None:
@@ -93,8 +110,12 @@ class WorkwireObject:
93
110
 
94
111
  def _wrap(self, name: str, value: Any) -> Any:
95
112
  cls = self._nested.get(name)
96
- if cls is not None and isinstance(value, list):
113
+ if cls is None:
114
+ return value
115
+ if isinstance(value, list):
97
116
  return [cls(item) if isinstance(item, Mapping) else item for item in value]
117
+ if isinstance(value, Mapping):
118
+ return cls(value)
98
119
  return value
99
120
 
100
121
  def __setattr__(self, name: str, value: Any) -> None:
@@ -150,8 +171,40 @@ class PO(WorkwireObject):
150
171
  created_at: str
151
172
 
152
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
+
183
+ class PartyJob(WorkwireObject):
184
+ """One Job on a wire party (``JOB-…``) — the party's work at one location
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."""
190
+
191
+ _nested = {"location_path": JobPathLocation}
192
+
193
+ id: str
194
+ display_id: str
195
+ location_id: str
196
+ location_name: str
197
+ location_kind: str
198
+ location_path: list[JobPathLocation]
199
+ your_reference: str | None
200
+ quantity: str | None
201
+ checked_at: str | None
202
+ archived_at: str | None
203
+ created_at: str
204
+
205
+
153
206
  class WireParty(WorkwireObject):
154
- _nested = {"milestones": Milestone, "pos": PO}
207
+ _nested = {"milestones": Milestone, "pos": PO, "jobs": PartyJob}
155
208
 
156
209
  id: str
157
210
  wire_id: str
@@ -177,6 +230,8 @@ class WireParty(WorkwireObject):
177
230
  milestones: list[Milestone] | None
178
231
  pos: list[PO] | None
179
232
  amendment_count: int | None
233
+ scope_locations: list[str] | None
234
+ jobs: list[PartyJob] | None
180
235
 
181
236
 
182
237
  class Project(WorkwireObject):
@@ -187,6 +242,7 @@ class Project(WorkwireObject):
187
242
  project_code: str | None
188
243
  status: str
189
244
  external_metadata: dict[str, Any] | None
245
+ root_location_id: str | None
190
246
  created_at: str
191
247
  updated_at: str
192
248
 
@@ -255,10 +311,108 @@ class PartnershipListItem(WorkwireObject):
255
311
  attested_at: str | None
256
312
 
257
313
 
314
+ class Location(WorkwireObject):
315
+ """One location of a project's tree. ``path`` is the ancestor ids, root
316
+ first (empty on the root, whose ``parent_id`` is ``None``)."""
317
+
318
+ id: str
319
+ project_id: str
320
+ parent_id: str | None
321
+ kind: str
322
+ name: str
323
+ path: list[str]
324
+ archived_at: str | None
325
+ created_at: str
326
+ updated_at: str
327
+
328
+
329
+ class LocationBatch(list[Location]):
330
+ """What ``projects.locations.create_batch`` returns: a plain ``list`` of
331
+ the created :class:`Location` objects, flat, every parent before its
332
+ children. The served envelope stays reachable — ``request_id`` and
333
+ ``to_dict()`` are the response's — so a field the API adds beside
334
+ ``locations`` is kept, not dropped."""
335
+
336
+ def __init__(self, data: Mapping[str, Any], *, request_id: str | None = None) -> None:
337
+ super().__init__(
338
+ Location(item, request_id=request_id) if isinstance(item, Mapping) else item
339
+ for item in data.get("locations") or []
340
+ )
341
+ self._data = dict(data)
342
+ self._request_id = request_id
343
+
344
+ @property
345
+ def request_id(self) -> str | None:
346
+ return self._request_id
347
+
348
+ def to_dict(self) -> dict[str, Any]:
349
+ import copy
350
+
351
+ return copy.deepcopy(self._data)
352
+
353
+
354
+ class JobMilestones(WorkwireObject):
355
+ """A Job row's party milestone counts (repeated across the party's Jobs)."""
356
+
357
+ confirmed: int
358
+ total: int
359
+
360
+
361
+ class JobRow(WorkwireObject):
362
+ """One row of :meth:`Jobs.list`: the Job with its party, wire, project,
363
+ location and counterparty context. ``your_reference``, ``quantity`` and
364
+ ``checked_at`` are your org's own fields, never the other side's."""
365
+
366
+ _nested = {"location_path": JobPathLocation, "milestones": JobMilestones}
367
+
368
+ id: str
369
+ display_id: str
370
+ your_reference: str | None
371
+ quantity: str | None
372
+ checked_at: str | None
373
+ wire_party_id: str
374
+ party_state: str
375
+ wire_id: str
376
+ wire_display_id: str
377
+ wire_state: str
378
+ project_id: str
379
+ project_name: str
380
+ location_id: str
381
+ location_name: str
382
+ location_kind: str
383
+ location_path: list[JobPathLocation]
384
+ counterparty_org_id: str | None
385
+ counterparty_name: str | None
386
+ milestones: JobMilestones
387
+ archived_at: str | None
388
+ created_at: str
389
+
390
+
258
391
  # --------------------------------------------------------------------------
259
392
  # Request-side typed constructors
260
393
  # --------------------------------------------------------------------------
261
394
 
395
+ # The served enums, as typing aids — the server stays the referee (an unknown
396
+ # kind is ``422 invalid_location_kind``). ``site`` is the root's alone,
397
+ # created with the project, so it is not a kind you can send.
398
+ LocationKind = Literal["house", "building", "floor", "unit", "room", "common_area"]
399
+ ProjectTemplate = Literal["development", "single_home", "renovation"]
400
+
401
+
402
+ class NotGiven:
403
+ """The type of :data:`NOT_GIVEN`: *this argument was not passed* — kept
404
+ apart from an explicit ``None``, which the API reads as *clear the
405
+ field*. Falsy, so ``if value:`` reads it as unset."""
406
+
407
+ def __bool__(self) -> bool:
408
+ return False
409
+
410
+ def __repr__(self) -> str:
411
+ return "NOT_GIVEN"
412
+
413
+
414
+ NOT_GIVEN = NotGiven()
415
+
262
416
 
263
417
  @dataclass(frozen=True)
264
418
  class MilestoneSpec:
@@ -297,6 +451,57 @@ class ColdInvite:
297
451
  return payload
298
452
 
299
453
 
454
+ @dataclass(frozen=True)
455
+ 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,
459
+ counted over every level."""
460
+
461
+ kind: LocationKind
462
+ name: str
463
+ children: Sequence[LocationNode] | None = None
464
+
465
+ def __post_init__(self) -> None:
466
+ if self.children is None:
467
+ return
468
+ if isinstance(self.children, LocationNode):
469
+ raise TypeError("children must be a list of workwire.LocationNode")
470
+ children = tuple(_ordered(self.children, "children")) # frozen all the way down
471
+ for child in children:
472
+ if not isinstance(child, LocationNode):
473
+ raise TypeError("children must be workwire.LocationNode objects")
474
+ object.__setattr__(self, "children", children)
475
+
476
+ def to_payload(self) -> dict[str, Any]:
477
+ payload: dict[str, Any] = {"kind": self.kind, "name": self.name}
478
+ if self.children is not None:
479
+ payload["children"] = [child.to_payload() for child in self.children]
480
+ return payload
481
+
482
+
483
+ def _ordered(items: Any, what: str) -> list[Any]:
484
+ # Read exactly once (an iterator must not be spent by a check). A str is
485
+ # a sequence of characters; a set or mapping has no order that holds
486
+ # across processes, so the same call would serialize to different bytes —
487
+ # a different idempotency fingerprint on a replay.
488
+ if isinstance(items, (str, bytes, AbstractSet, Mapping)):
489
+ raise TypeError(f"{what} must be a list or tuple, not {type(items).__name__}")
490
+ return list(items)
491
+
492
+
493
+ def _location_payloads(locations: Sequence[LocationNode]) -> list[dict[str, Any]]:
494
+ nodes = _ordered(locations, "locations")
495
+ for node in nodes:
496
+ if not isinstance(node, LocationNode):
497
+ raise TypeError("locations must be built with workwire.LocationNode(...)")
498
+ return [node.to_payload() for node in nodes]
499
+
500
+
501
+ def _scope_location_ids(ids: Sequence[str]) -> list[str]:
502
+ return _ordered(ids, "scope_location_ids")
503
+
504
+
300
505
  class Party:
301
506
  """One party on a wire being composed.
302
507
 
@@ -309,6 +514,12 @@ class Party:
309
514
 
310
515
  ``contract_value`` is a decimal string, e.g. ``"125000.00"``, visible
311
516
  only to the two sides of this party.
517
+
518
+ ``scope_location_ids`` names the locations of the project's tree this
519
+ party's work covers — the party gets one Job per location. Leave it
520
+ ``None`` (not sent) for the whole project. An empty list is sent as
521
+ given, never dropped — dropping it would widen the scope to the whole
522
+ project — and the server refuses it (``scope_empty``).
312
523
  """
313
524
 
314
525
  def __init__(self, payload: dict[str, Any], *, _guard: object = None) -> None:
@@ -327,6 +538,7 @@ class Party:
327
538
  scope_text: str,
328
539
  contract_value: str,
329
540
  milestones: Sequence[MilestoneSpec] | None = None,
541
+ scope_location_ids: Sequence[str] | None = None,
330
542
  ) -> Party:
331
543
  payload: dict[str, Any] = {
332
544
  "partnership_id": partnership_id,
@@ -336,6 +548,8 @@ class Party:
336
548
  }
337
549
  if milestones:
338
550
  payload["milestones"] = [m.to_payload() for m in milestones]
551
+ if scope_location_ids is not None:
552
+ payload["scope_location_ids"] = _scope_location_ids(scope_location_ids)
339
553
  return cls(payload, _guard=_PARTY_GUARD)
340
554
 
341
555
  @classmethod
@@ -348,6 +562,7 @@ class Party:
348
562
  scope_text: str,
349
563
  contract_value: str,
350
564
  milestones: Sequence[MilestoneSpec] | None = None,
565
+ scope_location_ids: Sequence[str] | None = None,
351
566
  ) -> Party:
352
567
  payload: dict[str, Any] = {
353
568
  "cold_invite": {
@@ -360,6 +575,8 @@ class Party:
360
575
  }
361
576
  if milestones:
362
577
  payload["milestones"] = [m.to_payload() for m in milestones]
578
+ if scope_location_ids is not None:
579
+ payload["scope_location_ids"] = _scope_location_ids(scope_location_ids)
363
580
  return cls(payload, _guard=_PARTY_GUARD)
364
581
 
365
582
  def to_payload(self) -> dict[str, Any]:
@@ -375,12 +592,14 @@ class Page(WorkwireObject):
375
592
  """One page of a cursor-paginated list.
376
593
 
377
594
  ``data`` is the page's items as typed objects (whatever the served key —
378
- ``projects``, ``wires``, ``partnerships``); ``next_cursor`` is the served
379
- cursor (``None`` at the end); ``has_more`` derives from it. Iteration
380
- ends **only** on a null cursor — a short page with a live cursor is a
381
- legal middle page.
382
-
383
- ``auto_paging_iter()`` walks the whole list transparently.
595
+ ``projects``, ``wires``, ``partnerships``, ``locations``, ``jobs``);
596
+ ``next_cursor`` is the served cursor (``None`` at the end); ``has_more``
597
+ derives from it. Iteration ends **only** on a null cursor — a short page
598
+ with a live cursor is a legal middle page.
599
+
600
+ Any other envelope field rides on the page as an attribute, like every
601
+ served field; a subclass such as :class:`JobPage` declares the ones its
602
+ list serves. ``auto_paging_iter()`` walks the whole list transparently.
384
603
  """
385
604
 
386
605
  def __init__(
@@ -419,3 +638,11 @@ class Page(WorkwireObject):
419
638
  if page._fetch_next is None:
420
639
  raise RuntimeError("this page cannot fetch further pages")
421
640
  page = page._fetch_next(page.next_cursor)
641
+
642
+
643
+ class JobPage(Page):
644
+ """A page of :meth:`Jobs.list` — a :class:`Page` whose envelope also
645
+ carries ``quantity_unit``: your org's one unit label for every
646
+ ``quantity`` (``"sheets"``), set in the app; ``None`` until it is."""
647
+
648
+ quantity_unit: str | None
@@ -0,0 +1,7 @@
1
+ from .jobs import Jobs
2
+ from .locations import ProjectLocations
3
+ from .partnerships import Partnerships
4
+ from .projects import Projects
5
+ from .wires import Wires
6
+
7
+ __all__ = ["Projects", "ProjectLocations", "Wires", "Partnerships", "Jobs"]
@@ -0,0 +1,171 @@
1
+ """Jobs — one Wire party's work at one location of its scope (``JOB-…``,
2
+ numbered per project).
3
+
4
+ Jobs are derived: a party gets one per scope location
5
+ (``scope_location_ids`` on ``Party.partnered(...)`` /
6
+ ``Party.cold_invite(...)``; absent, the whole project), and nothing creates,
7
+ names or closes one. Each org keeps its own private fields on a Job —
8
+ ``your_reference``, ``quantity`` (in the org's ``quantity_unit``) and
9
+ ``checked_at`` — never shown to the other side.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from decimal import Decimal
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from .._models import NOT_GIVEN, JobPage, JobRow, NotGiven, PartyJob
18
+
19
+ if TYPE_CHECKING:
20
+ from .._client import BaseClient
21
+
22
+
23
+ # The served quantity grammar is at most 32 digits either side of the point.
24
+ _QUANTITY_DIGITS = 32
25
+
26
+
27
+ def _quantity(value: str | Decimal | None) -> str | None:
28
+ if value is None or isinstance(value, str):
29
+ return value
30
+ if isinstance(value, Decimal):
31
+ if not value.is_finite():
32
+ raise ValueError(f"quantity must be a finite decimal, got {value}")
33
+ exponent = value.as_tuple().exponent
34
+ assert isinstance(exponent, int) # finite
35
+ if value.adjusted() >= _QUANTITY_DIGITS or exponent < -_QUANTITY_DIGITS:
36
+ # Far outside anything the API takes: fixed-point would expand it
37
+ # to as many digits as its exponent says. Send it compact and let
38
+ # the server refuse it.
39
+ return str(value)
40
+ # Fixed-point: identical to str() for an ordinary Decimal, but never
41
+ # exponent notation (str(Decimal("1E+2")) is "1E+2", which the API
42
+ # refuses).
43
+ return format(value, "f")
44
+ if isinstance(value, float):
45
+ raise TypeError(
46
+ "quantity must be a decimal string (\"12.5\") or a decimal.Decimal, "
47
+ "never a float: a binary float is not an exact quantity"
48
+ )
49
+ raise TypeError(
50
+ "quantity must be a decimal string (\"12.5\") or a decimal.Decimal, "
51
+ f"not {type(value).__name__} — pass str(n) or Decimal(n)"
52
+ )
53
+
54
+
55
+ class Jobs:
56
+ def __init__(self, client: BaseClient) -> None:
57
+ self._client = client
58
+
59
+ def list(
60
+ self,
61
+ *,
62
+ status: str | None = None,
63
+ project: str | None = None,
64
+ counterparty: str | None = None,
65
+ checked: bool | None = None,
66
+ cursor: str | None = None,
67
+ limit: int | None = None,
68
+ timeout: float | None = None,
69
+ ) -> JobPage:
70
+ """List every Job your org is a side of — on a Wire your org created,
71
+ or on your org's party once that Wire is sent (a draft's Jobs stay
72
+ hidden from the sub) — newest first. Requires ``wire_parties:read``.
73
+
74
+ ``status`` is ``open`` (default: not archived, and the party has not
75
+ ended), ``ended``, or ``all``; ``project`` and ``counterparty`` are
76
+ ids; ``checked`` keeps only the Jobs your org has (``True``) or has
77
+ not (``False``) checked. The filters AND together. ``limit``
78
+ defaults to 50, max 250 (clamped by the server). Each row carries
79
+ your org's own fields, and the page's ``quantity_unit`` is your
80
+ org's unit for every ``quantity`` on it.
81
+
82
+ The app's per-user ``pinned`` filter is not offered: pins belong to
83
+ a user, an API key has none, and the server refuses ``?pinned``
84
+ (``InvalidRequestError``).
85
+ """
86
+ params: dict[str, Any] = {}
87
+ if status is not None:
88
+ params["status"] = status
89
+ if project is not None:
90
+ params["project"] = project
91
+ if counterparty is not None:
92
+ params["counterparty"] = counterparty
93
+ if checked is not None:
94
+ if not isinstance(checked, bool):
95
+ raise TypeError("checked must be True, False, or None")
96
+ params["checked"] = "true" if checked else "false"
97
+ if cursor is not None:
98
+ params["cursor"] = cursor
99
+ if limit is not None:
100
+ params["limit"] = limit
101
+ data, request_id = self._client._request(
102
+ "GET", "/v1/jobs", params=params, timeout=timeout
103
+ )
104
+ return JobPage(
105
+ data,
106
+ request_id=request_id,
107
+ items_key="jobs",
108
+ item_cls=JobRow,
109
+ fetch_next=lambda next_cursor: self.list(
110
+ status=status,
111
+ project=project,
112
+ counterparty=counterparty,
113
+ checked=checked,
114
+ cursor=next_cursor,
115
+ limit=limit,
116
+ timeout=timeout,
117
+ ),
118
+ )
119
+
120
+ def update(
121
+ self,
122
+ job_id: str,
123
+ *,
124
+ your_reference: str | None | NotGiven = NOT_GIVEN,
125
+ quantity: str | Decimal | None | NotGiven = NOT_GIVEN,
126
+ checked: bool | None | NotGiven = NOT_GIVEN,
127
+ idempotency_key: str | None = None,
128
+ timeout: float | None = None,
129
+ ) -> PartyJob:
130
+ """Set your org's own fields on a Job. Requires
131
+ ``wire_parties:write``.
132
+
133
+ **Presence is the contract**: an argument you leave out is not sent
134
+ and the server keeps that field; an explicit ``None`` is sent as
135
+ ``null`` and clears it. At least one is required.
136
+
137
+ - ``your_reference`` — your own reference (≤200 characters).
138
+ - ``quantity`` — a decimal string (``"12.5"``) or a
139
+ ``decimal.Decimal``, in your org's ``quantity_unit``; at most 2
140
+ decimal places. Never a float.
141
+ - ``checked`` — ``True`` records your org's inspection now (again on
142
+ every ``True``); ``False`` or ``None`` clears it.
143
+
144
+ Never visible to the other side, and never the other side's. A Job
145
+ your org is not a side of — a draft's Job, for the sub — is a
146
+ ``NotFoundError``. Returns the Job as your org now sees it.
147
+ """
148
+ body: dict[str, Any] = {}
149
+ if not isinstance(your_reference, NotGiven):
150
+ if your_reference is not None and not isinstance(your_reference, str):
151
+ raise TypeError("your_reference must be a str or None")
152
+ body["your_reference"] = your_reference
153
+ if not isinstance(quantity, NotGiven):
154
+ body["quantity"] = _quantity(quantity)
155
+ if not isinstance(checked, NotGiven):
156
+ if checked is not None and not isinstance(checked, bool):
157
+ raise TypeError("checked must be True, False, or None")
158
+ body["checked"] = checked
159
+ if not body:
160
+ raise ValueError(
161
+ "pass at least one of your_reference, quantity, checked "
162
+ "(None clears a field; leaving one out keeps it)"
163
+ )
164
+ data, request_id = self._client._request(
165
+ "PATCH",
166
+ f"/v1/jobs/{job_id}",
167
+ json_body=body,
168
+ idempotency_key=idempotency_key,
169
+ timeout=timeout,
170
+ )
171
+ return PartyJob(data, request_id=request_id)
@@ -0,0 +1,138 @@
1
+ """Project locations — a project's location tree, reached as
2
+ ``client.projects.locations``.
3
+
4
+ Every project has one ``site`` root, created with the project
5
+ (``Project.root_location_id``), and beneath it houses, buildings, floors,
6
+ units, rooms and common areas, nested any way. Locations have stable ids —
7
+ nothing references one by name or path. Seed a tree when you create the
8
+ project (``projects.create(locations=...)``) or add to it here; renames,
9
+ moves and archives happen in the Workwire app.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from .._models import (
18
+ Location,
19
+ LocationBatch,
20
+ LocationKind,
21
+ LocationNode,
22
+ Page,
23
+ _location_payloads,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from .._client import BaseClient
28
+
29
+
30
+ class ProjectLocations:
31
+ def __init__(self, client: BaseClient) -> None:
32
+ self._client = client
33
+
34
+ def list(
35
+ self,
36
+ project_id: str,
37
+ *,
38
+ cursor: str | None = None,
39
+ limit: int | None = None,
40
+ timeout: float | None = None,
41
+ ) -> Page:
42
+ """Read a project's location tree. Requires ``projects:read``.
43
+
44
+ Tree order is ``path``, then ``id``: the root first, every location
45
+ after its parent, siblings in creation order — each parent's
46
+ children together, not depth-first. Archived locations are not
47
+ listed. ``limit`` defaults to and maxes at 1,000 (clamped by the
48
+ server), and a full page always carries ``next_cursor``, so a
49
+ smaller tree is one page; a larger tree's later pages are each in
50
+ tree order on their own — sort every page's rows together by
51
+ ``path``, then ``id``, for the whole tree's order
52
+ (``auto_paging_iter()`` reads them all). Another org's project is a
53
+ ``NotFoundError``.
54
+ """
55
+ params: dict[str, Any] = {}
56
+ if cursor is not None:
57
+ params["cursor"] = cursor
58
+ if limit is not None:
59
+ params["limit"] = limit
60
+ data, request_id = self._client._request(
61
+ "GET", f"/v1/projects/{project_id}/locations", params=params, timeout=timeout
62
+ )
63
+ return Page(
64
+ data,
65
+ request_id=request_id,
66
+ items_key="locations",
67
+ item_cls=Location,
68
+ fetch_next=lambda next_cursor: self.list(
69
+ project_id, cursor=next_cursor, limit=limit, timeout=timeout
70
+ ),
71
+ )
72
+
73
+ def create(
74
+ self,
75
+ project_id: str,
76
+ *,
77
+ kind: LocationKind,
78
+ name: str,
79
+ parent_id: str | None = None,
80
+ idempotency_key: str | None = None,
81
+ timeout: float | None = None,
82
+ ) -> Location:
83
+ """Add one location beneath ``parent_id`` — absent, the project's
84
+ root. Requires ``projects:write``.
85
+
86
+ 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``).
91
+ """
92
+ body: dict[str, Any] = {"kind": kind, "name": name}
93
+ if parent_id is not None:
94
+ body["parent_id"] = parent_id
95
+ data, request_id = self._client._request(
96
+ "POST",
97
+ f"/v1/projects/{project_id}/locations",
98
+ json_body=body,
99
+ idempotency_key=idempotency_key,
100
+ timeout=timeout,
101
+ )
102
+ return Location(data, request_id=request_id)
103
+
104
+ def create_batch(
105
+ self,
106
+ project_id: str,
107
+ *,
108
+ locations: Sequence[LocationNode],
109
+ parent_id: str | None = None,
110
+ idempotency_key: str | None = None,
111
+ timeout: float | None = None,
112
+ ) -> LocationBatch:
113
+ """Create a nested list of locations beneath ``parent_id`` — absent,
114
+ the project's root — in one transaction. Requires
115
+ ``projects:write``.
116
+
117
+ At most 500 locations, counted over every level: more raises
118
+ ``InvalidRequestError`` (code ``too_many_locations``, with
119
+ ``details["limit"]``), as does an unknown ``kind``
120
+ (``invalid_location_kind``); a refused node creates nothing. Returns
121
+ a :class:`LocationBatch` — a plain list of the created locations,
122
+ flat, every parent before its children, so you have every id; each
123
+ carries the response's ``request_id``.
124
+ """
125
+ payloads = _location_payloads(locations)
126
+ if not payloads:
127
+ raise ValueError("a batch needs at least one location")
128
+ body: dict[str, Any] = {"locations": payloads}
129
+ if parent_id is not None:
130
+ body["parent_id"] = parent_id
131
+ data, request_id = self._client._request(
132
+ "POST",
133
+ f"/v1/projects/{project_id}/locations/batch",
134
+ json_body=body,
135
+ idempotency_key=idempotency_key,
136
+ timeout=timeout,
137
+ )
138
+ return LocationBatch(data or {}, request_id=request_id)
@@ -7,10 +7,11 @@ signatures keyword-only and mirroring the spec's schemas. The drift gate
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from collections.abc import Mapping
10
+ from collections.abc import Mapping, Sequence
11
11
  from typing import TYPE_CHECKING, Any
12
12
 
13
- from .._models import Page, Project
13
+ from .._models import LocationNode, Page, Project, ProjectTemplate, _location_payloads
14
+ from .locations import ProjectLocations
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from .._client import BaseClient
@@ -19,6 +20,7 @@ if TYPE_CHECKING:
19
20
  class Projects:
20
21
  def __init__(self, client: BaseClient) -> None:
21
22
  self._client = client
23
+ self.locations = ProjectLocations(client)
22
24
 
23
25
  def create(
24
26
  self,
@@ -27,6 +29,8 @@ class Projects:
27
29
  address: str | None = None,
28
30
  project_code: str | None = None,
29
31
  external_metadata: Mapping[str, Any] | None = None,
32
+ template: ProjectTemplate | None = None,
33
+ locations: Sequence[LocationNode] | None = None,
30
34
  idempotency_key: str | None = None,
31
35
  timeout: float | None = None,
32
36
  ) -> Project:
@@ -36,6 +40,15 @@ class Projects:
36
40
  ``external_metadata`` (≤16 KB) round-trips verbatim — carry your own
37
41
  system's identifiers in it (keys beginning ``_workwire:`` are
38
42
  reserved).
43
+
44
+ The project is created with its location tree's ``site`` root
45
+ (``root_location_id``). ``locations`` seeds a tree beneath the root
46
+ in the same transaction — at most 500 locations counted over every
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.
39
52
  """
40
53
  body: dict[str, Any] = {"name": name}
41
54
  if address is not None:
@@ -44,6 +57,10 @@ class Projects:
44
57
  body["project_code"] = project_code
45
58
  if external_metadata is not None:
46
59
  body["external_metadata"] = dict(external_metadata)
60
+ if template is not None:
61
+ body["template"] = template
62
+ if locations is not None:
63
+ body["locations"] = _location_payloads(locations)
47
64
  data, request_id = self._client._request(
48
65
  "POST",
49
66
  "/v1/projects",
@@ -1,5 +0,0 @@
1
- from .partnerships import Partnerships
2
- from .projects import Projects
3
- from .wires import Wires
4
-
5
- __all__ = ["Projects", "Wires", "Partnerships"]
File without changes
File without changes
File without changes
File without changes