Metadata-Version: 2.4
Name: plansom-sdk
Version: 0.0.1
Summary: Python client SDK for Plansom's be3 v3 API.
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/Plansom/plansom-sdk/tree/main/python
Project-URL: Repository, https://github.com/Plansom/plansom-sdk
Project-URL: Issues, https://github.com/Plansom/plansom-sdk/issues
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.11.2
Provides-Extra: dev
Requires-Dist: mypy>=1.15.0; extra == "dev"
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
Requires-Dist: respx>=0.21.0; extra == "dev"
Requires-Dist: PyYAML>=6.0.1; extra == "dev"
Dynamic: license-file

# Plansom SDK — Python

Python client for Plansom's `be3` v3 API. Ships both a sync and an async client from one package.
Every request-building, response-parsing, and transport function is written directly against
[`openapi/v3.yaml`](https://github.com/Plansom/plansom-sdk/blob/main/openapi/v3.yaml).

> **Status: OAuth is live, API Key is still pending.** `OAuthTokenAuth` does the authorization-code
> exchange and transparent refresh for real. API Key issuance hasn't shipped on be3 yet, so
> `APIKeyAuth` still raises `NotImplementedError`. See
> [`plansom_sdk/auth.py`](https://github.com/Plansom/plansom-sdk/blob/main/python/plansom_sdk/auth.py).

## Layout

```
plansom_sdk/
  client.py, async_client.py   the public Plansom / PlansomAsync classes
  auth.py                       API Key (PENDING) / OAuth (live) strategies, see file docstring
  exceptions.py                  typed exception hierarchy
  models.py                       pydantic response models
  pagination.py                    page-iteration helper for list endpoints (sync + async)
  _version.py                       single source of truth for __version__ (also used for the User-Agent header)
  py.typed                           PEP 561 marker — this package's type hints are checked, not decorative
  transport/
    http.py                         SyncTransport / AsyncTransport — request building, retry, error translation
    retry.py                         retry configuration
  resources/
    goals.py, tasks.py, users.py, teams.py, organizations.py, search.py
                                       one resource class (+ async twin) per file, 34 operations
tests/
```

`transport/` groups the two files responsible for actually talking HTTP; `resources/` groups the
six per-resource method sets. Everything else (`client.py`, `auth.py`, `exceptions.py`,
`models.py`, `pagination.py`) is a single cross-cutting concern used by both the sync and async
client, so it stays at the package root rather than nested.

## Resource scope (v1)

Read-only: `goals`, `tasks`, `users`, `teams`, `organizations`, `search` — list / retrieve / search only.
Write operations and AI Plan integration are deferred to a later phase. 34 operations are implemented
as SDK methods, each with its response model fields and path/query parameters read directly from
[`openapi/v3.yaml`](https://github.com/Plansom/plansom-sdk/blob/main/openapi/v3.yaml).

## Usage

OAuth (live today — third-party app acting on behalf of its own end-user):

```python
from plansom_sdk import Plansom
from plansom_sdk.auth import OAuthTokenAuth

# Right after your redirect handler receives `?code=...`:
auth = OAuthTokenAuth.from_authorization_code(
    code=request.GET["code"],
    client_id="...",
    client_secret="...",
    redirect_uri="https://myapp.example/oauth/callback",
)
client = Plansom(auth=auth)
page = client.goals.list(organization=org_id)  # refreshes transparently once the token nears expiry

# async
from plansom_sdk import PlansomAsync
client = PlansomAsync(auth=auth)
page = await client.goals.list(organization=org_id)
```

`OAuthTokenAuth` only reaches the `read:goals_tasks`-scoped `goals`/`tasks` endpoints — be3 doesn't
authenticate OAuth tokens anywhere else (see "OAuth" below). Restoring a token you already have and
holding a refresh token without going through the exchange are both supported too — see
[`plansom_sdk/auth.py`](https://github.com/Plansom/plansom-sdk/blob/main/python/plansom_sdk/auth.py)'s docstring for both shapes.

API Key (once be3 ships issuance):

```python
from plansom_sdk.auth import APIKeyAuth

client = Plansom(auth=APIKeyAuth(api_key="pk_live_..."))
page = client.goals.list()
```

## Design notes worth knowing before extending this

- **List-endpoint filters**: be3's list endpoints each expose ~15-18 near-identical DRF filter
  query params. Rather than naming all of them on every method, the common ones (`page`,
  `page_size`, `search`, `ordering`) are explicit and typed; the rest pass through a loose
  `**filters` kwarg straight into the query string. See `resources/goals.py`'s module docstring for
  the tradeoff (no autocomplete/typo-catching on the long tail of filter names).
- **String IDs and timestamps**: UUID and datetime fields stay as `str` rather than being parsed
  into `uuid.UUID`/`datetime` objects, to keep every model a flat, direct field list. See
  `models.py`'s module docstring.
- **One generic `Paginated[T]`**: instead of a distinct wrapper class per list response, `models.Paginated`
  is one pydantic generic model parameterized by item type — `Paginated[Goal].model_validate(data)`.
  Some sub-list endpoints (shared-members, shared-teams, attachments, acceptance-tests) don't
  paginate at all and return plain `List[Model]` instead — see the relevant resource file.
- **Every model field is `Optional` except `id`**: regardless of what the schema marks "required."
  A DRF read serializer's "required" is a weaker guarantee than it looks — see `goal_type`'s write-
  side validation gap below — so parsing must not hard-fail on a missing key. `id` is the one
  exception, since it's about as close to guaranteed-present as any field gets. Stated once in
  `models.py`'s module docstring rather than justified per field.
- **`goal_type`/`task_type` are plain `str`, not `Literal`** — verified directly against be3 source:
  `task_type`'s write-side serializer uses a bare `CharField`, not a `ChoiceField`, so arbitrary
  strings can be written today with zero validation. A strict `Literal` would make a real, reachable
  API response fail to parse. The right fix is on the backend, not papered over here — but the SDK
  stays lenient regardless, since even a backend fix wouldn't retroactively clean up existing data.
- **OAuth**: OAuth-authenticated requests are served by the same `/api/v3/goals/` and
  `/api/v3/tasks/` endpoints as any other authenticated caller, gated by a `read:goals_tasks` scope
  check — there's no OAuth-only endpoint surface. That's why `Goals`/`Tasks` need no OAuth-specific
  routing: an `OAuthTokenAuth`-authenticated client calls the same resource methods as an
  `APIKeyAuth`-authenticated one. `client.users`/`teams`/`organizations`/`search` aren't reachable
  under OAuth auth (be3 doesn't authenticate OAuth tokens on those endpoints) — not guarded against
  client-side, since letting be3's own error surface is simpler than duplicating that policy here
  and risking drift; the resulting `401` surfaces as `PlansomAuthError`, same as any other auth
  failure.

  `AuthStrategy` exposes both `get_token()` (sync) and `async_get_token()` (defaults to the sync
  path — correct for anything with no I/O). `OAuthTokenAuth` overrides both independently (sync
  `httpx.Client` / async `httpx.AsyncClient`) so the async client's refresh never blocks the event
  loop with a sync HTTP call — see `transport/http.py`'s `AsyncTransport.get`, which `await`s the
  token callable. Refresh is proactive — checked and done inside `get_token`/`async_get_token`, 60s
  before `expires_at` — rather than reactive-on-401, so `transport/http.py` stays completely
  unaware refresh exists at all.

## Reliability and typing

- Every request has a bounded timeout (`DEFAULT_TIMEOUT_SECONDS = 30.0` in `transport/http.py`)
  unless a caller passes `timeout_ms` explicitly.
- The retry loop (exponential backoff, `transport/retry.py`) covers both retryable HTTP status
  codes and transport-level failures (timeouts, connection resets, DNS) with the same attempt
  budget — the latter surface as `PlansomConnectionError`, never a raw `httpx` exception, so callers
  only ever need to catch this package's own exception hierarchy.
- PEP 561 typed (`py.typed` ships in the built package) and `mypy`-clean.
- `pagination.paginate`/`async_paginate` walk every page of a list endpoint for both clients.
- Every request sends `User-Agent: plansom-sdk-python/<version>` (`_version.py`).

## Tests

```
pip install -e ".[dev]"
pytest
```

Covers client construction and auth behavior (`test_client.py`/`test_async_client.py`), the OAuth
exchange/refresh flow respx-mocked against `/o/token/` (`test_auth.py`), sync/async pagination
(`test_pagination.py`), pydantic model parsing (`test_models.py`), the HTTP transport layer —
successful requests, error-status mapping, retries, connection failures — respx-mocked without a
real network call (`test_transport.py`), and that every field a resource method's return type
relies on still exists in `openapi/v3.yaml`'s current schema (`test_schema_conformance.py`).

```
mypy plansom_sdk/
```
