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