Metadata-Version: 2.5
Name: workwire
Version: 0.3.0
Summary: Official Python SDK for the Workwire API
Project-URL: Homepage, https://github.com/workwire/workwire-python
Project-URL: Changelog, https://github.com/workwire/workwire-python/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/workwire/workwire-python/issues
Author-email: Workwire <support@workwire.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.24
Description-Content-Type: text/markdown

# Workwire Python SDK

The official Python library for the [Workwire](https://workwire.com) API.
Create projects, compose and send Wires, and initiate partnerships from your
own systems — no HTTP calls to write, no idempotency keys to invent, no error
envelopes to parse, no 429s to handle.

## Install

```bash
pip install workwire
```

Until the first stable PyPI release you can also install straight from
GitHub:

```bash
pip install git+https://github.com/workwire/workwire-python.git
```

Python 3.10+. The only runtime dependency is `httpx`.

## Quickstart

```python
import workwire

client = workwire.Workwire()  # reads WORKWIRE_API_KEY from the environment

project = client.projects.create(name="Elm St. Townhomes")

# The partnership with your sub — create one (see below) or pick an
# established one from client.partnerships.list().
partnership = client.partnerships.create(sub_org_id="...")

wire = client.wires.create(
    project_id=project.id,
    title="Drywall — Bldg A",
    parties=[
        workwire.Party.partnered(
            partnership_id=partnership.id,
            trade="Drywall",
            scope_text="Hang, tape, and finish all interior drywall.",
            contract_value="125000.00",
        ),
    ],
)

client.wires.go_live(wire.id)  # emails any cold-invited subs

for p in client.partnerships.list().auto_paging_iter():
    print(p.id, p.status)
```

A party can also cold-invite a sub who isn't on Workwire yet — the
invitation is minted and emailed at `go_live`:

```python
workwire.Party.cold_invite(
    recipient_email="estimator@acmedrywall.com",
    recipient_org_name="Acme Drywall",
    trade="Drywall",
    scope_text="North wing.",
    contract_value="80000.00",
)
```

The counterparty's acceptance always happens between people in the Workwire
app — there is deliberately no API call for the other side of the handshake.

The quickstart is runnable as a script:
[`examples/quickstart.py`](https://github.com/workwire/workwire-python/blob/main/examples/quickstart.py)
drafts the wire above against a partnership you name, and only sends it
with `--go-live`.

## Locations and Jobs

Every project has a location tree under one `site` root
(`project.root_location_id`). A party's scope is the locations its work
covers, and it gets one Job (`JOB-…`) per scope location. A Job's
`your_reference`, `quantity` and `checked_at` are your org's own, never
shown to the other side.

```python
from workwire import LocationNode, Party

project = client.projects.create(name="Elm St. Townhomes", template="development", locations=[
    LocationNode("building", "Bldg A", children=[LocationNode("floor", "Level 1")]),
])
tree = client.projects.locations.list(project.id).data  # the site root first
level_1 = next(loc for loc in tree if loc.name == "Level 1")
client.wires.create(project_id=project.id, title="Drywall — Level 1", parties=[Party.partnered(
    partnership_id=partnership.id, trade="Drywall", scope_text="Level 1.",
    contract_value="40000.00", scope_location_ids=[level_1.id],  # one Job per scope location
)])
page = client.jobs.list(project=project.id)  # page.quantity_unit is your org's unit
client.jobs.update(page.data[0].id, your_reference="PO-4471-A", quantity="12.5", checked=True)
```

Leave `scope_location_ids` out to scope a party to the whole project.
`client.jobs.update` sends only the fields you pass: leave one out to keep
it, pass `None` to clear it. Add to a tree later with
`client.projects.locations.create(...)` or `.create_batch(...)`.

## Authentication

An org admin issues an API key in the Workwire dashboard under **Settings →
API keys**; the secret (`wwk_live_…`) is shown exactly once. Put it in your
secret manager and export it:

```bash
export WORKWIRE_API_KEY="wwk_live_..."
```

`workwire.Workwire()` reads that variable, or takes `api_key=` explicitly.
The SDK never loads `.env` files itself — if you use one, load it with your
framework or `python-dotenv` before constructing the client. The client's
`repr()` and the SDK's errors never contain the key.

The base URL works the same way: an explicit `base_url=` argument wins,
otherwise the `WORKWIRE_BASE_URL` environment variable, otherwise production
(`https://api.workwire.com`). Point it at a local backend or a staging
environment without touching code:

```bash
export WORKWIRE_BASE_URL="http://localhost:8081"
```

There is no API version to configure: Workwire versions its API in the URL
path only (`/v1`), with no version header — the SDK sends no API version
and has none to pin, by design. (Pinning the `workwire` *package* version
is a separate matter — see Versioning and stability below.)

## Errors

Every API failure raises a typed exception from `workwire.errors`, carrying
the stable machine `code`, human `message`, `details`, HTTP `status`, and the
`request_id` to quote to support:

```python
from workwire import errors

try:
    client.wires.create(...)
except errors.ConflictError as e:
    if e.code == "partnership_not_established":
        print("partnership is", e.details["current_state"])
except errors.InvalidRequestError as e:
    print("fix these fields:", e.fields)
except errors.RateLimitError as e:
    print(f"over the {e.scope} budget, retry in {e.retry_after}s")
```

The hierarchy: `WorkwireError` → `APIConnectionError` (network),
`AuthenticationError` (401), `PermissionError` / `ForbiddenScopeError` (403,
with `.required_scope`), `NotFoundError` (404 — also another org's
resources; existence is never confirmed), `InvalidRequestError` (400/413/422,
with `.fields`), `ConflictError` (409, with `IdempotencyError` for the
idempotency codes), `RateLimitError` (429), `APIError` (5xx). Key on
`e.code`, never on the message text.

## Retries and idempotency

Every `POST` and `PATCH` automatically carries an `Idempotency-Key` (a fresh
UUID, or pass `idempotency_key=` to control replay across your own process
restarts).
That makes retries safe, so the SDK retries for you: connection errors,
timeouts, 429s (honoring `Retry-After`), 5xx responses, and the one
retryable 409 — `idempotency_in_flight`, where the API asks you to retry
after backoff because the original attempt is still running. All with
exponential backoff, up to `max_retries` (default 2, configurable on the
client). Every retry resends the identical bytes under the same key, which
is exactly what the server's replay detection requires. No other 4xx is
ever retried.

```python
client = workwire.Workwire(timeout=10, max_retries=3)
client.projects.create(name="X", idempotency_key="my-op-42", timeout=60)
```

## Pagination

`list()` returns one page: `.data`, `.next_cursor`, `.has_more`. Walk
everything with the iterator — it follows cursors until the server says done:

```python
for wire in client.wires.list(state="live").auto_paging_iter():
    ...
```

A list's other envelope fields ride on the page: `client.jobs.list()`
returns a `JobPage` with `.quantity_unit`.

## Responses

Responses are typed objects with attribute access, `.to_dict()`, and
`.request_id`. Fields the API adds in the future are preserved and
reachable, never dropped.

## Raw escape hatch

Anything the resource classes don't wrap yet:

```python
client.request("POST", "/v1/some/new/endpoint", json_body={...})
```

Same idempotency, retries, and typed errors.

## Versioning and stability

The SDK follows semver; the API is versioned in the URL (`/v1/`) and
additive changes ship without breaking. See
[CHANGELOG.md](CHANGELOG.md).

**Release history note:** the `0.0.1a1` release on PyPI was a yanked
name-reservation placeholder, not an SDK release. Real release history
starts at `0.1.0`.

**Pre-1.0 note:** while the version is below 1.0, minor releases (`0.x` →
`0.y`) may include breaking changes to the SDK's Python surface — pin
`workwire>=0.1,<0.2` (or an exact version) in anything you deploy. The
wire-level API contract itself is the served OpenAPI document and does not
break within `/v1`; new response fields may appear at any time, and the SDK
preserves them rather than dropping them.
