flowion 0.2.0__py3-none-any.whl

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.
flowion/client.py ADDED
@@ -0,0 +1,330 @@
1
+ """The Flowion client: builds requests, sends them through a `Transport`, decodes the answers.
2
+
3
+ `Client` implements `flowion._operation.Caller`, the seam the generated code
4
+ (`flowion.generated.api`) calls. It holds configuration only: no connection, no
5
+ session, no token beyond the string (or provider) it was given, no refresh. A
6
+ `401 invalidToken` is an `ApiError` the caller answers by refreshing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping
14
+ from contextlib import asynccontextmanager
15
+ from urllib.parse import quote, urlencode
16
+
17
+ from flowion._json import DecodePathError, JsonValue
18
+ from flowion._operation import OperationSpec
19
+ from flowion._wire import LOGGER, best_effort_json, excerpt_of, parse_json, path_of, request_id_of
20
+ from flowion.clock import Clock, SystemClock
21
+ from flowion.generated.api import Api
22
+ from flowion.generated.contract import CONTRACT_VERSION
23
+ from flowion.errors import ApiError, DecodeError, FlowionError, RateLimitedError
24
+ from flowion.pagination import RawPage, iterate_pages
25
+ from flowion.rate_limit import RateLimit, parse_rate_limit
26
+ from flowion.retry import RetryPolicy, send_with_retry, wait_before_retry
27
+ from flowion.sse import EventStream
28
+ from flowion.transport import Request, Response, StreamResponse, Transport
29
+
30
+ __all__ = ["Client", "TokenProvider"]
31
+
32
+ DEFAULT_BASE_URL = "https://api.flowion.io"
33
+
34
+ # Awaited once per request, so a component's own refresh runs under its own lock.
35
+ TokenProvider = Callable[[], Awaitable[str]]
36
+
37
+ _ACCEPT = {
38
+ "json": "application/json",
39
+ "empty": "application/json",
40
+ "text": "text/csv",
41
+ "stream": "text/event-stream",
42
+ }
43
+ _PATH_PARAMETER = re.compile(r"\{([^}]+)\}")
44
+ # How much of an error body is read from a stream that failed to open.
45
+ _ERROR_BODY_LIMIT = 64 * 1024
46
+
47
+
48
+ class Client(Api):
49
+ """The Flowion API client. Group attributes (`client.charging_stations`, ...) come from the generated tree.
50
+
51
+ ``Client(transport, base_url=..., token=..., retry=..., clock=..., mode=..., api_version=...)``
52
+
53
+ ``token`` is a string or a `TokenProvider` awaited per request. ``mode``
54
+ sends ``X-Flowion-Mode`` (for example ``"sandbox"``); ``api_version``
55
+ sends ``Api-Version`` (default: the contract version this SDK was generated against). ``timeout_seconds`` is the per-request timeout handed
56
+ to the transport (``None`` for the transport's own default); a live-event
57
+ stream has none.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ transport: Transport,
63
+ *,
64
+ base_url: str = DEFAULT_BASE_URL,
65
+ token: str | TokenProvider | None = None,
66
+ retry: RetryPolicy = RetryPolicy(),
67
+ clock: Clock = SystemClock(),
68
+ mode: str | None = None,
69
+ api_version: str | None = None,
70
+ timeout_seconds: float | None = 30.0,
71
+ ) -> None:
72
+ if not base_url.startswith(("https://", "http://")):
73
+ raise ValueError("base_url must be an absolute http(s) URL")
74
+ self._transport = transport
75
+ self._base_url = base_url.rstrip("/")
76
+ self._token = token
77
+ self._retry = retry
78
+ self._clock = clock
79
+ self._mode = mode
80
+ self._api_version = CONTRACT_VERSION if api_version is None else api_version
81
+ self._timeout = timeout_seconds
82
+ self._last_rate_limit: RateLimit | None = None
83
+ super().__init__(self)
84
+
85
+ def __repr__(self) -> str:
86
+ # Deliberately without the token.
87
+ return f"Client(base_url={self._base_url!r})"
88
+
89
+ @property
90
+ def last_rate_limit(self) -> RateLimit | None:
91
+ """The rate-limit headers of the most recent response (success or failure); `None` before any."""
92
+ return self._last_rate_limit
93
+
94
+ # ----- Caller ---------------------------------------------------------
95
+
96
+ async def call(
97
+ self,
98
+ spec: OperationSpec,
99
+ *,
100
+ path: Mapping[str, str] = {},
101
+ query: Mapping[str, JsonValue] = {},
102
+ body: JsonValue = None,
103
+ headers: Mapping[str, str] = {},
104
+ ) -> object:
105
+ if spec.response == "stream":
106
+ raise ValueError(f"{spec.operation_id} is a stream: use stream()")
107
+ response = await self._exchange(spec, path, query, body, headers)
108
+ return _decode(spec, response)
109
+
110
+ def paginate(
111
+ self,
112
+ spec: OperationSpec,
113
+ *,
114
+ path: Mapping[str, str] = {},
115
+ query: Mapping[str, JsonValue] = {},
116
+ headers: Mapping[str, str] = {},
117
+ ) -> AsyncIterator[object]:
118
+ decode_item = spec.paginated_item
119
+ if decode_item is None:
120
+ raise ValueError(f"{spec.operation_id} is not a cursor-paginated operation")
121
+
122
+ async def fetch(cursor: str | None) -> RawPage:
123
+ page_query = dict(query) if cursor is None else {**query, "cursor": cursor}
124
+ response = await self._exchange(spec, path, page_query, None, headers)
125
+ try:
126
+ value = parse_json(response.body)
127
+ except ValueError:
128
+ raise DecodeError(
129
+ "the response was not JSON",
130
+ status=response.status,
131
+ request_id=request_id_of(response.headers, None),
132
+ excerpt=excerpt_of(response.body),
133
+ ) from None
134
+ return RawPage(value, response.status, request_id_of(response.headers, value))
135
+
136
+ return iterate_pages(fetch, decode_item)
137
+
138
+ def stream(
139
+ self,
140
+ spec: OperationSpec,
141
+ *,
142
+ path: Mapping[str, str] = {},
143
+ query: Mapping[str, JsonValue] = {},
144
+ body: JsonValue = None,
145
+ headers: Mapping[str, str] = {},
146
+ ) -> AsyncIterator[object]:
147
+ return EventStream(lambda: self._open_stream(spec, path, query, body, headers), spec.decode)
148
+
149
+ # ----- requests -------------------------------------------------------
150
+
151
+ async def _request(
152
+ self,
153
+ spec: OperationSpec,
154
+ path: Mapping[str, str],
155
+ query: Mapping[str, JsonValue],
156
+ body: JsonValue,
157
+ extra: Mapping[str, str],
158
+ ) -> Request:
159
+ headers: dict[str, str] = {"Accept": _ACCEPT[spec.response]}
160
+ headers["Api-Version"] = self._api_version
161
+ token = await self._token_value()
162
+ if token:
163
+ headers["Authorization"] = f"Bearer {token}"
164
+ if self._mode is not None:
165
+ headers["X-Flowion-Mode"] = self._mode
166
+ data: bytes | None = None
167
+ if spec.request_body == "json" and body is not None:
168
+ headers["Content-Type"] = "application/json"
169
+ data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
170
+ for name, value in extra.items():
171
+ for existing in [h for h in headers if h.lower() == name.lower()]:
172
+ del headers[existing]
173
+ headers[name] = value
174
+ return Request(
175
+ method=spec.method.upper(),
176
+ url=self._url(spec, path, query),
177
+ headers=headers,
178
+ body=data,
179
+ timeout=None if spec.response == "stream" else self._timeout,
180
+ )
181
+
182
+ async def _token_value(self) -> str | None:
183
+ token = self._token
184
+ if token is None or isinstance(token, str):
185
+ return token
186
+ return await token()
187
+
188
+ def _url(self, spec: OperationSpec, path: Mapping[str, str], query: Mapping[str, JsonValue]) -> str:
189
+ def fill(match: re.Match[str]) -> str:
190
+ name = match.group(1)
191
+ if name not in path:
192
+ raise ValueError(f"missing path parameter {name} for {spec.operation_id}")
193
+ return quote(path[name], safe="")
194
+
195
+ url = self._base_url + _PATH_PARAMETER.sub(fill, spec.path)
196
+ pairs = _query_pairs(query)
197
+ return f"{url}?{urlencode(pairs, quote_via=quote)}" if pairs else url
198
+
199
+ async def _exchange(
200
+ self,
201
+ spec: OperationSpec,
202
+ path: Mapping[str, str],
203
+ query: Mapping[str, JsonValue],
204
+ body: JsonValue,
205
+ headers: Mapping[str, str],
206
+ ) -> Response:
207
+ """Sends one operation with the shared 429 retry; returns a 2xx response or raises."""
208
+ request = await self._request(spec, path, query, body, headers)
209
+ try:
210
+ response = await send_with_retry(self._transport, request, retry=self._retry, clock=self._clock)
211
+ except RateLimitedError as error:
212
+ self._last_rate_limit = error.rate_limit
213
+ raise
214
+ self._last_rate_limit = parse_rate_limit(response.headers)
215
+ if not 200 <= response.status < 300:
216
+ raise failure_for(response.status, response.headers, response.body)
217
+ return response
218
+
219
+ @asynccontextmanager
220
+ async def _open_stream(
221
+ self,
222
+ spec: OperationSpec,
223
+ path: Mapping[str, str],
224
+ query: Mapping[str, JsonValue],
225
+ body: JsonValue,
226
+ headers: Mapping[str, str],
227
+ ) -> AsyncGenerator[StreamResponse, None]:
228
+ """Opens the stream, retrying a 429 like any request; yields a 2xx response."""
229
+ request = await self._request(spec, path, query, body, headers)
230
+ route = path_of(request.url)
231
+ attempt = 1
232
+ while True:
233
+ async with self._transport.stream(request) as response:
234
+ self._last_rate_limit = parse_rate_limit(response.headers)
235
+ if 200 <= response.status < 300:
236
+ LOGGER.debug("%s %s -> %d (stream)", request.method, route, response.status)
237
+ yield response
238
+ return
239
+ failure = failure_for(response.status, response.headers, await _drain(response.chunks))
240
+ LOGGER.debug(
241
+ "%s %s -> %d (request id %s, attempt %d)",
242
+ request.method,
243
+ route,
244
+ response.status,
245
+ getattr(failure, "request_id", None),
246
+ attempt,
247
+ )
248
+ wait: float | None = None
249
+ if isinstance(failure, RateLimitedError):
250
+ wait = wait_before_retry(self._retry, attempt, failure.rate_limit.retry_after_seconds)
251
+ if wait is None:
252
+ raise failure
253
+ assert wait is not None # only a retryable 429 leaves the stream context without raising or returning
254
+ await self._clock.sleep(wait)
255
+ attempt += 1
256
+
257
+
258
+ def failure_for(status: int, headers: Mapping[str, str], body: bytes) -> FlowionError:
259
+ """The error for a non-2xx answer: 429 is rate limited, an error body is an API error, else undecodable."""
260
+ value = best_effort_json(body)
261
+ request_id = request_id_of(headers, value)
262
+ if status == 429:
263
+ return RateLimitedError(value, rate_limit=parse_rate_limit(headers), request_id=request_id)
264
+ if isinstance(value, dict):
265
+ code = value.get("error")
266
+ if isinstance(code, str):
267
+ return ApiError(status, code, value, request_id=request_id)
268
+ return DecodeError(
269
+ "the response carried no error body", status=status, request_id=request_id, excerpt=excerpt_of(body)
270
+ )
271
+
272
+
273
+ def _decode(spec: OperationSpec, response: Response) -> object:
274
+ request_id = request_id_of(response.headers, None)
275
+ status = response.status
276
+ if spec.response == "empty":
277
+ return None
278
+ if spec.response == "text":
279
+ try:
280
+ return response.body.decode("utf-8")
281
+ except UnicodeDecodeError:
282
+ raise DecodeError("the response was not UTF-8 text", status=status, request_id=request_id) from None
283
+ if not response.body:
284
+ if status == 204:
285
+ return None
286
+ raise DecodeError("the response body was empty", status=status, request_id=request_id)
287
+ try:
288
+ value = parse_json(response.body)
289
+ except ValueError:
290
+ raise DecodeError(
291
+ "the response was not JSON", status=status, request_id=request_id, excerpt=excerpt_of(response.body)
292
+ ) from None
293
+ if spec.decode is None:
294
+ return value
295
+ try:
296
+ return spec.decode(value)
297
+ except DecodePathError as error:
298
+ raise DecodeError(
299
+ f"the response did not match the contract at {error}", status=status, request_id=request_id
300
+ ) from None
301
+ except ValueError as error:
302
+ raise DecodeError(f"the response could not be decoded: {error}", status=status, request_id=request_id) from None
303
+
304
+
305
+ def _query_pairs(query: Mapping[str, JsonValue]) -> list[tuple[str, str]]:
306
+ pairs: list[tuple[str, str]] = []
307
+ for name, value in query.items():
308
+ for item in value if isinstance(value, list) else [value]:
309
+ if item is not None:
310
+ pairs.append((name, _query_text(item)))
311
+ return pairs
312
+
313
+
314
+ def _query_text(value: JsonValue) -> str:
315
+ if isinstance(value, bool):
316
+ return "true" if value else "false"
317
+ if isinstance(value, str):
318
+ return value
319
+ if isinstance(value, (int, float)):
320
+ return repr(value)
321
+ return json.dumps(value, separators=(",", ":"))
322
+
323
+
324
+ async def _drain(chunks: AsyncIterator[bytes]) -> bytes:
325
+ data = b""
326
+ async for chunk in chunks:
327
+ data += chunk
328
+ if len(data) >= _ERROR_BODY_LIMIT:
329
+ break
330
+ return data
flowion/clock.py ADDED
@@ -0,0 +1,30 @@
1
+ """The clock the SDK reads and waits on, injectable so tests never sleep (R8)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import UTC, datetime
7
+ from typing import Protocol
8
+
9
+ __all__ = ["Clock", "SystemClock"]
10
+
11
+
12
+ class Clock(Protocol):
13
+ def now(self) -> datetime:
14
+ """The current time, timezone-aware (UTC)."""
15
+ ...
16
+
17
+ async def sleep(self, seconds: float) -> None: ...
18
+
19
+
20
+ class SystemClock:
21
+ """The real clock: `datetime.now(UTC)` and `asyncio.sleep`."""
22
+
23
+ def now(self) -> datetime:
24
+ return datetime.now(UTC)
25
+
26
+ async def sleep(self, seconds: float) -> None:
27
+ await asyncio.sleep(seconds)
28
+
29
+ def __repr__(self) -> str:
30
+ return "SystemClock()"
flowion/errors.py ADDED
@@ -0,0 +1,158 @@
1
+ """The error hierarchy: transport, rate limit, API error, undecodable response, OAuth.
2
+
3
+ FlowionError
4
+ TransportError (flowion.transport) no response arrived
5
+ RateLimitedError a 429 that was not, or could no longer be, retried
6
+ ApiError a non-2xx answer carrying the platform's error body
7
+ DecodeError a body the SDK cannot decode (status, request id, bounded excerpt)
8
+ OAuthError the RFC 6749 failures of the token endpoint, and the redirect checks
9
+ AccessDenied, StateMismatch, InvalidGrant, InvalidClient, InvalidRequest
10
+
11
+ No message, `repr` or attribute here ever holds a token, code, verifier or an
12
+ `Authorization` header: callers hand these classes only what the platform sent
13
+ in an error body, and a response body is never copied in unbounded.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from flowion._json import JsonValue
19
+ from flowion.rate_limit import RateLimit
20
+
21
+ __all__ = [
22
+ "AccessDenied",
23
+ "ApiError",
24
+ "DecodeError",
25
+ "FlowionError",
26
+ "InvalidClient",
27
+ "InvalidGrant",
28
+ "InvalidRequest",
29
+ "OAuthError",
30
+ "RateLimitedError",
31
+ "StateMismatch",
32
+ ]
33
+
34
+ # How much of a body an error carries (spec Edge Cases: a bounded excerpt).
35
+ EXCERPT_LIMIT = 512
36
+
37
+
38
+ class FlowionError(Exception):
39
+ """Base of every error the SDK raises on purpose."""
40
+
41
+
42
+ def _message_of(body: JsonValue, fallback: str) -> str:
43
+ if isinstance(body, dict):
44
+ message = body.get("message")
45
+ if isinstance(message, str):
46
+ return message[:200]
47
+ return fallback
48
+
49
+
50
+ class RateLimitedError(FlowionError):
51
+ """A 429 that was not (or could no longer be) retried."""
52
+
53
+ status: int = 429
54
+
55
+ def __init__(
56
+ self,
57
+ body: JsonValue,
58
+ *,
59
+ rate_limit: RateLimit,
60
+ request_id: str | None = None,
61
+ ) -> None:
62
+ super().__init__(f"429: {_message_of(body, 'too many requests')}")
63
+ self.body = body
64
+ self.rate_limit = rate_limit
65
+ self.request_id = request_id
66
+
67
+ @property
68
+ def retry_after_seconds(self) -> int | None:
69
+ """`Retry-After` in seconds; `None` when the header was absent or unparsable."""
70
+ return self.rate_limit.retry_after_seconds
71
+
72
+
73
+ class ApiError(FlowionError):
74
+ """A non-2xx answer carrying the platform's error body.
75
+
76
+ `code` is the stable string the platform sent, verbatim, even one this SDK
77
+ does not know. `invalidToken` (a 401) is this error: the client does not
78
+ refresh and does not retry it; the caller does.
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ status: int,
84
+ code: str,
85
+ body: JsonValue,
86
+ *,
87
+ request_id: str | None = None,
88
+ ) -> None:
89
+ super().__init__(f"{status} {code}: {_message_of(body, f'request failed with status {status}')}")
90
+ self.status = status
91
+ self.code = code
92
+ self.body = body
93
+ self.request_id = request_id
94
+
95
+
96
+ class DecodeError(FlowionError):
97
+ """A response the SDK cannot decode: HTML from a proxy, truncated JSON, a wrong shape.
98
+
99
+ `excerpt` is the start of the body, at most 512 characters; empty where the
100
+ body might hold a secret (the OAuth token endpoint).
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ message: str,
106
+ *,
107
+ status: int | None = None,
108
+ request_id: str | None = None,
109
+ excerpt: str = "",
110
+ ) -> None:
111
+ bounded = excerpt[:EXCERPT_LIMIT]
112
+ suffix = f" (body starts: {bounded!r})" if bounded else ""
113
+ super().__init__(f"{message}{f' [status {status}]' if status is not None else ''}{suffix}")
114
+ self.status = status
115
+ self.request_id = request_id
116
+ self.excerpt = bounded
117
+
118
+
119
+ class OAuthError(FlowionError):
120
+ """A failure of the OAuth grant. `code` is the RFC 6749 error string the platform sent."""
121
+
122
+ def __init__(
123
+ self,
124
+ code: str,
125
+ *,
126
+ description: str | None = None,
127
+ status: int | None = None,
128
+ request_id: str | None = None,
129
+ ) -> None:
130
+ text = f"oauth error {code}" + (f": {description[:200]}" if description else "")
131
+ super().__init__(text)
132
+ self.code = code
133
+ self.description = description
134
+ self.status = status
135
+ self.request_id = request_id
136
+
137
+
138
+ class AccessDenied(OAuthError):
139
+ """The user (or the platform) declined the grant: `error=access_denied` on the redirect."""
140
+
141
+
142
+ class StateMismatch(OAuthError):
143
+ """The redirect's `state` is not the one this flow started with: stop, send nothing."""
144
+
145
+ def __init__(self) -> None:
146
+ super().__init__("state_mismatch", description="the redirect's state does not match the one issued")
147
+
148
+
149
+ class InvalidGrant(OAuthError):
150
+ """The grant is over (replayed, expired or revoked): authorize again."""
151
+
152
+
153
+ class InvalidClient(OAuthError):
154
+ """The platform does not accept this `client_id`."""
155
+
156
+
157
+ class InvalidRequest(OAuthError):
158
+ """The request or the redirect was malformed (for a redirect: no `code`)."""
@@ -0,0 +1,8 @@
1
+ """The generated core of the SDK: emitted by ``flowion-codegen py``, never hand-edited.
2
+
3
+ ``types`` holds the models, enums and open unions, ``operations`` the
4
+ ``OPERATIONS`` table, ``api`` one class per resource group and the ``Api``
5
+ aggregate, and ``contract`` the contract version and the list of open types.
6
+ Regenerate with ``bazelisk build //sdks/codegen:py_client`` and copy
7
+ ``bazel-bin/sdks/codegen/py/*.py`` here.
8
+ """