Metadata-Version: 2.5
Name: spreadspace
Version: 0.3.0
Summary: Official Python SDK for the SpreadSpace API.
Project-URL: Homepage, https://spreadspace.app
Project-URL: Documentation, https://docs.spreadspace.app
Project-URL: Source, https://github.com/spreadspace
Author: SpreadSpace
License: MIT
Keywords: api,document,extraction,lending,sdk,spreadspace
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: mypy<2,>=1.10; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# SpreadSpace Python SDK

Official Python client for the [SpreadSpace API](https://docs.spreadspace.app) —
document extraction and financial spreading for lending.

> Some examples below require the generated core (built in CI) or a live sandbox
> key. They are marked **[needs sandbox key]** / **[needs generated core]**.

## Install

```bash
pip install spreadspace
```

Requires Python 3.9+.

## Authentication

```python
from spreadspace import SpreadSpace

client = SpreadSpace(api_key="ss_test_...")   # or omit and set SPREADSPACE_API_KEY
```

The key prefix selects the environment:

- `ss_test_...` — routes to your **sandbox tenant** (seed data, safe to experiment).
- `ss_live_...` — routes to your live tenant (real data).

If `api_key` is omitted, the client reads `SPREADSPACE_API_KEY` from the
environment. Never hard-code a live key; never commit any key.

## Client options

```python
client = SpreadSpace(
    api_key="ss_test_...",
    base_url="https://api.spreadspace.app",   # override for a private deployment
    api_version="2026-05-03",                # pins the SpreadSpace-Version header
    timeout=60.0,                            # seconds, per request
    max_retries=2,                           # 429 + 5xx + transport errors
)
```

### API version pinning

Every request sends a dated `SpreadSpace-Version` header. The SDK pins a default
version per release (decoupled from the SDK's own semver). Pin it explicitly to
insulate your integration from server-side changes, and override per call when
you need a newer surface:

```python
client.borrowers.list(api_version="2026-06-01")   # one call on a newer version
```

## Pagination (lazy, auto cursor)

List endpoints return a lazy iterator that walks cursors for you — it fetches the
next page only as you consume it.

```python
for borrower in client.borrowers.list():
    print(borrower["id"])

# Filters pass straight through and persist across pages:
for job in client.jobs.list(status="completed", limit=50):
    print(job["id"])
```

## Extraction export + wait

Exports run asynchronously. `create` returns a handle; `wait` polls to
completion (raising `ExportFailedError` on `failed`, `ExportTimeoutError` on
timeout). Terminal statuses are `succeeded` / `failed` / `cancelled`;
`cancelling` — a cancel that landed mid-bundle — is NOT terminal, so `wait`
polls through it. `format` is `json`, `csv`, or `xlsx`; `xlsx` is available for
bank statements only.

`borrower_id`, `loan_id` and `document_ids` are required, as they are on the server.

```python
export = client.exports.create(
    borrower_id="abc123",
    loan_id="def456",
    document_ids=["doc_1f2e3d4c5b6a7908", "doc_2a3b4c5d6e7f8091"],
    format="json",
)
result = export.wait(timeout=300)   # seconds
print(result.status, result.download_url)   # link minted fresh on every read
print(result.document_counts)               # {"requested": .., "exported": .., "skipped": ..}
print(result.bundle)                        # {"name": .., "size_bytes": .., "line_item_count": ..}

# Or ask for a fresh link by id (raises ExportNotReadyError until succeeded).
# The expiry comes off the same read, so it describes this link:
link = client.exports.download_link(result.export_id)
print(link.url, link.expires_at)

# List exports as ExtractionExport rows (items live under `exports`, not `data`):
for row in client.exports.list(status="running"):
    print(row.export_id, row.status)

# Cancel a still-running export (cancelling a finished one raises ConflictError;
# cancelling an already-cancelled one is an idempotent success):
export.cancel()
# ...or by id: client.exports.cancel(result.export_id)
```

## Upload a document + wait for processing

```python
job = client.documents.upload("statement.pdf", loan_id="def456", borrower_id="abc123")
final = job.wait(timeout=600)   # seconds
print(final["status"])
```

The upload helper requests a presigned URL, PUTs the file bytes directly to
storage with the matching `Content-Type` (part of the V4 signature), then returns
a job handle you can `wait` on. Every upload belongs to a loan, so `loan_id` is
required; `borrower_id` is optional.

## Error handling

All errors derive from `SpreadSpaceError`. Match on the typed subclass, never on
the message string:

```python
from spreadspace import (
    SpreadSpaceError,       # base
    NetworkError,           # transport failure, no HTTP response
    BadRequestError,        # 400
    AuthenticationError,    # 401
    PermissionDeniedError,  # 403
    NotFoundError,          # 404
    ConflictError,          # 409
    RateLimitError,         # 429
    InternalServerError,    # 5xx
)

try:
    client.borrowers.get("missing-id")
except RateLimitError as e:
    print("retry after", e.retry_after, "seconds")
except NotFoundError as e:
    print("not found")
except SpreadSpaceError as e:
    # Every error carries request_id — quote it in support tickets.
    print(e.message, e.status_code, e.request_id)
```

`request_id` comes from the `X-Request-ID` response header (falling back to the
error body). Transient failures (429, 5xx, transport errors) are retried
automatically up to `max_retries` with exponential backoff + full jitter,
honoring `Retry-After`.

## Money is exact

Monetary values decode as `decimal.Decimal`, not `float` — the SDK reads the
literal digits off the wire, so amounts are exact with no float rounding:

```python
b = client.borrowers.get("...")          # [needs sandbox key]
assert b["total_revenue"] == Decimal("1234.56")   # never 1234.5600000000001
```

## Development

The generated OpenAPI core lives in `src/spreadspace/_generated/` and is built in
CI (`scripts/generate.sh`, Java-based — not run locally). It is **gitignored and
must never be hand-edited**. The hand-written ergonomic layer (transport,
helpers, typed errors) is the only code committed by hand.

```bash
pip install -e '.[dev]'
pytest
```

## License

MIT
