Metadata-Version: 2.5
Name: spreadspace
Version: 0.6.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.

## 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["borrower_id"])

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

Core lists yield typed `Borrower`, `Loan`, and `JobStatus` dictionaries. They
remain ordinary dictionaries at runtime.

```python
borrower = client.borrowers.create({"name": "Example borrower"})
loan = client.loans.create(borrower["borrower_id"], {"name": "Example loan", "requested_amount": "250000.00"})
current_loan = client.loans.get(loan["loan_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=["7a1d4e9c2b3f4a5d8e6c1b0f", "2a3b4c5d6e7f809112345678"],
    format="json",
)
result = export.wait(timeout=300)   # seconds
print(result.status, result.download_url)   # fresh download link on each retrieval
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",
    operation_key="application-123-document-1",  # persist before the first attempt
)
print(job.id)  # persist the accepted job ID alongside the operation key
status = client.jobs.get(job.id)  # JobStatus dictionary
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.

Persist the operation key and returned job ID. Reusing the key produces stable,
distinct presign and confirm idempotency keys. After accepted confirm, use
`client.jobs.get(job_id)` to resume status checks. Presigned URLs expire, so a
replayed presign response does not permit indefinite upload restarts. API version
and retry overrides apply to both API stages and stay off the storage PUT.

## Read an extracted document (typed)

`client.extractions` has one `get_<family>` / `list_<family>s` pair per
document family (`get_tax_return` / `list_tax_returns`, `get_bank_statement` /
`list_bank_statements`, ...), annotated with `TypedDict`s from
`spreadspace.extractions`, plus `get_report_data` for any category in the shape
of its family:

```python
schedule = client.extractions.get_debt_schedule(borrower_id, document_id)
print(schedule["form_variant"], len(schedule["debt_schedule"]))

for row in client.extractions.list_debt_schedules(borrower_id)["schedules"]:
    print(row["extracted_document_id"], row["report_data"]["as_of_date"])
```

## 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

Every money key in `report_data` is a JSON string with exactly two decimals,
typed `str`. Attribute snapshots and export payloads use the same two-decimal
strings. The SDK preserves them unchanged; parse with `Decimal` when computing.
Other JSON numbers arrive as `int` or `Decimal`, never `float`. Loan
`requested_amount`, extraction query amounts and totals, and EBITDA write-in amounts are also strings.
Create or update a loan with `"requested_amount": "250000.00"`; its allowed
range is `"0.00"` through `"999999999999.99"`. Invalid formats return
`400 validation_failed`.

```python
from decimal import Decimal

amount = Decimal("128986.06")
statement = client.extractions.get_bank_statement(borrower_id, document_id)
transaction_amount = Decimal(statement["transactions"][0]["amount"])
```

## 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
