Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ pip install oblidog-client

## Usage

Set `OBLIDOG_BASE_URL` to your server root and `OBLIDOG_API_KEY` through your
secret store or environment. This example assumes an active category schema
accepting `meter_reading_kwh`.

```python
import datetime
import os

from oblidog_client import OblidogClient

with OblidogClient(base_url="https://oblidog.example.com", api_key="fdg_live_...") as client:
with OblidogClient(
base_url=os.environ["OBLIDOG_BASE_URL"],
api_key=os.environ["OBLIDOG_API_KEY"],
) as client:
with client.integrations.run() as run:
client.category_data.create(
observed_at=datetime.datetime.now(datetime.UTC),
Expand All @@ -42,9 +50,14 @@ raises.

The full documentation is published at
[oblidog.github.io/oblidog-client-python](https://oblidog.github.io/oblidog-client-python/).
It includes a quickstart, public API reference, and supported API boundary; it
It includes a quickstart, task-oriented guides, public API reference, and supported API boundary; it
intentionally excludes generated endpoints and transport classes.

Start with [configuration](docs/guides/configuration.md), then follow the
[obligation import](docs/guides/obligation-import.md),
[category observations](docs/guides/category-data.md), and
[lifecycle and failure handling](docs/guides/lifecycle-and-errors.md) guides.

## Development

This project uses `uv` for dependency and environment management. The low-level client is generated from the Oblidog Ledger integration OpenAPI contract; generated code should not be edited manually.
Expand Down
29 changes: 23 additions & 6 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,36 @@ Install the package from PyPI:
pip install oblidog-client
```

[Configure the environment](guides/configuration.md) before running these
examples. The quickstart assumes an existing editable September 2026 obligation
and an active category schema accepting `meter_reading_kwh`.

Every synchronization should run inside `client.integrations.run()`. The
wrapper reads the integration context, starts a run using its current revision,
and reports a sanitized failure if the block raises.

```python
import datetime
import os

from oblidog_client import OblidogClient, ObligationPeriod

with OblidogClient(base_url="https://oblidog.example.com", api_key="fdg_live_...") as client:
with OblidogClient(
base_url=os.environ["OBLIDOG_BASE_URL"],
api_key=os.environ["OBLIDOG_API_KEY"],
) as client:
with client.integrations.run() as run:
period = ObligationPeriod(2026, 9)
client.obligations.update(period, current_amount="79.00")
client.obligations.mark_ready(period)
client.obligations.update(
period,
current_amount="79.00",
due_date=datetime.date(2026, 9, 20),
)
client.category_data.create(
observed_at=datetime.datetime.now(datetime.UTC),
data={"meter_reading_kwh": 1234.5},
)
client.obligations.mark_ready(period)
run.finish_success(changes_detected=True)
```

Expand All @@ -45,6 +57,8 @@ leaking an `httpx` transport exception.
The default retry policy uses four attempts. It can be customized or disabled:

```python
import os

from oblidog_client import OblidogClient, RetryPolicy

retry_policy = RetryPolicy(
Expand All @@ -55,12 +69,15 @@ retry_policy = RetryPolicy(
)

with OblidogClient(
base_url="https://oblidog.example.com",
api_key="fdg_live_...",
base_url=os.environ["OBLIDOG_BASE_URL"],
api_key=os.environ["OBLIDOG_API_KEY"],
retry_policy=retry_policy,
) as client:
context = client.integrations.get_context()
context = client.integrations.context()
```

To disable automatic retries while retaining the stable connection exception,
pass `RetryPolicy(enabled=False)`.

Continue with the [complete invoice import](guides/obligation-import.md) or
[category observations](guides/category-data.md) recipes.
74 changes: 74 additions & 0 deletions docs/guides/category-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Store and read category observations

Keep timestamped provider measurements, such as meter readings, separately from
invoice amounts. A category must already have an active data schema configured
in Ledger. This example expects a schema accepting a numeric
`meter_reading_kwh` property; adapt the payload to your category's schema.

After [configuring the environment](configuration.md), this complete example
inspects the schema, stores one observation, reads the latest observation, and
walks all records in a bounded time range:

```python
import datetime
import os

from oblidog_client import OblidogClient

with OblidogClient(
base_url=os.environ["OBLIDOG_BASE_URL"],
api_key=os.environ["OBLIDOG_API_KEY"],
) as client:
with client.integrations.run() as run:
schema = client.category_data.schema()
print(schema.version, schema.schema.to_dict())

# Sample provider timestamp and stable reading identity.
observed_at = datetime.datetime(2026, 8, 1, 10, tzinfo=datetime.UTC)
record = client.category_data.create(
observed_at=observed_at,
data={"meter_reading_kwh": 1234.5},
external_id="meter-42-reading-2026-08-01T10:00:00Z",
)
print(record.id, record.source, record.schema_version)
latest = client.category_data.latest()
print(latest.observed_at, latest.data.to_dict())

offset = 0
while True:
page = client.category_data.list(
from_=datetime.datetime(2026, 8, 1, tzinfo=datetime.UTC),
to=datetime.datetime(2026, 8, 31, 23, 59, 59, tzinfo=datetime.UTC),
limit=100,
offset=offset,
)
if not page.data:
break
for observation in page.data:
print(observation.observed_at, observation.data.to_dict())
offset += len(page.data)

run.finish_success(changes_detected=None)
```

`from_` and `to` are inclusive observation-time filters; the trailing underscore
avoids Python's reserved `from` keyword. Keep the same bounds while paginating
and advance `offset` by the number of returned records. Offset pagination is not
a snapshot: concurrent inserts can shift pages. The example reports unknown
changes because a repeated external identity may already exist.

`source` is assigned by Ledger from the authenticated integration's name; it is
returned on records and is not an argument to `create`. `external_id` is an
optional stable provider identity used for idempotency. Reuse it for the same
observation, not for different readings. Omit it when no stable identity exists
(or pass `None` explicitly); do not assume unidentified repeated submissions
will be deduplicated. Do not generate a fresh random ID for each retry.

The active schema validates submitted `data`. A schema rejection raises
`OblidogValidationError`. No active schema or no latest observation can produce
an API error; handle these as setup or empty-history conditions as appropriate
for the returned status. `latest()` refers to the category's latest observation,
which need not be the record just submitted if newer data already exists.

See [category-data reference](../reference/category-data.md) for models and
arguments, and [failure handling](lifecycle-and-errors.md) for a copyable handler.
44 changes: 44 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Configure an integration

Give each integration its own API key in Ledger. The key selects one integration
and one category: SDK calls do not take a ledger ID or category code. Configure
the Oblidog server URL and key through your deployment's environment or secret
store; never commit a real key or print it in diagnostics.

Set `OBLIDOG_BASE_URL` to the server root, for example
`https://oblidog.example.com` (without `/api/v1`). Set `OBLIDOG_API_KEY` to the
integration key through your secret manager or local environment.

This complete connection check reads context without starting a synchronization:

```python
import os

from oblidog_client import OblidogClient

with OblidogClient(
base_url=os.environ["OBLIDOG_BASE_URL"],
api_key=os.environ["OBLIDOG_API_KEY"],
timeout=15.0,
) as client:
context = client.integrations.context()
print(context.category.code)
```

`os.environ[...]` fails early if configuration is missing. The context manager
closes the HTTP client even when a request raises. Keep one client open for a
synchronization and wrap its work in `client.integrations.run()`; explicitly
call `run.finish_success(changes_detected=...)` after all steps succeed. An
exception escaping the run block reports a sanitized failure and propagates.
Leaving the block without finishing reports `run_incomplete`.

Use `datetime.date` for invoice dates and timezone-aware `datetime.datetime`
values for observations, for example `datetime.datetime.now(datetime.UTC)`.
Prefer the provider's actual observation timestamp when one is available; do not
silently interpret local wall time as UTC.

Continue with [importing an obligation](obligation-import.md),
[category observations](category-data.md), or
[lifecycle and failures](lifecycle-and-errors.md).
See [client and run reference](../reference/client.md) for configuration and
retry policy options.
141 changes: 141 additions & 0 deletions docs/guides/lifecycle-and-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Handle lifecycle decisions and failures

An integration must distinguish invalid obligation data from an unsuccessful
synchronization. `ERROR` is an alarm that the integration recognized invalid or
inconsistent data in Oblidog. A provider outage, login failure or network timeout
alone is a failed integration run; it is not a reason to mark an obligation
`ERROR`.

Ledger's [authoritative obligation lifecycle](https://github.com/oblidog/oblidog-ledger/blob/dev/docs/obligation-lifecycle.md)
defines allowed transitions and field effects. Consult it instead of assuming
all actions work from every state.

| SDK operation | Business intent |
| --- | --- |
| `mark_ready(period)` | Confirm complete, validated payment data and make the obligation ready for payment. |
| `mark_paid(period)` | Record a payment confirmed by the provider or another trusted source; importing an invoice alone is insufficient. |
| `cancel(period)` | Cancel an obligation that is being collected and should no longer be paid. |
| `reopen(period)` | Deliberately return a ready, paid, canceled or erroneous obligation to data collection so it can be corrected; clears `paid_at`. |
| `mark_error(period)` | Raise an alarm about invalid obligation data while preserving values, notes, components and payment timestamp. |

Updating values does not make an obligation ready automatically. Correct an
`ERROR` obligation by deliberately reopening it, correcting its data, and then
marking it ready. Do not automate reopening merely because an update was
rejected: that can undo a meaningful paid or canceled decision.

## Report recognized inconsistent data

Suppose your provider reconciliation has established that the total stored in
Oblidog is inconsistent. This reusable function appends a sanitized diagnostic,
raises the obligation alarm, and reports a failed run. Call it only after that
business validation has actually failed; it does not diagnose a mismatch itself.

```python
import os

from oblidog_client import OblidogClient, ObligationPeriod


def report_inconsistent_obligation(period: ObligationPeriod) -> None:
with OblidogClient(
base_url=os.environ["OBLIDOG_BASE_URL"],
api_key=os.environ["OBLIDOG_API_KEY"],
) as client:
with client.integrations.run() as run:
client.obligations.append_note(
period, "Reconciliation found an inconsistent invoice total"
)
client.obligations.mark_error(period)
run.finish_failure(
code="invalid_obligation_data",
message="Invoice total needs review",
)
```

Reporting run failure does not itself change the obligation lifecycle. Likewise,
marking an obligation `ERROR` does not finish the run. If the original
synchronization already has an active run, perform these calls inside that run
instead of starting a nested one.

## Catch SDK failures at the synchronization boundary

This complete entry point updates a known editable obligation. Configure the
environment and replace the sample invoice values as described in the
[import guide](obligation-import.md). Exceptions leave the run block before being
caught, allowing its context manager to report failure. Nonzero exit status lets
your scheduler detect failure.

```python
import datetime
import logging
import os

from oblidog_client import (
OblidogApiError,
OblidogClient,
OblidogConflictError,
OblidogConnectionError,
OblidogError,
OblidogValidationError,
ObligationPeriod,
)

logger = logging.getLogger(__name__)


def main() -> int:
try:
with OblidogClient(
base_url=os.environ["OBLIDOG_BASE_URL"],
api_key=os.environ["OBLIDOG_API_KEY"],
) as client:
with client.integrations.run() as run:
client.obligations.update(
ObligationPeriod(2026, 8),
current_amount="425.30",
due_date=datetime.date(2026, 8, 20),
)
run.finish_success(changes_detected=None)
except OblidogValidationError as error:
logger.error("Request rejected (HTTP %s); review payload", error.status_code)
return 1
except OblidogConflictError as error:
logger.error("Integration state conflict: %s", error.code.value)
return 1
except OblidogConnectionError:
logger.error("API unavailable; reconcile writes before another attempt")
return 1
except OblidogApiError as error:
logger.error("Unexpected API response (HTTP %s)", error.status_code)
return 1
except OblidogError:
logger.error("Synchronization failed with an SDK error")
return 1
return 0


if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
raise SystemExit(main())
```

Catch `OblidogConflictError` before `OblidogApiError` because it is a subclass.
Do not refresh the integration revision and blindly replay the invocation after
a conflict; check for another worker or a changed integration configuration.
A 422 rejects the request payload; it does not prove that stored obligation data
is inconsistent. Treat local `ValueError`/`TypeError` (such as an invalid
`ObligationPeriod`) as input or programming errors and correct them at the source.

`OblidogConnectionError` covers transport failures and exhausted retryable server
responses. Only reads are retried automatically. A timeout during a write can
mean the server applied it without returning a response. Read the current state
before deciding how to resume, especially after appending notes or creating
records without an external ID. The run wrapper attempts failure reporting on
an exception, but a disconnected API can prevent that report too.

API and validation exceptions retain `status_code` and raw response `content`.
Keep raw bodies, credentials, provider responses and exception tracebacks out of
user-facing notes and run failure messages. The handler logs only status codes
and typed conflict codes. See the [exceptions reference](../reference/exceptions.md),
[client/run reference](../reference/client.md), and
[obligations reference](../reference/obligations.md) for the supported contracts.
Loading
Loading