brokkr-client 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: brokkr-client
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Ergonomic Python wrapper around the auto-generated brokkr-client-generated low-level client
|
|
5
|
+
Requires-Dist: brokkr-client-generated
|
|
6
|
+
Requires-Dist: httpx>=0.23.0,<0.29.0
|
|
7
|
+
Requires-Dist: pytest>=8 ; extra == 'test'
|
|
8
|
+
Requires-Dist: pytest-asyncio>=0.23 ; extra == 'test'
|
|
9
|
+
Requires-Dist: respx>=0.21 ; extra == 'test'
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# brokkr-client
|
|
15
|
+
|
|
16
|
+
Ergonomic Python client for the Brokkr broker API.
|
|
17
|
+
|
|
18
|
+
The import name remains `brokkr` (`from brokkr import BrokkrClient`); the
|
|
19
|
+
PyPI distribution name is `brokkr-client`.
|
|
20
|
+
|
|
21
|
+
This is a thin wrapper around the auto-generated `brokkr-client-generated`
|
|
22
|
+
package (produced by `openapi-python-client` from the broker's OpenAPI
|
|
23
|
+
spec). The wrapper adds:
|
|
24
|
+
|
|
25
|
+
- A single-credential constructor that injects the `Authorization` header
|
|
26
|
+
on every request. The three security schemes the spec declares
|
|
27
|
+
(`admin_pak` / `agent_pak` / `generator_pak`) all map to the same header
|
|
28
|
+
and the broker disambiguates at runtime — the wrapper hides that detail.
|
|
29
|
+
- `BrokkrError`, a single exception type that wraps the generated typed
|
|
30
|
+
`ErrorResponse` and exposes `.code` for stable pattern-matching.
|
|
31
|
+
- An opt-in `retry(...)` helper with exponential backoff for transient
|
|
32
|
+
transport / 5xx failures. Retry is per-call so callers decide which
|
|
33
|
+
operations (typically idempotent GETs) are safe.
|
|
34
|
+
|
|
35
|
+
Pagination iterators are intentionally absent: the v1 broker API returns
|
|
36
|
+
full collections without cursor tokens. `Stream`-style adapters belong
|
|
37
|
+
here when the API adds pagination.
|
|
38
|
+
|
|
39
|
+
The wrapper is intentionally small. Most of the surface is the generated
|
|
40
|
+
client; reach for it via `client.api` when the wrapper doesn't cover what
|
|
41
|
+
you need.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# brokkr-client
|
|
2
|
+
|
|
3
|
+
Ergonomic Python client for the Brokkr broker API.
|
|
4
|
+
|
|
5
|
+
The import name remains `brokkr` (`from brokkr import BrokkrClient`); the
|
|
6
|
+
PyPI distribution name is `brokkr-client`.
|
|
7
|
+
|
|
8
|
+
This is a thin wrapper around the auto-generated `brokkr-client-generated`
|
|
9
|
+
package (produced by `openapi-python-client` from the broker's OpenAPI
|
|
10
|
+
spec). The wrapper adds:
|
|
11
|
+
|
|
12
|
+
- A single-credential constructor that injects the `Authorization` header
|
|
13
|
+
on every request. The three security schemes the spec declares
|
|
14
|
+
(`admin_pak` / `agent_pak` / `generator_pak`) all map to the same header
|
|
15
|
+
and the broker disambiguates at runtime — the wrapper hides that detail.
|
|
16
|
+
- `BrokkrError`, a single exception type that wraps the generated typed
|
|
17
|
+
`ErrorResponse` and exposes `.code` for stable pattern-matching.
|
|
18
|
+
- An opt-in `retry(...)` helper with exponential backoff for transient
|
|
19
|
+
transport / 5xx failures. Retry is per-call so callers decide which
|
|
20
|
+
operations (typically idempotent GETs) are safe.
|
|
21
|
+
|
|
22
|
+
Pagination iterators are intentionally absent: the v1 broker API returns
|
|
23
|
+
full collections without cursor tokens. `Stream`-style adapters belong
|
|
24
|
+
here when the API adds pagination.
|
|
25
|
+
|
|
26
|
+
The wrapper is intentionally small. Most of the surface is the generated
|
|
27
|
+
client; reach for it via `client.api` when the wrapper doesn't cover what
|
|
28
|
+
you need.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Ergonomic Python wrapper around brokkr-broker-client."""
|
|
2
|
+
|
|
3
|
+
from brokkr.client import BrokkrClient
|
|
4
|
+
from brokkr.errors import BrokkrError
|
|
5
|
+
|
|
6
|
+
# Re-export the typed ErrorResponse model so consumers don't need to dig into
|
|
7
|
+
# the generated package layout.
|
|
8
|
+
from brokkr_broker_client.models import ErrorResponse
|
|
9
|
+
|
|
10
|
+
# The generated `Generator` model clashes with `typing.Generator` and produces
|
|
11
|
+
# mypy false positives when imported into PEP 604 unions. Re-export under a
|
|
12
|
+
# clearer name; the original is still reachable as
|
|
13
|
+
# `brokkr_broker_client.models.Generator`.
|
|
14
|
+
from brokkr_broker_client.models import Generator as TemplateGenerator
|
|
15
|
+
|
|
16
|
+
__all__ = ["BrokkrClient", "BrokkrError", "ErrorResponse", "TemplateGenerator"]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""High-level Brokkr client wrapping the generated low-level client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any, Awaitable, Callable, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from brokkr_broker_client import AuthenticatedClient, Client
|
|
11
|
+
from brokkr_broker_client.models import ErrorResponse
|
|
12
|
+
|
|
13
|
+
from brokkr.errors import BrokkrError
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
# Default timeouts mirror the Rust wrapper.
|
|
18
|
+
_DEFAULT_REQUEST_TIMEOUT = 30.0 # seconds
|
|
19
|
+
_DEFAULT_CONNECT_TIMEOUT = 10.0 # seconds
|
|
20
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
21
|
+
_DEFAULT_INITIAL_BACKOFF = 0.2 # seconds
|
|
22
|
+
_MAX_BACKOFF = 10.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BrokkrClient:
|
|
26
|
+
"""Ergonomic Brokkr broker client.
|
|
27
|
+
|
|
28
|
+
Construct with a base URL and (optionally) a PAK token. The wrapper
|
|
29
|
+
holds the generated `AuthenticatedClient` (or `Client`, when no token
|
|
30
|
+
is supplied) plus a retry policy. Access the generated API surface via
|
|
31
|
+
`client.api`; reach for the raw httpx session via `client.api.get_httpx_client()`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
*,
|
|
38
|
+
token: str | None = None,
|
|
39
|
+
request_timeout: float = _DEFAULT_REQUEST_TIMEOUT,
|
|
40
|
+
connect_timeout: float = _DEFAULT_CONNECT_TIMEOUT,
|
|
41
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
42
|
+
initial_backoff: float = _DEFAULT_INITIAL_BACKOFF,
|
|
43
|
+
) -> None:
|
|
44
|
+
if max_retries < 0:
|
|
45
|
+
raise ValueError("max_retries must be >= 0")
|
|
46
|
+
if initial_backoff <= 0:
|
|
47
|
+
raise ValueError("initial_backoff must be > 0")
|
|
48
|
+
|
|
49
|
+
timeout = httpx.Timeout(request_timeout, connect=connect_timeout)
|
|
50
|
+
self._max_retries = max_retries
|
|
51
|
+
self._initial_backoff = initial_backoff
|
|
52
|
+
|
|
53
|
+
if token is not None:
|
|
54
|
+
self.api: AuthenticatedClient = AuthenticatedClient(
|
|
55
|
+
base_url=base_url,
|
|
56
|
+
token=token,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
# mypy: AuthenticatedClient and Client share most of their surface
|
|
61
|
+
# but are technically distinct types. Users who construct without
|
|
62
|
+
# a token can only call endpoints that don't require auth.
|
|
63
|
+
self.api = Client(base_url=base_url, timeout=timeout) # type: ignore[assignment]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def max_retries(self) -> int:
|
|
67
|
+
return self._max_retries
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def initial_backoff(self) -> float:
|
|
71
|
+
return self._initial_backoff
|
|
72
|
+
|
|
73
|
+
async def retry(self, op: Callable[[Any], Awaitable[T]]) -> T:
|
|
74
|
+
"""Run ``op(client)`` with exponential backoff on retryable failures.
|
|
75
|
+
|
|
76
|
+
``op`` is an async callable that takes the generated client
|
|
77
|
+
(``self.api``) and returns the operation's result. The closure form
|
|
78
|
+
keeps the wrapper free of per-endpoint glue while letting callers
|
|
79
|
+
decide which operations are safe to retry. Non-idempotent POSTs
|
|
80
|
+
should generally **not** be wrapped.
|
|
81
|
+
|
|
82
|
+
Retry classification matches the Rust wrapper: transport errors
|
|
83
|
+
and HTTP 408/429/502/503/504 qualify. Other 4xx/5xx responses
|
|
84
|
+
return immediately on the first failure.
|
|
85
|
+
"""
|
|
86
|
+
attempt = 0
|
|
87
|
+
while True:
|
|
88
|
+
try:
|
|
89
|
+
result = await op(self.api)
|
|
90
|
+
except httpx.HTTPError as exc:
|
|
91
|
+
err = BrokkrError.from_transport(exc)
|
|
92
|
+
if not err.is_retryable() or attempt >= self._max_retries:
|
|
93
|
+
raise err from exc
|
|
94
|
+
else:
|
|
95
|
+
if isinstance(result, ErrorResponse):
|
|
96
|
+
# The generator folds documented errors into return
|
|
97
|
+
# unions; we surface them as raises here. We don't know
|
|
98
|
+
# the status code in this codepath — the *_detailed
|
|
99
|
+
# variants carry it. Callers wanting status-aware retry
|
|
100
|
+
# should use those and convert manually.
|
|
101
|
+
err = BrokkrError.from_response(result, status=500)
|
|
102
|
+
if not err.is_retryable() or attempt >= self._max_retries:
|
|
103
|
+
raise err
|
|
104
|
+
else:
|
|
105
|
+
return result
|
|
106
|
+
|
|
107
|
+
backoff = min(self._initial_backoff * (2**attempt), _MAX_BACKOFF)
|
|
108
|
+
await asyncio.sleep(backoff)
|
|
109
|
+
attempt += 1
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Typed error wrapper for the Brokkr SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from brokkr_broker_client.models import ErrorResponse
|
|
9
|
+
|
|
10
|
+
# HTTP status codes worth retrying — transient failures only. Mirrors the
|
|
11
|
+
# Rust wrapper's `is_retryable_status` set.
|
|
12
|
+
_RETRYABLE_STATUSES: frozenset[int] = frozenset({408, 429, 502, 503, 504})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BrokkrError(Exception):
|
|
17
|
+
"""Single exception type surfaced by the wrapper.
|
|
18
|
+
|
|
19
|
+
Carries the typed `ErrorResponse` body when the broker returned a
|
|
20
|
+
documented 4xx/5xx, and a status code when one is known. Callers
|
|
21
|
+
pattern-match on `code` (machine-readable, stable across versions)
|
|
22
|
+
rather than `message` (human-readable, not stable).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
message: str
|
|
26
|
+
code: Optional[str] = None
|
|
27
|
+
status: Optional[int] = None
|
|
28
|
+
response: Optional[ErrorResponse] = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
super().__init__(self.message)
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str: # pragma: no cover - dataclass repr is fine
|
|
34
|
+
if self.status is not None and self.code is not None:
|
|
35
|
+
return f"{self.status} {self.code}: {self.message}"
|
|
36
|
+
if self.code is not None:
|
|
37
|
+
return f"{self.code}: {self.message}"
|
|
38
|
+
return self.message
|
|
39
|
+
|
|
40
|
+
def is_retryable(self) -> bool:
|
|
41
|
+
"""Whether this error qualifies for the wrapper's retry helper."""
|
|
42
|
+
if self.status is None:
|
|
43
|
+
# Transport / unknown errors are retryable by default.
|
|
44
|
+
return True
|
|
45
|
+
return self.status in _RETRYABLE_STATUSES
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_response(cls, body: ErrorResponse, status: int) -> "BrokkrError":
|
|
49
|
+
"""Build from a generated `ErrorResponse` returned in an operation
|
|
50
|
+
union (rather than raised). The generator folds documented errors
|
|
51
|
+
into the return type union; the wrapper converts them to raises."""
|
|
52
|
+
return cls(
|
|
53
|
+
message=body.message,
|
|
54
|
+
code=body.code,
|
|
55
|
+
status=status,
|
|
56
|
+
response=body,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_transport(cls, exc: BaseException) -> "BrokkrError":
|
|
61
|
+
"""Wrap an httpx / network exception."""
|
|
62
|
+
return cls(message=str(exc), code=None, status=None, response=None)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "brokkr-client"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Ergonomic Python wrapper around the auto-generated brokkr-client-generated low-level client"
|
|
5
|
+
authors = []
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
dependencies = [
|
|
9
|
+
# Low-level generated client. Local path dep in-tree; resolved from PyPI
|
|
10
|
+
# for installed releases.
|
|
11
|
+
"brokkr-client-generated",
|
|
12
|
+
"httpx>=0.23.0,<0.29.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = [
|
|
17
|
+
"pytest>=8",
|
|
18
|
+
"pytest-asyncio>=0.23",
|
|
19
|
+
"respx>=0.21",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[tool.uv.sources]
|
|
23
|
+
brokkr-client-generated = { path = "../brokkr-client" }
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-name = "brokkr"
|
|
27
|
+
module-root = ""
|
|
28
|
+
|
|
29
|
+
[build-system]
|
|
30
|
+
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
31
|
+
build-backend = "uv_build"
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|