workwire 0.0.1a1__tar.gz → 0.1.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.
- workwire-0.1.0/CHANGELOG.md +15 -0
- workwire-0.1.0/LICENSE +21 -0
- workwire-0.1.0/PKG-INFO +232 -0
- workwire-0.1.0/README.md +188 -0
- workwire-0.1.0/SECURITY.md +23 -0
- workwire-0.1.0/pyproject.toml +67 -0
- workwire-0.1.0/src/workwire/__init__.py +139 -0
- workwire-0.1.0/src/workwire/_client.py +248 -0
- workwire-0.1.0/src/workwire/_coverage.py +136 -0
- workwire-0.1.0/src/workwire/_models.py +421 -0
- workwire-0.1.0/src/workwire/_version.py +14 -0
- workwire-0.1.0/src/workwire/errors.py +200 -0
- workwire-0.1.0/src/workwire/py.typed +0 -0
- workwire-0.1.0/src/workwire/resources/__init__.py +5 -0
- workwire-0.1.0/src/workwire/resources/partnerships.py +103 -0
- workwire-0.1.0/src/workwire/resources/projects.py +95 -0
- workwire-0.1.0/src/workwire/resources/wires.py +140 -0
- workwire-0.0.1a1/PKG-INFO +0 -22
- workwire-0.0.1a1/README.md +0 -8
- workwire-0.0.1a1/pyproject.toml +0 -37
- workwire-0.0.1a1/src/workwire/__init__.py +0 -22
- {workwire-0.0.1a1 → workwire-0.1.0}/.gitignore +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
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))
|
workwire-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Workwire, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
workwire-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: workwire
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Workwire API
|
|
5
|
+
Project-URL: Homepage, https://github.com/workwire/workwire-python
|
|
6
|
+
Project-URL: Changelog, https://github.com/workwire/workwire-python/blob/main/CHANGELOG.md
|
|
7
|
+
Project-URL: Issues, https://github.com/workwire/workwire-python/issues
|
|
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.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Typing :: Typed
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Requires-Dist: httpx<1,>=0.24
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# Workwire Python SDK
|
|
46
|
+
|
|
47
|
+
The official Python library for the [Workwire](https://workwire.com) API.
|
|
48
|
+
Create projects, compose and send Wires, and initiate partnerships from your
|
|
49
|
+
own systems — no HTTP calls to write, no idempotency keys to invent, no error
|
|
50
|
+
envelopes to parse, no 429s to handle.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install workwire
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Until the first stable PyPI release you can also install straight from
|
|
59
|
+
GitHub:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install git+https://github.com/workwire/workwire-python.git
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Python 3.10+. The only runtime dependency is `httpx`.
|
|
66
|
+
|
|
67
|
+
## Quickstart
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import workwire
|
|
71
|
+
|
|
72
|
+
client = workwire.Workwire() # reads WORKWIRE_API_KEY from the environment
|
|
73
|
+
|
|
74
|
+
project = client.projects.create(name="Elm St. Townhomes")
|
|
75
|
+
|
|
76
|
+
# The partnership with your sub — create one (see below) or pick an
|
|
77
|
+
# established one from client.partnerships.list().
|
|
78
|
+
partnership = client.partnerships.create(sub_org_id="...")
|
|
79
|
+
|
|
80
|
+
wire = client.wires.create(
|
|
81
|
+
project_id=project.id,
|
|
82
|
+
title="Drywall — Bldg A",
|
|
83
|
+
parties=[
|
|
84
|
+
workwire.Party.partnered(
|
|
85
|
+
partnership_id=partnership.id,
|
|
86
|
+
trade="Drywall",
|
|
87
|
+
scope_text="Hang, tape, and finish all interior drywall.",
|
|
88
|
+
contract_value="125000.00",
|
|
89
|
+
),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
client.wires.go_live(wire.id) # emails any cold-invited subs
|
|
94
|
+
|
|
95
|
+
for p in client.partnerships.list().auto_paging_iter():
|
|
96
|
+
print(p.id, p.status)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A party can also cold-invite a sub who isn't on Workwire yet — the
|
|
100
|
+
invitation is minted and emailed at `go_live`:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
workwire.Party.cold_invite(
|
|
104
|
+
recipient_email="estimator@acmedrywall.com",
|
|
105
|
+
recipient_org_name="Acme Drywall",
|
|
106
|
+
trade="Drywall",
|
|
107
|
+
scope_text="North wing.",
|
|
108
|
+
contract_value="80000.00",
|
|
109
|
+
)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The counterparty's acceptance always happens between people in the Workwire
|
|
113
|
+
app — there is deliberately no API call for the other side of the handshake.
|
|
114
|
+
|
|
115
|
+
The quickstart is runnable as a script:
|
|
116
|
+
[`examples/quickstart.py`](https://github.com/workwire/workwire-python/blob/main/examples/quickstart.py)
|
|
117
|
+
drafts the wire above against a partnership you name, and only sends it
|
|
118
|
+
with `--go-live`.
|
|
119
|
+
|
|
120
|
+
## Authentication
|
|
121
|
+
|
|
122
|
+
An org admin issues an API key in the Workwire dashboard under **Settings →
|
|
123
|
+
API keys**; the secret (`wwk_live_…`) is shown exactly once. Put it in your
|
|
124
|
+
secret manager and export it:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
export WORKWIRE_API_KEY="wwk_live_..."
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`workwire.Workwire()` reads that variable, or takes `api_key=` explicitly.
|
|
131
|
+
The SDK never loads `.env` files itself — if you use one, load it with your
|
|
132
|
+
framework or `python-dotenv` before constructing the client. The client's
|
|
133
|
+
`repr()` and the SDK's errors never contain the key.
|
|
134
|
+
|
|
135
|
+
The base URL works the same way: an explicit `base_url=` argument wins,
|
|
136
|
+
otherwise the `WORKWIRE_BASE_URL` environment variable, otherwise production
|
|
137
|
+
(`https://api.workwire.com`). Point it at a local backend or a staging
|
|
138
|
+
environment without touching code:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
export WORKWIRE_BASE_URL="http://localhost:8081"
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
There is no API version to configure: Workwire versions its API in the URL
|
|
145
|
+
path only (`/v1`), with no version header — the SDK sends no API version
|
|
146
|
+
and has none to pin, by design. (Pinning the `workwire` *package* version
|
|
147
|
+
is a separate matter — see Versioning and stability below.)
|
|
148
|
+
|
|
149
|
+
## Errors
|
|
150
|
+
|
|
151
|
+
Every API failure raises a typed exception from `workwire.errors`, carrying
|
|
152
|
+
the stable machine `code`, human `message`, `details`, HTTP `status`, and the
|
|
153
|
+
`request_id` to quote to support:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
from workwire import errors
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
client.wires.create(...)
|
|
160
|
+
except errors.ConflictError as e:
|
|
161
|
+
if e.code == "partnership_not_established":
|
|
162
|
+
print("partnership is", e.details["current_state"])
|
|
163
|
+
except errors.InvalidRequestError as e:
|
|
164
|
+
print("fix these fields:", e.fields)
|
|
165
|
+
except errors.RateLimitError as e:
|
|
166
|
+
print(f"over the {e.scope} budget, retry in {e.retry_after}s")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The hierarchy: `WorkwireError` → `APIConnectionError` (network),
|
|
170
|
+
`AuthenticationError` (401), `PermissionError` / `ForbiddenScopeError` (403,
|
|
171
|
+
with `.required_scope`), `NotFoundError` (404 — also another org's
|
|
172
|
+
resources; existence is never confirmed), `InvalidRequestError` (400/413/422,
|
|
173
|
+
with `.fields`), `ConflictError` (409, with `IdempotencyError` for the
|
|
174
|
+
idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
|
|
175
|
+
`e.code`, never on the message text.
|
|
176
|
+
|
|
177
|
+
## Retries and idempotency
|
|
178
|
+
|
|
179
|
+
Every `POST` automatically carries an `Idempotency-Key` (a fresh UUID, or
|
|
180
|
+
pass `idempotency_key=` to control replay across your own process restarts).
|
|
181
|
+
That makes retries safe, so the SDK retries for you: connection errors,
|
|
182
|
+
timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
|
|
183
|
+
retryable 409 — `idempotency_in_flight`, where the API asks you to retry
|
|
184
|
+
after backoff because the original attempt is still running. All with
|
|
185
|
+
exponential backoff, up to `max_retries` (default 2, configurable on the
|
|
186
|
+
client). Every retry resends the identical bytes under the same key, which
|
|
187
|
+
is exactly what the server's replay detection requires. No other 4xx is
|
|
188
|
+
ever retried.
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
client = workwire.Workwire(timeout=10, max_retries=3)
|
|
192
|
+
client.projects.create(name="X", idempotency_key="my-op-42", timeout=60)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Pagination
|
|
196
|
+
|
|
197
|
+
`list()` returns one page: `.data`, `.next_cursor`, `.has_more`. Walk
|
|
198
|
+
everything with the iterator — it follows cursors until the server says done:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
for wire in client.wires.list(state="live").auto_paging_iter():
|
|
202
|
+
...
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Responses
|
|
206
|
+
|
|
207
|
+
Responses are typed objects with attribute access, `.to_dict()`, and
|
|
208
|
+
`.request_id`. Fields the API adds in the future are preserved and
|
|
209
|
+
reachable, never dropped.
|
|
210
|
+
|
|
211
|
+
## Raw escape hatch
|
|
212
|
+
|
|
213
|
+
Anything the resource classes don't wrap yet:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
client.request("POST", "/v1/some/new/endpoint", json_body={...})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Same idempotency, retries, and typed errors.
|
|
220
|
+
|
|
221
|
+
## Versioning and stability
|
|
222
|
+
|
|
223
|
+
The SDK follows semver; the API is versioned in the URL (`/v1/`) and
|
|
224
|
+
additive changes ship without breaking. See
|
|
225
|
+
[CHANGELOG.md](CHANGELOG.md).
|
|
226
|
+
|
|
227
|
+
**Pre-1.0 note:** while the version is below 1.0, minor releases (`0.x` →
|
|
228
|
+
`0.y`) may include breaking changes to the SDK's Python surface — pin
|
|
229
|
+
`workwire>=0.1,<0.2` (or an exact version) in anything you deploy. The
|
|
230
|
+
wire-level API contract itself is the served OpenAPI document and does not
|
|
231
|
+
break within `/v1`; new response fields may appear at any time, and the SDK
|
|
232
|
+
preserves them rather than dropping them.
|
workwire-0.1.0/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# Workwire Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python library for the [Workwire](https://workwire.com) API.
|
|
4
|
+
Create projects, compose and send Wires, and initiate partnerships from your
|
|
5
|
+
own systems — no HTTP calls to write, no idempotency keys to invent, no error
|
|
6
|
+
envelopes to parse, no 429s to handle.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install workwire
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Until the first stable PyPI release you can also install straight from
|
|
15
|
+
GitHub:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install git+https://github.com/workwire/workwire-python.git
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Python 3.10+. The only runtime dependency is `httpx`.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import workwire
|
|
27
|
+
|
|
28
|
+
client = workwire.Workwire() # reads WORKWIRE_API_KEY from the environment
|
|
29
|
+
|
|
30
|
+
project = client.projects.create(name="Elm St. Townhomes")
|
|
31
|
+
|
|
32
|
+
# The partnership with your sub — create one (see below) or pick an
|
|
33
|
+
# established one from client.partnerships.list().
|
|
34
|
+
partnership = client.partnerships.create(sub_org_id="...")
|
|
35
|
+
|
|
36
|
+
wire = client.wires.create(
|
|
37
|
+
project_id=project.id,
|
|
38
|
+
title="Drywall — Bldg A",
|
|
39
|
+
parties=[
|
|
40
|
+
workwire.Party.partnered(
|
|
41
|
+
partnership_id=partnership.id,
|
|
42
|
+
trade="Drywall",
|
|
43
|
+
scope_text="Hang, tape, and finish all interior drywall.",
|
|
44
|
+
contract_value="125000.00",
|
|
45
|
+
),
|
|
46
|
+
],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
client.wires.go_live(wire.id) # emails any cold-invited subs
|
|
50
|
+
|
|
51
|
+
for p in client.partnerships.list().auto_paging_iter():
|
|
52
|
+
print(p.id, p.status)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A party can also cold-invite a sub who isn't on Workwire yet — the
|
|
56
|
+
invitation is minted and emailed at `go_live`:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
workwire.Party.cold_invite(
|
|
60
|
+
recipient_email="estimator@acmedrywall.com",
|
|
61
|
+
recipient_org_name="Acme Drywall",
|
|
62
|
+
trade="Drywall",
|
|
63
|
+
scope_text="North wing.",
|
|
64
|
+
contract_value="80000.00",
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The counterparty's acceptance always happens between people in the Workwire
|
|
69
|
+
app — there is deliberately no API call for the other side of the handshake.
|
|
70
|
+
|
|
71
|
+
The quickstart is runnable as a script:
|
|
72
|
+
[`examples/quickstart.py`](https://github.com/workwire/workwire-python/blob/main/examples/quickstart.py)
|
|
73
|
+
drafts the wire above against a partnership you name, and only sends it
|
|
74
|
+
with `--go-live`.
|
|
75
|
+
|
|
76
|
+
## Authentication
|
|
77
|
+
|
|
78
|
+
An org admin issues an API key in the Workwire dashboard under **Settings →
|
|
79
|
+
API keys**; the secret (`wwk_live_…`) is shown exactly once. Put it in your
|
|
80
|
+
secret manager and export it:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
export WORKWIRE_API_KEY="wwk_live_..."
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`workwire.Workwire()` reads that variable, or takes `api_key=` explicitly.
|
|
87
|
+
The SDK never loads `.env` files itself — if you use one, load it with your
|
|
88
|
+
framework or `python-dotenv` before constructing the client. The client's
|
|
89
|
+
`repr()` and the SDK's errors never contain the key.
|
|
90
|
+
|
|
91
|
+
The base URL works the same way: an explicit `base_url=` argument wins,
|
|
92
|
+
otherwise the `WORKWIRE_BASE_URL` environment variable, otherwise production
|
|
93
|
+
(`https://api.workwire.com`). Point it at a local backend or a staging
|
|
94
|
+
environment without touching code:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
export WORKWIRE_BASE_URL="http://localhost:8081"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
There is no API version to configure: Workwire versions its API in the URL
|
|
101
|
+
path only (`/v1`), with no version header — the SDK sends no API version
|
|
102
|
+
and has none to pin, by design. (Pinning the `workwire` *package* version
|
|
103
|
+
is a separate matter — see Versioning and stability below.)
|
|
104
|
+
|
|
105
|
+
## Errors
|
|
106
|
+
|
|
107
|
+
Every API failure raises a typed exception from `workwire.errors`, carrying
|
|
108
|
+
the stable machine `code`, human `message`, `details`, HTTP `status`, and the
|
|
109
|
+
`request_id` to quote to support:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from workwire import errors
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
client.wires.create(...)
|
|
116
|
+
except errors.ConflictError as e:
|
|
117
|
+
if e.code == "partnership_not_established":
|
|
118
|
+
print("partnership is", e.details["current_state"])
|
|
119
|
+
except errors.InvalidRequestError as e:
|
|
120
|
+
print("fix these fields:", e.fields)
|
|
121
|
+
except errors.RateLimitError as e:
|
|
122
|
+
print(f"over the {e.scope} budget, retry in {e.retry_after}s")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The hierarchy: `WorkwireError` → `APIConnectionError` (network),
|
|
126
|
+
`AuthenticationError` (401), `PermissionError` / `ForbiddenScopeError` (403,
|
|
127
|
+
with `.required_scope`), `NotFoundError` (404 — also another org's
|
|
128
|
+
resources; existence is never confirmed), `InvalidRequestError` (400/413/422,
|
|
129
|
+
with `.fields`), `ConflictError` (409, with `IdempotencyError` for the
|
|
130
|
+
idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
|
|
131
|
+
`e.code`, never on the message text.
|
|
132
|
+
|
|
133
|
+
## Retries and idempotency
|
|
134
|
+
|
|
135
|
+
Every `POST` automatically carries an `Idempotency-Key` (a fresh UUID, or
|
|
136
|
+
pass `idempotency_key=` to control replay across your own process restarts).
|
|
137
|
+
That makes retries safe, so the SDK retries for you: connection errors,
|
|
138
|
+
timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
|
|
139
|
+
retryable 409 — `idempotency_in_flight`, where the API asks you to retry
|
|
140
|
+
after backoff because the original attempt is still running. All with
|
|
141
|
+
exponential backoff, up to `max_retries` (default 2, configurable on the
|
|
142
|
+
client). Every retry resends the identical bytes under the same key, which
|
|
143
|
+
is exactly what the server's replay detection requires. No other 4xx is
|
|
144
|
+
ever retried.
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
client = workwire.Workwire(timeout=10, max_retries=3)
|
|
148
|
+
client.projects.create(name="X", idempotency_key="my-op-42", timeout=60)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Pagination
|
|
152
|
+
|
|
153
|
+
`list()` returns one page: `.data`, `.next_cursor`, `.has_more`. Walk
|
|
154
|
+
everything with the iterator — it follows cursors until the server says done:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
for wire in client.wires.list(state="live").auto_paging_iter():
|
|
158
|
+
...
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Responses
|
|
162
|
+
|
|
163
|
+
Responses are typed objects with attribute access, `.to_dict()`, and
|
|
164
|
+
`.request_id`. Fields the API adds in the future are preserved and
|
|
165
|
+
reachable, never dropped.
|
|
166
|
+
|
|
167
|
+
## Raw escape hatch
|
|
168
|
+
|
|
169
|
+
Anything the resource classes don't wrap yet:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
client.request("POST", "/v1/some/new/endpoint", json_body={...})
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Same idempotency, retries, and typed errors.
|
|
176
|
+
|
|
177
|
+
## Versioning and stability
|
|
178
|
+
|
|
179
|
+
The SDK follows semver; the API is versioned in the URL (`/v1/`) and
|
|
180
|
+
additive changes ship without breaking. See
|
|
181
|
+
[CHANGELOG.md](CHANGELOG.md).
|
|
182
|
+
|
|
183
|
+
**Pre-1.0 note:** while the version is below 1.0, minor releases (`0.x` →
|
|
184
|
+
`0.y`) may include breaking changes to the SDK's Python surface — pin
|
|
185
|
+
`workwire>=0.1,<0.2` (or an exact version) in anything you deploy. The
|
|
186
|
+
wire-level API contract itself is the served OpenAPI document and does not
|
|
187
|
+
break within `/v1`; new response fields may appear at any time, and the SDK
|
|
188
|
+
preserves them rather than dropping them.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Reporting
|
|
4
|
+
|
|
5
|
+
Email security@workwire.com. Please do not open public issues for security
|
|
6
|
+
reports.
|
|
7
|
+
|
|
8
|
+
## API key handling
|
|
9
|
+
|
|
10
|
+
Workwire API keys (`wwk_live_…`) are org-scoped bearer secrets. The SDK's
|
|
11
|
+
posture, which your integration should mirror:
|
|
12
|
+
|
|
13
|
+
- The key is read from `api_key=` or `WORKWIRE_API_KEY` and sent only as the
|
|
14
|
+
`Authorization` header to your configured `base_url`.
|
|
15
|
+
- The key never appears in `repr()`, exceptions, logs, or telemetry emitted
|
|
16
|
+
by this SDK, and the SDK never writes it to disk.
|
|
17
|
+
- The SDK does not load `.env` files; your application owns its environment
|
|
18
|
+
loading.
|
|
19
|
+
- Keys are shown once at creation in the Workwire dashboard and are revoked
|
|
20
|
+
there; revocation takes effect on the key's next request.
|
|
21
|
+
|
|
22
|
+
If a key may have been exposed, revoke it in **Settings → API keys** and
|
|
23
|
+
issue a new one.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "workwire"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Workwire API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
authors = [{ name = "Workwire", email = "support@workwire.com" }]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = ["httpx>=0.24,<1"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/workwire/workwire-python"
|
|
29
|
+
Changelog = "https://github.com/workwire/workwire-python/blob/main/CHANGELOG.md"
|
|
30
|
+
Issues = "https://github.com/workwire/workwire-python/issues"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=8",
|
|
35
|
+
"ruff>=0.6",
|
|
36
|
+
"pyyaml>=6",
|
|
37
|
+
"bcrypt>=4", # scripts/local_contract_smoke.py key minting only
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/workwire"]
|
|
42
|
+
|
|
43
|
+
# The published sdist is the library, not the workshop: no repo plumbing,
|
|
44
|
+
# specs, tests, or agent docs (the publish.yml launch checklist's "trim the
|
|
45
|
+
# sdist" item). The wheel is package-only by construction above.
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
only-include = ["src", "README.md", "LICENSE", "CHANGELOG.md", "SECURITY.md", "pyproject.toml"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 96
|
|
51
|
+
src = ["src", "tests", "scripts"]
|
|
52
|
+
|
|
53
|
+
[tool.ruff.lint]
|
|
54
|
+
select = ["E", "F", "W", "I", "UP", "B", "S"]
|
|
55
|
+
ignore = [
|
|
56
|
+
"S101", # asserts are fine in tests and as internal invariants
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
"tests/*" = ["S105", "S106"] # fake keys in tests are not leaked secrets
|
|
61
|
+
# Dev-only smoke harness: psql runs with a fixed argv; values reach SQL
|
|
62
|
+
# only as psql -v variables (:'name' quoting), never spliced into the text.
|
|
63
|
+
"scripts/local_contract_smoke.py" = ["S603", "S607"]
|
|
64
|
+
|
|
65
|
+
[tool.pytest.ini_options]
|
|
66
|
+
testpaths = ["tests"]
|
|
67
|
+
addopts = "-q"
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Official Python SDK for the Workwire API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
import workwire
|
|
6
|
+
|
|
7
|
+
client = workwire.Workwire() # reads WORKWIRE_API_KEY
|
|
8
|
+
|
|
9
|
+
project = client.projects.create(name="Elm St. Townhomes")
|
|
10
|
+
wire = client.wires.create(
|
|
11
|
+
project_id=project.id,
|
|
12
|
+
title="Drywall — Bldg A",
|
|
13
|
+
parties=[
|
|
14
|
+
workwire.Party.partnered(
|
|
15
|
+
partnership_id=p.id,
|
|
16
|
+
trade="Drywall",
|
|
17
|
+
scope_text="Hang, tape, and finish all interior drywall.",
|
|
18
|
+
contract_value="125000.00",
|
|
19
|
+
)
|
|
20
|
+
],
|
|
21
|
+
)
|
|
22
|
+
client.wires.go_live(wire.id)
|
|
23
|
+
|
|
24
|
+
The served OpenAPI document is the contract this SDK mirrors; anything not
|
|
25
|
+
wrapped yet is reachable through ``client.request(...)`` with the same
|
|
26
|
+
idempotency, retries, and typed errors.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from . import errors
|
|
37
|
+
from ._client import (
|
|
38
|
+
DEFAULT_MAX_RETRIES,
|
|
39
|
+
DEFAULT_TIMEOUT,
|
|
40
|
+
BaseClient,
|
|
41
|
+
)
|
|
42
|
+
from ._models import (
|
|
43
|
+
PO,
|
|
44
|
+
ColdInvite,
|
|
45
|
+
Milestone,
|
|
46
|
+
MilestoneSpec,
|
|
47
|
+
Page,
|
|
48
|
+
Partnership,
|
|
49
|
+
PartnershipDetail,
|
|
50
|
+
PartnershipListItem,
|
|
51
|
+
Party,
|
|
52
|
+
Project,
|
|
53
|
+
Wire,
|
|
54
|
+
WireListItem,
|
|
55
|
+
WireParty,
|
|
56
|
+
WorkwireObject,
|
|
57
|
+
)
|
|
58
|
+
from ._version import __version__
|
|
59
|
+
from .resources import Partnerships, Projects, Wires
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"Workwire",
|
|
63
|
+
"errors",
|
|
64
|
+
"__version__",
|
|
65
|
+
# request-side constructors
|
|
66
|
+
"Party",
|
|
67
|
+
"MilestoneSpec",
|
|
68
|
+
"ColdInvite",
|
|
69
|
+
# response types
|
|
70
|
+
"WorkwireObject",
|
|
71
|
+
"Page",
|
|
72
|
+
"Project",
|
|
73
|
+
"Wire",
|
|
74
|
+
"WireListItem",
|
|
75
|
+
"WireParty",
|
|
76
|
+
"Milestone",
|
|
77
|
+
"PO",
|
|
78
|
+
"Partnership",
|
|
79
|
+
"PartnershipDetail",
|
|
80
|
+
"PartnershipListItem",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Workwire(BaseClient):
|
|
85
|
+
"""The Workwire API client.
|
|
86
|
+
|
|
87
|
+
``api_key`` falls back to the ``WORKWIRE_API_KEY`` environment variable;
|
|
88
|
+
with neither, construction raises
|
|
89
|
+
:class:`workwire.errors.AuthenticationError`. ``base_url`` falls back to
|
|
90
|
+
the ``WORKWIRE_BASE_URL`` environment variable, then to production
|
|
91
|
+
(``https://api.workwire.com``) — point it at a local backend or a staging
|
|
92
|
+
environment without touching code. The SDK does not load ``.env`` files —
|
|
93
|
+
your application owns its environment loading.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
api_key: str | None = None,
|
|
99
|
+
*,
|
|
100
|
+
base_url: str | None = None,
|
|
101
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
102
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
103
|
+
transport: httpx.BaseTransport | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
super().__init__(
|
|
106
|
+
api_key,
|
|
107
|
+
base_url=base_url,
|
|
108
|
+
timeout=timeout,
|
|
109
|
+
max_retries=max_retries,
|
|
110
|
+
transport=transport,
|
|
111
|
+
)
|
|
112
|
+
self.projects = Projects(self)
|
|
113
|
+
self.wires = Wires(self)
|
|
114
|
+
self.partnerships = Partnerships(self)
|
|
115
|
+
|
|
116
|
+
def request(
|
|
117
|
+
self,
|
|
118
|
+
method: str,
|
|
119
|
+
path: str,
|
|
120
|
+
*,
|
|
121
|
+
json_body: Mapping[str, Any] | None = None,
|
|
122
|
+
params: Mapping[str, Any] | None = None,
|
|
123
|
+
idempotency_key: str | None = None,
|
|
124
|
+
timeout: float | None = None,
|
|
125
|
+
) -> WorkwireObject:
|
|
126
|
+
"""Raw escape hatch: call any API path with the SDK's idempotency,
|
|
127
|
+
retries, and typed errors. Returns a :class:`WorkwireObject` (or
|
|
128
|
+
raises the typed error)."""
|
|
129
|
+
data, request_id = self._request(
|
|
130
|
+
method,
|
|
131
|
+
path,
|
|
132
|
+
json_body=json_body,
|
|
133
|
+
params=params,
|
|
134
|
+
idempotency_key=idempotency_key,
|
|
135
|
+
timeout=timeout,
|
|
136
|
+
)
|
|
137
|
+
if isinstance(data, Mapping):
|
|
138
|
+
return WorkwireObject(data, request_id=request_id)
|
|
139
|
+
return WorkwireObject({"data": data}, request_id=request_id)
|