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/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """The Flowion API client for Python.
2
+
3
+ Async-first, standard library only. Bring your own HTTP library through the
4
+ `Transport` protocol, or use the `aiohttp` adapter (`pip install "flowion[aiohttp]"`,
5
+ `flowion.aiohttp_transport.AiohttpTransport`) over a session you already have.
6
+ The public-client OAuth helpers live in `flowion.oauth`.
7
+ """
8
+
9
+ from flowion.client import Client, TokenProvider
10
+ from flowion.clock import Clock, SystemClock
11
+ from flowion.errors import (
12
+ AccessDenied,
13
+ ApiError,
14
+ DecodeError,
15
+ FlowionError,
16
+ InvalidClient,
17
+ InvalidGrant,
18
+ InvalidRequest,
19
+ OAuthError,
20
+ RateLimitedError,
21
+ StateMismatch,
22
+ )
23
+ from flowion.oauth import AuthorizationRequest, AuthorizationResult, OAuthClient, TokenSet
24
+ from flowion.rate_limit import RateLimit
25
+ from flowion.retry import RetryPolicy
26
+ from flowion.sse import EventStream
27
+ from flowion.transport import Request, Response, StreamResponse, Transport, TransportError
28
+
29
+ __all__ = [
30
+ "AccessDenied",
31
+ "ApiError",
32
+ "AuthorizationRequest",
33
+ "AuthorizationResult",
34
+ "Client",
35
+ "Clock",
36
+ "DecodeError",
37
+ "EventStream",
38
+ "FlowionError",
39
+ "InvalidClient",
40
+ "InvalidGrant",
41
+ "InvalidRequest",
42
+ "OAuthClient",
43
+ "OAuthError",
44
+ "RateLimit",
45
+ "RateLimitedError",
46
+ "Request",
47
+ "Response",
48
+ "RetryPolicy",
49
+ "StateMismatch",
50
+ "StreamResponse",
51
+ "SystemClock",
52
+ "TokenProvider",
53
+ "TokenSet",
54
+ "Transport",
55
+ "TransportError",
56
+ ]
flowion/_json.py ADDED
@@ -0,0 +1,145 @@
1
+ """The JSON value model and the strict readers generated code decodes with.
2
+
3
+ This module is one half of the seam between ``flowion.generated`` (emitted,
4
+ never hand-edited) and the runtime; its shape is fixed by
5
+ ``specs/167-python-sdk/contracts/generated-runtime-interface.md``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date, datetime
11
+ from typing import Final
12
+
13
+ __all__ = [
14
+ "UNSET",
15
+ "DecodePathError",
16
+ "JsonValue",
17
+ "Unset",
18
+ "expect_bool",
19
+ "expect_date",
20
+ "expect_datetime",
21
+ "expect_int",
22
+ "expect_list",
23
+ "expect_number",
24
+ "expect_object",
25
+ "expect_str",
26
+ ]
27
+
28
+ JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"]
29
+
30
+
31
+ class Unset:
32
+ """The marker for "leave this field unchanged" in a patch (``None`` clears it)."""
33
+
34
+ __slots__ = ()
35
+ _instance: Unset | None = None
36
+
37
+ def __new__(cls) -> Unset:
38
+ if cls._instance is None:
39
+ cls._instance = super().__new__(cls)
40
+ return cls._instance
41
+
42
+ def __repr__(self) -> str:
43
+ return "UNSET"
44
+
45
+ def __bool__(self) -> bool:
46
+ return False
47
+
48
+ def __copy__(self) -> Unset:
49
+ return self
50
+
51
+ def __deepcopy__(self, memo: object) -> Unset:
52
+ return self
53
+
54
+
55
+ UNSET: Final[Unset] = Unset()
56
+
57
+
58
+ class DecodePathError(ValueError):
59
+ """A value did not have the shape the contract declares.
60
+
61
+ ``path`` locates it inside the response body, for example ``data[3].state``
62
+ (empty for the body itself). The client turns this into the public
63
+ ``DecodeError``.
64
+ """
65
+
66
+ path: str
67
+
68
+ def __init__(self, path: str, message: str) -> None:
69
+ super().__init__(f"{path or '<body>'}: {message}")
70
+ self.path = path
71
+ self.message = message
72
+
73
+
74
+ def _describe(value: JsonValue) -> str:
75
+ if value is None:
76
+ return "null"
77
+ if isinstance(value, bool):
78
+ return "a boolean"
79
+ if isinstance(value, int):
80
+ return "an integer"
81
+ if isinstance(value, float):
82
+ return "a number"
83
+ if isinstance(value, str):
84
+ return "a string"
85
+ if isinstance(value, list):
86
+ return "an array"
87
+ return "an object"
88
+
89
+
90
+ def expect_object(value: JsonValue, path: str) -> dict[str, JsonValue]:
91
+ if not isinstance(value, dict):
92
+ raise DecodePathError(path, f"expected an object, got {_describe(value)}")
93
+ return value
94
+
95
+
96
+ def expect_list(value: JsonValue, path: str) -> list[JsonValue]:
97
+ if not isinstance(value, list):
98
+ raise DecodePathError(path, f"expected an array, got {_describe(value)}")
99
+ return value
100
+
101
+
102
+ def expect_str(value: JsonValue, path: str) -> str:
103
+ if not isinstance(value, str):
104
+ raise DecodePathError(path, f"expected a string, got {_describe(value)}")
105
+ return value
106
+
107
+
108
+ def expect_int(value: JsonValue, path: str) -> int:
109
+ """An integer: ``True`` and ``2.0`` are both refused."""
110
+ if isinstance(value, bool) or not isinstance(value, int):
111
+ raise DecodePathError(path, f"expected an integer, got {_describe(value)}")
112
+ return value
113
+
114
+
115
+ def expect_number(value: JsonValue, path: str) -> float:
116
+ """An int or a float, never a bool."""
117
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
118
+ raise DecodePathError(path, f"expected a number, got {_describe(value)}")
119
+ return float(value)
120
+
121
+
122
+ def expect_bool(value: JsonValue, path: str) -> bool:
123
+ if not isinstance(value, bool):
124
+ raise DecodePathError(path, f"expected a boolean, got {_describe(value)}")
125
+ return value
126
+
127
+
128
+ def expect_datetime(value: JsonValue, path: str) -> datetime:
129
+ """An RFC 3339 timestamp. The offset is required and the result is aware."""
130
+ text = expect_str(value, path)
131
+ try:
132
+ stamp = datetime.fromisoformat(text)
133
+ except ValueError:
134
+ raise DecodePathError(path, f"expected an RFC 3339 timestamp, got {text!r}") from None
135
+ if stamp.tzinfo is None:
136
+ raise DecodePathError(path, f"expected a timestamp with an offset, got {text!r}")
137
+ return stamp
138
+
139
+
140
+ def expect_date(value: JsonValue, path: str) -> date:
141
+ text = expect_str(value, path)
142
+ try:
143
+ return date.fromisoformat(text)
144
+ except ValueError:
145
+ raise DecodePathError(path, f"expected a date (YYYY-MM-DD), got {text!r}") from None
flowion/_operation.py ADDED
@@ -0,0 +1,87 @@
1
+ """What generated operations describe, and what they call.
2
+
3
+ This module is the other half of the seam between ``flowion.generated`` and
4
+ the runtime; its shape is fixed by
5
+ ``specs/167-python-sdk/contracts/generated-runtime-interface.md``. ``Caller`` is
6
+ implemented by the client (``flowion/client.py``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import AsyncIterator, Callable, Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Literal, Protocol
14
+
15
+ from flowion._json import JsonValue
16
+
17
+ __all__ = ["Caller", "OperationSpec"]
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class OperationSpec:
22
+ """One operation of the contract, as the runtime needs to send it."""
23
+
24
+ operation_id: str
25
+ """snake_case, as in ``openapi.json``."""
26
+ method: str
27
+ """``"GET"``, ``"POST"``, ..."""
28
+ path: str
29
+ """The path template, for example ``/v1/charging-stations/{chargingStationId}``."""
30
+ response: Literal["json", "text", "stream", "empty"]
31
+ """``empty`` is a ``204`` or a response with no body."""
32
+ success_statuses: tuple[int, ...]
33
+ """``(200,)``, ``(200, 201)`` or ``(200, 204)``."""
34
+ decode: Callable[[JsonValue], object] | None
35
+ """Decodes the response body; ``None`` unless ``response == "json"``."""
36
+ paginated_item: Callable[[JsonValue], object] | None
37
+ """Decodes one item when the response is a cursor page, else ``None``."""
38
+ request_body: Literal["none", "json"]
39
+
40
+
41
+ class Caller(Protocol):
42
+ """The transport-facing half that generated groups call."""
43
+
44
+ async def call(
45
+ self,
46
+ spec: OperationSpec,
47
+ *,
48
+ path: Mapping[str, str] = ...,
49
+ query: Mapping[str, JsonValue] = ...,
50
+ body: JsonValue = None,
51
+ headers: Mapping[str, str] = ...,
52
+ ) -> object:
53
+ """Send ``spec`` and return what ``spec.decode`` returned.
54
+
55
+ ``str`` for ``text``, ``None`` for ``empty``. Query values that are
56
+ ``None`` are omitted and lists are repeated parameters; path values are
57
+ percent-encoded here, not by the caller.
58
+ """
59
+ ...
60
+
61
+ def paginate(
62
+ self,
63
+ spec: OperationSpec,
64
+ *,
65
+ path: Mapping[str, str] = ...,
66
+ query: Mapping[str, JsonValue] = ...,
67
+ headers: Mapping[str, str] = ...,
68
+ ) -> AsyncIterator[object]:
69
+ """Yield each item of every page, decoded by ``spec.paginated_item``."""
70
+ ...
71
+
72
+ def stream(
73
+ self,
74
+ spec: OperationSpec,
75
+ *,
76
+ path: Mapping[str, str] = ...,
77
+ query: Mapping[str, JsonValue] = ...,
78
+ body: JsonValue = None,
79
+ headers: Mapping[str, str] = ...,
80
+ ) -> AsyncIterator[object]:
81
+ """Yield the parsed JSON payload of each ``data:`` frame.
82
+
83
+ ``body`` is the JSON request body of a streaming operation that takes
84
+ one (the assistant's ``send_*_message`` POSTs): the contract's
85
+ original ``stream`` signature had no body.
86
+ """
87
+ ...
flowion/_wire.py ADDED
@@ -0,0 +1,55 @@
1
+ """Small shared helpers for reading what came off the wire. Private to the runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Mapping
8
+ from urllib.parse import urlsplit
9
+
10
+ from flowion._json import JsonValue
11
+ from flowion.errors import EXCERPT_LIMIT
12
+
13
+ __all__ = ["LOGGER", "excerpt_of", "parse_json", "path_of", "request_id_of"]
14
+
15
+ # The one logger of the SDK. DEBUG only: method, path, status and request id.
16
+ # Never a header, a body, a token or a query string.
17
+ LOGGER = logging.getLogger("flowion")
18
+
19
+
20
+ def parse_json(body: bytes) -> JsonValue:
21
+ """The body as JSON; `ValueError` (which `UnicodeDecodeError` is) when it is not."""
22
+ try:
23
+ value: JsonValue = json.loads(body.decode("utf-8"))
24
+ except RecursionError:
25
+ raise ValueError("JSON nested too deeply") from None
26
+ return value
27
+
28
+
29
+ def best_effort_json(body: bytes) -> JsonValue:
30
+ """The body as JSON, or `None` when it is empty or not JSON (for error bodies)."""
31
+ if not body:
32
+ return None
33
+ try:
34
+ return parse_json(body)
35
+ except ValueError:
36
+ return None
37
+
38
+
39
+ def excerpt_of(body: bytes) -> str:
40
+ """The start of a body as text, bounded to 512 characters; undecodable bytes become U+FFFD."""
41
+ return body[: EXCERPT_LIMIT * 4].decode("utf-8", errors="replace")[:EXCERPT_LIMIT]
42
+
43
+
44
+ def path_of(url: str) -> str:
45
+ """The path of a URL, for logging: never the query string."""
46
+ return urlsplit(url).path
47
+
48
+
49
+ def request_id_of(headers: Mapping[str, str], body: JsonValue) -> str | None:
50
+ """The request's id, from the error body or the `x-request-id` header."""
51
+ if isinstance(body, dict):
52
+ in_body = body.get("requestId")
53
+ if isinstance(in_body, str):
54
+ return in_body
55
+ return headers.get("x-request-id")
@@ -0,0 +1,96 @@
1
+ """A `Transport` over an existing `aiohttp.ClientSession` (the optional `aiohttp` extra).
2
+
3
+ from flowion.aiohttp_transport import AiohttpTransport
4
+ client = Client(transport=AiohttpTransport(session)) # session: owned by the caller
5
+
6
+ The session belongs to the caller and may be shared with other components (Home
7
+ Assistant's is). This adapter therefore only ever calls `session.request(...)`
8
+ with the headers and timeout of that one request: it never touches
9
+ `session.headers`, never writes the cookie jar, never closes anything. It cannot
10
+ stop aiohttp sending a cookie the session already holds, and does not try; the
11
+ gateway is bearer-only and cookie-free, which is what makes sharing safe.
12
+
13
+ Needs aiohttp 3.9.0 or newer: `pip install "flowion[aiohttp]"`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ try:
19
+ import aiohttp
20
+ except ImportError as error: # pragma: no cover - exercised by blocking the import in a test
21
+ raise ImportError(
22
+ 'flowion.aiohttp_transport needs the aiohttp package: pip install "flowion[aiohttp]"'
23
+ ) from error
24
+
25
+ import asyncio
26
+ from collections.abc import AsyncGenerator, AsyncIterator, Mapping
27
+ from contextlib import asynccontextmanager
28
+ from typing import Any
29
+
30
+ from flowion.transport import Request, Response, StreamResponse, TransportError
31
+
32
+ __all__ = ["AiohttpTransport"]
33
+
34
+
35
+ def _lowercase(headers: Mapping[str, str] | Any) -> dict[str, str]:
36
+ """Lowercase names; a repeated header is joined with ", " as HTTP allows."""
37
+ merged: dict[str, str] = {}
38
+ for name, value in headers.items():
39
+ key = str(name).lower()
40
+ merged[key] = f"{merged[key]}, {value}" if key in merged else str(value)
41
+ return merged
42
+
43
+
44
+ class AiohttpTransport:
45
+ """Sends the SDK's requests through the caller's `aiohttp.ClientSession`."""
46
+
47
+ def __init__(self, session: aiohttp.ClientSession) -> None:
48
+ self._session = session
49
+
50
+ def __repr__(self) -> str:
51
+ return "AiohttpTransport()"
52
+
53
+ def _options(self, request: Request, *, streaming: bool) -> dict[str, Any]:
54
+ options: dict[str, Any] = {
55
+ "headers": dict(request.headers),
56
+ "data": request.body,
57
+ "allow_redirects": False,
58
+ }
59
+ if request.timeout is not None:
60
+ options["timeout"] = aiohttp.ClientTimeout(total=request.timeout)
61
+ elif streaming:
62
+ # A live stream has no total deadline; the session's own default would cut it.
63
+ options["timeout"] = aiohttp.ClientTimeout(total=None)
64
+ return options
65
+
66
+ async def send(self, request: Request) -> Response:
67
+ try:
68
+ async with self._session.request(
69
+ request.method, request.url, **self._options(request, streaming=False)
70
+ ) as response:
71
+ body = await response.read()
72
+ return Response(status=response.status, headers=_lowercase(response.headers), body=body)
73
+ except (aiohttp.ClientError, asyncio.TimeoutError) as error:
74
+ raise TransportError(f"{type(error).__name__}: {error}") from error
75
+
76
+ @asynccontextmanager
77
+ async def stream(self, request: Request) -> AsyncGenerator[StreamResponse, None]:
78
+ try:
79
+ response = await self._session.request(request.method, request.url, **self._options(request, streaming=True))
80
+ except (aiohttp.ClientError, asyncio.TimeoutError) as error:
81
+ raise TransportError(f"{type(error).__name__}: {error}") from error
82
+ try:
83
+ yield StreamResponse(status=response.status, headers=_lowercase(response.headers), chunks=_chunks(response))
84
+ finally:
85
+ if response.content.at_eof():
86
+ response.release()
87
+ else:
88
+ response.close()
89
+
90
+
91
+ async def _chunks(response: aiohttp.ClientResponse) -> AsyncIterator[bytes]:
92
+ try:
93
+ async for chunk in response.content.iter_any():
94
+ yield chunk
95
+ except (aiohttp.ClientError, asyncio.TimeoutError) as error:
96
+ raise TransportError(f"{type(error).__name__}: {error}") from error