Add Conduit Python client library

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chelsea
2026-07-05 17:41:15 +00:00
parent 1d9e7e0fab
commit a3e6ef70df
8 changed files with 719 additions and 0 deletions

75
pythonlib/README.md Normal file
View File

@@ -0,0 +1,75 @@
# Conduit Python Client
Small synchronous Python client for the Conduit / Mega API gateway.
```python
from conduit import Client
client = Client.from_env()
print(client.health())
print(client.auth.me())
print(client.keys.list())
```
Configure it with environment variables:
```sh
export MEGA_API_BASE_URL=https://api.cowtunnel.com
export MEGA_API_KEY='mega_sk_live_...'
```
Do not put raw API keys in source files. Mega API keys are shown once by the
gateway and should live in environment variables, a secret manager, or a local
ignored config file.
## Common Calls
```python
from conduit import Client
client = Client(base_url="https://api.cowtunnel.com", api_key="...")
client.services.list()
client.sms.history(limit=1)
client.dns.server_status()
client.compute.resources(type="vm")
client.media.library.search(term="matrix", limit=5)
client.media.ingest.jobs()
```
Key management:
```python
created = client.keys.create(
display_name="automation",
scopes=["gateway:read", "sms:read"],
)
print(created["key"]) # shown only once
client.keys.revoke(created["key_prefix"])
```
Admin audit:
```python
client.admin.audit(scope="sms:send", result="denied")
```
## Errors
The library raises:
| Exception | Meaning |
|---|---|
| `MegaAPIConfigError` | Invalid/missing client configuration |
| `MegaAPIHTTPError` | Non-2xx response; includes `status_code`, `detail`, and `body` |
| `MegaAPIConnectionError` | Could not reach the gateway |
| `MegaAPIResponseError` | Gateway returned malformed JSON where JSON was expected |
## Naming
The import package is `conduit` for the private package registry. The public PyPI
name `conduit` is already used by an unrelated project, so publish publicly as
`conduit-client` if this ever leaves the private registry. `megaapi` remains as a
compatibility import alias.

20
pythonlib/pyproject.toml Normal file
View File

@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "conduit-client"
version = "0.1.0"
description = "Python client for the Conduit / Mega API gateway"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
megaapi = ["py.typed"]
conduit = ["py.typed"]

View File

@@ -0,0 +1,26 @@
"""Compatibility import package for the Conduit / Mega API client.
The public PyPI name ``conduit`` is already occupied by an unrelated project, so
this package is intended for the private package registry. The same client is
also available as ``megaapi``.
"""
from megaapi import (
Client,
MegaAPIConfig,
MegaAPIConfigError,
MegaAPIConnectionError,
MegaAPIError,
MegaAPIHTTPError,
MegaAPIResponseError,
)
__all__ = [
"Client",
"MegaAPIConfig",
"MegaAPIConfigError",
"MegaAPIConnectionError",
"MegaAPIError",
"MegaAPIHTTPError",
"MegaAPIResponseError",
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,21 @@
"""Python client for the Mega API gateway."""
from .client import (
Client,
MegaAPIConfig,
MegaAPIConfigError,
MegaAPIConnectionError,
MegaAPIError,
MegaAPIHTTPError,
MegaAPIResponseError,
)
__all__ = [
"Client",
"MegaAPIConfig",
"MegaAPIConfigError",
"MegaAPIConnectionError",
"MegaAPIError",
"MegaAPIHTTPError",
"MegaAPIResponseError",
]

View File

@@ -0,0 +1,510 @@
"""Synchronous Python client for the Mega API gateway."""
from __future__ import annotations
from dataclasses import dataclass
import os
from typing import Any, Literal
import httpx
DEFAULT_BASE_URL = "https://api.cowtunnel.com"
USER_AGENT = "mega-api-python/0.1.0"
class MegaAPIError(RuntimeError):
"""Base client-side error."""
class MegaAPIConfigError(MegaAPIError):
"""Raised when client configuration is missing or invalid."""
class MegaAPIHTTPError(MegaAPIError):
"""Raised for non-2xx responses from the Mega API gateway."""
def __init__(self, status_code: int, detail: str, body: Any = None):
self.status_code = status_code
self.detail = detail
self.body = body
super().__init__(f"Mega API returned HTTP {status_code}: {detail}")
class MegaAPIConnectionError(MegaAPIError):
"""Raised when the gateway cannot be reached."""
class MegaAPIResponseError(MegaAPIError):
"""Raised when a response cannot be decoded as advertised."""
@dataclass(frozen=True)
class MegaAPIConfig:
"""Connection settings for :class:`Client`."""
base_url: str = DEFAULT_BASE_URL
api_key: str | None = None
timeout: float = 30.0
transport: httpx.BaseTransport | None = None
def __post_init__(self) -> None:
if not self.base_url.strip():
raise MegaAPIConfigError("base_url must not be empty")
parsed = httpx.URL(self.base_url)
if parsed.scheme not in {"http", "https"} or not parsed.host:
raise MegaAPIConfigError("base_url must be an absolute http(s) URL")
if self.timeout <= 0:
raise MegaAPIConfigError("timeout must be greater than zero")
@classmethod
def from_env(cls) -> "MegaAPIConfig":
raw_timeout = os.getenv("MEGA_API_TIMEOUT", "30")
try:
timeout = float(raw_timeout)
except ValueError as exc:
raise MegaAPIConfigError("MEGA_API_TIMEOUT must be numeric") from exc
return cls(
base_url=os.getenv("MEGA_API_BASE_URL", DEFAULT_BASE_URL),
api_key=os.getenv("MEGA_API_KEY") or None,
timeout=timeout,
)
def _payload(data: dict[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]:
merged: dict[str, Any] = {}
if data:
merged.update(data)
merged.update(kwargs)
return {key: value for key, value in merged.items() if value is not None}
class Client:
"""Synchronous Mega API client.
The client never stores keys anywhere except in memory. Pass ``api_key`` at
construction time or set ``MEGA_API_KEY`` in the process environment.
"""
def __init__(
self,
*,
base_url: str = DEFAULT_BASE_URL,
api_key: str | None = None,
timeout: float = 30.0,
transport: httpx.BaseTransport | None = None,
):
self.config = MegaAPIConfig(
base_url=base_url,
api_key=api_key,
timeout=timeout,
transport=transport,
)
self.auth = AuthNamespace(self)
self.services = ServicesNamespace(self)
self.keys = KeysNamespace(self)
self.admin = AdminNamespace(self)
self.sms = SmsNamespace(self)
self.media = MediaNamespace(self)
self.ingest = self.media.ingest
self.library = self.media.library
self.torrent = TorrentNamespace(self)
self.compute = ComputeNamespace(self)
self.dns = DnsNamespace(self)
@classmethod
def from_env(cls) -> "Client":
config = MegaAPIConfig.from_env()
return cls(
base_url=config.base_url,
api_key=config.api_key,
timeout=config.timeout,
transport=config.transport,
)
def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
auth: bool = True,
) -> Any:
headers: dict[str, str] = {"User-Agent": USER_AGENT}
if auth:
if not self.config.api_key:
raise MegaAPIConfigError("MEGA_API_KEY is required for this call")
headers["Authorization"] = f"Bearer {self.config.api_key}"
url = f"{self.config.base_url.rstrip('/')}/{path.lstrip('/')}"
clean_params = {key: value for key, value in (params or {}).items() if value is not None}
try:
with httpx.Client(
timeout=self.config.timeout,
transport=self.config.transport,
follow_redirects=False,
) as client:
response = client.request(
method,
url,
headers=headers,
params=clean_params,
json=json,
)
except httpx.HTTPError as exc:
raise MegaAPIConnectionError(f"Mega API request failed: {exc}") from exc
if response.status_code < 200 or response.status_code >= 300:
body = _response_body(response)
raise MegaAPIHTTPError(response.status_code, _detail(body, response.text), body)
if not response.content:
return None
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
try:
return response.json()
except ValueError as exc:
raise MegaAPIResponseError("Mega API returned invalid JSON") from exc
return response.text
def health(self) -> str:
return self.request("GET", "/health", auth=False)
class AuthNamespace:
def __init__(self, client: Client):
self._client = client
def me(self) -> dict[str, Any]:
return self._client.request("GET", "/auth/me")
class ServicesNamespace:
def __init__(self, client: Client):
self._client = client
def list(self) -> list[dict[str, Any]]:
data = self._client.request("GET", "/services")
return data["services"]
def health(self) -> list[dict[str, Any]]:
data = self._client.request("GET", "/health/services")
return data["services"]
class KeysNamespace:
def __init__(self, client: Client):
self._client = client
def list(self) -> list[dict[str, Any]]:
return self._client.request("GET", "/keys")
def create(
self,
*,
display_name: str,
scopes: list[str],
expires_at: str | None = None,
allowed_ips: list[str] | None = None,
rate_limit: int | None = None,
) -> dict[str, Any]:
return self._client.request(
"POST",
"/keys",
json=_payload(
display_name=display_name,
scopes=scopes,
expires_at=expires_at,
allowed_ips=allowed_ips or [],
rate_limit=rate_limit,
),
)
def revoke(self, key_prefix: str) -> None:
self._client.request("DELETE", f"/keys/{key_prefix}")
class AdminNamespace:
def __init__(self, client: Client):
self._client = client
def keys(self) -> list[dict[str, Any]]:
return self._client.request("GET", "/admin/keys")
def revoke_key(self, key_prefix: str) -> None:
self._client.request("DELETE", f"/admin/keys/{key_prefix}")
def audit(
self,
*,
actor_subject: str | None = None,
scope: str | None = None,
result: str | None = None,
endpoint: str | None = None,
since: str | None = None,
until: str | None = None,
limit: int = 100,
) -> list[dict[str, Any]]:
return self._client.request(
"GET",
"/admin/audit",
params={
"actor_subject": actor_subject,
"scope": scope,
"result": result,
"endpoint": endpoint,
"since": since,
"until": until,
"limit": limit,
},
)
class SmsNamespace:
def __init__(self, client: Client):
self._client = client
def send(self, dst: str, message: str, did: str | None = None) -> dict[str, Any]:
body = {"dst": dst, "message": message}
if did is None:
return self._client.request("POST", "/sms/send/default", json=body)
body["did"] = did
return self._client.request("POST", "/sms/send", json=body)
def send_media(
self,
dst: str,
message: str,
did: str | None = None,
media1: str | None = None,
media2: str | None = None,
media3: str | None = None,
) -> dict[str, Any]:
body = _payload(dst=dst, message=message, media1=media1, media2=media2, media3=media3)
if did is None:
return self._client.request("POST", "/mms/send/default", json=body)
body["did"] = did
return self._client.request("POST", "/mms/send", json=body)
def history(self, **params: Any) -> list[dict[str, Any]]:
if "from_" in params:
params["from"] = params.pop("from_")
return self._client.request("GET", "/sms", params=params)
def mms_history(self, **params: Any) -> list[dict[str, Any]]:
if "from_" in params:
params["from"] = params.pop("from_")
return self._client.request("GET", "/mms", params=params)
def media(self, id: int, media_as_array: bool = False) -> dict[str, Any]:
return self._client.request("GET", f"/mms/{id}/media", params={"media_as_array": media_as_array})
def delete(self, id: int) -> dict[str, Any]:
return self._client.request("DELETE", f"/sms/{id}")
def delete_media(self, id: int) -> dict[str, Any]:
return self._client.request("DELETE", f"/mms/{id}")
class IngestNamespace:
def __init__(self, client: Client):
self._client = client
def submit(self, url: str, **options: Any) -> Any:
return self._client.request("POST", "/media/ingest/jobs", json=_payload({"url": url}, **options))
def jobs(self) -> Any:
return self._client.request("GET", "/media/ingest/jobs")
def start(self, job_id: str) -> Any:
return self._client.request("POST", f"/media/ingest/jobs/{job_id}/start")
def cancel(self, job_id: str) -> Any:
return self._client.request("POST", f"/media/ingest/jobs/{job_id}/cancel")
def delete(self, job_id: str, delete_from_file: bool = False) -> Any:
return self._client.request(
"DELETE",
f"/media/ingest/jobs/{job_id}",
json={"delete_from_file": delete_from_file},
)
def presets(self) -> Any:
return self._client.request("GET", "/media/ingest/presets")
def subscriptions(self) -> Any:
return self._client.request("GET", "/media/ingest/subscriptions")
def create_subscription(self, url: str, **options: Any) -> Any:
return self._client.request(
"POST",
"/media/ingest/subscriptions",
json=_payload({"url": url}, **options),
)
def update_subscription(self, sub_id: str, **fields: Any) -> Any:
return self._client.request("PATCH", f"/media/ingest/subscriptions/{sub_id}", json=_payload(fields))
def delete_subscription(self, sub_id: str) -> Any:
return self._client.request("DELETE", f"/media/ingest/subscriptions/{sub_id}")
def check_subscription(self, sub_id: str) -> Any:
return self._client.request("POST", f"/media/ingest/subscriptions/{sub_id}/check")
def version(self) -> Any:
return self._client.request("GET", "/media/ingest/version")
list = jobs
class LibraryNamespace:
def __init__(self, client: Client):
self._client = client
def login(self, username: str, password: str = "") -> Any:
return self._client.request(
"POST",
"/media/library/auth/login",
json={"username": username, "password": password},
)
def search(self, **params: Any) -> Any:
return self._client.request("GET", "/media/library/search", params=params)
def item(self, item_id: str) -> Any:
return self._client.request("GET", f"/media/library/items/{item_id}")
def playback(self, item_id: str) -> Any:
return self._client.request("GET", f"/media/library/items/{item_id}/playback")
def refresh(self) -> Any:
return self._client.request("POST", "/media/library/refresh")
def sessions(self) -> Any:
return self._client.request("GET", "/media/library/sessions")
def system_info(self, public: bool = False) -> Any:
return self._client.request("GET", "/media/library/system/info", params={"public": public})
get = item
class MediaNamespace:
def __init__(self, client: Client):
self.ingest = IngestNamespace(client)
self.library = LibraryNamespace(client)
class TorrentNamespace:
def __init__(self, client: Client):
self._client = client
def add(self, **payload: Any) -> Any:
return self._client.request("POST", "/torrents", json=_payload(payload))
def list(self, fields: str | None = None, ids: str | None = None) -> Any:
return self._client.request("GET", "/torrents", params={"fields": fields, "ids": ids})
def get(self, torrent_id: int) -> Any:
return self._client.request("GET", f"/torrents/{torrent_id}")
def update(self, torrent_id: int, **fields: Any) -> Any:
return self._client.request("PATCH", f"/torrents/{torrent_id}", json=_payload(fields))
def action(self, torrent_id: int, action: Literal["start", "start-now", "stop", "verify", "reannounce"]) -> Any:
return self._client.request("POST", f"/torrents/{torrent_id}/{action}")
def delete(self, torrent_id: int, delete_local_data: bool = False) -> Any:
return self._client.request("DELETE", f"/torrents/{torrent_id}", json={"delete_local_data": delete_local_data})
start = lambda self, torrent_id: self.action(torrent_id, "start")
start_now = lambda self, torrent_id: self.action(torrent_id, "start-now")
stop = lambda self, torrent_id: self.action(torrent_id, "stop")
verify = lambda self, torrent_id: self.action(torrent_id, "verify")
reannounce = lambda self, torrent_id: self.action(torrent_id, "reannounce")
class ComputeNamespace:
def __init__(self, client: Client):
self._client = client
def resources(self, type: str | None = None) -> Any:
return self._client.request("GET", "/compute/resources", params={"type": type})
def cluster_status(self) -> Any:
return self._client.request("GET", "/compute/cluster/status")
def nodes(self) -> Any:
return self._client.request("GET", "/compute/nodes")
def vms(self) -> Any:
return self._client.request("GET", "/compute/vms")
def vm(self, vmid: int) -> Any:
return self._client.request("GET", f"/compute/vms/{vmid}")
def start_vm(self, vmid: int) -> Any:
return self._client.request("POST", f"/compute/vms/{vmid}/start")
def stop_vm(self, vmid: int) -> Any:
return self._client.request("POST", f"/compute/vms/{vmid}/stop")
def containers(self) -> Any:
return self._client.request("GET", "/compute/containers")
def storage(self, node: str | None = None) -> Any:
return self._client.request("GET", "/compute/storage", params={"node": node})
list_vms = vms
get_vm = vm
class DnsNamespace:
def __init__(self, client: Client):
self._client = client
def server_status(self, server_id: str = "default") -> Any:
return self._client.request("GET", f"/dns/servers/{server_id}/status")
def reload(self, server_id: str = "default", mode: str = "reload") -> Any:
return self._client.request("POST", f"/dns/servers/{server_id}/reload", json={"mode": mode})
def stats(self, server_id: str = "default") -> Any:
return self._client.request("GET", f"/dns/servers/{server_id}/stats")
def config(self, server_id: str = "default") -> Any:
return self._client.request("GET", f"/dns/servers/{server_id}/config")
def create_zone(self, zone: str, **payload: Any) -> Any:
return self._client.request("POST", "/dns/zones", json=_payload({"zone": zone}, **payload))
def zone(self, zone: str) -> Any:
return self._client.request("GET", f"/dns/zones/{zone}")
def update_zone(self, zone: str, **payload: Any) -> Any:
return self._client.request("PATCH", f"/dns/zones/{zone}", json=_payload(payload))
def delete_zone(self, zone: str, confirm: str | None = None) -> Any:
return self._client.request("DELETE", f"/dns/zones/{zone}", json={"confirm": confirm or zone})
def update_records(self, zone: str, updates: list[dict[str, Any]]) -> Any:
return self._client.request("POST", f"/dns/zones/{zone}/records:batchUpdate", json={"updates": updates})
status = server_status
get_zone = zone
def _response_body(response: httpx.Response) -> Any:
try:
return response.json()
except ValueError:
return response.text
def _detail(body: Any, text: str) -> str:
if isinstance(body, dict) and "detail" in body:
return str(body["detail"])
if isinstance(body, str) and body:
return body
return text or "request failed"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import httpx
from conduit import Client, MegaAPIHTTPError
def test_health_is_open():
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/health"
assert "authorization" not in request.headers
return httpx.Response(200, text="ok")
client = Client(transport=httpx.MockTransport(handler))
assert client.health() == "ok"
def test_auth_header_and_services():
seen: list[str | None] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers.get("authorization"))
return httpx.Response(200, json={"services": [{"id": "sms"}]})
client = Client(api_key="test-key", transport=httpx.MockTransport(handler))
assert client.services.list() == [{"id": "sms"}]
assert seen == ["Bearer test-key"]
def test_key_create_payload():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["path"] = request.url.path
captured["body"] = request.read()
return httpx.Response(
200,
json={
"id": "k1",
"key": "mega_sk_live_public_secret",
"key_prefix": "public",
"display_name": "bot",
"scopes": ["sms:read"],
"expires_at": None,
},
)
client = Client(api_key="test-key", transport=httpx.MockTransport(handler))
created = client.keys.create(display_name="bot", scopes=["sms:read"])
assert captured["path"] == "/keys"
assert created["key_prefix"] == "public"
def test_http_error_detail():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(403, json={"detail": "missing required scope"})
client = Client(api_key="test-key", transport=httpx.MockTransport(handler))
try:
client.auth.me()
except MegaAPIHTTPError as exc:
assert exc.status_code == 403
assert exc.detail == "missing required scope"
else:
raise AssertionError("expected MegaAPIHTTPError")