conduit-client (0.2.2)
Installation
pip install --index-url conduit-clientAbout 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_codemethodurldetailbodyheaders
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.