Initial commit: add Gitea PyPI publish workflow

This commit is contained in:
2026-07-06 11:02:19 -05:00
commit b0d2873cbc
40 changed files with 5831 additions and 0 deletions

View File

@@ -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/*

46
.gitignore vendored Normal file
View File

@@ -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

13
PLAN.md Normal file
View File

@@ -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.

184
README.md Normal file
View File

@@ -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_<prefix>_<secret>")
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_<prefix>_<secret>") 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_<prefix>_<secret>")
# 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_<prefix>_<secret>")
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_<prefix>_<secret>",
base_url="https://api.cowtunnel.com",
timeout=60.0,
max_retries=2,
)
```
## License
MIT

226
docs/usage.md Normal file
View File

@@ -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_<prefix>_<secret>` 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())
```

63
pyproject.toml Normal file
View File

@@ -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"]

1182
scripts/generate_docs.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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__",
]

View File

@@ -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)

View File

@@ -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()

View File

@@ -0,0 +1 @@
"""Per-façade Conduit API clients."""

View File

@@ -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")

View File

@@ -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",
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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."""

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

22
tests/conftest.py Normal file
View File

@@ -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")

119
tests/test_base_client.py Normal file
View File

@@ -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"

242
tests/test_compute.py Normal file
View File

@@ -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"

136
tests/test_dns.py Normal file
View File

@@ -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"

145
tests/test_gateway.py Normal file
View File

@@ -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"

129
tests/test_media_ingest.py Normal file
View File

@@ -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"

View File

@@ -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"

112
tests/test_sms.py Normal file
View File

@@ -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

128
tests/test_torrents.py Normal file
View File

@@ -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

55
tests/test_wiki.py Normal file
View File

@@ -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