Initial commit: add Gitea PyPI publish workflow
This commit is contained in:
35
.gitea/workflows/publish.yml
Normal file
35
.gitea/workflows/publish.yml
Normal 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
46
.gitignore
vendored
Normal 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
13
PLAN.md
Normal 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
184
README.md
Normal 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
226
docs/usage.md
Normal 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
63
pyproject.toml
Normal 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
1182
scripts/generate_docs.py
Normal file
File diff suppressed because it is too large
Load Diff
27
src/conduit_client/__init__.py
Normal file
27
src/conduit_client/__init__.py
Normal 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__",
|
||||
]
|
||||
224
src/conduit_client/_base_client.py
Normal file
224
src/conduit_client/_base_client.py
Normal 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)
|
||||
85
src/conduit_client/_client.py
Normal file
85
src/conduit_client/_client.py
Normal 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()
|
||||
1
src/conduit_client/clients/__init__.py
Normal file
1
src/conduit_client/clients/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Per-façade Conduit API clients."""
|
||||
518
src/conduit_client/clients/compute.py
Normal file
518
src/conduit_client/clients/compute.py
Normal 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")
|
||||
253
src/conduit_client/clients/dns.py
Normal file
253
src/conduit_client/clients/dns.py
Normal 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",
|
||||
)
|
||||
105
src/conduit_client/clients/gateway.py
Normal file
105
src/conduit_client/clients/gateway.py
Normal 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)
|
||||
210
src/conduit_client/clients/media_ingest.py
Normal file
210
src/conduit_client/clients/media_ingest.py
Normal 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,
|
||||
)
|
||||
120
src/conduit_client/clients/media_library.py
Normal file
120
src/conduit_client/clients/media_library.py
Normal 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,
|
||||
)
|
||||
175
src/conduit_client/clients/sms.py
Normal file
175
src/conduit_client/clients/sms.py
Normal 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,
|
||||
)
|
||||
312
src/conduit_client/clients/torrents.py
Normal file
312
src/conduit_client/clients/torrents.py
Normal 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)
|
||||
62
src/conduit_client/clients/wiki.py
Normal file
62
src/conduit_client/clients/wiki.py
Normal 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,
|
||||
)
|
||||
47
src/conduit_client/exceptions.py
Normal file
47
src/conduit_client/exceptions.py
Normal 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."""
|
||||
143
src/conduit_client/models/__init__.py
Normal file
143
src/conduit_client/models/__init__.py
Normal 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",
|
||||
]
|
||||
85
src/conduit_client/models/common.py
Normal file
85
src/conduit_client/models/common.py
Normal 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
|
||||
96
src/conduit_client/models/compute.py
Normal file
96
src/conduit_client/models/compute.py
Normal 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
|
||||
56
src/conduit_client/models/dns.py
Normal file
56
src/conduit_client/models/dns.py
Normal 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]
|
||||
21
src/conduit_client/models/gateway.py
Normal file
21
src/conduit_client/models/gateway.py
Normal 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)
|
||||
100
src/conduit_client/models/media_ingest.py
Normal file
100
src/conduit_client/models/media_ingest.py
Normal 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
|
||||
48
src/conduit_client/models/media_library.py
Normal file
48
src/conduit_client/models/media_library.py
Normal 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
|
||||
99
src/conduit_client/models/sms.py
Normal file
99
src/conduit_client/models/sms.py
Normal 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
|
||||
86
src/conduit_client/models/torrents.py
Normal file
86
src/conduit_client/models/torrents.py
Normal 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
|
||||
28
src/conduit_client/models/wiki.py
Normal file
28
src/conduit_client/models/wiki.py
Normal 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
22
tests/conftest.py
Normal 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
119
tests/test_base_client.py
Normal 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
242
tests/test_compute.py
Normal 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
136
tests/test_dns.py
Normal 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
145
tests/test_gateway.py
Normal 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
129
tests/test_media_ingest.py
Normal 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"
|
||||
93
tests/test_media_library.py
Normal file
93
tests/test_media_library.py
Normal 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
112
tests/test_sms.py
Normal 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
128
tests/test_torrents.py
Normal 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
55
tests/test_wiki.py
Normal 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
|
||||
Reference in New Issue
Block a user