Metadata-Version: 2.4
Name: conduit-client
Version: 0.2.2
Summary: Python client for the Conduit / Mega API gateway.
Author: Chelsea
License: MIT
Requires-Python: >=3.10
Requires-Dist: httpx>=0.25
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# conduit-client

`conduit-client` is a synchronous Python client for the Conduit / Mega API
gateway at `https://api.cowtunnel.com`.

It imports as `conduit`:

```python
from conduit import Client
```

The client covers the stable gateway routes and the currently routed provider
facades for SMS/MMS, MeTube ingestion, Jellyfin library search, Transmission,
Proxmox, and BIND9 DNS. Provider response bodies are intentionally permissive
because those backend schemas can evolve faster than the gateway surface.

## Install

For local development from this repository:

```sh
python -m pip install -e .
```

For the Gitea package registry used by this project:

```sh
python -m pip install \
  --index-url "https://git.scorpi.us/api/packages/chelsea/pypi/simple" \
  --extra-index-url https://pypi.org/simple \
  conduit-client
```

See `PUBLISHING.md` for build and publish commands.

## Quick Start

```python
from conduit import Client

client = Client.from_env()

print(client.health())
print(client.services.list())
print(client.services.health())
print(client.auth.me())
```

Use the client as a context manager when you want it to close its internal
HTTP connection pool automatically:

```python
from conduit import Client

with Client.from_env() as client:
    for service in client.services.list():
        print(service.id, service.enabled)
```

## Configuration

`Client.from_env()` reads:

| Variable | Default | Purpose |
| --- | --- | --- |
| `MEGA_API_BASE_URL` | `https://api.cowtunnel.com` | Gateway base URL |
| `MEGA_API_KEY` | unset | API key for protected endpoints |
| `MEGA_API_TIMEOUT` | `30` | Request timeout in seconds |

PowerShell:

```powershell
$env:MEGA_API_KEY = "<api-key>"
$env:MEGA_API_BASE_URL = "https://api.cowtunnel.com"
```

POSIX shells:

```sh
export MEGA_API_KEY="<api-key>"
export MEGA_API_BASE_URL="https://api.cowtunnel.com"
```

You can also configure the client explicitly:

```python
from conduit import Client

client = Client(
    base_url="https://api.cowtunnel.com",
    api_key="<api-key>",
    timeout=10,
)
```

By default, protected calls send:

```text
Authorization: Bearer <MEGA_API_KEY>
```

Use `X-API-Key` instead when needed:

```python
client = Client(api_key="<api-key>", auth_header="x-api-key")
```

Public methods (`health()`, `docs()`, `openapi()`, `auth.login()`, and
`auth.callback()`) can be called without an API key. Protected methods raise
`ConduitConfigError` locally when no key is configured.

## Return Values

Most methods return decoded JSON as `dict`, `list`, or provider-specific JSON
structures. Text endpoints return `str`.

The service catalog uses small dataclasses:

```python
service = client.services.list()[0]
print(service.id)
print(service.name)
print(service.prefixes)
print(service.enabled)
print(service.raw)
```

`ProviderInfo.raw` and `ServiceHealth.raw` preserve the original gateway body
for fields the client does not model directly.

## Errors

All library exceptions inherit from `ConduitAPIError`.

```python
from conduit import Client
from conduit.errors import (
    ConduitAPIError,
    ConduitAuthError,
    ConduitConfigError,
    ConduitHTTPError,
    ConduitNotFoundError,
    ConduitRateLimitError,
)

client = Client.from_env()

try:
    print(client.auth.me())
except ConduitConfigError as exc:
    print(f"client is not configured: {exc}")
except ConduitAuthError as exc:
    print(f"auth failed: {exc.status_code} {exc.detail}")
except ConduitRateLimitError as exc:
    print(f"rate limited: {exc.status_code}")
except ConduitNotFoundError as exc:
    print(f"not found: {exc.url}")
except ConduitHTTPError as exc:
    print(f"gateway error: {exc.status_code} {exc.detail}")
except ConduitAPIError as exc:
    print(f"client error: {exc}")
```

`ConduitHTTPError` exposes:

- `status_code`
- `method`
- `url`
- `detail`
- `body`
- `headers`

Network-level `httpx` failures are wrapped as `ConduitConnectionError`.
Invalid JSON from a response that claims to be JSON raises
`ConduitResponseError`.

## Namespace Map

Built-in namespaces are available as attributes on `Client`.

| Namespace | Alias | Gateway prefix | Purpose |
| --- | --- | --- | --- |
| `client.auth` | none | `/auth` | Login/callback helpers and current actor |
| `client.services` | none | `/services`, `/health/services` | Provider catalog and provider health |
| `client.keys` | none | `/keys` | API keys owned by the current actor |
| `client.admin` | none | `/admin` | Admin key inspection and audit logs |
| `client.ui` | none | `/ui` | Gateway HTML helpers |
| `client.sms` | `client.mms` | `/sms`, `/mms` | SMS/MMS send, history, media, delete |
| `client.metube` | `client.media.ingest` | `/media/ingest` | Media ingestion jobs and subscriptions |
| `client.jellyfin` | `client.media.library` | `/media/library` | Media library auth, search, playback |
| `client.transmission` | `client.torrents` | `/torrents` | Torrent and session operations |
| `client.proxmox` | `client.compute` | `/compute` | Compute, VM, container, storage, access |
| `client.bind9` | `client.dns` | `/dns` | DNS server and zone operations |

## Gateway Examples

Health, docs, and OpenAPI:

```python
print(client.health())       # GET /health
print(client.docs())         # GET /docs
schema = client.openapi()    # GET /openapi.json
```

Service discovery:

```python
for provider in client.services.list():
    print(provider.id, provider.enabled, provider.prefixes)

if client.services.is_enabled("bind9"):
    print(client.dns.server_status())
```

Authentication helpers:

```python
login = client.auth.login()
callback = client.auth.callback(code="...", state="...")
me = client.auth.me()
```

API keys:

```python
created = client.keys.create(
    display_name="automation",
    scopes=["gateway:read"],
    expires_at=None,
    allowed_ips=["203.0.113.10"],
    rate_limit=120,
)

print(created["key_prefix"])

# The raw key is returned by the gateway once. Do not log it.
raw_key = created.get("key")

client.keys.revoke(created["key_prefix"])
```

Admin:

```python
keys = client.admin.keys()
logs = client.admin.audit(limit=25, result="success")
client.admin.revoke_key("abc123")
```

## Provider Examples

SMS with the configured default sender DID:

```python
client.sms.send("2025550100", "hello")
```

SMS with an explicit sender DID:

```python
client.sms.send("2025550100", "hello", did="2025550199")
```

MMS:

```python
client.sms.send_mms(
    "2025550100",
    "image attached",
    media1="https://example.test/image.png",
)
```

Message history:

```python
messages = client.sms.history(
    from_date="2026-01-01",
    to_date="2026-01-31",
    limit=50,
    all_messages=True,
)
```

Media ingestion:

```python
jobs = client.media.ingest.jobs()

job = client.media.ingest.create_job(
    "https://example.test/video",
    quality="best",
)

client.media.ingest.start_job(job["id"])
```

Jellyfin library:

```python
results = client.media.library.search(term="matrix", limit=5)
item = client.media.library.item(results["Items"][0]["Id"])
playback = client.media.library.playback(item["Id"])
```

Transmission:

```python
client.torrents.add(filename="magnet:?xt=urn:btih:...")
client.torrents.list()
client.torrents.start(7)
client.torrents.stop(7)
```

Proxmox:

```python
client.compute.resources(type="vm")
client.compute.vms()
client.compute.start_vm(100)
client.compute.shutdown_vm(100)
```

BIND9:

```python
client.dns.server_status()
client.dns.zone("example.org")

client.dns.batch_update_records(
    "example.org",
    [
        {
            "action": "add",
            "name": "www",
            "type": "A",
            "ttl": 300,
            "value": "192.0.2.10",
        }
    ],
)
```

## Destructive Operations

High-risk helpers require an explicit `confirm` argument that matches the
resource identifier.

```python
client.dns.delete_zone("example.org", confirm="example.org")
client.compute.delete_vm(100, confirm=100)
client.torrents.remove(7, confirm=7, delete_local_data=False)
```

If the confirmation does not match, the client raises `ConduitConfigError`
before sending a request.

## Raw Requests

Use `client.request()` when a gateway route exists but does not have a typed
helper yet:

```python
data = client.request("GET", "/dns/servers/default/status")
created = client.request("POST", "/keys", json={"display_name": "bot", "scopes": []})
```

Requests are protected by default. Pass `protected=False` for public routes:

```python
schema = client.request("GET", "/openapi.json", protected=False)
```

Prefer typed namespace methods once they exist.

## Custom Namespaces

Custom namespaces share the same transport as the built-in namespaces.

```python
from conduit import Client
from conduit.namespaces import Namespace


class WikiNamespace(Namespace):
    def status(self):
        return self._get("/wiki/status")


client = Client.from_env()
wiki = client.register_namespace("wiki", WikiNamespace)

print(client.wiki.status())
print(wiki.status())
```

`register_namespace()` rejects duplicate names unless `replace=True` is passed.

## API Reference

### Client

| Method | Route | Auth | Returns |
| --- | --- | --- | --- |
| `Client.from_env()` | none | none | configured `Client` |
| `client.close()` | none | none | `None` |
| `client.health()` | `GET /health` | public | `str` |
| `client.docs()` | `GET /docs` | public | `str` |
| `client.openapi()` | `GET /openapi.json` | public | `dict` |
| `client.request(method, path, **kwargs)` | any | protected by default | decoded response |
| `client.register_namespace(name, cls, replace=False)` | none | none | namespace instance |
| `client.namespace(name)` | none | none | namespace instance |

### `client.auth`

| Method | Route | Auth |
| --- | --- | --- |
| `login()` | `GET /auth/login` | public |
| `callback(code, state)` | `GET /auth/callback` | public |
| `me()` | `GET /auth/me` | protected |

### `client.services`

| Method | Route | Returns |
| --- | --- | --- |
| `list()` | `GET /services` | `list[ProviderInfo]` |
| `health()` | `GET /health/services` | `list[ServiceHealth]` |
| `provider(provider_id)` | `GET /services` | `ProviderInfo | None` |
| `is_enabled(provider_id)` | `GET /services` | `bool` |

### `client.keys`

| Method | Route |
| --- | --- |
| `list()` | `GET /keys` |
| `create(*, display_name, scopes, expires_at=None, allowed_ips=None, rate_limit=None)` | `POST /keys` |
| `revoke(key_prefix)` | `DELETE /keys/{key_prefix}` |

### `client.admin`

| Method | Route |
| --- | --- |
| `keys()` | `GET /admin/keys` |
| `revoke_key(key_prefix)` | `DELETE /admin/keys/{key_prefix}` |
| `audit(*, actor_subject=None, scope=None, result=None, endpoint=None, since=None, until=None, limit=100)` | `GET /admin/audit` |

### `client.ui`

| Method | Route | Returns |
| --- | --- | --- |
| `keys()` | `GET /ui/keys` | `str` |

### `client.sms` / `client.mms`

| Method | Route |
| --- | --- |
| `send(dst, message, *, did=None)` | `POST /sms/send/default` or `POST /sms/send` |
| `send_mms(dst, message, *, did=None, media1=None, media2=None, media3=None)` | `POST /mms/send/default` or `POST /mms/send` |
| `history(*, sms=None, from_date=None, to_date=None, type=None, did=None, contact=None, limit=None, timezone=None, all_messages=None)` | `GET /sms` |
| `mms_history(*, id=None, from_date=None, to_date=None, type=None, did=None, contact=None, limit=None, timezone=None, all_messages=None)` | `GET /mms` |
| `media(id, *, media_as_array=None)` | `GET /mms/{id}/media` |
| `delete(id)` | `DELETE /sms/{id}` |
| `delete_mms(id)` | `DELETE /mms/{id}` |

### `client.metube` / `client.media.ingest`

| Method | Route |
| --- | --- |
| `jobs()` | `GET /media/ingest/jobs` |
| `create_job(url, **options)` | `POST /media/ingest/jobs` |
| `start_job(job_id)` | `POST /media/ingest/jobs/{job_id}/start` |
| `cancel_job(job_id)` | `POST /media/ingest/jobs/{job_id}/cancel` |
| `delete_job(job_id)` | `DELETE /media/ingest/jobs/{job_id}` |
| `presets()` | `GET /media/ingest/presets` |
| `subscriptions()` | `GET /media/ingest/subscriptions` |
| `create_subscription(**payload)` | `POST /media/ingest/subscriptions` |
| `update_subscription(id, **payload)` | `PATCH /media/ingest/subscriptions/{id}` |
| `delete_subscription(id)` | `DELETE /media/ingest/subscriptions/{id}` |
| `check_subscription(id)` | `POST /media/ingest/subscriptions/{id}/check` |
| `version()` | `GET /media/ingest/version` |

### `client.jellyfin` / `client.media.library`

| Method | Route |
| --- | --- |
| `login(username, password)` | `POST /media/library/auth/login` |
| `search(*, term=None, item_type=None, filters=None, parent_id=None, limit=None, offset=None, fields=None, **extra)` | `GET /media/library/search` |
| `item(item_id)` | `GET /media/library/items/{item_id}` |
| `playback(item_id)` | `GET /media/library/items/{item_id}/playback` |
| `refresh()` | `POST /media/library/refresh` |
| `sessions()` | `GET /media/library/sessions` |
| `system_info(*, public=False)` | `GET /media/library/system/info` |

### `client.transmission` / `client.torrents`

| Method | Route |
| --- | --- |
| `add(**payload)` | `POST /torrents` |
| `list(**params)` | `GET /torrents` |
| `get(torrent_id)` | `GET /torrents/{torrent_id}` |
| `update(torrent_id, **payload)` | `PATCH /torrents/{torrent_id}` |
| `start(torrent_id)` | `POST /torrents/{torrent_id}/start` |
| `start_now(torrent_id)` | `POST /torrents/{torrent_id}/start-now` |
| `stop(torrent_id)` | `POST /torrents/{torrent_id}/stop` |
| `verify(torrent_id)` | `POST /torrents/{torrent_id}/verify` |
| `reannounce(torrent_id)` | `POST /torrents/{torrent_id}/reannounce` |
| `move(torrent_id, **payload)` | `POST /torrents/{torrent_id}/move` |
| `rename_path(torrent_id, **payload)` | `POST /torrents/{torrent_id}/rename-path` |
| `remove(torrent_id, *, confirm, delete_local_data=False)` | `DELETE /torrents/{torrent_id}` |
| `queue_move(**payload)` | `POST /torrents/queue/move` |
| `session()` | `GET /torrents/session` |
| `update_session(**payload)` | `PATCH /torrents/session` |
| `session_stats()` | `GET /torrents/session/stats` |
| `close_session()` | `POST /torrents/session/close` |
| `free_space(**params)` | `GET /torrents/free-space` |
| `update_blocklist()` | `POST /torrents/blocklist/update` |
| `port_test()` | `GET /torrents/port-test` |
| `groups()` | `GET /torrents/groups` |
| `update_group(name, **payload)` | `PATCH /torrents/groups/{name}` |

### `client.proxmox` / `client.compute`

| Method | Route |
| --- | --- |
| `ticket(**payload)` | `POST /compute/auth/ticket` |
| `resources(**params)` | `GET /compute/resources` |
| `cluster_status()` | `GET /compute/cluster/status` |
| `nodes()` | `GET /compute/nodes` |
| `node_status(node)` | `GET /compute/nodes/{node}/status` |
| `node_tasks(node, **params)` | `GET /compute/nodes/{node}/tasks` |
| `vms(**params)` | `GET /compute/vms` |
| `create_vm(**payload)` | `POST /compute/vms` |
| `vm(vmid)` | `GET /compute/vms/{vmid}` |
| `update_vm(vmid, **payload)` | `PATCH /compute/vms/{vmid}` |
| `delete_vm(vmid, *, confirm)` | `DELETE /compute/vms/{vmid}` |
| `start_vm(vmid)` | `POST /compute/vms/{vmid}/start` |
| `stop_vm(vmid)` | `POST /compute/vms/{vmid}/stop` |
| `shutdown_vm(vmid)` | `POST /compute/vms/{vmid}/shutdown` |
| `reboot_vm(vmid)` | `POST /compute/vms/{vmid}/reboot` |
| `clone_vm(vmid, **payload)` | `POST /compute/vms/{vmid}/clone` |
| `migrate_vm(vmid, **payload)` | `POST /compute/vms/{vmid}/migrate` |
| `vm_snapshots(vmid)` | `GET /compute/vms/{vmid}/snapshots` |
| `create_vm_snapshot(vmid, **payload)` | `POST /compute/vms/{vmid}/snapshots` |
| `vm_agent_action(vmid, action, **payload)` | `POST /compute/vms/{vmid}/agent/{action}` |
| `containers(**params)` | `GET /compute/containers` |
| `create_container(**payload)` | `POST /compute/containers` |
| `start_container(vmid)` | `POST /compute/containers/{vmid}/start` |
| `stop_container(vmid)` | `POST /compute/containers/{vmid}/stop` |
| `storage(**params)` | `GET /compute/storage` |
| `storage_content(storage, **params)` | `GET /compute/storage/{storage}/content` |
| `upload_storage_metadata(storage, **payload)` | `POST /compute/storage/{storage}/upload` |
| `backup_jobs()` | `GET /compute/backups/jobs` |
| `create_backup_job(**payload)` | `POST /compute/backups/jobs` |
| `ha_resources()` | `GET /compute/ha/resources` |
| `users()` | `GET /compute/access/users` |
| `acl()` | `GET /compute/access/acl` |

### `client.bind9` / `client.dns`

| Method | Route |
| --- | --- |
| `server_status(server_id="default")` | `GET /dns/servers/{server_id}/status` |
| `reload(server_id="default", **payload)` | `POST /dns/servers/{server_id}/reload` |
| `stats(server_id="default")` | `GET /dns/servers/{server_id}/stats` |
| `flush_cache(server_id="default", **payload)` | `POST /dns/servers/{server_id}/cache/flush` |
| `config(server_id="default")` | `GET /dns/servers/{server_id}/config` |
| `create_zone(**payload)` | `POST /dns/zones` |
| `zone(zone)` | `GET /dns/zones/{zone}` |
| `update_zone(zone, **payload)` | `PATCH /dns/zones/{zone}` |
| `delete_zone(zone, *, confirm)` | `DELETE /dns/zones/{zone}` |
| `batch_update_records(zone, changes)` | `POST /dns/zones/{zone}/records:batchUpdate` |
| `freeze_zone(zone)` | `POST /dns/zones/{zone}/freeze` |
| `thaw_zone(zone)` | `POST /dns/zones/{zone}/thaw` |
| `sync_zone(zone)` | `POST /dns/zones/{zone}/sync` |

## Development

Install development dependencies:

```sh
python -m pip install -e ".[dev]"
```

Run the offline test suite:

```sh
python -m pytest
```

The tests use `httpx.MockTransport`; they do not require live gateway access or
an API key.
