Metadata-Version: 2.4
Name: boostspace-sdk
Version: 0.1.1
Summary: Official Boost.space API SDK (Python)
Author: Boost.space
License-Expression: MIT
Project-URL: Homepage, https://github.com/boostspace/sdk
Project-URL: Repository, https://github.com/boostspace/sdk
Project-URL: Issues, https://github.com/boostspace/sdk/issues
Keywords: boostspace,boost.space,sdk,api,client
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.28
Requires-Dist: pydantic>=2.13
Provides-Extra: dev
Requires-Dist: mypy>=2.3; extra == "dev"
Requires-Dist: ruff>=0.15; extra == "dev"
Requires-Dist: pytest>=9; extra == "dev"
Dynamic: license-file

# boostspace-sdk

Official [Boost.space](https://boost.space) API SDK for Python — fully typed
(pydantic v2 + httpx), generated from the OpenAPI spec. Sync **and** async.

## Install

```sh
pip install boostspace-sdk
```

Requires Python ≥ 3.11.

## Quickstart (sync)

```python
from boostspace import BoostSpace

# Per-tenant base URL: https://<system>.boost.space/api
with BoostSpace(token="...", system="acme") as bs:
    contact = bs.contact.get(1)          # typed pydantic model

    for c in bs.contact.list(limit=50):  # transparent pagination
        print(c.name)

    page = bs.contact.list().page()      # single page + total
    print(page.found_records)
```

## Quickstart (async)

```python
import asyncio
from boostspace import AsyncBoostSpace

async def main() -> None:
    async with AsyncBoostSpace(token="...", system="acme") as bs:
        contact = await bs.contact.get(1)
        labels = await bs.contact.labels(contact).all()   # async pagination
        async for c in bs.contact.list(limit=50):
            print(c.name)

asyncio.run(main())
```

## OAuth2 / MCP tokens

Besides a static API token, the client accepts a **callable token provider**,
resolved per request. `OAuth2Session` implements the Boost.space OAuth2 flow
(MCP clients included — their tokens are ordinary OAuth2 access tokens) and is
itself callable, refreshing proactively before expiry:

```python
from boostspace.runtime import OAuth2Session

session = OAuth2Session("client-id", "client-secret", system="acme",
                        refresh_token=stored_refresh_token)
# or bootstrap from an authorization code:
# session.exchange_code(code, redirect_uri="https://app.example.com/cb")

bs = BoostSpace(session, system="acme")   # token stays fresh automatically
```

## Relationship navigation

```python
creator = bs.contact.created_by(contact)   # belongsTo → related record (User)
for label in bs.contact.labels(contact):    # hasMany → related collection
    ...

nav = bs.contact.of(contact)                        # group an entity's relations
expanded = bs.contact.expand(contact, ["group"])    # eager-load several at once
print(expanded.group.name if expanded.group else None)
```

## Filtering

```python
from boostspace import Filter

page = bs.contact.list(
    filter=str(
        Filter.equals("status", "active")
        .and_(Filter.greater_than_or_equal("created", "2024-01-01"))
        .and_(Filter.any_of(Filter.equals("type", "company"), Filter.equals("type", "person")))
    )
).page()
# status=active;created>=2024-01-01;(type=company|type=person)
```

Operators: `eq ne gt gte lt lte like not_like in_ is_null`, combined with
`and_`/`or_` (OR nested in AND is parenthesised automatically).

## Upsert

Create-or-update by a match filter in a single call:

```python
from boostspace import Filter, models

saved = bs.category.upsert(
    Filter.equals("name", "VIP"),
    models.Category(name="VIP", color="#ff0000"),
)
```

`upsert` lists by the filter, then updates the single match or creates one when
none exists. More than one match raises `AmbiguousMatchError`. Available on
resources that expose list + create + update over the same model.

## Delta sync

Iterate only the records changed since a point in time (`updated >= since`),
across all pages:

```python
from datetime import datetime

for contact in bs.contact.iter_changes(datetime(2024, 1, 1)):
    ...  # only contacts updated at/after 2024-01-01
```

Available on resources whose model has an `updated` timestamp.

## Attach / detach relations

Link or unlink a related record (e.g. a label) by id:

```python
bs.note.attach_label(note, label_id)
bs.note.detach_label(note, label_id)
```

## Bulk with chunking

Split a large bulk update/delete into chunks and collect per-chunk failures
instead of failing all-or-nothing:

```python
report = bs.contact.update_chunked(contacts, chunk_size=100)
if not report.ok:
    for f in report.failed:  # BulkChunkError(index, size, error)
        print(f.index, f.error)
print(len(report.results))
```

## Resolve a boostId

Map a Boost.space `boostId` (e.g. `"Contact07"`) to its typed record across
entities, without knowing which resource it belongs to:

```python
record = bs.resolve("Contact07")   # a typed model, or None
if record is not None:
    print(record.boostId)
```

Returns `None` for an unknown prefix, a missing record, or a UUID-style id. The
result is verified against the input boostId, so it is never a wrong-type record.

## Field values

Read a record's Boost.space field values keyed by field name (self-contained from
the record — no extra request):

```python
values = bs.contact.field_values(contact)  # {"Rating": "5", "Tier": "gold"}
```

Available on resources whose model carries custom field values. Writing field
values by name is planned for a later release.

## Timeouts & observability

```python
from boostspace import Hooks

bs = BoostSpace(
    token="...", system="acme",
    timeout=10.0,   # per-request timeout in seconds (default 30)
    hooks=Hooks(
        on_request=lambda m, u, h: h.__setitem__("X-Trace-Id", trace_id),
        on_response=lambda m, u, s, ms: metrics.record(s, ms),
    ),
)
```

## Errors

```python
from boostspace import NotFoundError

try:
    bs.contact.get(999)
except NotFoundError as err:
    print(err.http_status, err.code)  # internal Boost.space error code
```

Requests retry with jittered backoff on `429`/`5xx`, only for idempotent methods.

## Testing without a live API

Inject an `httpx` client backed by a mock transport to unit-test your code:

```python
import httpx
from boostspace import BoostSpace

def handler(request: httpx.Request) -> httpx.Response:
    return httpx.Response(200, json={"id": 1, "name": "Mock Inc", "spaces": [1]})

bs = BoostSpace("token", system="acme",
                http_client=httpx.Client(transport=httpx.MockTransport(handler)))
assert bs.contact.get(1).name == "Mock Inc"  # no network
```

## License

MIT

## Guides

In-depth how-to guides live in [`docs/guides/`](../../docs/guides/):
[filtering](../../docs/guides/filtering.md) ·
[ordering & pagination](../../docs/guides/ordering-pagination.md) ·
[authentication](../../docs/guides/authentication.md) ·
[record handles (`ref`)](../../docs/guides/record-handles.md) ·
[relations & expand](../../docs/guides/relations-expand.md) ·
[delta sync & bulk](../../docs/guides/delta-sync-bulk.md) ·
[errors & retries](../../docs/guides/errors-retries.md) ·
[testing & mocking](../../docs/guides/testing-mocking.md)
