diff --git a/pythonlib/README.md b/pythonlib/README.md new file mode 100644 index 0000000..44cce65 --- /dev/null +++ b/pythonlib/README.md @@ -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. diff --git a/pythonlib/pyproject.toml b/pythonlib/pyproject.toml new file mode 100644 index 0000000..1d90b48 --- /dev/null +++ b/pythonlib/pyproject.toml @@ -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"] diff --git a/pythonlib/src/conduit/__init__.py b/pythonlib/src/conduit/__init__.py new file mode 100644 index 0000000..7b6a7aa --- /dev/null +++ b/pythonlib/src/conduit/__init__.py @@ -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", +] diff --git a/pythonlib/src/conduit/py.typed b/pythonlib/src/conduit/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pythonlib/src/conduit/py.typed @@ -0,0 +1 @@ + diff --git a/pythonlib/src/megaapi/__init__.py b/pythonlib/src/megaapi/__init__.py new file mode 100644 index 0000000..35fbc0f --- /dev/null +++ b/pythonlib/src/megaapi/__init__.py @@ -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", +] diff --git a/pythonlib/src/megaapi/client.py b/pythonlib/src/megaapi/client.py new file mode 100644 index 0000000..cd3704e --- /dev/null +++ b/pythonlib/src/megaapi/client.py @@ -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" diff --git a/pythonlib/src/megaapi/py.typed b/pythonlib/src/megaapi/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/pythonlib/src/megaapi/py.typed @@ -0,0 +1 @@ + diff --git a/pythonlib/tests/test_client.py b/pythonlib/tests/test_client.py new file mode 100644 index 0000000..dbd7f6d --- /dev/null +++ b/pythonlib/tests/test_client.py @@ -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")