diff --git a/README.md b/README.md index f6fd1b0..390b3a4 100644 --- a/README.md +++ b/README.md @@ -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), @@ -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. diff --git a/docs/getting-started.md b/docs/getting-started.md index 9f0b80e..6719645 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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) ``` @@ -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( @@ -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. diff --git a/docs/guides/category-data.md b/docs/guides/category-data.md new file mode 100644 index 0000000..428ecce --- /dev/null +++ b/docs/guides/category-data.md @@ -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. diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 0000000..4e00dfd --- /dev/null +++ b/docs/guides/configuration.md @@ -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. diff --git a/docs/guides/lifecycle-and-errors.md b/docs/guides/lifecycle-and-errors.md new file mode 100644 index 0000000..6693b34 --- /dev/null +++ b/docs/guides/lifecycle-and-errors.md @@ -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. diff --git a/docs/guides/obligation-import.md b/docs/guides/obligation-import.md new file mode 100644 index 0000000..7eea454 --- /dev/null +++ b/docs/guides/obligation-import.md @@ -0,0 +1,87 @@ +# Import an invoice into an obligation + +Update an existing monthly obligation with a provider invoice, attach its line +items, then make it ready for payment. Ledger creates obligations according to +category recurrence; this recipe does not create a missing obligation. + +First [configure the environment](configuration.md). The example assumes the +key-bound category has an August 2026 obligation in `DRAFT` or `COLLECTING_DATA`. +Replace the sample period, invoice dates, amount and identifiers with validated +provider data. The invoice currency must match the obligation currency. + +```python +import datetime +import os + +from oblidog_client import OblidogClient, ObligationLifecycle, ObligationPeriod + +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, 8) + obligation = client.obligations.get(period) + print(obligation.key) + + # Alternative discovery: filter obligations in this category. + candidates = client.obligations.list( + year=2026, + month=8, + lifecycle=ObligationLifecycle.COLLECTING_DATA, + ) + print([item.key for item in candidates.data]) + + if obligation.lifecycle not in ( + ObligationLifecycle.DRAFT, + ObligationLifecycle.COLLECTING_DATA, + ): + raise ValueError("Review the obligation lifecycle before importing") + + client.obligations.update( + period, + current_amount="425.30", + issue_date=datetime.date(2026, 8, 1), + due_date=datetime.date(2026, 8, 20), + ) + result = client.obligations.upsert_component( + period, + type="principal", + label="August electricity", + external_id="invoice-FV-123-2026-line-1", + amount="425.30", + metadata={"invoice_number": "FV/123/2026"}, + ) + print(result.result) + components = client.obligations.list_components(period) + print([item.label for item in components.data]) + client.obligations.append_note(period, "Imported invoice FV/123/2026") + client.obligations.mark_ready(period) + run.finish_success(changes_detected=True) +``` + +The obligation key (such as `ENRG-2026-08`) is useful for display and diagnostics. +The supported lookup accepts `ObligationPeriod`, not the full key: use the year +and month from the provider, with the category supplied by the API key. A 404 +means no visible obligation exists for that period; check the category and +recurrence instead of inventing a different key. + +Supply money as decimal strings to avoid binary floating-point rounding. +Omitting an `update` argument preserves it; passing `None` explicitly clears it. +Components do not replace the obligation's amount: set the intended total with +`update`. Keep a stable component `external_id` across repeated imports so the +same line is updated rather than duplicated. The upsert's `result` is +`created`, `updated`, or `unchanged`. + +This sequence is not a transaction. Earlier writes can remain if a later call +fails. Notes are appended on every call, and the entire sequence is not safe to +replay blindly. After a partial failure, read the obligation and components, +reconcile against the provider, and resume deliberately. In particular, avoid +reopening a paid or canceled obligation just to force an import through. + +`mark_ready` belongs last, after successful validation and all intended writes. +For a known no-op report `changes_detected=False`; use `None` when you cannot +reliably determine whether data changed. See +[lifecycle and failures](lifecycle-and-errors.md) for recovery guidance and the +[obligations reference](../reference/obligations.md) for method signatures and +return models. diff --git a/docs/index.md b/docs/index.md index 9854337..cc81997 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,3 +10,10 @@ surface and is intentionally absent from this reference. Start with [installation and quickstart](getting-started.md), then use the API reference for the exported public symbols. + +For complete business workflows, follow the integration guides: + +- [Configure an integration](guides/configuration.md). +- [Import an invoice into an obligation](guides/obligation-import.md). +- [Store and read category observations](guides/category-data.md). +- [Handle lifecycle decisions and failures](guides/lifecycle-and-errors.md). diff --git a/mkdocs.yml b/mkdocs.yml index 2695049..5409442 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,11 @@ markdown_extensions: nav: - Overview: index.md - Installation and quickstart: getting-started.md + - Integration guides: + - Configuration: guides/configuration.md + - Import an obligation: guides/obligation-import.md + - Category observations: guides/category-data.md + - Lifecycle and failures: guides/lifecycle-and-errors.md - API reference: - Client and runs: reference/client.md - Obligations: reference/obligations.md diff --git a/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..1db0fcd --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,28 @@ +"""Keep copyable documentation examples syntactically valid without API access.""" + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def python_examples() -> list[tuple[str, str]]: + examples = [] + for path in [ROOT / "README.md", *sorted((ROOT / "docs").rglob("*.md"))]: + for index, match in enumerate( + re.finditer( + r"^```python\n(.*?)^```\s*$", path.read_text(), re.MULTILINE | re.DOTALL + ), + start=1, + ): + examples.append((f"{path.relative_to(ROOT)}:example-{index}", match[1])) + if not examples: + raise AssertionError("No Python documentation examples found") + return examples + + +@pytest.mark.parametrize("name,source", python_examples()) +def test_documentation_example_compiles(name: str, source: str) -> None: + compile(source, name, "exec")