Initial commit: add Gitea PyPI publish workflow

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

View File

@@ -0,0 +1,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)