• Joined on 2025-10-11

conduit-client (0.2.2)

Published 2026-07-05 21:22:31 -05:00 by chelsea

Installation

pip install --index-url  conduit-client

About this package

Python client for the Conduit / Mega API gateway.

conduit-client

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

It imports as conduit:

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:

python -m pip install -e .

For the Gitea package registry used by this project:

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

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:

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:

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

POSIX shells:

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

You can also configure the client explicitly:

from conduit import Client

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

By default, protected calls send:

Authorization: Bearer <MEGA_API_KEY>

Use X-API-Key instead when needed:

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:

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.

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:

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

Service discovery:

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:

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

API keys:

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:

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:

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

SMS with an explicit sender DID:

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

MMS:

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

Message history:

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

Media ingestion:

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:

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:

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

Proxmox:

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

BIND9:

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.

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:

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:

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.

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
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:

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

Run the offline test suite:

python -m pytest

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

Requirements

Requires Python: >=3.10
Details
PyPI
2026-07-05 21:22:31 -05:00
10
Chelsea
MIT
39 KiB
Assets (2)
Versions (6) View all
0.2.4 2026-07-08
0.2.3 2026-07-08
0.2.2 2026-07-05
0.2.1 2026-07-05
0.2.0 2026-07-05