Add Conduit Python client library
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
75
pythonlib/README.md
Normal file
75
pythonlib/README.md
Normal 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
20
pythonlib/pyproject.toml
Normal 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"]
|
||||
26
pythonlib/src/conduit/__init__.py
Normal file
26
pythonlib/src/conduit/__init__.py
Normal 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",
|
||||
]
|
||||
1
pythonlib/src/conduit/py.typed
Normal file
1
pythonlib/src/conduit/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
21
pythonlib/src/megaapi/__init__.py
Normal file
21
pythonlib/src/megaapi/__init__.py
Normal 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",
|
||||
]
|
||||
510
pythonlib/src/megaapi/client.py
Normal file
510
pythonlib/src/megaapi/client.py
Normal 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"
|
||||
1
pythonlib/src/megaapi/py.typed
Normal file
1
pythonlib/src/megaapi/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
65
pythonlib/tests/test_client.py
Normal file
65
pythonlib/tests/test_client.py
Normal 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")
|
||||
Reference in New Issue
Block a user