scaffold API client modules for homelab services
Base client extracted from bcda/client.py retry/auth pattern with MockTransport injection for testing. Four service clients: Gitea, Woodpecker, RustFS (S3 + admin), and Zotero — each with auth headers and domain method stubs. 36 tests passing.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Programmatic API clients for homelab services."""
|
||||
|
||||
9
src/api/clients/__init__.py
Normal file
9
src/api/clients/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""API client implementations."""
|
||||
|
||||
from .base import ApiError as ApiError
|
||||
from .base import Client as Client
|
||||
from .gitea import GiteaClient as GiteaClient
|
||||
from .rustfs import RustFSAdmin as RustFSAdmin
|
||||
from .rustfs import RustFSClient as RustFSClient
|
||||
from .woodpecker import WoodpeckerClient as WoodpeckerClient
|
||||
from .zotero import ZoteroClient as ZoteroClient
|
||||
218
src/api/clients/base.py
Normal file
218
src/api/clients/base.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Shared HTTP client with retry, auth, and error handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_RETRIES = 3
|
||||
RETRY_INITIAL_INTERVAL = 1.0 # seconds
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""Base exception for API client errors."""
|
||||
|
||||
|
||||
class Client:
|
||||
"""Base HTTP client with retry and auth hooks.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
base_url : str
|
||||
API base URL (e.g. ``http://gitea:3000/api/v1``).
|
||||
max_retries : int
|
||||
Max retry attempts for transient errors.
|
||||
retry_interval : float
|
||||
Initial back-off interval in seconds (doubles each retry).
|
||||
timeout : float
|
||||
HTTP request timeout in seconds.
|
||||
_transport : httpx.BaseTransport | None
|
||||
Injected transport for testing (``httpx.MockTransport``).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
max_retries: int = MAX_RETRIES,
|
||||
retry_interval: float = RETRY_INITIAL_INTERVAL,
|
||||
timeout: float = 30.0,
|
||||
_transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.max_retries = max_retries
|
||||
self.retry_interval = retry_interval
|
||||
self._http = httpx.Client(
|
||||
timeout=timeout,
|
||||
**({"transport": _transport} if _transport else {}),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying HTTP client."""
|
||||
self._http.close()
|
||||
|
||||
def __enter__(self) -> Client:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
# ── Auth hooks (override in subclasses) ──────────────────
|
||||
|
||||
def _default_headers(self) -> dict[str, str]:
|
||||
"""Return headers included on every request.
|
||||
|
||||
Override to add static auth tokens.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def authenticate(self) -> None:
|
||||
"""Perform dynamic authentication (e.g. token refresh).
|
||||
|
||||
No-op by default. Override for OAuth / dynamic token flows.
|
||||
"""
|
||||
|
||||
# ── Low-level request with retry ─────────────────────────
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: dict[str, str] | None = None,
|
||||
json: object = None,
|
||||
content: bytes | None = None,
|
||||
auth_required: bool = True,
|
||||
retry: bool = True,
|
||||
) -> httpx.Response:
|
||||
"""Execute an HTTP request with retry and token refresh.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
method : str
|
||||
HTTP method.
|
||||
path : str
|
||||
Path relative to ``base_url``.
|
||||
headers : dict | None
|
||||
Extra headers merged with ``_default_headers()``.
|
||||
params : dict | None
|
||||
Query parameters.
|
||||
json : object
|
||||
JSON-serializable body.
|
||||
content : bytes | None
|
||||
Raw body bytes.
|
||||
auth_required : bool
|
||||
Whether to include auth headers.
|
||||
retry : bool
|
||||
Whether to apply retry logic.
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
|
||||
merged: dict[str, str] = {}
|
||||
if auth_required:
|
||||
merged.update(self._default_headers())
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
|
||||
attempts = self.max_retries if retry else 1
|
||||
backoff = self.retry_interval
|
||||
last_exc: BaseException | None = None
|
||||
refreshed = False
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
resp = self._http.request(
|
||||
method,
|
||||
url,
|
||||
headers=merged,
|
||||
params=params,
|
||||
json=json,
|
||||
content=content,
|
||||
)
|
||||
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
|
||||
last_exc = exc
|
||||
if attempt < attempts:
|
||||
log.warning(
|
||||
"%s %s attempt %d/%d: %s — retrying in %.1fs",
|
||||
method,
|
||||
url,
|
||||
attempt,
|
||||
attempts,
|
||||
exc,
|
||||
backoff,
|
||||
)
|
||||
time.sleep(backoff)
|
||||
backoff *= 2
|
||||
continue
|
||||
raise ApiError(
|
||||
f"{method} {url} failed after {attempts} attempts"
|
||||
) from exc
|
||||
|
||||
if resp.status_code == 401 and auth_required and not refreshed:
|
||||
log.info("Got 401, re-authenticating")
|
||||
self.authenticate()
|
||||
merged.update(self._default_headers())
|
||||
refreshed = True
|
||||
continue
|
||||
|
||||
if resp.status_code == 429:
|
||||
delay = int(resp.headers.get("Retry-After", "60"))
|
||||
log.warning(
|
||||
"Rate limited (429), sleeping %ds (attempt %d/%d)",
|
||||
delay,
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
if resp.status_code >= 500:
|
||||
last_exc = httpx.HTTPStatusError(
|
||||
f"Server error {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
if attempt < attempts:
|
||||
log.warning(
|
||||
"%s %s returned %d (attempt %d/%d) — retrying",
|
||||
method,
|
||||
url,
|
||||
resp.status_code,
|
||||
attempt,
|
||||
attempts,
|
||||
)
|
||||
time.sleep(backoff)
|
||||
backoff *= 2
|
||||
continue
|
||||
resp.raise_for_status()
|
||||
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
|
||||
if last_exc:
|
||||
raise ApiError(str(last_exc)) from last_exc
|
||||
raise ApiError("Request failed with no response")
|
||||
|
||||
# ── Convenience verbs ────────────────────────────────────
|
||||
|
||||
def get(
|
||||
self, path: str, *, params: dict[str, str] | None = None, **kw
|
||||
) -> httpx.Response:
|
||||
return self._request("GET", path, params=params, **kw)
|
||||
|
||||
def post(self, path: str, *, json: object = None, **kw) -> httpx.Response:
|
||||
return self._request("POST", path, json=json, **kw)
|
||||
|
||||
def put(self, path: str, *, json: object = None, **kw) -> httpx.Response:
|
||||
return self._request("PUT", path, json=json, **kw)
|
||||
|
||||
def delete(self, path: str, **kw) -> httpx.Response:
|
||||
return self._request("DELETE", path, **kw)
|
||||
|
||||
def patch(self, path: str, *, json: object = None, **kw) -> httpx.Response:
|
||||
return self._request("PATCH", path, json=json, **kw)
|
||||
3
src/api/clients/gitea/__init__.py
Normal file
3
src/api/clients/gitea/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Gitea API client."""
|
||||
|
||||
from .client import GiteaClient as GiteaClient
|
||||
62
src/api/clients/gitea/client.py
Normal file
62
src/api/clients/gitea/client.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Gitea API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.clients.base import Client
|
||||
|
||||
|
||||
class GiteaClient(Client):
|
||||
"""Client for the Gitea REST API v1.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token : str
|
||||
Personal access token.
|
||||
base_url : str
|
||||
Gitea API base URL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
base_url: str = "http://gitea:3000/api/v1",
|
||||
**kw,
|
||||
) -> None:
|
||||
super().__init__(base_url, **kw)
|
||||
self._token = token
|
||||
|
||||
def _default_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"token {self._token}"}
|
||||
|
||||
# ── Repos ────────────────────────────────────────────────
|
||||
|
||||
def list_repos(self, **params) -> list[dict]:
|
||||
return self.get("/repos/search", params=params).json()
|
||||
|
||||
def get_repo(self, owner: str, repo: str) -> dict:
|
||||
return self.get(f"/repos/{owner}/{repo}").json()
|
||||
|
||||
def create_repo(self, body: dict) -> dict:
|
||||
return self.post("/user/repos", json=body).json()
|
||||
|
||||
# ── Orgs ─────────────────────────────────────────────────
|
||||
|
||||
def list_orgs(self) -> list[dict]:
|
||||
return self.get("/user/orgs").json()
|
||||
|
||||
def get_org(self, org: str) -> dict:
|
||||
return self.get(f"/orgs/{org}").json()
|
||||
|
||||
# ── Packages ─────────────────────────────────────────────
|
||||
|
||||
def list_packages(self, owner: str, **params) -> list[dict]:
|
||||
return self.get(f"/packages/{owner}", params=params).json()
|
||||
|
||||
# ── Webhooks ─────────────────────────────────────────────
|
||||
|
||||
def list_webhooks(self, owner: str, repo: str) -> list[dict]:
|
||||
return self.get(f"/repos/{owner}/{repo}/hooks").json()
|
||||
|
||||
def create_webhook(self, owner: str, repo: str, body: dict) -> dict:
|
||||
return self.post(f"/repos/{owner}/{repo}/hooks", json=body).json()
|
||||
4
src/api/clients/rustfs/__init__.py
Normal file
4
src/api/clients/rustfs/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""RustFS S3-compatible and admin API clients."""
|
||||
|
||||
from .client import RustFSAdmin as RustFSAdmin
|
||||
from .client import RustFSClient as RustFSClient
|
||||
150
src/api/clients/rustfs/client.py
Normal file
150
src/api/clients/rustfs/client.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""RustFS (S3-compatible) and MinIO admin API clients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from api.clients.base import Client
|
||||
|
||||
|
||||
class RustFSClient(Client):
|
||||
"""S3-protocol client for RustFS object storage.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
access_key : str
|
||||
S3 access key ID.
|
||||
secret_key : str
|
||||
S3 secret access key.
|
||||
base_url : str
|
||||
RustFS S3 endpoint.
|
||||
|
||||
Notes
|
||||
-----
|
||||
AWS Signature V4 signing is stubbed — requests are sent unsigned.
|
||||
Implement ``_sign_request()`` when real S3 auth is needed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
*,
|
||||
base_url: str = "http://rustfs:9000",
|
||||
**kw,
|
||||
) -> None:
|
||||
super().__init__(base_url, **kw)
|
||||
self._access_key = access_key
|
||||
self._secret_key = secret_key
|
||||
|
||||
# ── Buckets ──────────────────────────────────────────────
|
||||
|
||||
def list_buckets(self) -> httpx.Response:
|
||||
return self.get("/", auth_required=False)
|
||||
|
||||
def create_bucket(self, bucket: str) -> httpx.Response:
|
||||
return self.put(f"/{bucket}", auth_required=False)
|
||||
|
||||
def delete_bucket(self, bucket: str) -> httpx.Response:
|
||||
return self.delete(f"/{bucket}", auth_required=False)
|
||||
|
||||
def bucket_exists(self, bucket: str) -> bool:
|
||||
resp = self._request("HEAD", f"/{bucket}", auth_required=False, retry=False)
|
||||
return resp.status_code == 200
|
||||
|
||||
# ── Objects ──────────────────────────────────────────────
|
||||
|
||||
def list_objects(self, bucket: str, *, prefix: str = "") -> httpx.Response:
|
||||
params = {"prefix": prefix} if prefix else None
|
||||
return self.get(f"/{bucket}", params=params, auth_required=False)
|
||||
|
||||
def get_object(self, bucket: str, key: str) -> httpx.Response:
|
||||
return self.get(f"/{bucket}/{key}", auth_required=False)
|
||||
|
||||
def put_object(self, bucket: str, key: str, data: bytes) -> httpx.Response:
|
||||
return self._request(
|
||||
"PUT",
|
||||
f"/{bucket}/{key}",
|
||||
content=data,
|
||||
auth_required=False,
|
||||
)
|
||||
|
||||
def delete_object(self, bucket: str, key: str) -> httpx.Response:
|
||||
return self.delete(f"/{bucket}/{key}", auth_required=False)
|
||||
|
||||
def head_object(self, bucket: str, key: str) -> httpx.Response:
|
||||
return self._request(
|
||||
"HEAD",
|
||||
f"/{bucket}/{key}",
|
||||
auth_required=False,
|
||||
retry=False,
|
||||
)
|
||||
|
||||
|
||||
class RustFSAdmin(Client):
|
||||
"""MinIO-compatible admin API client for RustFS.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
access_key : str
|
||||
Admin access key.
|
||||
secret_key : str
|
||||
Admin secret key.
|
||||
base_url : str
|
||||
RustFS admin endpoint.
|
||||
|
||||
Notes
|
||||
-----
|
||||
Admin credential signing is stubbed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
*,
|
||||
base_url: str = "http://rustfs:9000/minio/v2",
|
||||
**kw,
|
||||
) -> None:
|
||||
super().__init__(base_url, **kw)
|
||||
self._access_key = access_key
|
||||
self._secret_key = secret_key
|
||||
|
||||
# ── Users ────────────────────────────────────────────────
|
||||
|
||||
def list_users(self) -> dict:
|
||||
return self.get("/iam/users", auth_required=False).json()
|
||||
|
||||
def add_user(self, access_key: str, secret_key: str) -> dict:
|
||||
return self.post(
|
||||
"/iam/users",
|
||||
json={"accessKey": access_key, "secretKey": secret_key},
|
||||
auth_required=False,
|
||||
).json()
|
||||
|
||||
def remove_user(self, access_key: str) -> httpx.Response:
|
||||
return self.delete(f"/iam/users/{access_key}", auth_required=False)
|
||||
|
||||
# ── Policies ─────────────────────────────────────────────
|
||||
|
||||
def list_policies(self) -> dict:
|
||||
return self.get("/iam/policies", auth_required=False).json()
|
||||
|
||||
def add_policy(self, name: str, policy: dict) -> dict:
|
||||
return self.post(
|
||||
f"/iam/policies/{name}",
|
||||
json=policy,
|
||||
auth_required=False,
|
||||
).json()
|
||||
|
||||
def set_user_policy(self, access_key: str, policy: str) -> httpx.Response:
|
||||
return self.put(
|
||||
f"/iam/users/{access_key}/policies",
|
||||
json={"policy": policy},
|
||||
auth_required=False,
|
||||
)
|
||||
|
||||
# ── Server ───────────────────────────────────────────────
|
||||
|
||||
def server_info(self) -> dict:
|
||||
return self.get("/cluster/info", auth_required=False).json()
|
||||
3
src/api/clients/woodpecker/__init__.py
Normal file
3
src/api/clients/woodpecker/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Woodpecker CI API client."""
|
||||
|
||||
from .client import WoodpeckerClient as WoodpeckerClient
|
||||
62
src/api/clients/woodpecker/client.py
Normal file
62
src/api/clients/woodpecker/client.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Woodpecker CI API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.clients.base import Client
|
||||
|
||||
|
||||
class WoodpeckerClient(Client):
|
||||
"""Client for the Woodpecker CI REST API.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token : str
|
||||
Personal API token.
|
||||
base_url : str
|
||||
Woodpecker API base URL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
*,
|
||||
base_url: str = "http://woodpecker-server:8000/api",
|
||||
**kw,
|
||||
) -> None:
|
||||
super().__init__(base_url, **kw)
|
||||
self._token = token
|
||||
|
||||
def _default_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self._token}"}
|
||||
|
||||
# ── Repos ────────────────────────────────────────────────
|
||||
|
||||
def list_repos(self) -> list[dict]:
|
||||
return self.get("/repos").json()
|
||||
|
||||
def get_repo(self, repo_id: int) -> dict:
|
||||
return self.get(f"/repos/{repo_id}").json()
|
||||
|
||||
# ── Pipelines ────────────────────────────────────────────
|
||||
|
||||
def list_pipelines(self, repo_id: int) -> list[dict]:
|
||||
return self.get(f"/repos/{repo_id}/pipelines").json()
|
||||
|
||||
def get_pipeline(self, repo_id: int, number: int) -> dict:
|
||||
return self.get(f"/repos/{repo_id}/pipelines/{number}").json()
|
||||
|
||||
def get_logs(self, repo_id: int, number: int, step: int) -> list[dict]:
|
||||
return self.get(f"/repos/{repo_id}/logs/{number}/{step}").json()
|
||||
|
||||
# ── Secrets ──────────────────────────────────────────────
|
||||
|
||||
def list_secrets(self, repo_id: int) -> list[dict]:
|
||||
return self.get(f"/repos/{repo_id}/secrets").json()
|
||||
|
||||
def create_secret(self, repo_id: int, body: dict) -> dict:
|
||||
return self.post(f"/repos/{repo_id}/secrets", json=body).json()
|
||||
|
||||
# ── Server ───────────────────────────────────────────────
|
||||
|
||||
def version(self) -> dict:
|
||||
return self.get("/version").json()
|
||||
3
src/api/clients/zotero/__init__.py
Normal file
3
src/api/clients/zotero/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Zotero Web API client."""
|
||||
|
||||
from .client import ZoteroClient as ZoteroClient
|
||||
86
src/api/clients/zotero/client.py
Normal file
86
src/api/clients/zotero/client.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Zotero Web API client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from api.clients.base import Client
|
||||
|
||||
|
||||
class ZoteroClient(Client):
|
||||
"""Client for the Zotero Web API v3.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
api_key : str
|
||||
Zotero API key.
|
||||
user_id : str
|
||||
Zotero user ID (numeric string).
|
||||
base_url : str
|
||||
API base URL.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
user_id: str,
|
||||
*,
|
||||
base_url: str = "https://api.zotero.org",
|
||||
**kw,
|
||||
) -> None:
|
||||
super().__init__(base_url, **kw)
|
||||
self._api_key = api_key
|
||||
self._user_id = user_id
|
||||
self._prefix = f"/users/{user_id}"
|
||||
|
||||
def _default_headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Zotero-API-Key": self._api_key,
|
||||
"Zotero-API-Version": "3",
|
||||
}
|
||||
|
||||
# ── Items ────────────────────────────────────────────────
|
||||
|
||||
def list_items(self, **params) -> list[dict]:
|
||||
return self.get(f"{self._prefix}/items", params=params).json()
|
||||
|
||||
def get_item(self, key: str) -> dict:
|
||||
return self.get(f"{self._prefix}/items/{key}").json()
|
||||
|
||||
def create_item(self, body: dict) -> dict:
|
||||
return self.post(f"{self._prefix}/items", json=body).json()
|
||||
|
||||
def update_item(self, key: str, body: dict) -> dict:
|
||||
return self.put(f"{self._prefix}/items/{key}", json=body).json()
|
||||
|
||||
def delete_item(self, key: str, *, last_version: int) -> httpx.Response:
|
||||
return self._request(
|
||||
"DELETE",
|
||||
f"{self._prefix}/items/{key}",
|
||||
headers={"If-Unmodified-Since-Version": str(last_version)},
|
||||
)
|
||||
|
||||
# ── Collections ──────────────────────────────────────────
|
||||
|
||||
def list_collections(self, **params) -> list[dict]:
|
||||
return self.get(f"{self._prefix}/collections", params=params).json()
|
||||
|
||||
def get_collection(self, key: str) -> dict:
|
||||
return self.get(f"{self._prefix}/collections/{key}").json()
|
||||
|
||||
def collection_items(self, key: str, **params) -> list[dict]:
|
||||
return self.get(
|
||||
f"{self._prefix}/collections/{key}/items",
|
||||
params=params,
|
||||
).json()
|
||||
|
||||
# ── Tags ─────────────────────────────────────────────────
|
||||
|
||||
def list_tags(self, **params) -> list[dict]:
|
||||
return self.get(f"{self._prefix}/tags", params=params).json()
|
||||
|
||||
# ── Search ───────────────────────────────────────────────
|
||||
|
||||
def search(self, query: str, **params) -> list[dict]:
|
||||
params["q"] = query
|
||||
return self.get(f"{self._prefix}/items", params=params).json()
|
||||
0
tests/api/__init__.py
Normal file
0
tests/api/__init__.py
Normal file
51
tests/api/conftest.py
Normal file
51
tests/api/conftest.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Shared fixtures for API client tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def json_response():
|
||||
"""Factory for httpx.MockTransport handlers returning JSON."""
|
||||
|
||||
def _factory(body: object = None, status: int = 200):
|
||||
payload = json.dumps(body or {}).encode()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status,
|
||||
content=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
return _factory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def capture_transport():
|
||||
"""Transport that records the request and returns a JSON 200."""
|
||||
|
||||
class Capture:
|
||||
def __init__(self):
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
def transport(self, body: object = None, status: int = 200):
|
||||
payload = json.dumps(body or {}).encode()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
return httpx.Response(
|
||||
status,
|
||||
content=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
return Capture()
|
||||
193
tests/api/test_base.py
Normal file
193
tests/api/test_base.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Tests for the base Client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from api.clients.base import ApiError, Client
|
||||
|
||||
|
||||
def _counting_transport(responses):
|
||||
"""Return a transport that yields pre-defined responses in order."""
|
||||
call_count = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
idx = min(call_count["n"], len(responses) - 1)
|
||||
call_count["n"] += 1
|
||||
resp = responses[idx]
|
||||
if isinstance(resp, Exception):
|
||||
raise resp
|
||||
status, body = resp
|
||||
return httpx.Response(
|
||||
status,
|
||||
content=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
return httpx.MockTransport(handler), call_count
|
||||
|
||||
|
||||
class TestContextManager:
|
||||
def test_enter_exit(self, json_response):
|
||||
transport = json_response()
|
||||
with Client("http://test", _transport=transport) as c:
|
||||
assert isinstance(c, Client)
|
||||
|
||||
|
||||
class TestRetry5xx:
|
||||
def test_retries_on_500_then_succeeds(self):
|
||||
transport, counts = _counting_transport(
|
||||
[
|
||||
(500, {"error": "internal"}),
|
||||
(200, {"ok": True}),
|
||||
]
|
||||
)
|
||||
c = Client(
|
||||
"http://test",
|
||||
retry_interval=0.0,
|
||||
_transport=transport,
|
||||
)
|
||||
resp = c.get("/foo")
|
||||
assert resp.status_code == 200
|
||||
assert counts["n"] == 2
|
||||
|
||||
def test_raises_after_max_retries(self):
|
||||
transport, _ = _counting_transport(
|
||||
[
|
||||
(500, {"error": "fail"}),
|
||||
(500, {"error": "fail"}),
|
||||
(500, {"error": "fail"}),
|
||||
]
|
||||
)
|
||||
c = Client(
|
||||
"http://test",
|
||||
max_retries=3,
|
||||
retry_interval=0.0,
|
||||
_transport=transport,
|
||||
)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
c.get("/foo")
|
||||
|
||||
|
||||
class TestRetry429:
|
||||
def test_retries_on_rate_limit(self):
|
||||
call_count = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return httpx.Response(
|
||||
429,
|
||||
content=b"{}",
|
||||
headers={"Retry-After": "0"},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=b'{"ok": true}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
c = Client(
|
||||
"http://test",
|
||||
_transport=httpx.MockTransport(handler),
|
||||
)
|
||||
resp = c.get("/foo")
|
||||
assert resp.status_code == 200
|
||||
assert call_count["n"] == 2
|
||||
|
||||
|
||||
class TestConnectionError:
|
||||
def test_retries_on_connect_error(self):
|
||||
transport, counts = _counting_transport(
|
||||
[
|
||||
httpx.ConnectError("refused"),
|
||||
(200, {"ok": True}),
|
||||
]
|
||||
)
|
||||
c = Client(
|
||||
"http://test",
|
||||
retry_interval=0.0,
|
||||
_transport=transport,
|
||||
)
|
||||
resp = c.get("/foo")
|
||||
assert resp.status_code == 200
|
||||
assert counts["n"] == 2
|
||||
|
||||
def test_raises_api_error_after_exhaustion(self):
|
||||
transport, _ = _counting_transport(
|
||||
[
|
||||
httpx.ConnectError("refused"),
|
||||
httpx.ConnectError("refused"),
|
||||
httpx.ConnectError("refused"),
|
||||
]
|
||||
)
|
||||
c = Client(
|
||||
"http://test",
|
||||
max_retries=3,
|
||||
retry_interval=0.0,
|
||||
_transport=transport,
|
||||
)
|
||||
with pytest.raises(ApiError, match="failed after 3 attempts"):
|
||||
c.get("/foo")
|
||||
|
||||
|
||||
class TestHeaders:
|
||||
def test_default_headers_merged(self, capture_transport):
|
||||
cap = capture_transport
|
||||
|
||||
class TokenClient(Client):
|
||||
def _default_headers(self):
|
||||
return {"Authorization": "Bearer xyz"}
|
||||
|
||||
c = TokenClient("http://test", _transport=cap.transport())
|
||||
c.get("/foo")
|
||||
assert cap.requests[0].headers["authorization"] == "Bearer xyz"
|
||||
|
||||
def test_extra_headers_override(self, capture_transport):
|
||||
cap = capture_transport
|
||||
|
||||
class TokenClient(Client):
|
||||
def _default_headers(self):
|
||||
return {"Authorization": "Bearer old"}
|
||||
|
||||
c = TokenClient("http://test", _transport=cap.transport())
|
||||
c.get(
|
||||
"/foo",
|
||||
headers={"Authorization": "Bearer new"},
|
||||
)
|
||||
assert cap.requests[0].headers["authorization"] == "Bearer new"
|
||||
|
||||
|
||||
class TestAuth401Refresh:
|
||||
def test_re_authenticates_on_401(self):
|
||||
call_count = {"n": 0}
|
||||
refreshed = {"called": False}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return httpx.Response(401, content=b"unauth")
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=b'{"ok": true}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
class RefreshClient(Client):
|
||||
def _default_headers(self):
|
||||
tok = "new" if refreshed["called"] else "old"
|
||||
return {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
def authenticate(self):
|
||||
refreshed["called"] = True
|
||||
|
||||
c = RefreshClient(
|
||||
"http://test",
|
||||
_transport=httpx.MockTransport(handler),
|
||||
)
|
||||
resp = c.get("/foo")
|
||||
assert resp.status_code == 200
|
||||
assert refreshed["called"]
|
||||
53
tests/api/test_gitea.py
Normal file
53
tests/api/test_gitea.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for GiteaClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.clients.gitea import GiteaClient
|
||||
|
||||
|
||||
class TestGiteaAuth:
|
||||
def test_token_header(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("my-token", _transport=cap.transport())
|
||||
c.list_repos()
|
||||
assert cap.requests[0].headers["authorization"] == "token my-token"
|
||||
|
||||
|
||||
class TestGiteaRoutes:
|
||||
def test_list_repos(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport([]))
|
||||
c.list_repos()
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/v1/repos/search"
|
||||
|
||||
def test_get_repo(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport({}))
|
||||
c.get_repo("owner", "repo")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/v1/repos/owner/repo"
|
||||
|
||||
def test_create_repo(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport({}))
|
||||
c.create_repo({"name": "new"})
|
||||
req = cap.requests[0]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/api/v1/user/repos"
|
||||
|
||||
def test_list_orgs(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport([]))
|
||||
c.list_orgs()
|
||||
assert cap.requests[0].url.path == "/api/v1/user/orgs"
|
||||
|
||||
def test_create_webhook(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport({}))
|
||||
c.create_webhook("o", "r", {"type": "gitea"})
|
||||
req = cap.requests[0]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/api/v1/repos/o/r/hooks"
|
||||
76
tests/api/test_rustfs.py
Normal file
76
tests/api/test_rustfs.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Tests for RustFSClient and RustFSAdmin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.clients.rustfs import RustFSAdmin, RustFSClient
|
||||
|
||||
|
||||
class TestRustFSClientRoutes:
|
||||
def test_list_buckets(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSClient("ak", "sk", _transport=cap.transport())
|
||||
c.list_buckets()
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/"
|
||||
|
||||
def test_create_bucket(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSClient("ak", "sk", _transport=cap.transport())
|
||||
c.create_bucket("my-bucket")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "PUT"
|
||||
assert req.url.path == "/my-bucket"
|
||||
|
||||
def test_put_object(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSClient("ak", "sk", _transport=cap.transport())
|
||||
c.put_object("bkt", "key.txt", b"hello")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "PUT"
|
||||
assert req.url.path == "/bkt/key.txt"
|
||||
|
||||
def test_get_object(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSClient("ak", "sk", _transport=cap.transport())
|
||||
c.get_object("bkt", "file.csv")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/bkt/file.csv"
|
||||
|
||||
def test_delete_object(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSClient("ak", "sk", _transport=cap.transport())
|
||||
c.delete_object("bkt", "old.txt")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "DELETE"
|
||||
assert req.url.path == "/bkt/old.txt"
|
||||
|
||||
|
||||
class TestRustFSAdminRoutes:
|
||||
def test_list_users(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSAdmin("ak", "sk", _transport=cap.transport({}))
|
||||
c.list_users()
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/minio/v2/iam/users"
|
||||
|
||||
def test_add_user(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSAdmin("ak", "sk", _transport=cap.transport({}))
|
||||
c.add_user("new-ak", "new-sk")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/minio/v2/iam/users"
|
||||
|
||||
def test_server_info(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = RustFSAdmin("ak", "sk", _transport=cap.transport({"nodes": 1}))
|
||||
result = c.server_info()
|
||||
assert result == {"nodes": 1}
|
||||
assert req_path(cap) == "/minio/v2/cluster/info"
|
||||
|
||||
|
||||
def req_path(cap):
|
||||
return cap.requests[0].url.path
|
||||
46
tests/api/test_woodpecker.py
Normal file
46
tests/api/test_woodpecker.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Tests for WoodpeckerClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.clients.woodpecker import WoodpeckerClient
|
||||
|
||||
|
||||
class TestWoodpeckerAuth:
|
||||
def test_bearer_header(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("wp-tok", _transport=cap.transport())
|
||||
c.list_repos()
|
||||
assert cap.requests[0].headers["authorization"] == "Bearer wp-tok"
|
||||
|
||||
|
||||
class TestWoodpeckerRoutes:
|
||||
def test_list_repos(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport([]))
|
||||
c.list_repos()
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/repos"
|
||||
|
||||
def test_get_pipeline(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport({}))
|
||||
c.get_pipeline(42, 7)
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/repos/42/pipelines/7"
|
||||
|
||||
def test_create_secret(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport({}))
|
||||
c.create_secret(1, {"name": "s", "value": "v"})
|
||||
req = cap.requests[0]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/api/repos/1/secrets"
|
||||
|
||||
def test_version(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport({"version": "2.0"}))
|
||||
result = c.version()
|
||||
assert result == {"version": "2.0"}
|
||||
assert cap.requests[0].url.path == "/api/version"
|
||||
67
tests/api/test_zotero.py
Normal file
67
tests/api/test_zotero.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Tests for ZoteroClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.clients.zotero import ZoteroClient
|
||||
|
||||
|
||||
class TestZoteroAuth:
|
||||
def test_api_key_and_version_headers(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("zk", "12345", _transport=cap.transport([]))
|
||||
c.list_items()
|
||||
h = cap.requests[0].headers
|
||||
assert h["zotero-api-key"] == "zk"
|
||||
assert h["zotero-api-version"] == "3"
|
||||
|
||||
|
||||
class TestZoteroRoutes:
|
||||
def test_list_items(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("k", "99", _transport=cap.transport([]))
|
||||
c.list_items()
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/users/99/items"
|
||||
|
||||
def test_get_item(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("k", "99", _transport=cap.transport({}))
|
||||
c.get_item("ABC123")
|
||||
assert cap.requests[0].url.path == "/users/99/items/ABC123"
|
||||
|
||||
def test_create_item(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("k", "99", _transport=cap.transport({}))
|
||||
c.create_item({"itemType": "book"})
|
||||
req = cap.requests[0]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/users/99/items"
|
||||
|
||||
def test_list_collections(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("k", "99", _transport=cap.transport([]))
|
||||
c.list_collections()
|
||||
assert cap.requests[0].url.path == "/users/99/collections"
|
||||
|
||||
def test_collection_items(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("k", "99", _transport=cap.transport([]))
|
||||
c.collection_items("COL1")
|
||||
assert cap.requests[0].url.path == "/users/99/collections/COL1/items"
|
||||
|
||||
def test_search(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("k", "99", _transport=cap.transport([]))
|
||||
c.search("machine learning")
|
||||
req = cap.requests[0]
|
||||
assert req.url.path == "/users/99/items"
|
||||
assert "q=machine" in str(req.url)
|
||||
|
||||
def test_delete_item_version_header(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = ZoteroClient("k", "99", _transport=cap.transport())
|
||||
c.delete_item("XYZ", last_version=5)
|
||||
req = cap.requests[0]
|
||||
assert req.method == "DELETE"
|
||||
assert req.headers["if-unmodified-since-version"] == "5"
|
||||
Reference in New Issue
Block a user