From b0d2873cbc0ef87a6570cbdf36f679c04b14cd60 Mon Sep 17 00:00:00 2001 From: Chelsea Date: Mon, 6 Jul 2026 11:02:19 -0500 Subject: [PATCH] Initial commit: add Gitea PyPI publish workflow --- .gitea/workflows/publish.yml | 35 + .gitignore | 46 + PLAN.md | 13 + README.md | 184 +++ docs/usage.md | 226 ++++ pyproject.toml | 63 + scripts/generate_docs.py | 1182 +++++++++++++++++++ src/conduit_client/__init__.py | 27 + src/conduit_client/_base_client.py | 224 ++++ src/conduit_client/_client.py | 85 ++ src/conduit_client/clients/__init__.py | 1 + src/conduit_client/clients/compute.py | 518 ++++++++ src/conduit_client/clients/dns.py | 253 ++++ src/conduit_client/clients/gateway.py | 105 ++ src/conduit_client/clients/media_ingest.py | 210 ++++ src/conduit_client/clients/media_library.py | 120 ++ src/conduit_client/clients/sms.py | 175 +++ src/conduit_client/clients/torrents.py | 312 +++++ src/conduit_client/clients/wiki.py | 62 + src/conduit_client/exceptions.py | 47 + src/conduit_client/models/__init__.py | 143 +++ src/conduit_client/models/common.py | 85 ++ src/conduit_client/models/compute.py | 96 ++ src/conduit_client/models/dns.py | 56 + src/conduit_client/models/gateway.py | 21 + src/conduit_client/models/media_ingest.py | 100 ++ src/conduit_client/models/media_library.py | 48 + src/conduit_client/models/sms.py | 99 ++ src/conduit_client/models/torrents.py | 86 ++ src/conduit_client/models/wiki.py | 28 + tests/conftest.py | 22 + tests/test_base_client.py | 119 ++ tests/test_compute.py | 242 ++++ tests/test_dns.py | 136 +++ tests/test_gateway.py | 145 +++ tests/test_media_ingest.py | 129 ++ tests/test_media_library.py | 93 ++ tests/test_sms.py | 112 ++ tests/test_torrents.py | 128 ++ tests/test_wiki.py | 55 + 40 files changed, 5831 insertions(+) create mode 100644 .gitea/workflows/publish.yml create mode 100644 .gitignore create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 docs/usage.md create mode 100644 pyproject.toml create mode 100644 scripts/generate_docs.py create mode 100644 src/conduit_client/__init__.py create mode 100644 src/conduit_client/_base_client.py create mode 100644 src/conduit_client/_client.py create mode 100644 src/conduit_client/clients/__init__.py create mode 100644 src/conduit_client/clients/compute.py create mode 100644 src/conduit_client/clients/dns.py create mode 100644 src/conduit_client/clients/gateway.py create mode 100644 src/conduit_client/clients/media_ingest.py create mode 100644 src/conduit_client/clients/media_library.py create mode 100644 src/conduit_client/clients/sms.py create mode 100644 src/conduit_client/clients/torrents.py create mode 100644 src/conduit_client/clients/wiki.py create mode 100644 src/conduit_client/exceptions.py create mode 100644 src/conduit_client/models/__init__.py create mode 100644 src/conduit_client/models/common.py create mode 100644 src/conduit_client/models/compute.py create mode 100644 src/conduit_client/models/dns.py create mode 100644 src/conduit_client/models/gateway.py create mode 100644 src/conduit_client/models/media_ingest.py create mode 100644 src/conduit_client/models/media_library.py create mode 100644 src/conduit_client/models/sms.py create mode 100644 src/conduit_client/models/torrents.py create mode 100644 src/conduit_client/models/wiki.py create mode 100644 tests/conftest.py create mode 100644 tests/test_base_client.py create mode 100644 tests/test_compute.py create mode 100644 tests/test_dns.py create mode 100644 tests/test_gateway.py create mode 100644 tests/test_media_ingest.py create mode 100644 tests/test_media_library.py create mode 100644 tests/test_sms.py create mode 100644 tests/test_torrents.py create mode 100644 tests/test_wiki.py diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..1861fad --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,35 @@ +name: Publish to Gitea PyPI + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install build tools + run: pip install --upgrade build twine + + - name: Build package + run: python -m build + + - name: Publish to Gitea PyPI + env: + TWINE_REPOSITORY_URL: https://git.scorpi.us/api/packages/${{ github.repository_owner }}/pypi + TWINE_USERNAME: ${{ github.repository_owner }} + TWINE_PASSWORD: ${{ secrets.GITEA_TOKEN }} + run: twine upload dist/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3e4e10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +.eggs/ +*.egg +MANIFEST + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing / coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ + +# OS +.DS_Store +Thumbs.db + +# Local docs build +site/ + +# Environment / secrets +.env +*.env diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..c12fb2c --- /dev/null +++ b/PLAN.md @@ -0,0 +1,13 @@ +# Plan: Add async examples to the wiki usage guide + +The `docs/usage.md` guide currently only shows sync usage in its per-section examples. The user wants every major example to also demonstrate the equivalent async pattern with `AsyncConduitClient` and `await`. + +## Approach + +1. Keep the existing sync examples intact. +2. Add matching async examples immediately after each sync snippet (Authentication, Gateway keys/SMS/media/torrents/wiki/compute/DNS, timeouts/retries, error handling, free-form bodies). +3. Make the pattern consistent across all snippets: + - sync: `with ConduitClient(...) as client:` + - async: `async with AsyncConduitClient(...) as client:` and `await client...` +4. Ensure required imports (`asyncio`, `AsyncConduitClient`) are shown where needed. +5. Run `ruff` and `pytest` to confirm nothing broke. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d15835 --- /dev/null +++ b/README.md @@ -0,0 +1,184 @@ +# conduit-client + +A typed Python client for the [Conduit](https://conduit.librewiki.org/doku.php?id=examples) unified API gateway. + +## Features + +- Synchronous and asynchronous clients (`ConduitClient` and `AsyncConduitClient`). +- Pydantic v2 request/response models. +- Covers every documented Conduit endpoint across gateway, SMS/MMS, media, torrents, wiki, compute, and DNS façades. +- Clean error handling with typed exceptions. + +## Installation + +```bash +pip install conduit-client +``` + +## Quick start + +```python +from conduit_client import ConduitClient + +client = ConduitClient("mega_sk_live__") +me = client.gateway.me() +print(me.scopes) +``` + +## Async usage + +```python +import asyncio +from conduit_client import AsyncConduitClient + +async def main(): + async with AsyncConduitClient("mega_sk_live__") as client: + me = await client.gateway.me() + print(me.scopes) + +asyncio.run(main()) +``` + +## Per-façade examples + +### Gateway + +```python +from conduit_client import ConduitClient +from conduit_client.models.common import CreateKeyRequest +from conduit_client.models.gateway import AuditLogQuery + +client = ConduitClient("mega_sk_live__") + +# API keys +keys = client.gateway.list_keys() +new_key = client.gateway.create_key(CreateKeyRequest(display_name="ci", scopes=["sms:send"])) +print(new_key.key) # one-time secret + +# Audit log +entries = client.gateway.admin_audit(AuditLogQuery(scope="sms:send", limit=50)) +``` + +### SMS / MMS + +```python +from conduit_client.models.sms import SendSmsRequest, SendMmsRequest + +sms_id = client.sms.send_sms(SendSmsRequest(did="5550100", dst="5550200", message="hi")) +print(sms_id.id) + +mms_id = client.sms.send_mms( + SendMmsRequest( + did="5550100", + dst="5550200", + message="pic", + media1="https://example.com/photo.jpg", + ) +) +``` + +### Media ingestion (MeTube) + +```python +from conduit_client.models.media_ingest import CreateIngestJobRequest + +job = client.media_ingest.create_job( + CreateIngestJobRequest(url="https://www.youtube.com/watch?v=abc", quality="best") +) +print(job.id, job.status) +``` + +### Media library (Jellyfin) + +```python +from conduit_client.models.media_library import LibraryLoginRequest, LibrarySearchQuery + +client.media_library.login(LibraryLoginRequest(username="user", password="pw")) +results = client.media_library.search(LibrarySearchQuery(term="Inception")) +``` + +### Torrents (Transmission) + +```python +from conduit_client.models.torrents import AddTorrentRequest + +client.torrents.add(AddTorrentRequest(url="magnet:?xt=urn:btih:...", paused=False)) +client.torrents.list(fields=["id", "name"]) +``` + +### Wiki (DokuWiki) + +```python +from conduit_client.models.wiki import WriteWikiPageRequest + +page = client.wiki.get_raw("namespace:page") +client.wiki.write("namespace:page", WriteWikiPageRequest(text="updated content")) +``` + +### Compute (Proxmox) + +```python +from conduit_client.models.compute import CreateVmRequest, ComputeTicketRequest + +ticket = client.compute.auth_ticket(ComputeTicketRequest(username="root", password="pw")) +print(ticket.ticket) + +# Create a VM; extra Proxmox fields are forwarded as-is. +client.compute.create_vm( + CreateVmRequest(node="pve", vmid=100, cores=2, memory=2048, storage="local-lvm") +) +client.compute.start_vm(100) +``` + +### DNS (BIND9) + +```python +from conduit_client.models.dns import ( + AddZoneRequest, + BatchRecordUpdate, + BatchUpdateRecordsRequest, +) + +client.dns.create_zone(AddZoneRequest(zone="example.com", klass="IN")) +client.dns.batch_update_records( + "example.com", + BatchUpdateRecordsRequest( + updates=[ + BatchRecordUpdate(op="add", name="www", ttl=300, rtype="A", rdata="1.2.3.4") + ] + ), +) +client.dns.reload_server("default") +``` + +## Error handling + +```python +from conduit_client import ConduitClient, AuthenticationError, NotFoundError + +client = ConduitClient("mega_sk_live__") + +try: + client.sms.delete_sms(999999) +except NotFoundError as exc: + print(exc.status_code, exc.response_body) +except AuthenticationError as exc: + print("Invalid API key", exc.status_code) +``` + +## Configuration + +```python +from conduit_client import ConduitClient + +client = ConduitClient( + "mega_sk_live__", + base_url="https://api.cowtunnel.com", + timeout=60.0, + max_retries=2, +) +``` + +## License + +MIT diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..619467a --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,226 @@ +# Usage guide + +## Installation + +```bash +pip install conduit-client +``` + +For development: + +```bash +pip install conduit-client[dev] +``` + +## Authentication + +The Conduit gateway uses API keys in the `Authorization: Bearer mega_sk_live__` header. Pass your key to `ConduitClient` or `AsyncConduitClient`: + +```python +from conduit_client import ConduitClient + +client = ConduitClient("mega_sk_live_abc123_xyz") +``` + +Async version: + +```python +import asyncio +from conduit_client import AsyncConduitClient + +async def main() -> None: + async with AsyncConduitClient("mega_sk_live_abc123_xyz") as client: + me = await client.gateway.me() + print(me.scopes) + +asyncio.run(main()) +``` + +You can create new keys programmatically using the gateway client: + +```python +from conduit_client.models.common import CreateKeyRequest + +new_key = client.gateway.create_key( + CreateKeyRequest(display_name="production", scopes=["sms:send", "sms:read"]) +) +print(new_key.key) # returned exactly once by the server +``` + +Async version: + +```python +from conduit_client import AsyncConduitClient +from conduit_client.models.common import CreateKeyRequest + +async def main() -> None: + async with AsyncConduitClient("mega_sk_live_...") as client: + new_key = await client.gateway.create_key( + CreateKeyRequest(display_name="production", scopes=["sms:send", "sms:read"]) + ) + print(new_key.key) + +asyncio.run(main()) +``` + +## Sync vs async + +Both clients expose the same façade subclients: + +```python +# Sync +with ConduitClient("mega_sk_live_...") as client: + me = client.gateway.me() + +# Async +async with AsyncConduitClient("mega_sk_live_...") as client: + me = await client.gateway.me() +``` + +The asynchronous client uses `httpx.AsyncClient` under the hood and must be awaited. + +## Timeouts and retries + +```python +client = ConduitClient( + "mega_sk_live_...", + timeout=60.0, + max_retries=2, +) +``` + +Async version: + +```python +from conduit_client import AsyncConduitClient + +client = AsyncConduitClient( + "mega_sk_live_...", + timeout=60.0, + max_retries=2, +) +``` + +Retries are applied only to idempotent methods (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`) and only for status codes `429`, `502`, `503`, and `504`. Backoff is exponential (0.5s, 1s, 2s, ...). + +## Custom httpx options + +Any extra keyword arguments are forwarded to the underlying `httpx.Client` or `httpx.AsyncClient`: + +```python +client = ConduitClient( + "mega_sk_live_...", + proxies="http://proxy.example.com:8080", +) +``` + +Async version: + +```python +from conduit_client import AsyncConduitClient + +client = AsyncConduitClient( + "mega_sk_live_...", + proxies="http://proxy.example.com:8080", +) +``` + +## Handling free-form API bodies + +Some Conduit endpoints forward extra fields directly to the upstream provider (for example, Proxmox VM/container creation, Transmission session patches, DNS zone modifications). For these, the client accepts a `dict[str, Any]` or a Pydantic model that allows extra fields: + +```python +client.compute.patch_vm(100, {"memory": 4096, "cores": 4}) +client.torrents.patch_session({"download-dir": "/mnt/media"}) +client.dns.patch_zone("example.com", {"type": "slave"}) +``` + +Async version: + +```python +from conduit_client import AsyncConduitClient + +async def main() -> None: + async with AsyncConduitClient("mega_sk_live_...") as client: + await client.compute.patch_vm(100, {"memory": 4096, "cores": 4}) + await client.torrents.patch_session({"download-dir": "/mnt/media"}) + await client.dns.patch_zone("example.com", {"type": "slave"}) + +asyncio.run(main()) +``` + +## Errors + +All client errors inherit from `ConduitError`. HTTP error responses are raised as typed exceptions: + +| Exception | Trigger | +| --- | --- | +| `AuthenticationError` | 401 / 403 | +| `NotFoundError` | 404 | +| `ValidationError` | 422 | +| `ConflictError` | 409 | +| `RateLimitError` | 429 | +| `ConduitAPIError` | any other 4xx / 5xx | + +Each exception exposes `status_code` and `response_body`: + +```python +from conduit_client import NotFoundError + +try: + client.wiki.get_raw("missing:page") +except NotFoundError as exc: + print(exc.status_code) + print(exc.response_body) +``` + +Async version: + +```python +from conduit_client import AsyncConduitClient, NotFoundError + +async def main() -> None: + async with AsyncConduitClient("mega_sk_live_...") as client: + try: + await client.wiki.get_raw("missing:page") + except NotFoundError as exc: + print(exc.status_code) + print(exc.response_body) + +asyncio.run(main()) +``` + +## Façade clients + +| Attribute | Scope | Description | +| --- | --- | --- | +| `client.gateway` | `gateway:read`, `keys:create`, etc. | Health, keys, audit log | +| `client.sms` | `sms:read`, `sms:send` | SMS/MMS send/list/delete | +| `client.media_ingest` | `video:read`, `video:admin` | MeTube jobs/subscriptions | +| `client.media_library` | `video:read`, `video:admin` | Jellyfin search/sessions | +| `client.torrents` | `video:read`, `video:admin` | Transmission management | +| `client.wiki` | `wiki:read`, `wiki:write` | DokuWiki read/write | +| `client.compute` | `vm:read`, `vm:start`, `vm:stop` | Proxmox VMs/containers/storage | +| `client.dns` | `dns:read`, `dns:write`, `dns:delete` | BIND9 servers/zones/records | + +## Path parameters + +Path parameters such as DokuWiki page IDs and DNS zone names are URL-encoded automatically. Slashes, colons, and other special characters are handled safely. + +```python +client.wiki.get_raw("namespace:sub:page") +client.dns.get_zone("example.com") +``` + +Async version: + +```python +from conduit_client import AsyncConduitClient + +async def main() -> None: + async with AsyncConduitClient("mega_sk_live_...") as client: + await client.wiki.get_raw("namespace:sub:page") + await client.dns.get_zone("example.com") + +asyncio.run(main()) +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1fb6782 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "conduit-client" +version = "0.1.0" +description = "Typed Python client for the Conduit unified API gateway" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.10" +authors = [{ name = "Chelsea" }] +keywords = ["conduit", "api", "client", "gateway"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27.0", + "pydantic>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "respx>=0.21.0", + "mypy>=1.8.0", + "ruff>=0.3.0", +] + +[project.urls] +Homepage = "https://conduit.librewiki.org/doku.php?id=examples" +Repository = "https://git.scorpi.us/chelsea/conduit-client" + +[tool.hatch.build.targets.wheel] +packages = ["src/conduit_client"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.mypy] +python_version = "3.10" +strict = true +warn_return_any = true +warn_unused_ignores = true +disable_error_code = ["no-any-return"] + +[tool.ruff] +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"] +ignore = ["E501"] diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py new file mode 100644 index 0000000..fb735a7 --- /dev/null +++ b/scripts/generate_docs.py @@ -0,0 +1,1182 @@ +"""Generate a psychotic static API documentation site for conduit-client.""" + +from __future__ import annotations + +import ast +import html +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" +sys.path.insert(0, str(SRC)) + +METHOD_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(/[^-]+)\s*-\s*(.*)$", re.IGNORECASE) +HTTP_RE = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(.+?)(?:\s+-\s*|\s+—\s*|\n)(.*)$", re.DOTALL | re.IGNORECASE) + + +@dataclass +class ArgInfo: + name: str + annotation: str | None + default: str | None + is_keyword_only: bool = False + + +@dataclass +class MethodInfo: + name: str + http_method: str + endpoint: str + description: str + args: list[ArgInfo] + request_model: str | None + response_model: str | None + return_annotation: str | None + module_name: str + class_name: str + is_async: bool = False + docstring: str = "" + + +@dataclass +class NamespaceInfo: + name: str + title: str + description: str + sync_class: str + async_class: str + methods: list[MethodInfo] = field(default_factory=list) + + +def parse_docstring(node: ast.AsyncFunctionDef | ast.FunctionDef) -> str: + doc = ast.get_docstring(node) + return doc or "" + + +def extract_http_info(doc: str) -> tuple[str, str, str]: + if not doc: + return "?", "?", "" + # Try the common "METHOD /path - description" format. + m = HTTP_RE.match(doc.strip()) + if m: + method, endpoint, desc = m.groups() + return method.upper(), endpoint.strip(), desc.strip() + # Fallback: first sentence. + first = doc.strip().split("\n")[0] + return "?", "?", first + + +def annotation_str(node: ast.AST | None) -> str | None: + if node is None: + return None + if isinstance(node, ast.Constant): + return repr(node.value) + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + parts = [] + n: ast.AST = node + while isinstance(n, ast.Attribute): + parts.append(n.attr) + n = n.value + if isinstance(n, ast.Name): + parts.append(n.id) + return ".".join(reversed(parts)) + if isinstance(node, ast.Subscript): + value = annotation_str(node.value) + slice_node = node.slice + if isinstance(slice_node, ast.Tuple): + slices = ", ".join(annotation_str(s) or "" for s in slice_node.elts) + return f"{value}[{slices}]" + sl = annotation_str(slice_node) + return f"{value}[{sl}]" if sl else value + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + left = annotation_str(node.left) + right = annotation_str(node.right) + return f"{left} | {right}" + if isinstance(node, ast.List): + return "[" + ", ".join(annotation_str(e) or "" for e in node.elts) + "]" + return ast.unparse(node) + + +def default_str(node: ast.expr | None) -> str | None: + if node is None: + return None + if isinstance(node, ast.Constant): + if node.value is None: + return "None" + return repr(node.value) + if isinstance(node, ast.NameConstant): # py3.8 compat + return repr(node.value) + if isinstance(node, ast.Name): + return node.id + return ast.unparse(node) + + +def extract_args(args_node: ast.arguments) -> list[ArgInfo]: + out: list[ArgInfo] = [] + defaults = [None] * (len(args_node.args) - len(args_node.defaults)) + [ + default_str(d) for d in args_node.defaults + ] + for arg, default in zip(args_node.args, defaults, strict=True): + if arg.arg in ("self", "cls"): + continue + out.append( + ArgInfo( + name=arg.arg, + annotation=annotation_str(arg.annotation), + default=default, + ) + ) + # keyword-only + kw_defaults = [None] * (len(args_node.kwonlyargs) - len(args_node.kw_defaults)) + [ + default_str(d) for d in args_node.kw_defaults + ] + for arg, default in zip(args_node.kwonlyargs, kw_defaults, strict=True): + out.append( + ArgInfo( + name=arg.arg, + annotation=annotation_str(arg.annotation), + default=default, + is_keyword_only=True, + ) + ) + return out + + +def _model_name_from_expr(expr: ast.expr) -> str | None: + """Extract a model identifier from a keyword value (Name or Call).""" + if isinstance(expr, ast.Name): + return expr.id + if isinstance(expr, ast.Call): + func = expr.func + if isinstance(func, ast.Attribute) and func.attr == "model_dump": + return annotation_str(func.value) + if isinstance(func, ast.Name): + return func.id + return None + + +def find_model_in_call(body: list[ast.stmt], attr: str) -> str | None: + """Look for self._transport.request(..., json=MODEL.model_dump(...), response_model=MODEL).""" + for stmt in body: + for node in ast.walk(stmt): + if not isinstance(node, ast.Call): + continue + for kw in node.keywords: + if kw.arg == attr: + model = _model_name_from_expr(kw.value) + if model: + return model + return None + + +def find_response_model(body: list[ast.stmt]) -> str | None: + return find_model_in_call(body, "response_model") + + +def find_request_model(body: list[ast.stmt]) -> str | None: + return find_model_in_call(body, "json") + + +def get_request_model_from_args(args: list[ArgInfo], module_name: str) -> tuple[str | None, str | None]: + """Return the request model name and the argument name that carries it.""" + for arg in args: + if not arg.annotation: + continue + ann = arg.annotation + # strip list[...] + base = ann + if base.startswith("list["): + continue + if " | " in base: + base = base.split(" | ")[0] + if base in ("str", "int", "float", "bool", "dict", "Any", "None"): + continue + # Heuristic: if the arg name is request/settings/query/body/job, treat as model + if arg.name in ("request", "settings", "query", "body", "job"): + return base, arg.name + return None, None + + +def extract_methods(module_path: Path, sync_class_name: str, async_class_name: str) -> tuple[list[MethodInfo], list[MethodInfo]]: + tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path)) + sync_methods: list[MethodInfo] = [] + async_methods: list[MethodInfo] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + is_async = node.name == async_class_name + is_sync = node.name == sync_class_name + if not (is_async or is_sync): + continue + for item in node.body: + if not isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if item.name.startswith("_"): + continue + doc = parse_docstring(item) + http_method, endpoint, desc = extract_http_info(doc) + args = extract_args(item.args) + req_model, req_arg_name = get_request_model_from_args(args, module_path.stem) + if req_model is None: + req_model = find_request_model(item.body) + resp_model = find_response_model(item.body) + return_ann = annotation_str(item.returns) + info = MethodInfo( + name=item.name, + http_method=http_method, + endpoint=endpoint, + description=desc, + args=args, + request_model=req_model, + response_model=resp_model, + return_annotation=return_ann, + module_name=module_path.stem, + class_name=node.name, + is_async=is_async, + docstring=doc, + ) + if is_async: + async_methods.append(info) + else: + sync_methods.append(info) + return sync_methods, async_methods + + +@dataclass +class FieldSample: + name: str + value: Any + annotation: str | None + required: bool + + +def resolve_model(model_name: str, module_name: str) -> Any | None: + """Import a model class by name.""" + # Map: GatewayClient -> gateway models; DnsClient -> dns models, etc. + parts = model_name.split(".") + if len(parts) == 1: + try: + module = __import__(f"conduit_client.models.{module_name}", fromlist=[parts[0]]) + return getattr(module, parts[0], None) + except Exception: + try: + module = __import__("conduit_client.models.common", fromlist=[parts[0]]) + return getattr(module, parts[0], None) + except Exception: + return None + else: + try: + module = __import__("conduit_client.models." + ".".join(parts[:-1]), fromlist=[parts[-1]]) + return getattr(module, parts[-1], None) + except Exception: + return None + + +def infer_value_for_field(name: str, field_info: Any, annotation: str | None) -> tuple[Any, bool]: + """Return (sample_value, required).""" + required = field_info.is_required() + # Special-case obvious identifiers. + lowered = name.lower() + if "api_key" in lowered or "token" in lowered or "secret" in lowered or "password" in lowered: + return "YOUR_VALUE", required + if lowered == "did": + return "5550100", required + if lowered == "dst": + return "5550200", required + if lowered in ("url", "media1", "media2", "media3"): + return "https://example.com/resource", required + if lowered in ("id", "message_id", "job_id", "sub_id", "torrent_id", "vmid", "item_id"): + return 1, required + if lowered == "zone": + return "example.com", required + if lowered == "node": + return "pve", required + if lowered == "storage": + return "local-lvm", required + if lowered in ("username", "user"): + return "user", required + if lowered == "password": + return "pw", required + + ann = annotation or "" + if "str" in ann and "int" not in ann: + if "list" in ann: + return ["value"], required + return "value", required + if "int" in ann and "str" not in ann: + if "list" in ann: + return [1], required + return 1, required + if "bool" in ann: + return True, required + if "list" in ann or "List" in ann: + return [], required + if "dict" in ann or "Dict" in ann: + return {}, required + if "float" in ann: + return 1.0, required + + # Pydantic field metadata. + try: + examples = field_info.examples + if examples: + return examples[0], required + except Exception: + pass + try: + if field_info.default is not None and field_info.default is not ...: + return field_info.default, False + except Exception: + pass + try: + if field_info.default_factory is not None: + return field_info.default_factory(), False + except Exception: + pass + + return "value", required + + +def model_samples(model_cls: Any) -> list[FieldSample]: + if model_cls is None or not hasattr(model_cls, "model_fields"): + return [] + samples: list[FieldSample] = [] + for name, field_info in model_cls.model_fields.items(): + ann = annotation_str_from_any(field_info.annotation) + value, required = infer_value_for_field(name, field_info, ann) + samples.append(FieldSample(name=name, value=value, annotation=ann, required=required)) + return samples + + +def annotation_str_from_any(obj: Any) -> str | None: + if obj is None: + return None + if isinstance(obj, type): + return obj.__name__ + if hasattr(obj, "__origin__"): + origin = getattr(obj, "__origin__", None) + args = getattr(obj, "__args__", ()) + if origin is list or origin is set: + inner = annotation_str_from_any(args[0]) if args else None + return f"list[{inner}]" if inner else "list" + if origin is dict: + k = annotation_str_from_any(args[0]) if args else None + v = annotation_str_from_any(args[1]) if len(args) > 1 else None + return f"dict[{k}, {v}]" if k and v else "dict" + if origin is type or origin is Any: + return "Any" + return str(obj).replace("typing.", "").replace("", "") + + +def render_value(value: Any, indent: int = 0) -> str: + if isinstance(value, str): + return repr(value) + if isinstance(value, (list, tuple)): + if not value: + return "[]" + inner = ",\n".join(" " * (indent + 4) + render_value(v, indent + 4) for v in value) + return "[\n" + inner + "\n" + " " * indent + "]" + if isinstance(value, dict): + if not value: + return "{}" + items = [] + for k, v in value.items(): + items.append(" " * (indent + 4) + repr(k) + ": " + render_value(v, indent + 4)) + return "{\n" + ",\n".join(items) + "\n" + " " * indent + "}" + return repr(value) + + +def build_call_args(method: MethodInfo, request_model_cls: Any | None, req_arg_name: str | None) -> tuple[str, set[str]]: + """Build a complete call-arguments snippet. Returns (snippet, used_arg_names).""" + used: set[str] = set() + pieces: list[str] = [] + + # Positional-like args first (as kwargs for clarity), except the request arg. + for arg in method.args: + if arg.name == req_arg_name: + continue + val = placeholder_for_arg(arg) + pieces.append(f"{arg.name}={val}") + used.add(arg.name) + + if request_model_cls and req_arg_name: + samples = model_samples(request_model_cls) + required = [s for s in samples if s.required] + optional_included = [s for s in samples if not s.required][:1] # include one optional demo + included = required + optional_included + if included: + lines = [f"{req_arg_name}={request_model_cls.__name__}("] + for s in included: + lines.append(f" {s.name}={render_value(s.value)},") + lines.append(")") + pieces.append("\n".join(lines)) + else: + pieces.append(f"{req_arg_name}={request_model_cls.__name__}()") + used.add(req_arg_name) + + if not pieces: + return "", used + + # Single-line if everything is short; multi-line if request model present. + if len(pieces) == 1 and "\n" not in pieces[0]: + return pieces[0], used + return ",\n".join(pieces), used + + +def placeholder_for_arg(arg: ArgInfo) -> str: + ann = arg.annotation or "" + name = arg.name.lower() + if arg.default is not None and arg.default != "None": + return arg.default + if "list" in ann: + return "[]" + if "dict" in ann: + return "{}" + if "bool" in ann: + return "True" + if "int" in ann and "str" not in ann: + return "1" + if "id" in name or name in ("vmid", "did", "dst"): + return '"value"' + return '"value"' + + +def generate_example(method: MethodInfo, async_client: bool) -> str: + """Generate a complete Python example for one method.""" + sync_async = "Async" if async_client else "" + await_kw = "await " if async_client else "" + ctx = "async with" if async_client else "with" + + # Imports. + lines: list[str] = [] + lines.append(f"from conduit_client import {sync_async}ConduitClient") + + model_imports: list[str] = [] + request_model_cls = None + req_arg_name = None + if method.request_model: + request_model_cls = resolve_model(method.request_model, method.module_name) + if request_model_cls is None: + request_model_cls = resolve_model(method.request_model, "common") + if request_model_cls is not None: + model_imports.append(request_model_cls.__name__) + + if model_imports: + # Determine import path. + model_module = method.module_name + if request_model_cls is not None: + model_module = request_model_cls.__module__.replace("conduit_client.models.", "") + lines.append(f"from conduit_client.models.{model_module} import {', '.join(model_imports)}") + + if async_client: + lines.append("import asyncio") + + lines.append("") + + # Build call arguments. + req_arg_name = None + if "request" in [a.name for a in method.args]: + req_arg_name = "request" + elif "settings" in [a.name for a in method.args]: + req_arg_name = "settings" + elif "query" in [a.name for a in method.args]: + req_arg_name = "query" + elif "body" in [a.name for a in method.args]: + req_arg_name = "body" + elif "job" in [a.name for a in method.args]: + req_arg_name = "job" + call_args, used = build_call_args(method, request_model_cls, req_arg_name) + + # If request model wasn't detected by arg name, fall back to first model-looking arg. + if method.request_model and req_arg_name is None: + for arg in method.args: + if arg.annotation and arg.annotation not in ("str", "int", "float", "bool", "dict", "Any") and not arg.annotation.startswith("list["): + call_args, used = build_call_args(method, request_model_cls, arg.name) + break + + # Method call. + client_var = "client" + + def format_call(base_indent: int, prefix: str) -> list[str]: + if not call_args: + return [f"{' ' * base_indent}{prefix}{client_var}.{method.module_name}.{method.name}()"] + if "\n" not in call_args: + return [f"{' ' * base_indent}{prefix}{client_var}.{method.module_name}.{method.name}({call_args})"] + inner_indent = " " * (base_indent + 4) + call_lines = [f"{' ' * base_indent}{prefix}{client_var}.{method.module_name}.{method.name}("] + for raw in call_args.splitlines(): + call_lines.append(inner_indent + raw) + call_lines.append(f"{' ' * base_indent})") + return call_lines + + example_lines: list[str] = [] + if async_client: + example_lines.append("async def main():") + example_lines.append(f" {ctx} {sync_async}ConduitClient(\"mega_sk_live__\") as {client_var}:") + example_lines.extend(format_call(8, f"result = {await_kw}")) + example_lines.append(" print(result)") + example_lines.append("") + example_lines.append("asyncio.run(main())") + else: + example_lines.append(f"{ctx} {sync_async}ConduitClient(\"mega_sk_live__\") as {client_var}:") + example_lines.extend(format_call(4, "result = ")) + example_lines.append(" print(result)") + + return "\n".join(lines + example_lines) + + +def signature_line(method: MethodInfo) -> str: + """Render a Python-like signature string.""" + parts: list[str] = [] + for arg in method.args: + chunk = arg.name + if arg.annotation: + chunk += f": {arg.annotation}" + if arg.default is not None: + chunk += f" = {arg.default}" + parts.append(chunk) + sig = ", ".join(parts) + ret = method.response_model or method.return_annotation or "None" + return f"{method.name}({sig}) -> {ret}" + + +def build_namespaces() -> list[NamespaceInfo]: + namespaces: list[NamespaceInfo] = [] + client_dir = SRC / "conduit_client" / "clients" + # Order matches the main client attribute order. + order = ["gateway", "sms", "media_ingest", "media_library", "torrents", "wiki", "compute", "dns"] + modules = sorted(client_dir.glob("*.py"), key=lambda p: order.index(p.stem) if p.stem in order else 99) + + for module_path in modules: + if module_path.name.startswith("_"): + continue + tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path)) + sync_class = None + async_class = None + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + if node.name.startswith("Async"): + async_class = node.name + elif node.name.endswith("Client"): + sync_class = node.name + if sync_class is None: + continue + if async_class is None: + async_class = "Async" + sync_class + sync_methods, async_methods = extract_methods(module_path, sync_class, async_class) + + # Pair sync and async methods by name. + methods_by_name: dict[str, tuple[MethodInfo | None, MethodInfo | None]] = {} + for m in sync_methods: + methods_by_name.setdefault(m.name, (None, None)) + methods_by_name[m.name] = (m, methods_by_name[m.name][1]) + for m in async_methods: + methods_by_name.setdefault(m.name, (None, None)) + methods_by_name[m.name] = (methods_by_name[m.name][0], m) + + # Use sync method as canonical, fallback to async. + paired_methods: list[MethodInfo] = [] + for name in sorted(methods_by_name): + sync_m, async_m = methods_by_name[name] + canonical = sync_m or async_m + if canonical is None: + continue + paired_methods.append(canonical) + + module_doc = ast.get_docstring(tree) or "" + title = sync_class.replace("Client", "") + ns = NamespaceInfo( + name=module_path.stem, + title=title, + description=module_doc, + sync_class=sync_class, + async_class=async_class, + methods=paired_methods, + ) + namespaces.append(ns) + return namespaces + + +def escape_js(s: str) -> str: + return json.dumps(s) + + +def render_html(namespaces: list[NamespaceInfo]) -> str: + total_methods = sum(len(ns.methods) for ns in namespaces) + + nav_items: list[str] = [] + cards: list[str] = [] + + for ns in namespaces: + method_links: list[str] = [] + for method in ns.methods: + anchor = f"{ns.name}-{method.name}" + method_links.append( + f'' + f'' + f'{html.escape(method.name)}' + f'' + ) + + nav_items.append( + f'
' + f'' + f'' + f'
' + ) + + cards.append( + f'
' + f'
' + f'

{html.escape(ns.name)}

' + f'

{html.escape(ns.description or f"Methods for the {ns.title} façade.")}

' + f'
' + ) + + for method in ns.methods: + anchor = f"{ns.name}-{method.name}" + sync_ex = generate_example(method, async_client=False) + async_ex = generate_example(method, async_client=True) + sig = signature_line(method) + cards.append( + f'
' + f'
' + f'

{html.escape(method.name)}

' + f'{html.escape(method.http_method)}' + f'
' + f'

{html.escape(method.endpoint)}

' + f'

{html.escape(method.description)}

' + f'
{html.escape(sig)}
' + f'
' + f'' + f'' + f'
' + f'
' + f'
' + f'' + f'
{html.escape(sync_ex)}
' + f'
' + f'
' + f'' + f'
{html.escape(async_ex)}
' + f'
' + f'
' + f'
' + ) + + cards.append("
") + + css = """ +:root { + --bg: #0a0a0f; + --bg-2: #111118; + --bg-3: #1a1a24; + --fg: #e8e8f0; + --muted: #8b8ba0; + --accent: #ff0055; + --accent-2: #00f0ff; + --accent-3: #ccff00; + --border: #2a2a3a; + --get: #00f0ff; + --post: #ccff00; + --put: #aa88ff; + --patch: #ffaa00; + --delete: #ff0055; + --font: "JetBrains Mono", "Fira Code", Consolas, monospace; + --display: "Arial Black", Impact, sans-serif; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg); + color: var(--fg); + font-family: var(--font); + line-height: 1.55; + min-height: 100vh; + overflow-x: hidden; +} +body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + repeating-linear-gradient( + 0deg, + rgba(0,0,0,0.15), + rgba(0,0,0,0.15) 1px, + transparent 1px, + transparent 4px + ); + z-index: 1000; +} +.container { + display: grid; + grid-template-columns: 320px 1fr; + min-height: 100vh; +} +.sidebar { + position: sticky; + top: 0; + height: 100vh; + background: var(--bg-2); + border-right: 2px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} +.sidebar-header { + padding: 1.25rem; + border-bottom: 2px dashed var(--accent); +} +.brand { + font-family: var(--display); + font-size: 1.6rem; + letter-spacing: -0.05em; + text-transform: uppercase; + color: var(--accent); + text-shadow: 2px 2px 0 var(--accent-2), -2px -2px 0 #000; + margin: 0 0 0.25rem; + animation: glitch 2.5s infinite; +} +.brand-sub { + color: var(--muted); + font-size: 0.75rem; + margin: 0; +} +.search-wrap { + padding: 0.75rem 1.25rem; +} +.search-wrap input { + width: 100%; + background: var(--bg); + border: 2px solid var(--border); + color: var(--fg); + padding: 0.6rem 0.8rem; + font-family: var(--font); + outline: none; +} +.search-wrap input:focus { + border-color: var(--accent); + box-shadow: 0 0 10px var(--accent); +} +.nav-scroll { + flex: 1; + overflow-y: auto; + padding: 0 0.75rem 1.5rem; +} +.ns-group { + margin-bottom: 0.5rem; +} +.ns-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--fg); + padding: 0.55rem 0.7rem; + font-family: var(--font); + font-size: 0.85rem; + text-align: left; + cursor: pointer; + text-transform: uppercase; +} +.ns-toggle:hover { border-color: var(--accent-2); color: var(--accent-2); } +.ns-toggle[aria-expanded="true"] .ns-chevron { transform: rotate(90deg); } +.ns-chevron { transition: transform 0.15s; } +.ns-name { flex: 1; } +.ns-count { + background: var(--accent); + color: #000; + padding: 0.1rem 0.35rem; + font-size: 0.7rem; + font-weight: bold; +} +.ns-methods { + display: none; + flex-direction: column; + padding-left: 0.75rem; + border-left: 2px solid var(--border); + margin-left: 0.75rem; +} +.ns-methods.open { display: flex; } +.method-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.5rem; + color: var(--muted); + text-decoration: none; + font-size: 0.78rem; +} +.method-link:hover { color: var(--accent-3); background: rgba(255,255,255,0.03); } +.method-dot { width: 7px; height: 7px; border-radius: 50%; } +.method-dot.get { background: var(--get); } +.method-dot.post { background: var(--post); } +.method-dot.put { background: var(--put); } +.method-dot.patch { background: var(--patch); } +.method-dot.delete { background: var(--delete); } +.main { + padding: 2rem 2.5rem; + max-width: 1100px; +} +.hero { + margin-bottom: 2.5rem; + border: 2px solid var(--accent); + padding: 1.5rem; + background: var(--bg-2); + position: relative; +} +.hero::after { + content: "!!!"; + position: absolute; + top: -0.8rem; + right: 1rem; + background: var(--bg); + color: var(--accent); + padding: 0 0.5rem; + font-family: var(--display); + font-size: 1.2rem; +} +.hero h1 { + font-family: var(--display); + text-transform: uppercase; + font-size: 2.4rem; + margin: 0 0 0.5rem; + color: var(--accent-2); + text-shadow: 3px 3px 0 var(--accent); +} +.hero p { margin: 0; color: var(--muted); } +.hero .stat { + margin-top: 1rem; + color: var(--accent-3); + font-weight: bold; +} +.namespace-section { + margin-bottom: 3rem; +} +.ns-header { + margin-bottom: 1.5rem; + border-bottom: 3px solid var(--border); + padding-bottom: 0.75rem; +} +.ns-title { + font-family: var(--display); + text-transform: uppercase; + font-size: 1.8rem; + margin: 0; + color: var(--accent-3); +} +.ns-desc { color: var(--muted); margin: 0.4rem 0 0; } +.method-card { + background: var(--bg-2); + border: 1px solid var(--border); + margin-bottom: 1.25rem; + padding: 1.25rem; + position: relative; +} +.method-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: var(--accent); +} +.method-card.get::before { background: var(--get); } +.method-card.post::before { background: var(--post); } +.method-card.put::before { background: var(--put); } +.method-card.patch::before { background: var(--patch); } +.method-card.delete::before { background: var(--delete); } +.method-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.4rem; +} +.method-name { + font-size: 1.15rem; + margin: 0; + color: var(--fg); +} +.http-badge { + font-size: 0.7rem; + font-weight: bold; + padding: 0.15rem 0.4rem; + border: 1px solid currentColor; + text-transform: uppercase; +} +.http-badge.get { color: var(--get); border-color: var(--get); } +.http-badge.post { color: var(--post); border-color: var(--post); } +.http-badge.put { color: var(--put); border-color: var(--put); } +.http-badge.patch { color: var(--patch); border-color: var(--patch); } +.http-badge.delete { color: var(--delete); border-color: var(--delete); } +.method-endpoint { + color: var(--accent-2); + font-size: 0.85rem; + margin: 0 0 0.6rem; + font-weight: bold; +} +.method-desc { color: var(--muted); margin: 0 0 1rem; } +.signature { + background: var(--bg); + border: 1px dashed var(--border); + padding: 0.75rem 1rem; + margin: 0 0 1rem; + overflow-x: auto; +} +.signature code { + font-family: var(--font); + color: var(--accent-2); +} +.example-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.tab-btn { + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--muted); + padding: 0.35rem 0.8rem; + font-family: var(--font); + cursor: pointer; +} +.tab-btn.active { border-color: var(--accent); color: var(--fg); } +.example-wrap { position: relative; } +.example { + display: none; + position: relative; +} +.example.active { display: block; } +.example pre { + background: var(--bg); + border: 1px solid var(--border); + padding: 1rem; + margin: 0; + overflow-x: auto; +} +.example code { + font-family: var(--font); + font-size: 0.82rem; + color: var(--fg); +} +.copy-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: var(--bg-3); + border: 1px solid var(--border); + color: var(--muted); + padding: 0.25rem 0.5rem; + font-family: var(--font); + font-size: 0.7rem; + cursor: pointer; + z-index: 2; +} +.copy-btn:hover { border-color: var(--accent-2); color: var(--accent-2); } +.hidden { display: none !important; } +@keyframes glitch { + 0%, 90%, 100% { transform: translate(0); } + 91% { transform: translate(2px, 1px); } + 92% { transform: translate(-2px, -1px); } + 93% { transform: translate(1px, -1px); } + 94% { transform: translate(0); } +} +@media (max-width: 900px) { + .container { grid-template-columns: 1fr; } + .sidebar { position: static; height: auto; } +} +""" + + js = """ +document.addEventListener('DOMContentLoaded', () => { + const search = document.getElementById('search'); + const groups = document.querySelectorAll('.ns-group'); + + function updateSearch(term) { + const low = term.toLowerCase(); + groups.forEach(g => { + const nsName = g.querySelector('.ns-name').textContent.toLowerCase(); + const links = Array.from(g.querySelectorAll('.method-link')); + let nsMatch = nsName.includes(low); + links.forEach(lnk => { + const txt = lnk.textContent.toLowerCase(); + const show = !term || nsMatch || txt.includes(low); + lnk.classList.toggle('hidden', !show); + }); + const any = links.some(l => !l.classList.contains('hidden')); + g.classList.toggle('hidden', !any); + if (term && any) { + g.querySelector('.ns-methods').classList.add('open'); + g.querySelector('.ns-toggle').setAttribute('aria-expanded', 'true'); + } + }); + } + + search.addEventListener('input', e => updateSearch(e.target.value)); + + document.querySelectorAll('.ns-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const open = btn.getAttribute('aria-expanded') === 'true'; + btn.setAttribute('aria-expanded', String(!open)); + const methods = document.getElementById('nav-' + btn.dataset.ns); + methods.classList.toggle('open'); + }); + }); + + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + const parent = btn.closest('.method-card'); + parent.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + parent.querySelectorAll('.example').forEach(ex => ex.classList.remove('active')); + document.getElementById(btn.dataset.target).classList.add('active'); + }); + }); + + document.querySelectorAll('.copy-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const code = btn.dataset.code || ''; + try { + await navigator.clipboard.writeText(code); + const old = btn.textContent; + btn.textContent = 'copied'; + setTimeout(() => btn.textContent = old, 1200); + } catch (e) { + console.error(e); + } + }); + }); + + // Open the group targeted by a deep link. + if (location.hash) { + const card = document.querySelector(location.hash); + if (card) { + const ns = card.closest('.namespace-section').id.replace('ns-', ''); + const toggle = document.querySelector(`.ns-toggle[data-ns="${ns}"]`); + if (toggle) { + toggle.setAttribute('aria-expanded', 'true'); + document.getElementById('nav-' + ns).classList.add('open'); + } + card.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } +}); +""" + + html_doc = f""" + + + + + CONDUIT CLIENT DOCS — PSYCHOTIC EDITION + + + +
+ +
+
+

ABANDON HOPE

+

Typed Python client for the Conduit unified API gateway. Every façade. Every method. Sync and async examples. Copy, paste, and pray.

+

{total_methods} METHODS ACROSS {len(namespaces)} FAÇADES

+
+ {''.join(cards)} +
+
+ + + +""" + return html_doc + + +def as_json_data(namespaces: list[NamespaceInfo]) -> list[dict[str, Any]]: + """Serialize namespaces/methods/examples to plain JSON.""" + out: list[dict[str, Any]] = [] + for ns in namespaces: + methods: list[dict[str, Any]] = [] + for method in ns.methods: + methods.append( + { + "name": method.name, + "http_method": method.http_method, + "endpoint": method.endpoint, + "description": method.description, + "signature": signature_line(method), + "args": [ + { + "name": a.name, + "annotation": a.annotation, + "default": a.default, + "keyword_only": a.is_keyword_only, + } + for a in method.args + ], + "request_model": method.request_model, + "response_model": method.response_model, + "return_annotation": method.return_annotation, + "sync_example": generate_example(method, async_client=False), + "async_example": generate_example(method, async_client=True), + } + ) + out.append( + { + "name": ns.name, + "title": ns.title, + "description": ns.description, + "sync_class": ns.sync_class, + "async_class": ns.async_class, + "methods": methods, + } + ) + return out + + +def main() -> None: + site_dir = ROOT / "site" + site_dir.mkdir(exist_ok=True) + namespaces = build_namespaces() + + html_doc = render_html(namespaces) + (site_dir / "index.html").write_text(html_doc, encoding="utf-8") + + data = as_json_data(namespaces) + (site_dir / "api.json").write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + + total_methods = sum(len(ns.methods) for ns in namespaces) + print(f"Generated {site_dir / 'index.html'} and {site_dir / 'api.json'} ({len(namespaces)} namespaces, {total_methods} methods)") + + +if __name__ == "__main__": + main() diff --git a/src/conduit_client/__init__.py b/src/conduit_client/__init__.py new file mode 100644 index 0000000..cb18e3d --- /dev/null +++ b/src/conduit_client/__init__.py @@ -0,0 +1,27 @@ +"""Conduit Python client library.""" + +from ._client import AsyncConduitClient, ConduitClient +from .exceptions import ( + AuthenticationError, + ConduitAPIError, + ConduitError, + ConflictError, + NotFoundError, + RateLimitError, + ValidationError, +) + +__version__ = "0.1.0" + +__all__ = [ + "AsyncConduitClient", + "AuthenticationError", + "ConduitAPIError", + "ConduitClient", + "ConduitError", + "ConflictError", + "NotFoundError", + "RateLimitError", + "ValidationError", + "__version__", +] diff --git a/src/conduit_client/_base_client.py b/src/conduit_client/_base_client.py new file mode 100644 index 0000000..380efc8 --- /dev/null +++ b/src/conduit_client/_base_client.py @@ -0,0 +1,224 @@ +"""Shared HTTP transport and helpers for sync and async Conduit clients.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any +from urllib.parse import quote + +import httpx +from pydantic import TypeAdapter + +from .exceptions import ( + AuthenticationError, + ConduitAPIError, + ConflictError, + NotFoundError, + RateLimitError, + ValidationError, +) + +_IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) +_RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504}) + + +class _BaseClientMixin: + """Helpers shared by sync and async transports.""" + + api_key: str + base_url: str + timeout: float + max_retries: int + + def _auth_header(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.api_key}"} + + def _build_url(self, path: str) -> str: + return f"{self.base_url}/{path.lstrip('/')}" + + @staticmethod + def _encode_path_param(value: str) -> str: + return quote(value, safe="") + + def _should_retry(self, method: str, status_code: int, attempt: int) -> bool: + return ( + self.max_retries > 0 + and attempt < self.max_retries + and method in _IDEMPOTENT_METHODS + and status_code in _RETRYABLE_STATUS_CODES + ) + + def _handle_response(self, response: httpx.Response, response_model: Any | None = None) -> Any: + if response.status_code >= 400: + self._raise_for_status(response) + if response.status_code == 204: + return None + content_type = response.headers.get("content-type", "") + looks_like_json = ( + "application/json" in content_type + or response.text.lstrip().startswith(("{", "[")) + ) + if looks_like_json: + data = response.json() + else: + return response.text + if response_model is None: + return data + return TypeAdapter(response_model).validate_python(data) + + @staticmethod + def _raise_for_status(response: httpx.Response) -> None: + try: + body = response.json() + except Exception: + body = response.text + message = body if isinstance(body, str) else str(body) + status_code = response.status_code + if status_code in (401, 403): + raise AuthenticationError(message, status_code=status_code, response_body=body) + if status_code == 404: + raise NotFoundError(message, status_code=status_code, response_body=body) + if status_code == 422: + raise ValidationError(message, status_code=status_code, response_body=body) + if status_code == 409: + raise ConflictError(message, status_code=status_code, response_body=body) + if status_code == 429: + raise RateLimitError(message, status_code=status_code, response_body=body) + raise ConduitAPIError(message, status_code=status_code, response_body=body) + + +class SyncTransport(_BaseClientMixin): + """Synchronous HTTP transport backed by httpx.Client.""" + + def __init__( + self, + api_key: str, + base_url: str = "https://api.cowtunnel.com", + timeout: float = 30.0, + max_retries: int = 0, + **httpx_kwargs: Any, + ) -> None: + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.max_retries = max_retries + self._client = httpx.Client( + base_url=self.base_url, + timeout=self.timeout, + headers=self._auth_header(), + **httpx_kwargs, + ) + + def request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any = None, + headers: dict[str, str] | None = None, + response_model: Any | None = None, + ) -> Any: + url = self._build_url(path) + request_headers = {**(headers or {}), **self._auth_header()} + last_exception: Exception | None = None + for attempt in range(self.max_retries + 1): + response = self._client.request( + method, + url, + params=params, + json=json, + headers=request_headers, + timeout=self.timeout, + ) + if response.status_code < 400 or not self._should_retry( + method, response.status_code, attempt + ): + return self._handle_response(response, response_model) + last_exception = self._make_exception(response) + if attempt < self.max_retries: + time.sleep(2**attempt * 0.5) + if last_exception is not None: + raise last_exception + return None # pragma: no cover + + def close(self) -> None: + self._client.close() + + @staticmethod + def _make_exception(response: httpx.Response) -> ConduitAPIError: + try: + body = response.json() + except Exception: + body = response.text + message = body if isinstance(body, str) else str(body) + return ConduitAPIError(message, status_code=response.status_code, response_body=body) + + +class AsyncTransport(_BaseClientMixin): + """Asynchronous HTTP transport backed by httpx.AsyncClient.""" + + def __init__( + self, + api_key: str, + base_url: str = "https://api.cowtunnel.com", + timeout: float = 30.0, + max_retries: int = 0, + **httpx_kwargs: Any, + ) -> None: + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.max_retries = max_retries + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout, + headers=self._auth_header(), + **httpx_kwargs, + ) + + async def request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any = None, + headers: dict[str, str] | None = None, + response_model: Any | None = None, + ) -> Any: + url = self._build_url(path) + request_headers = {**(headers or {}), **self._auth_header()} + last_exception: Exception | None = None + for attempt in range(self.max_retries + 1): + response = await self._client.request( + method, + url, + params=params, + json=json, + headers=request_headers, + timeout=self.timeout, + ) + if response.status_code < 400 or not self._should_retry( + method, response.status_code, attempt + ): + return self._handle_response(response, response_model) + last_exception = self._make_exception(response) + if attempt < self.max_retries: + await asyncio.sleep(2**attempt * 0.5) + if last_exception is not None: + raise last_exception + return None # pragma: no cover + + async def aclose(self) -> None: + await self._client.aclose() + + @staticmethod + def _make_exception(response: httpx.Response) -> ConduitAPIError: + try: + body = response.json() + except Exception: + body = response.text + message = body if isinstance(body, str) else str(body) + return ConduitAPIError(message, status_code=response.status_code, response_body=body) diff --git a/src/conduit_client/_client.py b/src/conduit_client/_client.py new file mode 100644 index 0000000..f26310f --- /dev/null +++ b/src/conduit_client/_client.py @@ -0,0 +1,85 @@ +"""Public sync and async Conduit clients.""" + +from __future__ import annotations + +from typing import Any + +from ._base_client import AsyncTransport, SyncTransport +from .clients.compute import AsyncComputeClient, ComputeClient +from .clients.dns import AsyncDnsClient, DnsClient +from .clients.gateway import AsyncGatewayClient, GatewayClient +from .clients.media_ingest import AsyncMediaIngestClient, MediaIngestClient +from .clients.media_library import AsyncMediaLibraryClient, MediaLibraryClient +from .clients.sms import AsyncSmsClient, SmsClient +from .clients.torrents import AsyncTorrentsClient, TorrentsClient +from .clients.wiki import AsyncWikiClient, WikiClient + + +class ConduitClient: + """Synchronous client for the Conduit API.""" + + def __init__( + self, + api_key: str, + *, + base_url: str = "https://api.cowtunnel.com", + timeout: float = 30.0, + max_retries: int = 0, + **httpx_kwargs: Any, + ) -> None: + self._transport = SyncTransport( + api_key=api_key, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + **httpx_kwargs, + ) + self.gateway = GatewayClient(self._transport) + self.sms = SmsClient(self._transport) + self.media_ingest = MediaIngestClient(self._transport) + self.media_library = MediaLibraryClient(self._transport) + self.torrents = TorrentsClient(self._transport) + self.wiki = WikiClient(self._transport) + self.compute = ComputeClient(self._transport) + self.dns = DnsClient(self._transport) + + def __enter__(self) -> ConduitClient: + return self + + def __exit__(self, *exc_info: object) -> None: + self._transport.close() + + +class AsyncConduitClient: + """Asynchronous client for the Conduit API.""" + + def __init__( + self, + api_key: str, + *, + base_url: str = "https://api.cowtunnel.com", + timeout: float = 30.0, + max_retries: int = 0, + **httpx_kwargs: Any, + ) -> None: + self._transport = AsyncTransport( + api_key=api_key, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + **httpx_kwargs, + ) + self.gateway = AsyncGatewayClient(self._transport) + self.sms = AsyncSmsClient(self._transport) + self.media_ingest = AsyncMediaIngestClient(self._transport) + self.media_library = AsyncMediaLibraryClient(self._transport) + self.torrents = AsyncTorrentsClient(self._transport) + self.wiki = AsyncWikiClient(self._transport) + self.compute = AsyncComputeClient(self._transport) + self.dns = AsyncDnsClient(self._transport) + + async def __aenter__(self) -> AsyncConduitClient: + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self._transport.aclose() diff --git a/src/conduit_client/clients/__init__.py b/src/conduit_client/clients/__init__.py new file mode 100644 index 0000000..0cb052f --- /dev/null +++ b/src/conduit_client/clients/__init__.py @@ -0,0 +1 @@ +"""Per-façade Conduit API clients.""" diff --git a/src/conduit_client/clients/compute.py b/src/conduit_client/clients/compute.py new file mode 100644 index 0000000..159315f --- /dev/null +++ b/src/conduit_client/clients/compute.py @@ -0,0 +1,518 @@ +"""Client for the Proxmox compute façade.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..models.compute import ( + CloneVmRequest, + ComputeTicketRequest, + ComputeTicketResponse, + CreateContainerRequest, + CreateSnapshotRequest, + CreateVmRequest, + MigrateVmRequest, + NodeTasksQuery, + StorageQuery, + TaskResponse, + UploadStorageRequest, +) + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class ComputeClient: + """Synchronous client for the Proxmox compute façade.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def auth_ticket(self, request: ComputeTicketRequest) -> ComputeTicketResponse: + """POST /compute/auth/ticket - obtain a Proxmox ticket.""" + return self._transport.request( + "POST", + "/compute/auth/ticket", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=ComputeTicketResponse, + ) + + def list_resources(self, *, type_: str | None = None) -> list[dict[str, Any]]: + """GET /compute/resources - list cluster resources.""" + params = None + if type_ is not None: + params = {"type": type_} + return self._transport.request("GET", "/compute/resources", params=params) + + def cluster_status(self) -> dict[str, Any]: + """GET /compute/cluster/status - cluster status and quorate flag.""" + return self._transport.request("GET", "/compute/cluster/status") + + def list_nodes(self) -> list[dict[str, Any]]: + """GET /compute/nodes - list nodes.""" + return self._transport.request("GET", "/compute/nodes") + + def get_node_status(self, node: str) -> dict[str, Any]: + """GET /compute/nodes/{node}/status - node status.""" + return self._transport.request("GET", f"/compute/nodes/{node}/status") + + def list_node_tasks(self, node: str, query: NodeTasksQuery | None = None) -> list[dict[str, Any]]: + """GET /compute/nodes/{node}/tasks - list node tasks.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return self._transport.request( + "GET", f"/compute/nodes/{node}/tasks", params=params + ) + + def list_vms(self) -> list[dict[str, Any]]: + """GET /compute/vms - list QEMU VMs.""" + return self._transport.request("GET", "/compute/vms") + + def create_vm(self, request: CreateVmRequest) -> TaskResponse: + """POST /compute/vms - create a VM. Extra fields are forwarded.""" + return self._transport.request( + "POST", + "/compute/vms", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + def get_vm(self, vmid: int | str) -> dict[str, Any]: + """GET /compute/vms/{vmid} - VM config/status.""" + return self._transport.request("GET", f"/compute/vms/{vmid}") + + def patch_vm(self, vmid: int | str, settings: dict[str, Any]) -> TaskResponse: + """PATCH /compute/vms/{vmid} - patch VM settings.""" + return self._transport.request( + "PATCH", + f"/compute/vms/{vmid}", + json=settings, + response_model=TaskResponse, + ) + + def delete_vm(self, vmid: int | str, *, confirm: int | str | None = None) -> None: + """DELETE /compute/vms/{vmid} - delete a VM.""" + body = None + if confirm is not None: + body = {"confirm": str(confirm)} + self._transport.request("DELETE", f"/compute/vms/{vmid}", json=body) + + def start_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/start - start a VM.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/start", + response_model=TaskResponse, + ) + + def stop_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/stop - stop a VM.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/stop", + response_model=TaskResponse, + ) + + def shutdown_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/shutdown - shutdown a VM.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/shutdown", + response_model=TaskResponse, + ) + + def reboot_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/reboot - reboot a VM.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/reboot", + response_model=TaskResponse, + ) + + def clone_vm(self, vmid: int | str, request: CloneVmRequest) -> TaskResponse: + """POST /compute/vms/{vmid}/clone - clone a VM.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/clone", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + def migrate_vm(self, vmid: int | str, request: MigrateVmRequest) -> TaskResponse: + """POST /compute/vms/{vmid}/migrate - migrate a VM.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/migrate", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + def list_snapshots(self, vmid: int | str) -> list[dict[str, Any]]: + """GET /compute/vms/{vmid}/snapshots - list VM snapshots.""" + return self._transport.request("GET", f"/compute/vms/{vmid}/snapshots") + + def create_snapshot( + self, vmid: int | str, request: CreateSnapshotRequest + ) -> TaskResponse: + """POST /compute/vms/{vmid}/snapshots - create a snapshot.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/snapshots", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + def agent_action( + self, vmid: int | str, action: str, body: dict[str, Any] | None = None + ) -> dict[str, Any]: + """POST /compute/vms/{vmid}/agent/{action} - run a guest agent action.""" + return self._transport.request( + "POST", + f"/compute/vms/{vmid}/agent/{action}", + json=body, + ) + + def list_containers(self) -> list[dict[str, Any]]: + """GET /compute/containers - list LXC containers.""" + return self._transport.request("GET", "/compute/containers") + + def create_container( + self, request: CreateContainerRequest + ) -> TaskResponse: + """POST /compute/containers - create an LXC container.""" + return self._transport.request( + "POST", + "/compute/containers", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + def start_container(self, vmid: int | str) -> TaskResponse: + """POST /compute/containers/{vmid}/start - start a container.""" + return self._transport.request( + "POST", + f"/compute/containers/{vmid}/start", + response_model=TaskResponse, + ) + + def stop_container(self, vmid: int | str) -> TaskResponse: + """POST /compute/containers/{vmid}/stop - stop a container.""" + return self._transport.request( + "POST", + f"/compute/containers/{vmid}/stop", + response_model=TaskResponse, + ) + + def list_storage(self, query: StorageQuery | None = None) -> list[dict[str, Any]]: + """GET /compute/storage - list storage.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return self._transport.request("GET", "/compute/storage", params=params) + + def list_storage_content( + self, storage: str, query: StorageQuery | None = None + ) -> list[dict[str, Any]]: + """GET /compute/storage/{storage}/content - list storage content.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return self._transport.request( + "GET", + f"/compute/storage/{storage}/content", + params=params, + ) + + def upload_to_storage( + self, + storage: str, + request: UploadStorageRequest, + *, + node: str, + ) -> TaskResponse: + """POST /compute/storage/{storage}/upload - upload to storage.""" + return self._transport.request( + "POST", + f"/compute/storage/{storage}/upload", + params={"node": node}, + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + def list_backup_jobs(self) -> list[dict[str, Any]]: + """GET /compute/backups/jobs - list backup jobs.""" + return self._transport.request("GET", "/compute/backups/jobs") + + def create_backup_job(self, job: dict[str, Any]) -> TaskResponse: + """POST /compute/backups/jobs - create a backup job.""" + return self._transport.request( + "POST", + "/compute/backups/jobs", + json=job, + response_model=TaskResponse, + ) + + def list_ha_resources(self) -> list[dict[str, Any]]: + """GET /compute/ha/resources - list HA-managed resources.""" + return self._transport.request("GET", "/compute/ha/resources") + + def list_access_users(self) -> list[dict[str, Any]]: + """GET /compute/access/users - list Proxmox users.""" + return self._transport.request("GET", "/compute/access/users") + + def list_access_acl(self) -> list[dict[str, Any]]: + """GET /compute/access/acl - list ACL entries.""" + return self._transport.request("GET", "/compute/access/acl") + + +class AsyncComputeClient: + """Asynchronous client for the Proxmox compute façade.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def auth_ticket(self, request: ComputeTicketRequest) -> ComputeTicketResponse: + """POST /compute/auth/ticket - obtain a Proxmox ticket.""" + return await self._transport.request( + "POST", + "/compute/auth/ticket", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=ComputeTicketResponse, + ) + + async def list_resources(self, *, type_: str | None = None) -> list[dict[str, Any]]: + """GET /compute/resources - list cluster resources.""" + params = None + if type_ is not None: + params = {"type": type_} + return await self._transport.request("GET", "/compute/resources", params=params) + + async def cluster_status(self) -> dict[str, Any]: + """GET /compute/cluster/status - cluster status and quorate flag.""" + return await self._transport.request("GET", "/compute/cluster/status") + + async def list_nodes(self) -> list[dict[str, Any]]: + """GET /compute/nodes - list nodes.""" + return await self._transport.request("GET", "/compute/nodes") + + async def get_node_status(self, node: str) -> dict[str, Any]: + """GET /compute/nodes/{node}/status - node status.""" + return await self._transport.request("GET", f"/compute/nodes/{node}/status") + + async def list_node_tasks( + self, node: str, query: NodeTasksQuery | None = None + ) -> list[dict[str, Any]]: + """GET /compute/nodes/{node}/tasks - list node tasks.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return await self._transport.request( + "GET", f"/compute/nodes/{node}/tasks", params=params + ) + + async def list_vms(self) -> list[dict[str, Any]]: + """GET /compute/vms - list QEMU VMs.""" + return await self._transport.request("GET", "/compute/vms") + + async def create_vm(self, request: CreateVmRequest) -> TaskResponse: + """POST /compute/vms - create a VM. Extra fields are forwarded.""" + return await self._transport.request( + "POST", + "/compute/vms", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + async def get_vm(self, vmid: int | str) -> dict[str, Any]: + """GET /compute/vms/{vmid} - VM config/status.""" + return await self._transport.request("GET", f"/compute/vms/{vmid}") + + async def patch_vm(self, vmid: int | str, settings: dict[str, Any]) -> TaskResponse: + """PATCH /compute/vms/{vmid} - patch VM settings.""" + return await self._transport.request( + "PATCH", + f"/compute/vms/{vmid}", + json=settings, + response_model=TaskResponse, + ) + + async def delete_vm( + self, vmid: int | str, *, confirm: int | str | None = None + ) -> None: + """DELETE /compute/vms/{vmid} - delete a VM.""" + body = None + if confirm is not None: + body = {"confirm": str(confirm)} + await self._transport.request("DELETE", f"/compute/vms/{vmid}", json=body) + + async def start_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/start - start a VM.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/start", + response_model=TaskResponse, + ) + + async def stop_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/stop - stop a VM.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/stop", + response_model=TaskResponse, + ) + + async def shutdown_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/shutdown - shutdown a VM.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/shutdown", + response_model=TaskResponse, + ) + + async def reboot_vm(self, vmid: int | str) -> TaskResponse: + """POST /compute/vms/{vmid}/reboot - reboot a VM.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/reboot", + response_model=TaskResponse, + ) + + async def clone_vm(self, vmid: int | str, request: CloneVmRequest) -> TaskResponse: + """POST /compute/vms/{vmid}/clone - clone a VM.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/clone", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + async def migrate_vm( + self, vmid: int | str, request: MigrateVmRequest + ) -> TaskResponse: + """POST /compute/vms/{vmid}/migrate - migrate a VM.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/migrate", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + async def list_snapshots(self, vmid: int | str) -> list[dict[str, Any]]: + """GET /compute/vms/{vmid}/snapshots - list VM snapshots.""" + return await self._transport.request("GET", f"/compute/vms/{vmid}/snapshots") + + async def create_snapshot( + self, vmid: int | str, request: CreateSnapshotRequest + ) -> TaskResponse: + """POST /compute/vms/{vmid}/snapshots - create a snapshot.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/snapshots", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + async def agent_action( + self, vmid: int | str, action: str, body: dict[str, Any] | None = None + ) -> dict[str, Any]: + """POST /compute/vms/{vmid}/agent/{action} - run a guest agent action.""" + return await self._transport.request( + "POST", + f"/compute/vms/{vmid}/agent/{action}", + json=body, + ) + + async def list_containers(self) -> list[dict[str, Any]]: + """GET /compute/containers - list LXC containers.""" + return await self._transport.request("GET", "/compute/containers") + + async def create_container( + self, request: CreateContainerRequest + ) -> TaskResponse: + """POST /compute/containers - create an LXC container.""" + return await self._transport.request( + "POST", + "/compute/containers", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + async def start_container(self, vmid: int | str) -> TaskResponse: + """POST /compute/containers/{vmid}/start - start a container.""" + return await self._transport.request( + "POST", + f"/compute/containers/{vmid}/start", + response_model=TaskResponse, + ) + + async def stop_container(self, vmid: int | str) -> TaskResponse: + """POST /compute/containers/{vmid}/stop - stop a container.""" + return await self._transport.request( + "POST", + f"/compute/containers/{vmid}/stop", + response_model=TaskResponse, + ) + + async def list_storage( + self, query: StorageQuery | None = None + ) -> list[dict[str, Any]]: + """GET /compute/storage - list storage.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return await self._transport.request("GET", "/compute/storage", params=params) + + async def list_storage_content( + self, storage: str, query: StorageQuery | None = None + ) -> list[dict[str, Any]]: + """GET /compute/storage/{storage}/content - list storage content.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return await self._transport.request( + "GET", + f"/compute/storage/{storage}/content", + params=params, + ) + + async def upload_to_storage( + self, + storage: str, + request: UploadStorageRequest, + *, + node: str, + ) -> TaskResponse: + """POST /compute/storage/{storage}/upload - upload to storage.""" + return await self._transport.request( + "POST", + f"/compute/storage/{storage}/upload", + params={"node": node}, + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=TaskResponse, + ) + + async def list_backup_jobs(self) -> list[dict[str, Any]]: + """GET /compute/backups/jobs - list backup jobs.""" + return await self._transport.request("GET", "/compute/backups/jobs") + + async def create_backup_job(self, job: dict[str, Any]) -> TaskResponse: + """POST /compute/backups/jobs - create a backup job.""" + return await self._transport.request( + "POST", + "/compute/backups/jobs", + json=job, + response_model=TaskResponse, + ) + + async def list_ha_resources(self) -> list[dict[str, Any]]: + """GET /compute/ha/resources - list HA-managed resources.""" + return await self._transport.request("GET", "/compute/ha/resources") + + async def list_access_users(self) -> list[dict[str, Any]]: + """GET /compute/access/users - list Proxmox users.""" + return await self._transport.request("GET", "/compute/access/users") + + async def list_access_acl(self) -> list[dict[str, Any]]: + """GET /compute/access/acl - list ACL entries.""" + return await self._transport.request("GET", "/compute/access/acl") diff --git a/src/conduit_client/clients/dns.py b/src/conduit_client/clients/dns.py new file mode 100644 index 0000000..8d017c1 --- /dev/null +++ b/src/conduit_client/clients/dns.py @@ -0,0 +1,253 @@ +"""Client for the BIND9 DNS façade.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..models.dns import ( + AddZoneRequest, + BatchUpdateRecordsRequest, + CacheFlushRequest, + ServerReloadRequest, +) + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class DnsClient: + """Synchronous client for the BIND9 DNS façade.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def server_status(self, server_id: str) -> str: + """GET /dns/servers/{server_id}/status - BIND status text.""" + return self._transport.request( + "GET", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/status", + ) + + def reload_server( + self, server_id: str, request: ServerReloadRequest | None = None + ) -> None: + """POST /dns/servers/{server_id}/reload - reload BIND configuration.""" + body = None + if request is not None: + body = request.model_dump(by_alias=True, exclude_none=True) + self._transport.request( + "POST", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/reload", + json=body, + ) + + def server_stats(self, server_id: str) -> dict[str, Any]: + """GET /dns/servers/{server_id}/stats - BIND statistics JSON.""" + return self._transport.request( + "GET", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/stats", + ) + + def flush_cache( + self, server_id: str, request: CacheFlushRequest | None = None + ) -> None: + """POST /dns/servers/{server_id}/cache/flush - flush BIND cache.""" + body = None + if request is not None: + body = request.model_dump(by_alias=True, exclude_none=True) + self._transport.request( + "POST", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/cache/flush", + json=body, + ) + + def server_config(self, server_id: str) -> dict[str, Any]: + """GET /dns/servers/{server_id}/config - BIND configuration JSON.""" + return self._transport.request( + "GET", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/config", + ) + + def create_zone(self, request: AddZoneRequest) -> dict[str, Any]: + """POST /dns/zones - add a new zone.""" + return self._transport.request( + "POST", + "/dns/zones", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + def get_zone(self, zone: str) -> dict[str, Any]: + """GET /dns/zones/{zone} - fetch zone info.""" + return self._transport.request( + "GET", + f"/dns/zones/{self._transport._encode_path_param(zone)}", + ) + + def patch_zone(self, zone: str, settings: dict[str, Any]) -> None: + """PATCH /dns/zones/{zone} - modify a zone.""" + self._transport.request( + "PATCH", + f"/dns/zones/{self._transport._encode_path_param(zone)}", + json=settings, + ) + + def delete_zone(self, zone: str, *, confirm: str | None = None) -> None: + """DELETE /dns/zones/{zone} - delete a zone.""" + body = None + if confirm is not None: + body = {"confirm": confirm} + self._transport.request( + "DELETE", + f"/dns/zones/{self._transport._encode_path_param(zone)}", + json=body, + ) + + def batch_update_records( + self, zone: str, request: BatchUpdateRecordsRequest + ) -> None: + """POST /dns/zones/{zone}/records:batchUpdate - batch update records.""" + self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/records:batchUpdate", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + def freeze_zone(self, zone: str) -> None: + """POST /dns/zones/{zone}/freeze - freeze a zone.""" + self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/freeze", + ) + + def thaw_zone(self, zone: str) -> None: + """POST /dns/zones/{zone}/thaw - thaw a zone.""" + self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/thaw", + ) + + def sync_zone(self, zone: str) -> None: + """POST /dns/zones/{zone}/sync - sync a zone.""" + self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/sync", + ) + + +class AsyncDnsClient: + """Asynchronous client for the BIND9 DNS façade.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def server_status(self, server_id: str) -> str: + """GET /dns/servers/{server_id}/status - BIND status text.""" + return await self._transport.request( + "GET", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/status", + ) + + async def reload_server( + self, server_id: str, request: ServerReloadRequest | None = None + ) -> None: + """POST /dns/servers/{server_id}/reload - reload BIND configuration.""" + body = None + if request is not None: + body = request.model_dump(by_alias=True, exclude_none=True) + await self._transport.request( + "POST", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/reload", + json=body, + ) + + async def server_stats(self, server_id: str) -> dict[str, Any]: + """GET /dns/servers/{server_id}/stats - BIND statistics JSON.""" + return await self._transport.request( + "GET", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/stats", + ) + + async def flush_cache( + self, server_id: str, request: CacheFlushRequest | None = None + ) -> None: + """POST /dns/servers/{server_id}/cache/flush - flush BIND cache.""" + body = None + if request is not None: + body = request.model_dump(by_alias=True, exclude_none=True) + await self._transport.request( + "POST", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/cache/flush", + json=body, + ) + + async def server_config(self, server_id: str) -> dict[str, Any]: + """GET /dns/servers/{server_id}/config - BIND configuration JSON.""" + return await self._transport.request( + "GET", + f"/dns/servers/{self._transport._encode_path_param(server_id)}/config", + ) + + async def create_zone(self, request: AddZoneRequest) -> dict[str, Any]: + """POST /dns/zones - add a new zone.""" + return await self._transport.request( + "POST", + "/dns/zones", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + async def get_zone(self, zone: str) -> dict[str, Any]: + """GET /dns/zones/{zone} - fetch zone info.""" + return await self._transport.request( + "GET", + f"/dns/zones/{self._transport._encode_path_param(zone)}", + ) + + async def patch_zone(self, zone: str, settings: dict[str, Any]) -> None: + """PATCH /dns/zones/{zone} - modify a zone.""" + await self._transport.request( + "PATCH", + f"/dns/zones/{self._transport._encode_path_param(zone)}", + json=settings, + ) + + async def delete_zone(self, zone: str, *, confirm: str | None = None) -> None: + """DELETE /dns/zones/{zone} - delete a zone.""" + body = None + if confirm is not None: + body = {"confirm": confirm} + await self._transport.request( + "DELETE", + f"/dns/zones/{self._transport._encode_path_param(zone)}", + json=body, + ) + + async def batch_update_records( + self, zone: str, request: BatchUpdateRecordsRequest + ) -> None: + """POST /dns/zones/{zone}/records:batchUpdate - batch update records.""" + await self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/records:batchUpdate", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + async def freeze_zone(self, zone: str) -> None: + """POST /dns/zones/{zone}/freeze - freeze a zone.""" + await self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/freeze", + ) + + async def thaw_zone(self, zone: str) -> None: + """POST /dns/zones/{zone}/thaw - thaw a zone.""" + await self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/thaw", + ) + + async def sync_zone(self, zone: str) -> None: + """POST /dns/zones/{zone}/sync - sync a zone.""" + await self._transport.request( + "POST", + f"/dns/zones/{self._transport._encode_path_param(zone)}/sync", + ) diff --git a/src/conduit_client/clients/gateway.py b/src/conduit_client/clients/gateway.py new file mode 100644 index 0000000..67e2439 --- /dev/null +++ b/src/conduit_client/clients/gateway.py @@ -0,0 +1,105 @@ +"""Client for the top-level Conduit gateway endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..models.common import CallerIdentity, CreatedKey, CreateKeyRequest, KeyInfo, ServiceInfo +from ..models.gateway import AuditLogQuery + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class GatewayClient: + """Synchronous client for top-level gateway endpoints.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def health(self) -> str: + """GET /health - returns the literal string ``ok``.""" + return self._transport.request("GET", "/health") + + def me(self) -> CallerIdentity: + """GET /auth/me - caller identity and effective scopes.""" + return self._transport.request("GET", "/auth/me", response_model=CallerIdentity) + + def services(self) -> list[ServiceInfo]: + """GET /services - provider registry.""" + return self._transport.request("GET", "/services", response_model=list[ServiceInfo]) + + def service_health(self) -> dict[str, Any]: + """GET /health/services - fan-out upstream health check.""" + return self._transport.request("GET", "/health/services") + + def list_keys(self) -> list[KeyInfo]: + """GET /keys - list the caller's API keys.""" + return self._transport.request("GET", "/keys", response_model=list[KeyInfo]) + + def create_key(self, request: CreateKeyRequest) -> CreatedKey: + """POST /keys - create a new API key.""" + return self._transport.request( + "POST", + "/keys", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=CreatedKey, + ) + + def revoke_key(self, key_prefix: str) -> None: + """DELETE /keys/{key_prefix} - revoke an API key.""" + self._transport.request("DELETE", f"/keys/{key_prefix}") + + def admin_audit(self, query: AuditLogQuery | None = None) -> list[Any]: + """GET /admin/audit - query the audit log.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return self._transport.request("GET", "/admin/audit", params=params) + + +class AsyncGatewayClient: + """Asynchronous client for top-level gateway endpoints.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def health(self) -> str: + """GET /health - returns the literal string ``ok``.""" + return await self._transport.request("GET", "/health") + + async def me(self) -> CallerIdentity: + """GET /auth/me - caller identity and effective scopes.""" + return await self._transport.request("GET", "/auth/me", response_model=CallerIdentity) + + async def services(self) -> list[ServiceInfo]: + """GET /services - provider registry.""" + return await self._transport.request("GET", "/services", response_model=list[ServiceInfo]) + + async def service_health(self) -> dict[str, Any]: + """GET /health/services - fan-out upstream health check.""" + return await self._transport.request("GET", "/health/services") + + async def list_keys(self) -> list[KeyInfo]: + """GET /keys - list the caller's API keys.""" + return await self._transport.request("GET", "/keys", response_model=list[KeyInfo]) + + async def create_key(self, request: CreateKeyRequest) -> CreatedKey: + """POST /keys - create a new API key.""" + return await self._transport.request( + "POST", + "/keys", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=CreatedKey, + ) + + async def revoke_key(self, key_prefix: str) -> None: + """DELETE /keys/{key_prefix} - revoke an API key.""" + await self._transport.request("DELETE", f"/keys/{key_prefix}") + + async def admin_audit(self, query: AuditLogQuery | None = None) -> list[Any]: + """GET /admin/audit - query the audit log.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return await self._transport.request("GET", "/admin/audit", params=params) diff --git a/src/conduit_client/clients/media_ingest.py b/src/conduit_client/clients/media_ingest.py new file mode 100644 index 0000000..ae23406 --- /dev/null +++ b/src/conduit_client/clients/media_ingest.py @@ -0,0 +1,210 @@ +"""Client for the MeTube media ingestion façade.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..models.media_ingest import ( + CreateIngestJobRequest, + CreateIngestSubscriptionRequest, + DeleteIngestJobRequest, + IngestJobResponse, + IngestJobsBucketed, + IngestPreset, + IngestSubscription, + IngestVersionInfo, + PatchIngestSubscriptionRequest, +) + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class MediaIngestClient: + """Synchronous client for the MeTube media ingestion façade.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def list_jobs(self) -> IngestJobsBucketed: + """GET /media/ingest/jobs - list jobs grouped by status.""" + return self._transport.request( + "GET", + "/media/ingest/jobs", + response_model=IngestJobsBucketed, + ) + + def create_job(self, request: CreateIngestJobRequest) -> IngestJobResponse: + """POST /media/ingest/jobs - submit a new download job.""" + return self._transport.request( + "POST", + "/media/ingest/jobs", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=IngestJobResponse, + ) + + def start_job(self, job_id: str) -> None: + """POST /media/ingest/jobs/{job_id}/start - start or resume a job.""" + self._transport.request("POST", f"/media/ingest/jobs/{job_id}/start") + + def cancel_job(self, job_id: str) -> None: + """POST /media/ingest/jobs/{job_id}/cancel - cancel an in-progress job.""" + self._transport.request("POST", f"/media/ingest/jobs/{job_id}/cancel") + + def delete_job(self, job_id: str, *, delete_from_file: bool = False) -> None: + """DELETE /media/ingest/jobs/{job_id} - remove a job.""" + body = None + if delete_from_file: + body = DeleteIngestJobRequest(delete_from_file=True).model_dump( + by_alias=True, exclude_none=True + ) + self._transport.request("DELETE", f"/media/ingest/jobs/{job_id}", json=body) + + def list_presets(self) -> list[IngestPreset]: + """GET /media/ingest/presets - list quality/format presets.""" + return self._transport.request( + "GET", + "/media/ingest/presets", + response_model=list[IngestPreset], + ) + + def list_subscriptions(self) -> list[IngestSubscription]: + """GET /media/ingest/subscriptions - list subscriptions.""" + return self._transport.request( + "GET", + "/media/ingest/subscriptions", + response_model=list[IngestSubscription], + ) + + def create_subscription( + self, request: CreateIngestSubscriptionRequest + ) -> IngestSubscription: + """POST /media/ingest/subscriptions - create a subscription.""" + return self._transport.request( + "POST", + "/media/ingest/subscriptions", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=IngestSubscription, + ) + + def patch_subscription( + self, sub_id: str, request: PatchIngestSubscriptionRequest + ) -> IngestSubscription: + """PATCH /media/ingest/subscriptions/{sub_id} - update a subscription.""" + return self._transport.request( + "PATCH", + f"/media/ingest/subscriptions/{sub_id}", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=IngestSubscription, + ) + + def delete_subscription(self, sub_id: str) -> None: + """DELETE /media/ingest/subscriptions/{sub_id} - remove a subscription.""" + self._transport.request("DELETE", f"/media/ingest/subscriptions/{sub_id}") + + def check_subscription(self, sub_id: str) -> None: + """POST /media/ingest/subscriptions/{sub_id}/check - trigger feed check.""" + self._transport.request("POST", f"/media/ingest/subscriptions/{sub_id}/check") + + def version(self) -> IngestVersionInfo: + """GET /media/ingest/version - MeTube and yt-dlp versions.""" + return self._transport.request( + "GET", + "/media/ingest/version", + response_model=IngestVersionInfo, + ) + + +class AsyncMediaIngestClient: + """Asynchronous client for the MeTube media ingestion façade.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def list_jobs(self) -> IngestJobsBucketed: + """GET /media/ingest/jobs - list jobs grouped by status.""" + return await self._transport.request( + "GET", + "/media/ingest/jobs", + response_model=IngestJobsBucketed, + ) + + async def create_job(self, request: CreateIngestJobRequest) -> IngestJobResponse: + """POST /media/ingest/jobs - submit a new download job.""" + return await self._transport.request( + "POST", + "/media/ingest/jobs", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=IngestJobResponse, + ) + + async def start_job(self, job_id: str) -> None: + """POST /media/ingest/jobs/{job_id}/start - start or resume a job.""" + await self._transport.request("POST", f"/media/ingest/jobs/{job_id}/start") + + async def cancel_job(self, job_id: str) -> None: + """POST /media/ingest/jobs/{job_id}/cancel - cancel an in-progress job.""" + await self._transport.request("POST", f"/media/ingest/jobs/{job_id}/cancel") + + async def delete_job(self, job_id: str, *, delete_from_file: bool = False) -> None: + """DELETE /media/ingest/jobs/{job_id} - remove a job.""" + body = None + if delete_from_file: + body = DeleteIngestJobRequest(delete_from_file=True).model_dump( + by_alias=True, exclude_none=True + ) + await self._transport.request("DELETE", f"/media/ingest/jobs/{job_id}", json=body) + + async def list_presets(self) -> list[IngestPreset]: + """GET /media/ingest/presets - list quality/format presets.""" + return await self._transport.request( + "GET", + "/media/ingest/presets", + response_model=list[IngestPreset], + ) + + async def list_subscriptions(self) -> list[IngestSubscription]: + """GET /media/ingest/subscriptions - list subscriptions.""" + return await self._transport.request( + "GET", + "/media/ingest/subscriptions", + response_model=list[IngestSubscription], + ) + + async def create_subscription( + self, request: CreateIngestSubscriptionRequest + ) -> IngestSubscription: + """POST /media/ingest/subscriptions - create a subscription.""" + return await self._transport.request( + "POST", + "/media/ingest/subscriptions", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=IngestSubscription, + ) + + async def patch_subscription( + self, sub_id: str, request: PatchIngestSubscriptionRequest + ) -> IngestSubscription: + """PATCH /media/ingest/subscriptions/{sub_id} - update a subscription.""" + return await self._transport.request( + "PATCH", + f"/media/ingest/subscriptions/{sub_id}", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=IngestSubscription, + ) + + async def delete_subscription(self, sub_id: str) -> None: + """DELETE /media/ingest/subscriptions/{sub_id} - remove a subscription.""" + await self._transport.request("DELETE", f"/media/ingest/subscriptions/{sub_id}") + + async def check_subscription(self, sub_id: str) -> None: + """POST /media/ingest/subscriptions/{sub_id}/check - trigger feed check.""" + await self._transport.request("POST", f"/media/ingest/subscriptions/{sub_id}/check") + + async def version(self) -> IngestVersionInfo: + """GET /media/ingest/version - MeTube and yt-dlp versions.""" + return await self._transport.request( + "GET", + "/media/ingest/version", + response_model=IngestVersionInfo, + ) diff --git a/src/conduit_client/clients/media_library.py b/src/conduit_client/clients/media_library.py new file mode 100644 index 0000000..56697bb --- /dev/null +++ b/src/conduit_client/clients/media_library.py @@ -0,0 +1,120 @@ +"""Client for the Jellyfin media library façade.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..models.media_library import ( + LibraryLoginRequest, + LibraryLoginResponse, + LibrarySearchQuery, + LibrarySession, + LibrarySystemInfo, +) + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class MediaLibraryClient: + """Synchronous client for the Jellyfin media library façade.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def login(self, request: LibraryLoginRequest) -> LibraryLoginResponse: + """POST /media/library/auth/login - authenticate with Jellyfin.""" + return self._transport.request( + "POST", + "/media/library/auth/login", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=LibraryLoginResponse, + ) + + def search(self, query: LibrarySearchQuery | None = None) -> dict[str, Any]: + """GET /media/library/search - search the library.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return self._transport.request("GET", "/media/library/search", params=params) + + def get_item(self, item_id: str) -> dict[str, Any]: + """GET /media/library/items/{item_id} - fetch an item by ID.""" + return self._transport.request("GET", f"/media/library/items/{item_id}") + + def get_playback_info(self, item_id: str) -> dict[str, Any]: + """GET /media/library/items/{item_id}/playback - fetch playback info.""" + return self._transport.request("GET", f"/media/library/items/{item_id}/playback") + + def refresh(self) -> None: + """POST /media/library/refresh - refresh the Jellyfin library.""" + self._transport.request("POST", "/media/library/refresh") + + def list_sessions(self) -> list[LibrarySession]: + """GET /media/library/sessions - list active sessions.""" + return self._transport.request( + "GET", + "/media/library/sessions", + response_model=list[LibrarySession], + ) + + def system_info(self, *, public: bool = False) -> LibrarySystemInfo: + """GET /media/library/system/info - Jellyfin system information.""" + return self._transport.request( + "GET", + "/media/library/system/info", + params={"public": str(public).lower()}, + response_model=LibrarySystemInfo, + ) + + +class AsyncMediaLibraryClient: + """Asynchronous client for the Jellyfin media library façade.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def login(self, request: LibraryLoginRequest) -> LibraryLoginResponse: + """POST /media/library/auth/login - authenticate with Jellyfin.""" + return await self._transport.request( + "POST", + "/media/library/auth/login", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=LibraryLoginResponse, + ) + + async def search(self, query: LibrarySearchQuery | None = None) -> dict[str, Any]: + """GET /media/library/search - search the library.""" + params = None + if query is not None: + params = query.model_dump(by_alias=True, exclude_none=True) + return await self._transport.request("GET", "/media/library/search", params=params) + + async def get_item(self, item_id: str) -> dict[str, Any]: + """GET /media/library/items/{item_id} - fetch an item by ID.""" + return await self._transport.request("GET", f"/media/library/items/{item_id}") + + async def get_playback_info(self, item_id: str) -> dict[str, Any]: + """GET /media/library/items/{item_id}/playback - fetch playback info.""" + return await self._transport.request("GET", f"/media/library/items/{item_id}/playback") + + async def refresh(self) -> None: + """POST /media/library/refresh - refresh the Jellyfin library.""" + await self._transport.request("POST", "/media/library/refresh") + + async def list_sessions(self) -> list[LibrarySession]: + """GET /media/library/sessions - list active sessions.""" + return await self._transport.request( + "GET", + "/media/library/sessions", + response_model=list[LibrarySession], + ) + + async def system_info(self, *, public: bool = False) -> LibrarySystemInfo: + """GET /media/library/system/info - Jellyfin system information.""" + return await self._transport.request( + "GET", + "/media/library/system/info", + params={"public": str(public).lower()}, + response_model=LibrarySystemInfo, + ) diff --git a/src/conduit_client/clients/sms.py b/src/conduit_client/clients/sms.py new file mode 100644 index 0000000..20c64c7 --- /dev/null +++ b/src/conduit_client/clients/sms.py @@ -0,0 +1,175 @@ +"""Client for the SMS/MMS façade.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..models.sms import ( + DeleteMmsResponse, + DeleteSmsResponse, + MmsMediaResponse, + MmsRecord, + SendMmsDefaultRequest, + SendMmsRequest, + SendSmsDefaultRequest, + SendSmsRequest, + SendSmsResponse, + SmsRecord, +) + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class SmsClient: + """Synchronous client for the SMS/MMS façade.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def list_sms(self) -> list[SmsRecord]: + """GET /sms - list SMS records.""" + return self._transport.request("GET", "/sms", response_model=list[SmsRecord]) + + def list_mms(self) -> list[MmsRecord]: + """GET /mms - list MMS records.""" + return self._transport.request("GET", "/mms", response_model=list[MmsRecord]) + + def send_sms(self, request: SendSmsRequest) -> SendSmsResponse: + """POST /sms/send - send an SMS.""" + return self._transport.request( + "POST", + "/sms/send", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + def send_mms(self, request: SendMmsRequest) -> SendSmsResponse: + """POST /mms/send - send an MMS.""" + return self._transport.request( + "POST", + "/mms/send", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + def send_sms_default(self, request: SendSmsDefaultRequest) -> SendSmsResponse: + """POST /sms/send/default - send SMS using the server default DID.""" + return self._transport.request( + "POST", + "/sms/send/default", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + def send_mms_default(self, request: SendMmsDefaultRequest) -> SendSmsResponse: + """POST /mms/send/default - send MMS using the server default DID.""" + return self._transport.request( + "POST", + "/mms/send/default", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + def get_mms_media(self, message_id: int | str, *, media_as_array: bool = False) -> MmsMediaResponse: + """GET /mms/{id}/media - retrieve media for an MMS message.""" + return self._transport.request( + "GET", + f"/mms/{message_id}/media", + params={"media_as_array": str(media_as_array).lower()}, + response_model=MmsMediaResponse, + ) + + def delete_sms(self, message_id: int | str) -> DeleteSmsResponse: + """DELETE /sms/{id} - delete an SMS record.""" + return self._transport.request( + "DELETE", + f"/sms/{message_id}", + response_model=DeleteSmsResponse, + ) + + def delete_mms(self, message_id: int | str) -> DeleteMmsResponse: + """DELETE /mms/{id} - delete an MMS record.""" + return self._transport.request( + "DELETE", + f"/mms/{message_id}", + response_model=DeleteMmsResponse, + ) + + +class AsyncSmsClient: + """Asynchronous client for the SMS/MMS façade.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def list_sms(self) -> list[SmsRecord]: + """GET /sms - list SMS records.""" + return await self._transport.request("GET", "/sms", response_model=list[SmsRecord]) + + async def list_mms(self) -> list[MmsRecord]: + """GET /mms - list MMS records.""" + return await self._transport.request("GET", "/mms", response_model=list[MmsRecord]) + + async def send_sms(self, request: SendSmsRequest) -> SendSmsResponse: + """POST /sms/send - send an SMS.""" + return await self._transport.request( + "POST", + "/sms/send", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + async def send_mms(self, request: SendMmsRequest) -> SendSmsResponse: + """POST /mms/send - send an MMS.""" + return await self._transport.request( + "POST", + "/mms/send", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + async def send_sms_default(self, request: SendSmsDefaultRequest) -> SendSmsResponse: + """POST /sms/send/default - send SMS using the server default DID.""" + return await self._transport.request( + "POST", + "/sms/send/default", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + async def send_mms_default(self, request: SendMmsDefaultRequest) -> SendSmsResponse: + """POST /mms/send/default - send MMS using the server default DID.""" + return await self._transport.request( + "POST", + "/mms/send/default", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=SendSmsResponse, + ) + + async def get_mms_media( + self, message_id: int | str, *, media_as_array: bool = False + ) -> MmsMediaResponse: + """GET /mms/{id}/media - retrieve media for an MMS message.""" + return await self._transport.request( + "GET", + f"/mms/{message_id}/media", + params={"media_as_array": str(media_as_array).lower()}, + response_model=MmsMediaResponse, + ) + + async def delete_sms(self, message_id: int | str) -> DeleteSmsResponse: + """DELETE /sms/{id} - delete an SMS record.""" + return await self._transport.request( + "DELETE", + f"/sms/{message_id}", + response_model=DeleteSmsResponse, + ) + + async def delete_mms(self, message_id: int | str) -> DeleteMmsResponse: + """DELETE /mms/{id} - delete an MMS record.""" + return await self._transport.request( + "DELETE", + f"/mms/{message_id}", + response_model=DeleteMmsResponse, + ) diff --git a/src/conduit_client/clients/torrents.py b/src/conduit_client/clients/torrents.py new file mode 100644 index 0000000..c708c1a --- /dev/null +++ b/src/conduit_client/clients/torrents.py @@ -0,0 +1,312 @@ +"""Client for the Transmission torrents façade.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..models.torrents import ( + AddTorrentRequest, + DeleteTorrentRequest, + DownloadLinkResponse, + FreeSpaceResponse, + MoveQueueRequest, + MoveTorrentRequest, + PatchTorrentRequest, + PortTestResponse, + RenamePathRequest, +) + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class TorrentsClient: + """Synchronous client for the Transmission torrents façade.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def add(self, request: AddTorrentRequest) -> dict[str, Any]: + """POST /torrents - add a new torrent.""" + return self._transport.request( + "POST", + "/torrents", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + def list( + self, *, fields: list[str], ids: list[int] | None = None + ) -> dict[str, Any]: + """GET /torrents - list torrents. ``fields`` is required by the API.""" + params: dict[str, Any] = {"fields": ",".join(fields)} + if ids is not None: + params["ids"] = ",".join(str(i) for i in ids) + return self._transport.request("GET", "/torrents", params=params) + + def move_queue(self, request: MoveQueueRequest) -> None: + """POST /torrents/queue/move - move the queue.""" + self._transport.request( + "POST", + "/torrents/queue/move", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + def get_session(self) -> dict[str, Any]: + """GET /torrents/session - current session settings.""" + return self._transport.request("GET", "/torrents/session") + + def patch_session(self, settings: dict[str, Any]) -> None: + """PATCH /torrents/session - update session settings.""" + self._transport.request("PATCH", "/torrents/session", json=settings) + + def get_session_stats(self) -> dict[str, Any]: + """GET /torrents/session/stats - cumulative and current stats.""" + return self._transport.request("GET", "/torrents/session/stats") + + def close_session(self) -> None: + """POST /torrents/session/close - close the session.""" + self._transport.request("POST", "/torrents/session/close") + + def free_space(self, path: str) -> FreeSpaceResponse: + """GET /torrents/free-space - free disk space for a path.""" + return self._transport.request( + "GET", + "/torrents/free-space", + params={"path": path}, + response_model=FreeSpaceResponse, + ) + + def update_blocklist(self) -> dict[str, Any]: + """POST /torrents/blocklist/update - update the peer blocklist.""" + return self._transport.request("POST", "/torrents/blocklist/update") + + def port_test(self) -> PortTestResponse: + """GET /torrents/port-test - test the peer port.""" + return self._transport.request( + "GET", + "/torrents/port-test", + response_model=PortTestResponse, + ) + + def list_groups(self) -> dict[str, Any]: + """GET /torrents/groups - list bandwidth groups.""" + return self._transport.request("GET", "/torrents/groups") + + def patch_group(self, name: str, settings: dict[str, Any]) -> None: + """PATCH /torrents/groups/{name} - patch a bandwidth group.""" + self._transport.request( + "PATCH", + f"/torrents/groups/{name}", + json=settings, + ) + + def download_link(self, torrent_id: int | str) -> DownloadLinkResponse: + """GET /torrents/{torrent_id}/download-link - public download links.""" + return self._transport.request( + "GET", + f"/torrents/{torrent_id}/download-link", + response_model=DownloadLinkResponse, + ) + + def get(self, torrent_id: int | str) -> dict[str, Any]: + """GET /torrents/{torrent_id} - fetch a single torrent.""" + return self._transport.request("GET", f"/torrents/{torrent_id}") + + def patch(self, torrent_id: int | str, request: PatchTorrentRequest) -> None: + """PATCH /torrents/{torrent_id} - modify a torrent.""" + self._transport.request( + "PATCH", + f"/torrents/{torrent_id}", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + def start(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/start - start a torrent.""" + self._transport.request("POST", f"/torrents/{torrent_id}/start") + + def start_now(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/start-now - start bypassing queue.""" + self._transport.request("POST", f"/torrents/{torrent_id}/start-now") + + def stop(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/stop - stop a torrent.""" + self._transport.request("POST", f"/torrents/{torrent_id}/stop") + + def verify(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/verify - verify torrent data.""" + self._transport.request("POST", f"/torrents/{torrent_id}/verify") + + def reannounce(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/reannounce - force reannounce.""" + self._transport.request("POST", f"/torrents/{torrent_id}/reannounce") + + def move(self, torrent_id: int | str, request: MoveTorrentRequest) -> None: + """POST /torrents/{torrent_id}/move - move or relink data.""" + self._transport.request( + "POST", + f"/torrents/{torrent_id}/move", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + def rename_path(self, torrent_id: int | str, request: RenamePathRequest) -> None: + """POST /torrents/{torrent_id}/rename-path - rename a path.""" + self._transport.request( + "POST", + f"/torrents/{torrent_id}/rename-path", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + def remove(self, torrent_id: int | str, *, delete_local_data: bool = False) -> None: + """DELETE /torrents/{torrent_id} - remove a torrent.""" + body = None + if delete_local_data: + body = DeleteTorrentRequest(delete_local_data=True).model_dump( + by_alias=True, exclude_none=True + ) + self._transport.request("DELETE", f"/torrents/{torrent_id}", json=body) + + +class AsyncTorrentsClient: + """Asynchronous client for the Transmission torrents façade.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def add(self, request: AddTorrentRequest) -> dict[str, Any]: + """POST /torrents - add a new torrent.""" + return await self._transport.request( + "POST", + "/torrents", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + async def list( + self, *, fields: list[str], ids: list[int] | None = None + ) -> dict[str, Any]: + """GET /torrents - list torrents. ``fields`` is required by the API.""" + params: dict[str, Any] = {"fields": ",".join(fields)} + if ids is not None: + params["ids"] = ",".join(str(i) for i in ids) + return await self._transport.request("GET", "/torrents", params=params) + + async def move_queue(self, request: MoveQueueRequest) -> None: + """POST /torrents/queue/move - move the queue.""" + await self._transport.request( + "POST", + "/torrents/queue/move", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + async def get_session(self) -> dict[str, Any]: + """GET /torrents/session - current session settings.""" + return await self._transport.request("GET", "/torrents/session") + + async def patch_session(self, settings: dict[str, Any]) -> None: + """PATCH /torrents/session - update session settings.""" + await self._transport.request("PATCH", "/torrents/session", json=settings) + + async def get_session_stats(self) -> dict[str, Any]: + """GET /torrents/session/stats - cumulative and current stats.""" + return await self._transport.request("GET", "/torrents/session/stats") + + async def close_session(self) -> None: + """POST /torrents/session/close - close the session.""" + await self._transport.request("POST", "/torrents/session/close") + + async def free_space(self, path: str) -> FreeSpaceResponse: + """GET /torrents/free-space - free disk space for a path.""" + return await self._transport.request( + "GET", + "/torrents/free-space", + params={"path": path}, + response_model=FreeSpaceResponse, + ) + + async def update_blocklist(self) -> dict[str, Any]: + """POST /torrents/blocklist/update - update the peer blocklist.""" + return await self._transport.request("POST", "/torrents/blocklist/update") + + async def port_test(self) -> PortTestResponse: + """GET /torrents/port-test - test the peer port.""" + return await self._transport.request( + "GET", + "/torrents/port-test", + response_model=PortTestResponse, + ) + + async def list_groups(self) -> dict[str, Any]: + """GET /torrents/groups - list bandwidth groups.""" + return await self._transport.request("GET", "/torrents/groups") + + async def patch_group(self, name: str, settings: dict[str, Any]) -> None: + """PATCH /torrents/groups/{name} - patch a bandwidth group.""" + await self._transport.request( + "PATCH", + f"/torrents/groups/{name}", + json=settings, + ) + + async def download_link(self, torrent_id: int | str) -> DownloadLinkResponse: + """GET /torrents/{torrent_id}/download-link - public download links.""" + return await self._transport.request( + "GET", + f"/torrents/{torrent_id}/download-link", + response_model=DownloadLinkResponse, + ) + + async def get(self, torrent_id: int | str) -> dict[str, Any]: + """GET /torrents/{torrent_id} - fetch a single torrent.""" + return await self._transport.request("GET", f"/torrents/{torrent_id}") + + async def patch(self, torrent_id: int | str, request: PatchTorrentRequest) -> None: + """PATCH /torrents/{torrent_id} - modify a torrent.""" + await self._transport.request( + "PATCH", + f"/torrents/{torrent_id}", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + async def start(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/start - start a torrent.""" + await self._transport.request("POST", f"/torrents/{torrent_id}/start") + + async def start_now(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/start-now - start bypassing queue.""" + await self._transport.request("POST", f"/torrents/{torrent_id}/start-now") + + async def stop(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/stop - stop a torrent.""" + await self._transport.request("POST", f"/torrents/{torrent_id}/stop") + + async def verify(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/verify - verify torrent data.""" + await self._transport.request("POST", f"/torrents/{torrent_id}/verify") + + async def reannounce(self, torrent_id: int | str) -> None: + """POST /torrents/{torrent_id}/reannounce - force reannounce.""" + await self._transport.request("POST", f"/torrents/{torrent_id}/reannounce") + + async def move(self, torrent_id: int | str, request: MoveTorrentRequest) -> None: + """POST /torrents/{torrent_id}/move - move or relink data.""" + await self._transport.request( + "POST", + f"/torrents/{torrent_id}/move", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + async def rename_path(self, torrent_id: int | str, request: RenamePathRequest) -> None: + """POST /torrents/{torrent_id}/rename-path - rename a path.""" + await self._transport.request( + "POST", + f"/torrents/{torrent_id}/rename-path", + json=request.model_dump(by_alias=True, exclude_none=True), + ) + + async def remove(self, torrent_id: int | str, *, delete_local_data: bool = False) -> None: + """DELETE /torrents/{torrent_id} - remove a torrent.""" + body = None + if delete_local_data: + body = DeleteTorrentRequest(delete_local_data=True).model_dump( + by_alias=True, exclude_none=True + ) + await self._transport.request("DELETE", f"/torrents/{torrent_id}", json=body) diff --git a/src/conduit_client/clients/wiki.py b/src/conduit_client/clients/wiki.py new file mode 100644 index 0000000..1966a46 --- /dev/null +++ b/src/conduit_client/clients/wiki.py @@ -0,0 +1,62 @@ +"""Client for the DokuWiki publishing façade.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..models.wiki import WikiPageRaw, WriteWikiPageRequest, WriteWikiPageResponse + +if TYPE_CHECKING: + from .._base_client import AsyncTransport, SyncTransport + + +class WikiClient: + """Synchronous client for the DokuWiki publishing façade.""" + + def __init__(self, transport: SyncTransport) -> None: + self._transport = transport + + def get_raw(self, page_id: str) -> WikiPageRaw: + """GET /wiki/pages/{page_id}/raw - raw page source.""" + encoded = self._transport._encode_path_param(page_id) + return self._transport.request( + "GET", + f"/wiki/pages/{encoded}/raw", + response_model=WikiPageRaw, + ) + + def write(self, page_id: str, request: WriteWikiPageRequest) -> WriteWikiPageResponse: + """PUT /wiki/pages/{page_id} - create or replace a page.""" + encoded = self._transport._encode_path_param(page_id) + return self._transport.request( + "PUT", + f"/wiki/pages/{encoded}", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=WriteWikiPageResponse, + ) + + +class AsyncWikiClient: + """Asynchronous client for the DokuWiki publishing façade.""" + + def __init__(self, transport: AsyncTransport) -> None: + self._transport = transport + + async def get_raw(self, page_id: str) -> WikiPageRaw: + """GET /wiki/pages/{page_id}/raw - raw page source.""" + encoded = self._transport._encode_path_param(page_id) + return await self._transport.request( + "GET", + f"/wiki/pages/{encoded}/raw", + response_model=WikiPageRaw, + ) + + async def write(self, page_id: str, request: WriteWikiPageRequest) -> WriteWikiPageResponse: + """PUT /wiki/pages/{page_id} - create or replace a page.""" + encoded = self._transport._encode_path_param(page_id) + return await self._transport.request( + "PUT", + f"/wiki/pages/{encoded}", + json=request.model_dump(by_alias=True, exclude_none=True), + response_model=WriteWikiPageResponse, + ) diff --git a/src/conduit_client/exceptions.py b/src/conduit_client/exceptions.py new file mode 100644 index 0000000..0cb107f --- /dev/null +++ b/src/conduit_client/exceptions.py @@ -0,0 +1,47 @@ +"""Exceptions raised by the Conduit client.""" + +from __future__ import annotations + +from typing import Any + + +class ConduitError(Exception): + """Base exception for all Conduit client errors.""" + + +class ConduitAPIError(ConduitError): + """Raised when the Conduit API returns an error response.""" + + def __init__( + self, + message: str, + *, + status_code: int, + response_body: Any, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.response_body = response_body + + def __str__(self) -> str: + return f"[{self.status_code}] {super().__str__()}" + + +class AuthenticationError(ConduitAPIError): + """Raised for 401/403 responses or invalid API keys.""" + + +class NotFoundError(ConduitAPIError): + """Raised when a requested resource returns 404.""" + + +class ValidationError(ConduitAPIError): + """Raised when the request fails validation (commonly 422).""" + + +class ConflictError(ConduitAPIError): + """Raised when a request conflicts with the current state (commonly 409).""" + + +class RateLimitError(ConduitAPIError): + """Raised when the API returns a 429 rate-limit response.""" diff --git a/src/conduit_client/models/__init__.py b/src/conduit_client/models/__init__.py new file mode 100644 index 0000000..31fac09 --- /dev/null +++ b/src/conduit_client/models/__init__.py @@ -0,0 +1,143 @@ +"""Pydantic models for the Conduit API.""" + +from __future__ import annotations + +from .common import ( + AuditLogEntry, + CallerIdentity, + ConduitModel, + CreatedKey, + CreateKeyRequest, + EmptyResponse, + KeyInfo, + ServiceInfo, +) +from .compute import ( + CloneVmRequest, + ComputeTicketRequest, + ComputeTicketResponse, + CreateContainerRequest, + CreateSnapshotRequest, + CreateVmRequest, + MigrateVmRequest, + NodeTasksQuery, + StorageQuery, + TaskResponse, + UploadStorageRequest, +) +from .dns import ( + AddZoneRequest, + BatchRecordUpdate, + BatchUpdateRecordsRequest, + CacheFlushRequest, + ServerReloadRequest, +) +from .media_ingest import ( + CreateIngestJobRequest, + CreateIngestSubscriptionRequest, + DeleteIngestJobRequest, + IngestJob, + IngestJobResponse, + IngestJobsBucketed, + IngestPreset, + IngestSubscription, + IngestVersionInfo, + PatchIngestSubscriptionRequest, +) +from .media_library import ( + LibraryLoginRequest, + LibraryLoginResponse, + LibrarySearchQuery, + LibrarySession, + LibrarySystemInfo, +) +from .sms import ( + DeleteMmsResponse, + DeleteSmsResponse, + MmsMediaItem, + MmsMediaResponse, + MmsRecord, + SendMmsDefaultRequest, + SendMmsRequest, + SendSmsDefaultRequest, + SendSmsRequest, + SendSmsResponse, + SmsRecord, +) +from .torrents import ( + AddTorrentRequest, + DeleteTorrentRequest, + DownloadLinkResponse, + FreeSpaceResponse, + MoveQueueRequest, + MoveTorrentRequest, + PatchTorrentRequest, + PortTestResponse, + RenamePathRequest, +) +from .wiki import WikiPageRaw, WriteWikiPageRequest, WriteWikiPageResponse + +__all__ = [ + "AddTorrentRequest", + "AddZoneRequest", + "AuditLogEntry", + "BatchRecordUpdate", + "BatchUpdateRecordsRequest", + "CacheFlushRequest", + "CallerIdentity", + "CloneVmRequest", + "ComputeTicketRequest", + "ComputeTicketResponse", + "ConduitModel", + "CreateContainerRequest", + "CreateIngestJobRequest", + "CreateIngestSubscriptionRequest", + "CreateSnapshotRequest", + "CreateVmRequest", + "CreatedKey", + "CreateKeyRequest", + "DeleteIngestJobRequest", + "DeleteMmsResponse", + "DeleteSmsResponse", + "DeleteTorrentRequest", + "DownloadLinkResponse", + "EmptyResponse", + "FreeSpaceResponse", + "IngestJob", + "IngestJobResponse", + "IngestJobsBucketed", + "IngestPreset", + "IngestSubscription", + "IngestVersionInfo", + "KeyInfo", + "LibraryLoginRequest", + "LibraryLoginResponse", + "LibrarySearchQuery", + "LibrarySession", + "LibrarySystemInfo", + "MigrateVmRequest", + "MmsMediaItem", + "MmsMediaResponse", + "MmsRecord", + "MoveQueueRequest", + "MoveTorrentRequest", + "NodeTasksQuery", + "PatchIngestSubscriptionRequest", + "PatchTorrentRequest", + "PortTestResponse", + "RenamePathRequest", + "SendMmsDefaultRequest", + "SendMmsRequest", + "SendSmsDefaultRequest", + "SendSmsRequest", + "SendSmsResponse", + "ServerReloadRequest", + "ServiceInfo", + "SmsRecord", + "StorageQuery", + "TaskResponse", + "UploadStorageRequest", + "WikiPageRaw", + "WriteWikiPageRequest", + "WriteWikiPageResponse", +] diff --git a/src/conduit_client/models/common.py b/src/conduit_client/models/common.py new file mode 100644 index 0000000..63879f4 --- /dev/null +++ b/src/conduit_client/models/common.py @@ -0,0 +1,85 @@ +"""Shared Pydantic models used across multiple Conduit façades.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class ConduitModel(BaseModel): + """Base model for all Conduit API models.""" + + model_config = ConfigDict( + populate_by_name=True, + str_strip_whitespace=True, + extra="ignore", + ) + + +class CallerIdentity(ConduitModel): + """Response from GET /auth/me.""" + + subject: str + actor_type: str + auth_method: str + scopes: list[str] + grantable_scopes: list[str] + + +class ServiceInfo(ConduitModel): + """Provider registry entry from GET /services.""" + + id: str + name: str + prefixes: list[str] + enabled: bool + + +class KeyInfo(ConduitModel): + """API key metadata from GET /keys.""" + + key_prefix: str + display_name: str | None = None + scopes: list[str] + created_at: datetime + expires_at: datetime | None = None + last_used_at: datetime | None = None + revoked_at: datetime | None = None + + +class CreatedKey(ConduitModel): + """One-time response from POST /keys.""" + + key: str + key_prefix: str + display_name: str | None = None + scopes: list[str] + created_at: datetime + expires_at: datetime | None = None + + +class CreateKeyRequest(ConduitModel): + """Body for POST /keys.""" + + display_name: str | None = None + scopes: list[str] + + +class AuditLogEntry(ConduitModel): + """Single entry from GET /admin/audit.""" + + timestamp: datetime + actor_subject: str + actor_type: str | None = None + scope: str + endpoint: str + result: str + metadata: dict[str, Any] | None = None + + +class EmptyResponse(ConduitModel): + """Placeholder for endpoints that return no meaningful body.""" + + pass diff --git a/src/conduit_client/models/compute.py b/src/conduit_client/models/compute.py new file mode 100644 index 0000000..ebf36f8 --- /dev/null +++ b/src/conduit_client/models/compute.py @@ -0,0 +1,96 @@ +"""Models for the Proxmox compute façade.""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from .common import ConduitModel + + +class _AllowExtra(ConduitModel): + """Mixin that allows unknown fields to be forwarded to the API.""" + + model_config = ConfigDict( + populate_by_name=True, + str_strip_whitespace=True, + extra="allow", + ) + + +class ComputeTicketRequest(ConduitModel): + """Body for POST /compute/auth/ticket.""" + + username: str + password: str + + +class ComputeTicketResponse(ConduitModel): + """Response from POST /compute/auth/ticket.""" + + ticket: str + csrfpreventiontoken: str = Field(..., alias="CSRFPreventionToken") + username: str + + +class TaskResponse(ConduitModel): + """Response from async compute actions.""" + + task: str + + +class CreateVmRequest(_AllowExtra): + """Body for POST /compute/vms.""" + + node: str + vmid: int | None = None + + +class CreateContainerRequest(_AllowExtra): + """Body for POST /compute/containers.""" + + node: str + ostemplate: str + vmid: int | None = None + + +class CloneVmRequest(ConduitModel): + """Body for POST /compute/vms/{vmid}/clone.""" + + newid: int + name: str | None = None + full: bool | None = None + target: str | None = None + + +class MigrateVmRequest(ConduitModel): + """Body for POST /compute/vms/{vmid}/migrate.""" + + target: str + online: bool | None = None + + +class CreateSnapshotRequest(ConduitModel): + """Body for POST /compute/vms/{vmid}/snapshots.""" + + snapname: str + vmstate: bool | None = None + description: str | None = None + + +class UploadStorageRequest(ConduitModel): + """Body for POST /compute/storage/{storage}/upload.""" + + content: str + filename: str + + +class NodeTasksQuery(ConduitModel): + """Query parameters for GET /compute/nodes/{node}/tasks.""" + + limit: int | None = None + + +class StorageQuery(ConduitModel): + """Query parameters for storage listing endpoints.""" + + node: str | None = None diff --git a/src/conduit_client/models/dns.py b/src/conduit_client/models/dns.py new file mode 100644 index 0000000..ed3b242 --- /dev/null +++ b/src/conduit_client/models/dns.py @@ -0,0 +1,56 @@ +"""Models for the BIND9 DNS façade.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import ConfigDict + +from .common import ConduitModel + + +class _AllowExtra(ConduitModel): + """Mixin that allows unknown fields to be forwarded to the API.""" + + model_config = ConfigDict( + populate_by_name=True, + str_strip_whitespace=True, + extra="allow", + ) + + +class ServerReloadRequest(ConduitModel): + """Body for POST /dns/servers/{server_id}/reload.""" + + mode: Literal["reload", "reconfig"] = "reload" + + +class CacheFlushRequest(ConduitModel): + """Body for POST /dns/servers/{server_id}/cache/flush.""" + + mode: Literal["flush", "flushname", "flushtree"] = "flush" + name: str | None = None + + +class AddZoneRequest(_AllowExtra): + """Body for POST /dns/zones.""" + + zone: str + klass: str | None = None + view: str | None = None + + +class BatchRecordUpdate(ConduitModel): + """Single update within a record batch update.""" + + op: Literal["add", "delete", "replace"] | None = None + name: str | None = None + ttl: int | None = None + rtype: str | None = None + rdata: str | list[str] | None = None + + +class BatchUpdateRecordsRequest(ConduitModel): + """Body for POST /dns/zones/{zone}/records:batchUpdate.""" + + updates: list[BatchRecordUpdate] diff --git a/src/conduit_client/models/gateway.py b/src/conduit_client/models/gateway.py new file mode 100644 index 0000000..e593bd2 --- /dev/null +++ b/src/conduit_client/models/gateway.py @@ -0,0 +1,21 @@ +"""Models for the top-level Conduit gateway endpoints.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import Field + +from .common import ConduitModel + + +class AuditLogQuery(ConduitModel): + """Query parameters for GET /admin/audit.""" + + actor_subject: str | None = None + scope: str | None = None + result: str | None = None + endpoint: str | None = None + since: datetime | None = None + until: datetime | None = None + limit: int | None = Field(default=None, ge=1, le=500) diff --git a/src/conduit_client/models/media_ingest.py b/src/conduit_client/models/media_ingest.py new file mode 100644 index 0000000..6f01d4c --- /dev/null +++ b/src/conduit_client/models/media_ingest.py @@ -0,0 +1,100 @@ +"""Models for the MeTube media ingestion façade.""" + +from __future__ import annotations + +from .common import ConduitModel + + +class IngestJob(ConduitModel): + """A single MeTube job.""" + + id: str + url: str | None = None + status: str | None = None + quality: str | None = None + format: str | None = None + folder: str | None = None + title: str | None = None + error: str | None = None + + +class IngestJobsBucketed(ConduitModel): + """Response from GET /media/ingest/jobs - jobs grouped by status.""" + + done: list[IngestJob] | None = None + downloading: list[IngestJob] | None = None + error: list[IngestJob] | None = None + pending: list[IngestJob] | None = None + + +class CreateIngestJobRequest(ConduitModel): + """Body for POST /media/ingest/jobs.""" + + url: str + quality: str | None = None + format: str | None = None + codec: str | None = None + folder: str | None = None + auto_start: bool | None = None + add_meta_tags: bool | None = None + + +class IngestJobResponse(ConduitModel): + """Response from POST /media/ingest/jobs.""" + + id: str + status: str | None = None + + +class IngestPreset(ConduitModel): + """MeTube quality/format preset.""" + + name: str | None = None + quality: str | None = None + format: str | None = None + + +class IngestSubscription(ConduitModel): + """MeTube subscription.""" + + id: str | None = None + url: str + name: str | None = None + quality: str | None = None + format: str | None = None + folder: str | None = None + auto_start: bool | None = None + + +class CreateIngestSubscriptionRequest(ConduitModel): + """Body for POST /media/ingest/subscriptions.""" + + url: str + name: str | None = None + quality: str | None = None + format: str | None = None + folder: str | None = None + auto_start: bool | None = None + + +class PatchIngestSubscriptionRequest(ConduitModel): + """Body for PATCH /media/ingest/subscriptions/{sub_id}.""" + + name: str | None = None + quality: str | None = None + format: str | None = None + folder: str | None = None + auto_start: bool | None = None + + +class IngestVersionInfo(ConduitModel): + """Response from GET /media/ingest/version.""" + + metube_version: str | None = None + ytdlp_version: str | None = None + + +class DeleteIngestJobRequest(ConduitModel): + """Optional body for DELETE /media/ingest/jobs/{job_id}.""" + + delete_from_file: bool = False diff --git a/src/conduit_client/models/media_library.py b/src/conduit_client/models/media_library.py new file mode 100644 index 0000000..cb89b60 --- /dev/null +++ b/src/conduit_client/models/media_library.py @@ -0,0 +1,48 @@ +"""Models for the Jellyfin media library façade.""" + +from __future__ import annotations + +from .common import ConduitModel + + +class LibraryLoginRequest(ConduitModel): + """Body for POST /media/library/auth/login.""" + + username: str + password: str = "" + + +class LibraryLoginResponse(ConduitModel): + """Response from POST /media/library/auth/login.""" + + access_token: str | None = None + user_id: str | None = None + + +class LibrarySearchQuery(ConduitModel): + """Query parameters for GET /media/library/search.""" + + term: str | None = None + item_type: str | None = None + filters: str | None = None + parent_id: str | None = None + limit: int | None = None + offset: int | None = None + fields: str | None = None + + +class LibrarySession(ConduitModel): + """Active Jellyfin session.""" + + id: str | None = None + user_id: str | None = None + device_name: str | None = None + client: str | None = None + + +class LibrarySystemInfo(ConduitModel): + """Response from GET /media/library/system/info.""" + + version: str | None = None + id: str | None = None + public: bool | None = None diff --git a/src/conduit_client/models/sms.py b/src/conduit_client/models/sms.py new file mode 100644 index 0000000..ff596af --- /dev/null +++ b/src/conduit_client/models/sms.py @@ -0,0 +1,99 @@ +"""Models for the SMS/MMS façade.""" + +from __future__ import annotations + +from pydantic import Field + +from .common import ConduitModel + + +class SmsRecord(ConduitModel): + """Single SMS record returned by GET /sms.""" + + id: int | str + did: str | None = None + dst: str | None = None + message: str | None = None + date: str | None = None + + +class MmsRecord(ConduitModel): + """Single MMS record returned by GET /mms.""" + + id: int | str + did: str | None = None + dst: str | None = None + message: str | None = None + date: str | None = None + media1: str | None = None + media2: str | None = None + media3: str | None = None + + +class SendSmsRequest(ConduitModel): + """Body for POST /sms/send.""" + + did: str + dst: str + message: str = Field(..., max_length=160) + + +class SendSmsDefaultRequest(ConduitModel): + """Body for POST /sms/send/default.""" + + dst: str + message: str = Field(..., max_length=160) + + +class SendMmsRequest(ConduitModel): + """Body for POST /mms/send.""" + + did: str + dst: str + message: str | None = Field(default=None, max_length=2048) + media1: str | None = None + media2: str | None = None + media3: str | None = None + + +class SendMmsDefaultRequest(ConduitModel): + """Body for POST /mms/send/default.""" + + dst: str + message: str | None = Field(default=None, max_length=2048) + media1: str | None = None + media2: str | None = None + media3: str | None = None + + +class SendSmsResponse(ConduitModel): + """Response from POST /sms/send.""" + + id: int | str + + +class MmsMediaItem(ConduitModel): + """Media item within an MMS media response.""" + + url: str | None = None + type: str | None = None + + +class MmsMediaResponse(ConduitModel): + """Response from GET /mms/{id}/media.""" + + id: int | str + date: str | None = None + media: list[MmsMediaItem] | None = None + + +class DeleteSmsResponse(ConduitModel): + """Response from DELETE /sms/{id}.""" + + status: str + + +class DeleteMmsResponse(ConduitModel): + """Response from DELETE /mms/{id}.""" + + status: str diff --git a/src/conduit_client/models/torrents.py b/src/conduit_client/models/torrents.py new file mode 100644 index 0000000..c34a2f2 --- /dev/null +++ b/src/conduit_client/models/torrents.py @@ -0,0 +1,86 @@ +"""Models for the Transmission torrents façade.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field, model_validator + +from .common import ConduitModel + + +class AddTorrentRequest(ConduitModel): + """Body for POST /torrents.""" + + url: str | None = None + filename: str | None = None + metainfo: str | None = None + labels: list[str] | None = None + paused: bool | None = None + download_dir: str | None = None + + @model_validator(mode="after") + def _require_source(self) -> AddTorrentRequest: + if not any((self.url, self.filename, self.metainfo)): + raise ValueError("One of url, filename, or metainfo is required") + return self + + +class MoveQueueRequest(ConduitModel): + """Body for POST /torrents/queue/move.""" + + direction: Literal["top", "up", "down", "bottom"] + + +class FreeSpaceResponse(ConduitModel): + """Response from GET /torrents/free-space.""" + + path: str + size_bytes: int | None = Field(default=None, alias="size-bytes") + total_size: int | None = None + + +class PortTestResponse(ConduitModel): + """Response from GET /torrents/port-test.""" + + port_is_open: bool | None = None + + +class DownloadLinkResponse(ConduitModel): + """Response from GET /torrents/{id}/download-link.""" + + torrent_link: str | None = None + file_links: list[str] | None = None + + +class PatchTorrentRequest(ConduitModel): + """Body for PATCH /torrents/{id}.""" + + labels: list[str] | None = None + priority: Literal[-1, 0, 1] | None = None + files_wanted: list[int] | None = None + files_unwanted: list[int] | None = None + speed_limit_down: int | None = None + speed_limit_up: int | None = None + queue_position: int | None = None + ratio_limit: float | None = None + + +class MoveTorrentRequest(ConduitModel): + """Body for POST /torrents/{id}/move.""" + + location: str + move: bool = True + + +class RenamePathRequest(ConduitModel): + """Body for POST /torrents/{id}/rename-path.""" + + path: str + name: str + + +class DeleteTorrentRequest(ConduitModel): + """Optional body for DELETE /torrents/{id}.""" + + delete_local_data: bool = False diff --git a/src/conduit_client/models/wiki.py b/src/conduit_client/models/wiki.py new file mode 100644 index 0000000..b5a2cb9 --- /dev/null +++ b/src/conduit_client/models/wiki.py @@ -0,0 +1,28 @@ +"""Models for the DokuWiki publishing façade.""" + +from __future__ import annotations + +from .common import ConduitModel + + +class WikiPageRaw(ConduitModel): + """Response from GET /wiki/pages/{page_id}/raw.""" + + id: str + text: str + + +class WriteWikiPageRequest(ConduitModel): + """Body for PUT /wiki/pages/{page_id}.""" + + text: str + summary: str = "API publish" + verify: bool = True + + +class WriteWikiPageResponse(ConduitModel): + """Response from PUT /wiki/pages/{page_id}.""" + + id: str + url: str + verified: bool diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4f54811 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +"""Shared pytest fixtures.""" + +from __future__ import annotations + +import pytest + +from conduit_client import AsyncConduitClient, ConduitClient + + +@pytest.fixture +def api_key() -> str: + return "mega_sk_live_test_prefix_testsecret" + + +@pytest.fixture +def client(api_key: str) -> ConduitClient: + return ConduitClient(api_key, base_url="https://api.example.com") + + +@pytest.fixture +def async_client(api_key: str) -> AsyncConduitClient: + return AsyncConduitClient(api_key, base_url="https://api.example.com") diff --git a/tests/test_base_client.py b/tests/test_base_client.py new file mode 100644 index 0000000..3172fff --- /dev/null +++ b/tests/test_base_client.py @@ -0,0 +1,119 @@ +"""Tests for the shared HTTP transport layer.""" + +from __future__ import annotations + +import pytest +import respx +from httpx import Response + +from conduit_client import ( + AsyncConduitClient, + AuthenticationError, + ConduitClient, + NotFoundError, + RateLimitError, + ValidationError, +) +from conduit_client._base_client import _BaseClientMixin + + +@respx.mock +def test_sync_auth_header(api_key: str) -> None: + route = respx.get("https://api.example.com/auth/me").mock(return_value=Response(200, json={})) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + client._transport.request("GET", "/auth/me") + assert route.calls[0].request.headers["Authorization"] == f"Bearer {api_key}" + + +@pytest.mark.asyncio +@respx.mock +async def test_async_auth_header(api_key: str) -> None: + route = respx.get("https://api.example.com/auth/me").mock(return_value=Response(200, json={})) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + await client._transport.request("GET", "/auth/me") + assert route.calls[0].request.headers["Authorization"] == f"Bearer {api_key}" + + +@respx.mock +def test_sync_json_response(api_key: str) -> None: + respx.get("https://api.example.com/auth/me").mock(return_value=Response(200, json={"id": 1})) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client._transport.request("GET", "/auth/me") + assert result == {"id": 1} + + +@respx.mock +def test_sync_text_response(api_key: str) -> None: + respx.get("https://api.example.com/health").mock(return_value=Response(200, text="ok")) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client._transport.request("GET", "/health") + assert result == "ok" + + +@respx.mock +def test_sync_204_returns_none(api_key: str) -> None: + respx.delete("https://api.example.com/keys/abc").mock(return_value=Response(204)) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client._transport.request("DELETE", "/keys/abc") + assert result is None + + +@respx.mock +def test_authentication_error(api_key: str) -> None: + respx.get("https://api.example.com/auth/me").mock( + return_value=Response(401, json={"error": "unauthorized"}) + ) + with pytest.raises(AuthenticationError) as exc, ConduitClient( + api_key, base_url="https://api.example.com" + ) as client: + client._transport.request("GET", "/auth/me") + assert exc.value.status_code == 401 + + +@respx.mock +def test_not_found_error(api_key: str) -> None: + respx.get("https://api.example.com/wiki/pages/missing").mock( + return_value=Response(404, text="not found") + ) + with pytest.raises(NotFoundError) as exc, ConduitClient( + api_key, base_url="https://api.example.com" + ) as client: + client._transport.request("GET", "/wiki/pages/missing") + assert exc.value.status_code == 404 + + +@respx.mock +def test_validation_error(api_key: str) -> None: + respx.post("https://api.example.com/keys").mock( + return_value=Response(422, json={"error": "unknown scopes"}) + ) + with pytest.raises(ValidationError) as exc, ConduitClient( + api_key, base_url="https://api.example.com" + ) as client: + client._transport.request("POST", "/keys", json={"scopes": ["bad"]}) + assert exc.value.status_code == 422 + + +@respx.mock +def test_rate_limit_retry_then_success(api_key: str) -> None: + route = respx.get("https://api.example.com/health").mock( + side_effect=[Response(429, text="slow down"), Response(200, text="ok")] + ) + with ConduitClient(api_key, base_url="https://api.example.com", max_retries=2) as client: + result = client._transport.request("GET", "/health") + assert result == "ok" + assert route.call_count == 2 + + +@respx.mock +def test_rate_limit_exhausted(api_key: str) -> None: + respx.get("https://api.example.com/health").mock(return_value=Response(429, text="slow down")) + with pytest.raises(RateLimitError), ConduitClient( + api_key, base_url="https://api.example.com", max_retries=1 + ) as client: + client._transport.request("GET", "/health") + + +def test_encode_path_param() -> None: + assert _BaseClientMixin._encode_path_param("foo/bar") == "foo%2Fbar" + assert _BaseClientMixin._encode_path_param("example.com") == "example.com" diff --git a/tests/test_compute.py b/tests/test_compute.py new file mode 100644 index 0000000..c9c3544 --- /dev/null +++ b/tests/test_compute.py @@ -0,0 +1,242 @@ +"""Tests for the Proxmox compute client.""" + +from __future__ import annotations + +import json + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.compute import ( + CloneVmRequest, + ComputeTicketRequest, + CreateContainerRequest, + CreateSnapshotRequest, + CreateVmRequest, + MigrateVmRequest, + NodeTasksQuery, + StorageQuery, + UploadStorageRequest, +) + + +@respx.mock +def test_auth_ticket(api_key: str) -> None: + route = respx.post("https://api.example.com/compute/auth/ticket").mock( + return_value=Response( + 200, + json={ + "ticket": "tck", + "CSRFPreventionToken": "csrf", + "username": "root", + }, + ) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.compute.auth_ticket( + ComputeTicketRequest(username="root", password="pw") + ) + assert result.ticket == "tck" + assert result.csrfpreventiontoken == "csrf" + assert json.loads(route.calls[0].request.content) == { + "username": "root", + "password": "pw", + } + + +@respx.mock +def test_list_resources_and_nodes(api_key: str) -> None: + respx.get("https://api.example.com/compute/resources").mock( + return_value=Response(200, json=[{"id": "qemu/100"}]) + ) + respx.get("https://api.example.com/compute/resources").mock( + return_value=Response(200, json=[{"id": "qemu/100"}]) + ) + respx.get("https://api.example.com/compute/cluster/status").mock( + return_value=Response(200, json={"quorate": True}) + ) + respx.get("https://api.example.com/compute/nodes").mock( + return_value=Response(200, json=[{"node": "pve"}]) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + assert client.compute.list_resources() == [{"id": "qemu/100"}] + assert client.compute.list_resources(type_="vm") == [{"id": "qemu/100"}] + assert client.compute.cluster_status() == {"quorate": True} + assert client.compute.list_nodes() == [{"node": "pve"}] + + +@respx.mock +def test_node_status_and_tasks(api_key: str) -> None: + respx.get("https://api.example.com/compute/nodes/pve/status").mock( + return_value=Response(200, json={"cpu": 0.1}) + ) + route = respx.get("https://api.example.com/compute/nodes/pve/tasks").mock( + return_value=Response(200, json=[]) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + assert client.compute.get_node_status("pve") == {"cpu": 0.1} + assert client.compute.list_node_tasks("pve", NodeTasksQuery(limit=10)) == [] + assert route.calls[0].request.url.params["limit"] == "10" + + +@respx.mock +def test_vm_lifecycle(api_key: str) -> None: + respx.get("https://api.example.com/compute/vms").mock( + return_value=Response(200, json=[{"vmid": 100}]) + ) + create_route = respx.post("https://api.example.com/compute/vms").mock( + return_value=Response(200, json={"task": "UPID:abc"}) + ) + respx.get("https://api.example.com/compute/vms/100").mock( + return_value=Response(200, json={"vmid": 100, "name": "vm1"}) + ) + patch_route = respx.patch("https://api.example.com/compute/vms/100").mock( + return_value=Response(200, json={"task": "UPID:patch"}) + ) + respx.delete("https://api.example.com/compute/vms/100").mock( + return_value=Response(204) + ) + respx.post("https://api.example.com/compute/vms/100/start").mock( + return_value=Response(200, json={"task": "UPID:start"}) + ) + respx.post("https://api.example.com/compute/vms/100/stop").mock( + return_value=Response(200, json={"task": "UPID:stop"}) + ) + respx.post("https://api.example.com/compute/vms/100/shutdown").mock( + return_value=Response(200, json={"task": "UPID:shutdown"}) + ) + respx.post("https://api.example.com/compute/vms/100/reboot").mock( + return_value=Response(200, json={"task": "UPID:reboot"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + assert client.compute.list_vms() == [{"vmid": 100}] + result = client.compute.create_vm( + CreateVmRequest(node="pve", vmid=100, cores=2, memory=1024) + ) + assert result.task == "UPID:abc" + assert client.compute.get_vm(100)["name"] == "vm1" + assert client.compute.patch_vm(100, {"memory": 2048}).task == "UPID:patch" + client.compute.delete_vm(100, confirm=100) + assert client.compute.start_vm(100).task == "UPID:start" + assert client.compute.stop_vm(100).task == "UPID:stop" + assert client.compute.shutdown_vm(100).task == "UPID:shutdown" + assert client.compute.reboot_vm(100).task == "UPID:reboot" + body = json.loads(create_route.calls[0].request.content) + assert body["node"] == "pve" + assert body["cores"] == 2 + assert json.loads(patch_route.calls[0].request.content) == {"memory": 2048} + + +@respx.mock +def test_vm_clone_migrate_snapshots_agent(api_key: str) -> None: + respx.post("https://api.example.com/compute/vms/100/clone").mock( + return_value=Response(200, json={"task": "UPID:clone"}) + ) + respx.post("https://api.example.com/compute/vms/100/migrate").mock( + return_value=Response(200, json={"task": "UPID:migrate"}) + ) + respx.get("https://api.example.com/compute/vms/100/snapshots").mock( + return_value=Response(200, json=[{"name": "snap1"}]) + ) + respx.post("https://api.example.com/compute/vms/100/snapshots").mock( + return_value=Response(200, json={"task": "UPID:snap"}) + ) + respx.post("https://api.example.com/compute/vms/100/agent/exec").mock( + return_value=Response(200, json={"out-data": "ok"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + assert client.compute.clone_vm(100, CloneVmRequest(newid=200)).task == "UPID:clone" + assert client.compute.migrate_vm( + 100, MigrateVmRequest(target="pve2", online=True) + ).task == "UPID:migrate" + assert client.compute.list_snapshots(100) == [{"name": "snap1"}] + assert client.compute.create_snapshot( + 100, CreateSnapshotRequest(snapname="snap2") + ).task == "UPID:snap" + assert client.compute.agent_action(100, "exec", {"command": "ls"}) == {"out-data": "ok"} + + +@respx.mock +def test_containers(api_key: str) -> None: + create_route = respx.post("https://api.example.com/compute/containers").mock( + return_value=Response(200, json={"task": "UPID:ct"}) + ) + respx.post("https://api.example.com/compute/containers/200/start").mock( + return_value=Response(200, json={"task": "UPID:start"}) + ) + respx.post("https://api.example.com/compute/containers/200/stop").mock( + return_value=Response(200, json={"task": "UPID:stop"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.compute.create_container( + CreateContainerRequest(node="pve", ostemplate="local:vztmpl/debian.tar.gz", vmid=200) + ) + assert result.task == "UPID:ct" + assert client.compute.start_container(200).task == "UPID:start" + assert client.compute.stop_container(200).task == "UPID:stop" + body = json.loads(create_route.calls[0].request.content) + assert body["node"] == "pve" + assert body["ostemplate"] == "local:vztmpl/debian.tar.gz" + + +@respx.mock +def test_storage(api_key: str) -> None: + respx.get("https://api.example.com/compute/storage").mock( + return_value=Response(200, json=[{"storage": "local"}]) + ) + respx.get("https://api.example.com/compute/storage/local/content").mock( + return_value=Response(200, json=[{"volid": "local:iso/img.iso"}]) + ) + upload_route = respx.post("https://api.example.com/compute/storage/local/upload").mock( + return_value=Response(200, json={"task": "UPID:upload"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + assert client.compute.list_storage(StorageQuery(node="pve")) == [{"storage": "local"}] + assert client.compute.list_storage_content("local", StorageQuery(node="pve")) == [ + {"volid": "local:iso/img.iso"} + ] + result = client.compute.upload_to_storage( + "local", + UploadStorageRequest(content="abc", filename="img.iso"), + node="pve", + ) + assert result.task == "UPID:upload" + assert upload_route.calls[0].request.url.params["node"] == "pve" + + +@respx.mock +def test_backup_jobs_and_misc(api_key: str) -> None: + respx.get("https://api.example.com/compute/backups/jobs").mock( + return_value=Response(200, json=[]) + ) + respx.post("https://api.example.com/compute/backups/jobs").mock( + return_value=Response(200, json={"task": "UPID:backup"}) + ) + respx.get("https://api.example.com/compute/ha/resources").mock( + return_value=Response(200, json=[]) + ) + respx.get("https://api.example.com/compute/access/users").mock( + return_value=Response(200, json=[]) + ) + respx.get("https://api.example.com/compute/access/acl").mock( + return_value=Response(200, json=[]) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + assert client.compute.list_backup_jobs() == [] + assert client.compute.create_backup_job({"id": "job1"}).task == "UPID:backup" + assert client.compute.list_ha_resources() == [] + assert client.compute.list_access_users() == [] + assert client.compute.list_access_acl() == [] + + +@pytest.mark.asyncio +@respx.mock +async def test_async_start_vm(api_key: str) -> None: + respx.post("https://api.example.com/compute/vms/100/start").mock( + return_value=Response(200, json={"task": "UPID:start"}) + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.compute.start_vm(100) + assert result.task == "UPID:start" diff --git a/tests/test_dns.py b/tests/test_dns.py new file mode 100644 index 0000000..c3b19f2 --- /dev/null +++ b/tests/test_dns.py @@ -0,0 +1,136 @@ +"""Tests for the BIND9 DNS client.""" + +from __future__ import annotations + +import json + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.dns import ( + AddZoneRequest, + BatchRecordUpdate, + BatchUpdateRecordsRequest, + CacheFlushRequest, + ServerReloadRequest, +) + + +@respx.mock +def test_server_status(api_key: str) -> None: + respx.get("https://api.example.com/dns/servers/default/status").mock( + return_value=Response(200, text="named is running") + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.dns.server_status("default") + assert result == "named is running" + + +@respx.mock +def test_server_reload_and_stats(api_key: str) -> None: + reload_route = respx.post("https://api.example.com/dns/servers/default/reload").mock( + return_value=Response(204) + ) + respx.get("https://api.example.com/dns/servers/default/stats").mock( + return_value=Response(200, json={"zones": 5}) + ) + respx.get("https://api.example.com/dns/servers/default/config").mock( + return_value=Response(200, json={"options": {}}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + client.dns.reload_server("default", ServerReloadRequest(mode="reconfig")) + assert client.dns.server_stats("default") == {"zones": 5} + assert client.dns.server_config("default") == {"options": {}} + assert json.loads(reload_route.calls[0].request.content) == {"mode": "reconfig"} + + +@respx.mock +def test_cache_flush(api_key: str) -> None: + route = respx.post("https://api.example.com/dns/servers/default/cache/flush").mock( + return_value=Response(204) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + client.dns.flush_cache( + "default", CacheFlushRequest(mode="flushname", name="example.com") + ) + assert json.loads(route.calls[0].request.content) == { + "mode": "flushname", + "name": "example.com", + } + + +@respx.mock +def test_zone_crud(api_key: str) -> None: + create_route = respx.post("https://api.example.com/dns/zones").mock( + return_value=Response(200, json={"zone": "example.com"}) + ) + respx.get("https://api.example.com/dns/zones/example.com").mock( + return_value=Response(200, json={"zone": "example.com", "type": "master"}) + ) + patch_route = respx.patch("https://api.example.com/dns/zones/example.com").mock( + return_value=Response(204) + ) + respx.delete("https://api.example.com/dns/zones/example.com").mock( + return_value=Response(204) + ) + respx.post("https://api.example.com/dns/zones/example.com/freeze").mock( + return_value=Response(204) + ) + respx.post("https://api.example.com/dns/zones/example.com/thaw").mock( + return_value=Response(204) + ) + respx.post("https://api.example.com/dns/zones/example.com/sync").mock( + return_value=Response(204) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.dns.create_zone( + AddZoneRequest(zone="example.com", klass="IN", extra="value") + ) + assert result["zone"] == "example.com" + assert client.dns.get_zone("example.com")["type"] == "master" + client.dns.patch_zone("example.com", {"type": "slave"}) + client.dns.delete_zone("example.com", confirm="example.com") + client.dns.freeze_zone("example.com") + client.dns.thaw_zone("example.com") + client.dns.sync_zone("example.com") + body = json.loads(create_route.calls[0].request.content) + assert body["zone"] == "example.com" + assert body["klass"] == "IN" + assert body["extra"] == "value" + assert json.loads(patch_route.calls[0].request.content) == {"type": "slave"} + + +@respx.mock +def test_batch_update_records(api_key: str) -> None: + route = respx.post("https://api.example.com/dns/zones/example.com/records:batchUpdate").mock( + return_value=Response(204) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + client.dns.batch_update_records( + "example.com", + BatchUpdateRecordsRequest( + updates=[ + BatchRecordUpdate( + op="add", name="www", ttl=300, rtype="A", rdata="1.2.3.4" + ) + ] + ), + ) + assert json.loads(route.calls[0].request.content) == { + "updates": [ + {"op": "add", "name": "www", "ttl": 300, "rtype": "A", "rdata": "1.2.3.4"} + ] + } + + +@pytest.mark.asyncio +@respx.mock +async def test_async_server_status(api_key: str) -> None: + respx.get("https://api.example.com/dns/servers/default/status").mock( + return_value=Response(200, text="named is running") + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.dns.server_status("default") + assert result == "named is running" diff --git a/tests/test_gateway.py b/tests/test_gateway.py new file mode 100644 index 0000000..b1a0ca8 --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,145 @@ +"""Tests for the gateway client.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.common import CreateKeyRequest +from conduit_client.models.gateway import AuditLogQuery + + +@respx.mock +def test_health(api_key: str) -> None: + respx.get("https://api.example.com/health").mock(return_value=Response(200, text="ok")) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.gateway.health() + assert result == "ok" + + +@respx.mock +def test_me(api_key: str) -> None: + respx.get("https://api.example.com/auth/me").mock( + return_value=Response( + 200, + json={ + "subject": "user:123", + "actor_type": "user", + "auth_method": "api_key", + "scopes": ["gateway:read"], + "grantable_scopes": ["gateway:read", "sms:send"], + }, + ) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.gateway.me() + assert result.subject == "user:123" + assert result.scopes == ["gateway:read"] + + +@respx.mock +def test_services(api_key: str) -> None: + respx.get("https://api.example.com/services").mock( + return_value=Response( + 200, + json=[ + {"id": "sms", "name": "SMS", "prefixes": ["/sms"], "enabled": True}, + ], + ) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.gateway.services() + assert len(result) == 1 + assert result[0].id == "sms" + + +@respx.mock +def test_list_keys(api_key: str) -> None: + created = datetime.now(timezone.utc).isoformat() + respx.get("https://api.example.com/keys").mock( + return_value=Response( + 200, + json=[ + { + "key_prefix": "abc123", + "display_name": "test", + "scopes": ["gateway:read"], + "created_at": created, + }, + ], + ) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.gateway.list_keys() + assert result[0].key_prefix == "abc123" + + +@respx.mock +def test_create_key(api_key: str) -> None: + created = datetime.now(timezone.utc).isoformat() + route = respx.post("https://api.example.com/keys").mock( + return_value=Response( + 200, + json={ + "key": "mega_sk_live_abc_secret", + "key_prefix": "abc", + "display_name": "new key", + "scopes": ["sms:read"], + "created_at": created, + }, + ) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.gateway.create_key(CreateKeyRequest(display_name="new key", scopes=["sms:read"])) + assert result.key == "mega_sk_live_abc_secret" + assert json.loads(route.calls[0].request.content) == { + "display_name": "new key", + "scopes": ["sms:read"], + } + + +@respx.mock +def test_revoke_key(api_key: str) -> None: + respx.delete("https://api.example.com/keys/abc").mock(return_value=Response(204)) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.gateway.revoke_key("abc") + assert result is None + + +@respx.mock +def test_admin_audit(api_key: str) -> None: + route = respx.get("https://api.example.com/admin/audit").mock( + return_value=Response(200, json=[]) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.gateway.admin_audit( + AuditLogQuery(actor_subject="user:123", limit=50) + ) + assert result == [] + assert route.calls[0].request.url.params["actor_subject"] == "user:123" + assert route.calls[0].request.url.params["limit"] == "50" + + +@pytest.mark.asyncio +@respx.mock +async def test_async_me(api_key: str) -> None: + respx.get("https://api.example.com/auth/me").mock( + return_value=Response( + 200, + json={ + "subject": "user:123", + "actor_type": "user", + "auth_method": "api_key", + "scopes": ["gateway:read"], + "grantable_scopes": ["gateway:read", "sms:send"], + }, + ) + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.gateway.me() + assert result.subject == "user:123" diff --git a/tests/test_media_ingest.py b/tests/test_media_ingest.py new file mode 100644 index 0000000..546b9dc --- /dev/null +++ b/tests/test_media_ingest.py @@ -0,0 +1,129 @@ +"""Tests for the MeTube media ingestion client.""" + +from __future__ import annotations + +import json + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.media_ingest import ( + CreateIngestJobRequest, + CreateIngestSubscriptionRequest, + PatchIngestSubscriptionRequest, +) + + +@respx.mock +def test_list_jobs(api_key: str) -> None: + respx.get("https://api.example.com/media/ingest/jobs").mock( + return_value=Response( + 200, + json={"done": [{"id": "job1", "status": "done"}], "downloading": []}, + ) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_ingest.list_jobs() + assert len(result.done or []) == 1 + + +@respx.mock +def test_create_job(api_key: str) -> None: + route = respx.post("https://api.example.com/media/ingest/jobs").mock( + return_value=Response(200, json={"id": "job2", "status": "pending"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_ingest.create_job( + CreateIngestJobRequest(url="https://youtube.com/watch?v=abc", quality="best") + ) + assert result.id == "job2" + assert json.loads(route.calls[0].request.content) == { + "url": "https://youtube.com/watch?v=abc", + "quality": "best", + } + + +@respx.mock +def test_start_cancel_delete_job(api_key: str) -> None: + respx.post("https://api.example.com/media/ingest/jobs/job3/start").mock( + return_value=Response(204) + ) + respx.post("https://api.example.com/media/ingest/jobs/job3/cancel").mock( + return_value=Response(204) + ) + respx.delete("https://api.example.com/media/ingest/jobs/job3").mock( + return_value=Response(204) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + client.media_ingest.start_job("job3") + client.media_ingest.cancel_job("job3") + client.media_ingest.delete_job("job3", delete_from_file=True) + + +@respx.mock +def test_list_presets(api_key: str) -> None: + respx.get("https://api.example.com/media/ingest/presets").mock( + return_value=Response(200, json=[{"name": "best", "quality": "best", "format": "mp4"}]) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_ingest.list_presets() + assert result[0].name == "best" + + +@respx.mock +def test_subscriptions(api_key: str) -> None: + respx.get("https://api.example.com/media/ingest/subscriptions").mock( + return_value=Response(200, json=[{"id": "sub1", "url": "https://feed.example.com"}]) + ) + create_route = respx.post("https://api.example.com/media/ingest/subscriptions").mock( + return_value=Response(200, json={"id": "sub2", "url": "https://feed2.example.com"}) + ) + respx.patch("https://api.example.com/media/ingest/subscriptions/sub1").mock( + return_value=Response(200, json={"id": "sub1", "url": "https://feed.example.com", "name": "new"}) + ) + respx.delete("https://api.example.com/media/ingest/subscriptions/sub1").mock( + return_value=Response(204) + ) + respx.post("https://api.example.com/media/ingest/subscriptions/sub1/check").mock( + return_value=Response(204) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + subs = client.media_ingest.list_subscriptions() + assert len(subs) == 1 + new = client.media_ingest.create_subscription( + CreateIngestSubscriptionRequest(url="https://feed2.example.com") + ) + assert new.id == "sub2" + client.media_ingest.patch_subscription( + "sub1", PatchIngestSubscriptionRequest(name="new") + ) + client.media_ingest.delete_subscription("sub1") + client.media_ingest.check_subscription("sub1") + assert json.loads(create_route.calls[0].request.content) == { + "url": "https://feed2.example.com" + } + + +@respx.mock +def test_version(api_key: str) -> None: + respx.get("https://api.example.com/media/ingest/version").mock( + return_value=Response(200, json={"metube_version": "1.0", "ytdlp_version": "2024.01.01"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_ingest.version() + assert result.metube_version == "1.0" + + +@pytest.mark.asyncio +@respx.mock +async def test_async_create_job(api_key: str) -> None: + respx.post("https://api.example.com/media/ingest/jobs").mock( + return_value=Response(200, json={"id": "job2", "status": "pending"}) + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.media_ingest.create_job( + CreateIngestJobRequest(url="https://youtube.com/watch?v=abc") + ) + assert result.id == "job2" diff --git a/tests/test_media_library.py b/tests/test_media_library.py new file mode 100644 index 0000000..eb93d05 --- /dev/null +++ b/tests/test_media_library.py @@ -0,0 +1,93 @@ +"""Tests for the Jellyfin media library client.""" + +from __future__ import annotations + +import json + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.media_library import LibraryLoginRequest, LibrarySearchQuery + + +@respx.mock +def test_login(api_key: str) -> None: + route = respx.post("https://api.example.com/media/library/auth/login").mock( + return_value=Response(200, json={"access_token": "tok", "user_id": "u1"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_library.login(LibraryLoginRequest(username="user", password="pw")) + assert result.access_token == "tok" + assert json.loads(route.calls[0].request.content) == { + "username": "user", + "password": "pw", + } + + +@respx.mock +def test_search(api_key: str) -> None: + route = respx.get("https://api.example.com/media/library/search").mock( + return_value=Response(200, json={"Items": []}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_library.search(LibrarySearchQuery(term="cat")) + assert result == {"Items": []} + assert route.calls[0].request.url.params["term"] == "cat" + + +@respx.mock +def test_get_item_and_playback(api_key: str) -> None: + respx.get("https://api.example.com/media/library/items/i1").mock( + return_value=Response(200, json={"Id": "i1", "Name": "Movie"}) + ) + respx.get("https://api.example.com/media/library/items/i1/playback").mock( + return_value=Response(200, json={"MediaSources": []}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + item = client.media_library.get_item("i1") + playback = client.media_library.get_playback_info("i1") + assert item["Name"] == "Movie" + assert playback == {"MediaSources": []} + + +@respx.mock +def test_refresh(api_key: str) -> None: + respx.post("https://api.example.com/media/library/refresh").mock(return_value=Response(204)) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + client.media_library.refresh() + + +@respx.mock +def test_list_sessions(api_key: str) -> None: + respx.get("https://api.example.com/media/library/sessions").mock( + return_value=Response(200, json=[{"id": "s1", "device_name": "TV"}]) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_library.list_sessions() + assert result[0].device_name == "TV" + + +@respx.mock +def test_system_info(api_key: str) -> None: + route = respx.get("https://api.example.com/media/library/system/info").mock( + return_value=Response(200, json={"version": "10.0", "id": "abc"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.media_library.system_info(public=True) + assert result.version == "10.0" + assert route.calls[0].request.url.params["public"] == "true" + + +@pytest.mark.asyncio +@respx.mock +async def test_async_login(api_key: str) -> None: + respx.post("https://api.example.com/media/library/auth/login").mock( + return_value=Response(200, json={"access_token": "tok", "user_id": "u1"}) + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.media_library.login( + LibraryLoginRequest(username="user", password="pw") + ) + assert result.access_token == "tok" diff --git a/tests/test_sms.py b/tests/test_sms.py new file mode 100644 index 0000000..c7a8db7 --- /dev/null +++ b/tests/test_sms.py @@ -0,0 +1,112 @@ +"""Tests for the SMS/MMS client.""" + +from __future__ import annotations + +import json + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.sms import ( + SendMmsRequest, + SendSmsDefaultRequest, + SendSmsRequest, +) + + +@respx.mock +def test_list_sms(api_key: str) -> None: + respx.get("https://api.example.com/sms").mock( + return_value=Response(200, json=[{"id": 1, "did": "5550100", "dst": "5550200", "message": "hi"}]) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.sms.list_sms() + assert len(result) == 1 + assert result[0].id == 1 + + +@respx.mock +def test_send_sms(api_key: str) -> None: + route = respx.post("https://api.example.com/sms/send").mock( + return_value=Response(200, json={"id": 123456}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.sms.send_sms(SendSmsRequest(did="5550100", dst="5550200", message="hello")) + assert result.id == 123456 + assert json.loads(route.calls[0].request.content) == { + "did": "5550100", + "dst": "5550200", + "message": "hello", + } + + +@respx.mock +def test_send_sms_default(api_key: str) -> None: + route = respx.post("https://api.example.com/sms/send/default").mock( + return_value=Response(200, json={"id": 123457}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.sms.send_sms_default(SendSmsDefaultRequest(dst="5550200", message="hello")) + assert result.id == 123457 + assert json.loads(route.calls[0].request.content) == { + "dst": "5550200", + "message": "hello", + } + + +@respx.mock +def test_send_mms(api_key: str) -> None: + route = respx.post("https://api.example.com/mms/send").mock( + return_value=Response(200, json={"id": 123458}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.sms.send_mms( + SendMmsRequest( + did="5550100", + dst="5550200", + message="pic", + media1="https://example.com/img.jpg", + ) + ) + assert result.id == 123458 + assert json.loads(route.calls[0].request.content)["media1"] == "https://example.com/img.jpg" + + +@respx.mock +def test_get_mms_media(api_key: str) -> None: + route = respx.get("https://api.example.com/mms/123/media").mock( + return_value=Response( + 200, + json={"id": 123, "date": "2024-01-01", "media": [{"url": "https://x.com/a.jpg"}]}, + ) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.sms.get_mms_media(123, media_as_array=True) + assert result.media is not None + assert len(result.media) == 1 + assert route.calls[0].request.url.params["media_as_array"] == "true" + + +@respx.mock +def test_delete_sms(api_key: str) -> None: + respx.delete("https://api.example.com/sms/123").mock( + return_value=Response(200, json={"status": "success"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.sms.delete_sms(123) + assert result.status == "success" + + +@pytest.mark.asyncio +@respx.mock +async def test_async_send_sms(api_key: str) -> None: + respx.post("https://api.example.com/sms/send").mock( + return_value=Response(200, json={"id": 123456}) + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.sms.send_sms( + SendSmsRequest(did="5550100", dst="5550200", message="hello") + ) + assert result.id == 123456 diff --git a/tests/test_torrents.py b/tests/test_torrents.py new file mode 100644 index 0000000..94bae99 --- /dev/null +++ b/tests/test_torrents.py @@ -0,0 +1,128 @@ +"""Tests for the Transmission torrents client.""" + +from __future__ import annotations + +import json + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.torrents import ( + AddTorrentRequest, + MoveQueueRequest, + MoveTorrentRequest, + PatchTorrentRequest, + RenamePathRequest, +) + + +@respx.mock +def test_add_torrent(api_key: str) -> None: + route = respx.post("https://api.example.com/torrents").mock( + return_value=Response(200, json={"id": 7, "name": "file.iso"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.torrents.add( + AddTorrentRequest(url="magnet:?xt=urn:btih:abc", paused=True) + ) + assert result["id"] == 7 + assert json.loads(route.calls[0].request.content)["paused"] is True + + +@respx.mock +def test_list_torrents(api_key: str) -> None: + route = respx.get("https://api.example.com/torrents").mock( + return_value=Response(200, json={"torrents": []}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.torrents.list(fields=["id", "name"], ids=[1, 2]) + assert result == {"torrents": []} + assert route.calls[0].request.url.params["fields"] == "id,name" + assert route.calls[0].request.url.params["ids"] == "1,2" + + +@respx.mock +def test_session_and_stats(api_key: str) -> None: + respx.get("https://api.example.com/torrents/session").mock( + return_value=Response(200, json={"download-dir": "/tmp"}) + ) + patch_route = respx.patch("https://api.example.com/torrents/session").mock( + return_value=Response(204) + ) + respx.get("https://api.example.com/torrents/session/stats").mock( + return_value=Response(200, json={"cumulative-stats": {}}) + ) + respx.post("https://api.example.com/torrents/session/close").mock( + return_value=Response(204) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + session = client.torrents.get_session() + assert session["download-dir"] == "/tmp" + client.torrents.patch_session({"download-dir": "/new"}) + stats = client.torrents.get_session_stats() + assert stats == {"cumulative-stats": {}} + client.torrents.close_session() + assert json.loads(patch_route.calls[0].request.content) == {"download-dir": "/new"} + + +@respx.mock +def test_free_space(api_key: str) -> None: + route = respx.get("https://api.example.com/torrents/free-space").mock( + return_value=Response(200, json={"path": "/tmp", "size-bytes": 1000, "total_size": 2000}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.torrents.free_space("/tmp") + assert result.path == "/tmp" + assert result.size_bytes == 1000 + assert route.calls[0].request.url.params["path"] == "/tmp" + + +@respx.mock +def test_torrent_actions(api_key: str) -> None: + respx.post("https://api.example.com/torrents/queue/move").mock(return_value=Response(204)) + respx.get("https://api.example.com/torrents/groups").mock(return_value=Response(200, json={})) + respx.patch("https://api.example.com/torrents/groups/fast").mock(return_value=Response(204)) + respx.get("https://api.example.com/torrents/7/download-link").mock( + return_value=Response(200, json={"torrent_link": "https://x.com/t", "file_links": []}) + ) + respx.get("https://api.example.com/torrents/7").mock(return_value=Response(200, json={"id": 7})) + respx.patch("https://api.example.com/torrents/7").mock(return_value=Response(204)) + respx.post("https://api.example.com/torrents/7/start").mock(return_value=Response(204)) + respx.post("https://api.example.com/torrents/7/start-now").mock(return_value=Response(204)) + respx.post("https://api.example.com/torrents/7/stop").mock(return_value=Response(204)) + respx.post("https://api.example.com/torrents/7/verify").mock(return_value=Response(204)) + respx.post("https://api.example.com/torrents/7/reannounce").mock(return_value=Response(204)) + respx.post("https://api.example.com/torrents/7/move").mock(return_value=Response(204)) + respx.post("https://api.example.com/torrents/7/rename-path").mock(return_value=Response(204)) + respx.delete("https://api.example.com/torrents/7").mock(return_value=Response(204)) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + client.torrents.move_queue(MoveQueueRequest(direction="top")) + client.torrents.list_groups() + client.torrents.patch_group("fast", {"speed_limit_down": 100}) + link = client.torrents.download_link(7) + assert link.torrent_link == "https://x.com/t" + assert client.torrents.get(7)["id"] == 7 + client.torrents.patch(7, PatchTorrentRequest(queue_position=1)) + client.torrents.start(7) + client.torrents.start_now(7) + client.torrents.stop(7) + client.torrents.verify(7) + client.torrents.reannounce(7) + client.torrents.move(7, MoveTorrentRequest(location="/new", move=True)) + client.torrents.rename_path(7, RenamePathRequest(path="old", name="new")) + client.torrents.remove(7, delete_local_data=True) + + +@pytest.mark.asyncio +@respx.mock +async def test_async_add_torrent(api_key: str) -> None: + respx.post("https://api.example.com/torrents").mock( + return_value=Response(200, json={"id": 7, "name": "file.iso"}) + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.torrents.add( + AddTorrentRequest(url="magnet:?xt=urn:btih:abc") + ) + assert result["id"] == 7 diff --git a/tests/test_wiki.py b/tests/test_wiki.py new file mode 100644 index 0000000..3472c48 --- /dev/null +++ b/tests/test_wiki.py @@ -0,0 +1,55 @@ +"""Tests for the DokuWiki client.""" + +from __future__ import annotations + +import json + +import pytest +import respx +from httpx import Response + +from conduit_client import AsyncConduitClient, ConduitClient +from conduit_client.models.wiki import WriteWikiPageRequest + + +@respx.mock +def test_get_raw(api_key: str) -> None: + route = respx.get("https://api.example.com/wiki/pages/foo%3Abar/raw").mock( + return_value=Response(200, json={"id": "foo:bar", "text": "hello"}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.wiki.get_raw("foo:bar") + assert result.text == "hello" + assert route.calls[0].request.url.path == "/wiki/pages/foo:bar/raw" + + +@respx.mock +def test_write(api_key: str) -> None: + route = respx.put("https://api.example.com/wiki/pages/foo%3Abar").mock( + return_value=Response(200, json={"id": "foo:bar", "url": "https://wiki.example.com/foo:bar", "verified": True}) + ) + with ConduitClient(api_key, base_url="https://api.example.com") as client: + result = client.wiki.write( + "foo:bar", + WriteWikiPageRequest(text="new content", summary="update", verify=False), + ) + assert result.url == "https://wiki.example.com/foo:bar" + assert json.loads(route.calls[0].request.content) == { + "text": "new content", + "summary": "update", + "verify": False, + } + + +@pytest.mark.asyncio +@respx.mock +async def test_async_write(api_key: str) -> None: + respx.put("https://api.example.com/wiki/pages/foo%3Abar").mock( + return_value=Response(200, json={"id": "foo:bar", "url": "https://wiki.example.com/foo:bar", "verified": True}) + ) + async with AsyncConduitClient(api_key, base_url="https://api.example.com") as client: + result = await client.wiki.write( + "foo:bar", + WriteWikiPageRequest(text="new content"), + ) + assert result.verified is True