workwire 0.1.0__tar.gz → 0.3.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.
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ ## [0.3.0](https://github.com/workwire/workwire-python/compare/v0.2.0...v0.3.0) (2026-10-16)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+ ## [0.2.0](https://github.com/workwire/workwire-python/compare/v0.1.0...v0.2.0) (2026-09-04)
11
+
12
+
13
+ ### Features
14
+
15
+ * honor WORKWIRE_BASE_URL alongside the base_url argument ([#8](https://github.com/workwire/workwire-python/issues/8)) ([41f8982](https://github.com/workwire/workwire-python/commit/41f898267d1352a7353869819eb6bdf59f35ec21))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * note the SDK pins no API version header — none exists, by design ([#11](https://github.com/workwire/workwire-python/issues/11)) ([420756e](https://github.com/workwire/workwire-python/commit/420756ea94d55ee19ffede18edd7eaccec8d5d95))
21
+
22
+ ## 0.1.0 (2026-08-31)
23
+
24
+
25
+ ### Features
26
+
27
+ * retry the 409 idempotency_in_flight after backoff (amended ruling) ([04008a0](https://github.com/workwire/workwire-python/commit/04008a0728d1f5f3d177534898d93960570ddea8))
28
+ * v1 SDK — projects, wires, partnerships on the served contract ([#1](https://github.com/workwire/workwire-python/pull/1))
29
+
30
+
31
+ ### Bug Fixes
32
+
33
+ * apply CodeRabbit review round 1 (6/6 accepted) ([4120c8c](https://github.com/workwire/workwire-python/commit/4120c8cde13833821a516e664735d75717e47e81))
34
+ * derive __version__ from installed metadata — pyproject is the only version literal ([#3](https://github.com/workwire/workwire-python/pull/3))
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Workwire, Inc.
3
+ Copyright (c) 2026 Workwire Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,36 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: workwire
3
- Version: 0.1.0
3
+ Version: 0.3.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,35 @@ 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
+ Leave `scope_location_ids` out to scope a party to the whole project.
124
+ `client.jobs.update` sends only the fields you pass: leave one out to keep
125
+ it, pass `None` to clear it. Add to a tree later with
126
+ `client.projects.locations.create(...)` or `.create_batch(...)`.
127
+
120
128
  ## Authentication
121
129
 
122
130
  An org admin issues an API key in the Workwire dashboard under **Settings →
@@ -176,8 +184,9 @@ idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
176
184
 
177
185
  ## Retries and idempotency
178
186
 
179
- Every `POST` automatically carries an `Idempotency-Key` (a fresh UUID, or
180
- pass `idempotency_key=` to control replay across your own process restarts).
187
+ Every `POST` and `PATCH` automatically carries an `Idempotency-Key` (a fresh
188
+ UUID, or pass `idempotency_key=` to control replay across your own process
189
+ restarts).
181
190
  That makes retries safe, so the SDK retries for you: connection errors,
182
191
  timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
183
192
  retryable 409 — `idempotency_in_flight`, where the API asks you to retry
@@ -202,6 +211,9 @@ for wire in client.wires.list(state="live").auto_paging_iter():
202
211
  ...
203
212
  ```
204
213
 
214
+ A list's other envelope fields ride on the page: `client.jobs.list()`
215
+ returns a `JobPage` with `.quantity_unit`.
216
+
205
217
  ## Responses
206
218
 
207
219
  Responses are typed objects with attribute access, `.to_dict()`, and
@@ -224,6 +236,10 @@ The SDK follows semver; the API is versioned in the URL (`/v1/`) and
224
236
  additive changes ship without breaking. See
225
237
  [CHANGELOG.md](CHANGELOG.md).
226
238
 
239
+ **Release history note:** the `0.0.1a1` release on PyPI was a yanked
240
+ name-reservation placeholder, not an SDK release. Real release history
241
+ starts at `0.1.0`.
242
+
227
243
  **Pre-1.0 note:** while the version is below 1.0, minor releases (`0.x` →
228
244
  `0.y`) may include breaking changes to the SDK's Python surface — pin
229
245
  `workwire>=0.1,<0.2` (or an exact version) in anything you deploy. The
@@ -73,6 +73,35 @@ 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
+ Leave `scope_location_ids` out to scope a party to the whole project.
101
+ `client.jobs.update` sends only the fields you pass: leave one out to keep
102
+ it, pass `None` to clear it. Add to a tree later with
103
+ `client.projects.locations.create(...)` or `.create_batch(...)`.
104
+
76
105
  ## Authentication
77
106
 
78
107
  An org admin issues an API key in the Workwire dashboard under **Settings →
@@ -132,8 +161,9 @@ idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
132
161
 
133
162
  ## Retries and idempotency
134
163
 
135
- Every `POST` automatically carries an `Idempotency-Key` (a fresh UUID, or
136
- pass `idempotency_key=` to control replay across your own process restarts).
164
+ Every `POST` and `PATCH` automatically carries an `Idempotency-Key` (a fresh
165
+ UUID, or pass `idempotency_key=` to control replay across your own process
166
+ restarts).
137
167
  That makes retries safe, so the SDK retries for you: connection errors,
138
168
  timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
139
169
  retryable 409 — `idempotency_in_flight`, where the API asks you to retry
@@ -158,6 +188,9 @@ for wire in client.wires.list(state="live").auto_paging_iter():
158
188
  ...
159
189
  ```
160
190
 
191
+ A list's other envelope fields ride on the page: `client.jobs.list()`
192
+ returns a `JobPage` with `.quantity_unit`.
193
+
161
194
  ## Responses
162
195
 
163
196
  Responses are typed objects with attribute access, `.to_dict()`, and
@@ -180,6 +213,10 @@ The SDK follows semver; the API is versioned in the URL (`/v1/`) and
180
213
  additive changes ship without breaking. See
181
214
  [CHANGELOG.md](CHANGELOG.md).
182
215
 
216
+ **Release history note:** the `0.0.1a1` release on PyPI was a yanked
217
+ name-reservation placeholder, not an SDK release. Real release history
218
+ starts at `0.1.0`.
219
+
183
220
  **Pre-1.0 note:** while the version is below 1.0, minor releases (`0.x` →
184
221
  `0.y`) may include breaking changes to the SDK's Python surface — pin
185
222
  `workwire>=0.1,<0.2` (or an exact version) in anything you deploy. The
@@ -4,17 +4,17 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "workwire"
7
- version = "0.1.0"
7
+ version = "0.3.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,23 @@ class PO(WorkwireObject):
150
171
  created_at: str
151
172
 
152
173
 
174
+ class PartyJob(WorkwireObject):
175
+ """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."""
178
+
179
+ id: str
180
+ display_id: str
181
+ location_id: str
182
+ your_reference: str | None
183
+ quantity: str | None
184
+ checked_at: str | None
185
+ archived_at: str | None
186
+ created_at: str
187
+
188
+
153
189
  class WireParty(WorkwireObject):
154
- _nested = {"milestones": Milestone, "pos": PO}
190
+ _nested = {"milestones": Milestone, "pos": PO, "jobs": PartyJob}
155
191
 
156
192
  id: str
157
193
  wire_id: str
@@ -177,6 +213,8 @@ class WireParty(WorkwireObject):
177
213
  milestones: list[Milestone] | None
178
214
  pos: list[PO] | None
179
215
  amendment_count: int | None
216
+ scope_locations: list[str] | None
217
+ jobs: list[PartyJob] | None
180
218
 
181
219
 
182
220
  class Project(WorkwireObject):
@@ -187,6 +225,7 @@ class Project(WorkwireObject):
187
225
  project_code: str | None
188
226
  status: str
189
227
  external_metadata: dict[str, Any] | None
228
+ root_location_id: str | None
190
229
  created_at: str
191
230
  updated_at: str
192
231
 
@@ -255,10 +294,116 @@ class PartnershipListItem(WorkwireObject):
255
294
  attested_at: str | None
256
295
 
257
296
 
297
+ class Location(WorkwireObject):
298
+ """One location of a project's tree. ``path`` is the ancestor ids, root
299
+ first (empty on the root, whose ``parent_id`` is ``None``)."""
300
+
301
+ id: str
302
+ project_id: str
303
+ parent_id: str | None
304
+ kind: str
305
+ name: str
306
+ path: list[str]
307
+ archived_at: str | None
308
+ created_at: str
309
+ updated_at: str
310
+
311
+
312
+ class LocationBatch(list[Location]):
313
+ """What ``projects.locations.create_batch`` returns: a plain ``list`` of
314
+ the created :class:`Location` objects, flat, every parent before its
315
+ children. The served envelope stays reachable — ``request_id`` and
316
+ ``to_dict()`` are the response's — so a field the API adds beside
317
+ ``locations`` is kept, not dropped."""
318
+
319
+ def __init__(self, data: Mapping[str, Any], *, request_id: str | None = None) -> None:
320
+ super().__init__(
321
+ Location(item, request_id=request_id) if isinstance(item, Mapping) else item
322
+ for item in data.get("locations") or []
323
+ )
324
+ self._data = dict(data)
325
+ self._request_id = request_id
326
+
327
+ @property
328
+ def request_id(self) -> str | None:
329
+ return self._request_id
330
+
331
+ def to_dict(self) -> dict[str, Any]:
332
+ import copy
333
+
334
+ return copy.deepcopy(self._data)
335
+
336
+
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
+ class JobMilestones(WorkwireObject):
346
+ """A Job row's party milestone counts (repeated across the party's Jobs)."""
347
+
348
+ confirmed: int
349
+ total: int
350
+
351
+
352
+ class JobRow(WorkwireObject):
353
+ """One row of :meth:`Jobs.list`: the Job with its party, wire, project,
354
+ location and counterparty context. ``your_reference``, ``quantity`` and
355
+ ``checked_at`` are your org's own fields, never the other side's."""
356
+
357
+ _nested = {"location_path": JobPathLocation, "milestones": JobMilestones}
358
+
359
+ id: str
360
+ display_id: str
361
+ your_reference: str | None
362
+ quantity: str | None
363
+ checked_at: str | None
364
+ wire_party_id: str
365
+ party_state: str
366
+ wire_id: str
367
+ wire_display_id: str
368
+ wire_state: str
369
+ project_id: str
370
+ project_name: str
371
+ location_id: str
372
+ location_name: str
373
+ location_kind: str
374
+ location_path: list[JobPathLocation]
375
+ counterparty_org_id: str | None
376
+ counterparty_name: str | None
377
+ milestones: JobMilestones
378
+ archived_at: str | None
379
+ created_at: str
380
+
381
+
258
382
  # --------------------------------------------------------------------------
259
383
  # Request-side typed constructors
260
384
  # --------------------------------------------------------------------------
261
385
 
386
+ # The served enums, as typing aids — the server stays the referee (an unknown
387
+ # kind is ``422 invalid_location_kind``). ``site`` is the root's alone,
388
+ # created with the project, so it is not a kind you can send.
389
+ LocationKind = Literal["house", "building", "floor", "unit", "room", "common_area"]
390
+ ProjectTemplate = Literal["development", "single_home", "renovation"]
391
+
392
+
393
+ class NotGiven:
394
+ """The type of :data:`NOT_GIVEN`: *this argument was not passed* — kept
395
+ apart from an explicit ``None``, which the API reads as *clear the
396
+ field*. Falsy, so ``if value:`` reads it as unset."""
397
+
398
+ def __bool__(self) -> bool:
399
+ return False
400
+
401
+ def __repr__(self) -> str:
402
+ return "NOT_GIVEN"
403
+
404
+
405
+ NOT_GIVEN = NotGiven()
406
+
262
407
 
263
408
  @dataclass(frozen=True)
264
409
  class MilestoneSpec:
@@ -297,6 +442,57 @@ class ColdInvite:
297
442
  return payload
298
443
 
299
444
 
445
+ @dataclass(frozen=True)
446
+ 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,
450
+ counted over every level."""
451
+
452
+ kind: LocationKind
453
+ name: str
454
+ children: Sequence[LocationNode] | None = None
455
+
456
+ def __post_init__(self) -> None:
457
+ if self.children is None:
458
+ return
459
+ if isinstance(self.children, LocationNode):
460
+ raise TypeError("children must be a list of workwire.LocationNode")
461
+ children = tuple(_ordered(self.children, "children")) # frozen all the way down
462
+ for child in children:
463
+ if not isinstance(child, LocationNode):
464
+ raise TypeError("children must be workwire.LocationNode objects")
465
+ object.__setattr__(self, "children", children)
466
+
467
+ def to_payload(self) -> dict[str, Any]:
468
+ payload: dict[str, Any] = {"kind": self.kind, "name": self.name}
469
+ if self.children is not None:
470
+ payload["children"] = [child.to_payload() for child in self.children]
471
+ return payload
472
+
473
+
474
+ def _ordered(items: Any, what: str) -> list[Any]:
475
+ # Read exactly once (an iterator must not be spent by a check). A str is
476
+ # a sequence of characters; a set or mapping has no order that holds
477
+ # across processes, so the same call would serialize to different bytes —
478
+ # a different idempotency fingerprint on a replay.
479
+ if isinstance(items, (str, bytes, AbstractSet, Mapping)):
480
+ raise TypeError(f"{what} must be a list or tuple, not {type(items).__name__}")
481
+ return list(items)
482
+
483
+
484
+ def _location_payloads(locations: Sequence[LocationNode]) -> list[dict[str, Any]]:
485
+ nodes = _ordered(locations, "locations")
486
+ for node in nodes:
487
+ if not isinstance(node, LocationNode):
488
+ raise TypeError("locations must be built with workwire.LocationNode(...)")
489
+ return [node.to_payload() for node in nodes]
490
+
491
+
492
+ def _scope_location_ids(ids: Sequence[str]) -> list[str]:
493
+ return _ordered(ids, "scope_location_ids")
494
+
495
+
300
496
  class Party:
301
497
  """One party on a wire being composed.
302
498
 
@@ -309,6 +505,12 @@ class Party:
309
505
 
310
506
  ``contract_value`` is a decimal string, e.g. ``"125000.00"``, visible
311
507
  only to the two sides of this party.
508
+
509
+ ``scope_location_ids`` names the locations of the project's tree this
510
+ party's work covers — the party gets one Job per location. Leave it
511
+ ``None`` (not sent) for the whole project. An empty list is sent as
512
+ given, never dropped — dropping it would widen the scope to the whole
513
+ project — and the server refuses it (``scope_empty``).
312
514
  """
313
515
 
314
516
  def __init__(self, payload: dict[str, Any], *, _guard: object = None) -> None:
@@ -327,6 +529,7 @@ class Party:
327
529
  scope_text: str,
328
530
  contract_value: str,
329
531
  milestones: Sequence[MilestoneSpec] | None = None,
532
+ scope_location_ids: Sequence[str] | None = None,
330
533
  ) -> Party:
331
534
  payload: dict[str, Any] = {
332
535
  "partnership_id": partnership_id,
@@ -336,6 +539,8 @@ class Party:
336
539
  }
337
540
  if milestones:
338
541
  payload["milestones"] = [m.to_payload() for m in milestones]
542
+ if scope_location_ids is not None:
543
+ payload["scope_location_ids"] = _scope_location_ids(scope_location_ids)
339
544
  return cls(payload, _guard=_PARTY_GUARD)
340
545
 
341
546
  @classmethod
@@ -348,6 +553,7 @@ class Party:
348
553
  scope_text: str,
349
554
  contract_value: str,
350
555
  milestones: Sequence[MilestoneSpec] | None = None,
556
+ scope_location_ids: Sequence[str] | None = None,
351
557
  ) -> Party:
352
558
  payload: dict[str, Any] = {
353
559
  "cold_invite": {
@@ -360,6 +566,8 @@ class Party:
360
566
  }
361
567
  if milestones:
362
568
  payload["milestones"] = [m.to_payload() for m in milestones]
569
+ if scope_location_ids is not None:
570
+ payload["scope_location_ids"] = _scope_location_ids(scope_location_ids)
363
571
  return cls(payload, _guard=_PARTY_GUARD)
364
572
 
365
573
  def to_payload(self) -> dict[str, Any]:
@@ -375,12 +583,14 @@ class Page(WorkwireObject):
375
583
  """One page of a cursor-paginated list.
376
584
 
377
585
  ``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.
586
+ ``projects``, ``wires``, ``partnerships``, ``locations``, ``jobs``);
587
+ ``next_cursor`` is the served cursor (``None`` at the end); ``has_more``
588
+ derives from it. Iteration ends **only** on a null cursor — a short page
589
+ with a live cursor is a legal middle page.
590
+
591
+ Any other envelope field rides on the page as an attribute, like every
592
+ served field; a subclass such as :class:`JobPage` declares the ones its
593
+ list serves. ``auto_paging_iter()`` walks the whole list transparently.
384
594
  """
385
595
 
386
596
  def __init__(
@@ -419,3 +629,11 @@ class Page(WorkwireObject):
419
629
  if page._fetch_next is None:
420
630
  raise RuntimeError("this page cannot fetch further pages")
421
631
  page = page._fetch_next(page.next_cursor)
632
+
633
+
634
+ class JobPage(Page):
635
+ """A page of :meth:`Jobs.list` — a :class:`Page` whose envelope also
636
+ carries ``quantity_unit``: your org's one unit label for every
637
+ ``quantity`` (``"sheets"``), set in the app; ``None`` until it is."""
638
+
639
+ 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,15 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.0 (2026-08-31)
4
-
5
-
6
- ### Features
7
-
8
- * retry the 409 idempotency_in_flight after backoff (amended ruling) ([04008a0](https://github.com/workwire/workwire-python/commit/04008a0728d1f5f3d177534898d93960570ddea8))
9
- * v1 SDK — projects, wires, partnerships on the served contract ([#1](https://github.com/workwire/workwire-python/pull/1))
10
-
11
-
12
- ### Bug Fixes
13
-
14
- * apply CodeRabbit review round 1 (6/6 accepted) ([4120c8c](https://github.com/workwire/workwire-python/commit/4120c8cde13833821a516e664735d75717e47e81))
15
- * derive __version__ from installed metadata — pyproject is the only version literal ([#3](https://github.com/workwire/workwire-python/pull/3))
@@ -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