Metadata-Version: 2.4
Name: periplus-python-sdk
Version: 0.12.1
Summary: Typed Periplus platform client with SQL and notebook integration
License-Expression: AGPL-3.0-only
Project-URL: Repository, https://github.com/elei-io/periplus
Project-URL: Issues, https://github.com/elei-io/periplus/issues
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: licensing/LICENSE
License-File: licensing/NOTICE
License-File: licensing/README.md
License-File: licensing/THIRD_PARTY_NOTICES.md
Requires-Dist: httpx>=0.28
Requires-Dist: pydantic<3,>=2.12
Requires-Dist: sqlalchemy<3,>=2.0
Provides-Extra: notebook
Requires-Dist: marimo[sql]>=0.24.1; extra == "notebook"
Dynamic: license-file

# Periplus Python SDK

Typed organization operations and SQL access through the Periplus HTTP API.
The 0.12.0 platform interface requires the matching API release. It replaces the
discovery, retention and monitoring namespaces with captures, crawls, pins and
schedules, and includes the source-snapshot metadata introduced in 0.10.0.
Install from PyPI:

```sh
python -m pip install --upgrade periplus-python-sdk
```

```python
from periplus_sdk import Client

with Client("https://api.periplus.dev", api_key="ppl_…") as client:
    result = client.execute(
        "SELECT capture_id, url FROM public_v1.captures LIMIT ?", [10]
    )
    print(result.columns, result.rows)
```

The default origin is `https://api.periplus.dev`. Use `PERIPLUS_API_URL` to override it and `PERIPLUS_API_KEY` to
supply your personal organization API key. A key with `organization:sql:exec`
is required. Database usernames/passwords and anonymous access are not supported.
Use HTTPS outside loopback development. Connect directly to the API origin,
not the public marketing site.

Version 0.9.0 requires personal API keys instead of database credentials. Create
a Developer key in the app's Access → SDK & API. It inherits your current access in
that organization; SQL requires your membership to have `organization:sql:exec`.
For local development, install `./clients/periplus-python-sdk` from the repository
root and connect to `http://localhost:8000`.

`AsyncClient` accepts the same options. `prepare` explains a SELECT; `execute`
returns typed columns/rows for read-only queries. `schema()` returns
visible tables, column types/descriptions and helper documentation. All SQL uses
`POST /api/v1/sql`; schema discovery uses `GET /api/v1/schema`. ClickHouse enforces
permissions. Buffered SQL reads retry HTTP 502, 503 and 504 up to twice with bounded
backoff starting in 0.12.1. Set `sql_retries=0` to disable this, or choose up to three retries. Each
attempt may observe a newer corpus snapshot. Mutations, transport failures and
streamed queries are not retried automatically.

Public HTML joins use `capture_id` and `node_index`; `document_id` identifies exact
raw bytes. Public shorthand uses the `public_v1` schema.

In 0.10.0, `result.source_snapshot` is a typed `SourceSnapshot` containing
`layout_id` (UUID) and `publication_epoch` (integer), or `None` when no build-bound
public corpus was read. Buffered, asynchronous, streamed and DB-API results share
this contract. Compare both fields, not just the epoch. The identity describes
the public corpus inputs, not any native staff/external inputs. It does not retain
the data or request historical reads. This replaces the old nullable integer field;
use this SDK version with the corresponding API release.

For notebook/SQLAlchemy integration:

```python
from periplus_sdk import sql_api
from sqlalchemy import text

engine = sql_api.create_engine(base_url="http://localhost:8000", api_key="ppl_…")
with engine.connect() as connection:
    print(connection.execute(text("SELECT url FROM public_v1.captures LIMIT 5")).all())
engine.dispose()
```

Marimo discovers accessible tables, views, typed columns and comments through the
schema endpoint. Reflection does not execute SQL. One SQLAlchemy Inspector caches
its metadata; call `inspector.clear_cache()` to refresh it. Missing metadata raises
an error rather than presenting an apparently complete empty schema.

The DB-API connection advertises the ClickHouse dialect and converts native
nullable integer, decimal, date and datetime types. Nested types retain JSON wire
values. Writes are not exposed through the query API. There are no client
transactions; each statement is independent.
Streaming cursors expose incomplete/truncated results explicitly; configure
`allow_partial` only when partial results suit the application.

See [the public schema](../../docs/SCHEMA.md) and [query boundary](../../docs/QUERY.md).

## Organization resources

A Developer key selects your organization and uses your live membership access. Agent keys authenticate only to the hosted MCP endpoint, not this SDK.
`Client` and `AsyncClient` expose the same namespaces:

| Namespace | Operations |
| --- | --- |
| `identity`, `availability` | `get` |
| `captures`, `crawls` | `create`, `get`, `list`, `iter`, `pages`, `cancel` |
| `pins` | `create`, `get`, `list`, `iter`, `captures`, `update`, `delete` |
| `schedules` | `create`, `get`, `list`, `iter`, `pages`, `captures`, `update`, `pause`, `resume`, `delete` |
| `saved_queries` | `create`, `get`, `list`, `iter`, `rename`, `delete` |
| `members` | `list`, `update_role`, `remove` |
| `invitations` | `list`, `create`, `cancel` |
| `api_keys` | `create`, `list`, `iter`, `revoke` |
| `usage` | `get` |
| `query_history` | `list`, `iter`, `get`, `summary` |
| `audit` | `list`, `iter` |

Choose the resource by what you know and how long you need it:

| Resource | Use it when |
| --- | --- |
| `captures` | You know the URLs (1–100) and need them in SQL now |
| `crawls` | You know a starting point, not the URLs |
| `pins` | You need exact captures for longer than seven days |
| `schedules` | You need the same pages again later |

```python
from periplus_sdk import Client

with Client() as client:
    capture = client.captures.create(urls=["https://example.com/pricing"])
    capture = client.captures.get(capture, wait_seconds=30)
    if capture.finished and capture.queryable:
        for page in client.captures.pages(capture).items:
            print(page.url, page.status, page.capture_id)

    crawl = client.crawls.create(seeds=["https://example.com/docs/"], page_limit=200,
                                 allowed_paths=["/docs/*"])

    pin = client.pins.create(
        name="Research sources", days=90,
        sql="SELECT capture_id FROM captures WHERE domain(url) = ?",
        parameters=["example.com"],
    )
    schedule = client.schedules.create(name="Pricing", every_days=7,
                                       urls=["https://example.com/pricing"])
    print(pin.captures, schedule.projected_pages_per_month)
```

Every capture is kept for seven days after collection unless a pin keeps it. A
capture always fetches again and never returns an existing capture, so check
coverage with SQL first. `request_id` (a capture or crawl `id`) is what you
ordered; each page's `capture_id` is what the crawler produced and is the SQL key.
`get(..., wait_seconds=N)` returns as soon as the request is finished and queryable,
or after at most 30 seconds; the SDK never polls on its own.

Pins and schedules accept explicit IDs/URLs or one read-only SQL query with
parameters, and are active immediately. SQL runs once at creation and its exact
result is locked in; running the same query beforehand is only an estimate.
Schedule URLs need not be in the corpus. `pin_days` on a capture, crawl or schedule
pins each successful capture it produces and requires `organization:pins:write`.
`pins.update` changes `days` (moving every capture's expiry by the difference) or
the name; `schedules.update` changes `every_days` or the name. Both accept a fetched
object or an ID with `expected_version`; conflicts are never retried. Deletion needs
no version: deleting a pin removes future protection, not captures.

Paginated lists return `Page[T]` (`items`, `next_cursor`). Pass cursors unchanged
or use `iter()`; membership/invitation lists are bounded snapshots instead.
Offset-based lists can shift during concurrent changes. Query history, request
page feeds and scheduled capture feeds use opaque keyset cursors. UTC usage ranges have an exclusive end date, at most 93 days. Usage reports pages
captured (broken down by capture, crawl or schedule), challenge-resolution pages and
pinned capture-days.
Query history is best-effort and expires after 30 days; it is not a billing ledger.

`ApiError` includes HTTP status, code, optional request ID, validation fields and
Retry-After seconds. `TransportError` means the outcome of a write can be unknown.
Keep creation IDs to reconcile; never blindly retry. Key creation is one-time:
`created.secret.get_secret_value()` reveals the secret and must only be used for
secure storage. Its ordinary representation is masked. Lost secrets cannot be
recovered.

For async streaming, use `async with await client.stream(sql) as stream` followed
by `async for batch in stream`. Streams validate completion and close on early
exit, errors, and cancellation. No threads or background polling are introduced.
