slen 0.4.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.
- slen/__init__.py +160 -0
- slen/_client.py +244 -0
- slen/_config.py +117 -0
- slen/_errors.py +176 -0
- slen/_health.py +43 -0
- slen/_http/__init__.py +0 -0
- slen/_http/_answer.py +51 -0
- slen/_http/_async.py +102 -0
- slen/_http/_clock.py +19 -0
- slen/_http/_endpoints.py +29 -0
- slen/_http/_envelope.py +117 -0
- slen/_http/_error_map.py +115 -0
- slen/_http/_fields.py +134 -0
- slen/_http/_journal.py +47 -0
- slen/_http/_pool.py +13 -0
- slen/_http/_request.py +91 -0
- slen/_http/_retry.py +85 -0
- slen/_http/_sync.py +117 -0
- slen/_http/_wire.py +102 -0
- slen/_idempotency.py +17 -0
- slen/_logger.py +71 -0
- slen/_modules/__init__.py +0 -0
- slen/_modules/_context.py +18 -0
- slen/_modules/_pages.py +65 -0
- slen/_modules/customers/__init__.py +0 -0
- slen/_modules/customers/_async.py +318 -0
- slen/_modules/customers/_calls.py +193 -0
- slen/_modules/customers/_read.py +106 -0
- slen/_modules/customers/_sync.py +318 -0
- slen/_modules/customers/_types.py +101 -0
- slen/_modules/executions/__init__.py +0 -0
- slen/_modules/executions/_async.py +97 -0
- slen/_modules/executions/_calls.py +17 -0
- slen/_modules/executions/_read.py +118 -0
- slen/_modules/executions/_sync.py +93 -0
- slen/_modules/executions/_types.py +160 -0
- slen/_modules/executions/_wait.py +127 -0
- slen/_modules/operations/__init__.py +0 -0
- slen/_modules/operations/_async.py +88 -0
- slen/_modules/operations/_calls.py +85 -0
- slen/_modules/operations/_read.py +33 -0
- slen/_modules/operations/_sync.py +88 -0
- slen/_modules/operations/_types.py +46 -0
- slen/_modules/webhooks/__init__.py +0 -0
- slen/_modules/webhooks/_events.py +138 -0
- slen/_modules/webhooks/_signature.py +72 -0
- slen/_modules/webhooks/_types.py +137 -0
- slen/_modules/webhooks/_webhooks.py +149 -0
- slen/_observability/__init__.py +0 -0
- slen/_observability/_recorders.py +50 -0
- slen/_observability/_scope.py +86 -0
- slen/_observability/_span.py +36 -0
- slen/_page.py +13 -0
- slen/_version.py +3 -0
- slen/otel.py +36 -0
- slen/py.typed +0 -0
- slen/testing.py +152 -0
- slen-0.4.0.dist-info/METADATA +341 -0
- slen-0.4.0.dist-info/RECORD +61 -0
- slen-0.4.0.dist-info/WHEEL +4 -0
- slen-0.4.0.dist-info/licenses/LICENSE +21 -0
slen/__init__.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""The Slen Python SDK: run flows and read their outcome, keep end customers and their credits, check webhooks."""
|
|
2
|
+
|
|
3
|
+
from slen._client import AsyncSlen, Slen
|
|
4
|
+
from slen._errors import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
BudgetExceededError,
|
|
7
|
+
ConcurrencyLimitError,
|
|
8
|
+
ConnectionError,
|
|
9
|
+
CustomerBlockedError,
|
|
10
|
+
CustomerNotFoundError,
|
|
11
|
+
FlowRuntimeError,
|
|
12
|
+
IdempotencyConflictError,
|
|
13
|
+
InsufficientCreditsError,
|
|
14
|
+
InvalidRequestError,
|
|
15
|
+
InvalidResponseError,
|
|
16
|
+
OperationLimitExceededError,
|
|
17
|
+
OperationUncertainError,
|
|
18
|
+
OverrunApprovalRequiredError,
|
|
19
|
+
OverrunStoppedError,
|
|
20
|
+
PolicyDeniedError,
|
|
21
|
+
ProviderError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
ServiceUnavailableError,
|
|
24
|
+
SlenError,
|
|
25
|
+
SpendRefusedError,
|
|
26
|
+
TimeoutError,
|
|
27
|
+
WebhookSignatureError,
|
|
28
|
+
WebhookSignatureReason,
|
|
29
|
+
)
|
|
30
|
+
from slen._health import Health
|
|
31
|
+
from slen._idempotency import generate_idempotency_key, idempotency_key_for_job
|
|
32
|
+
from slen._logger import LOG_EVENTS, SlenLogger
|
|
33
|
+
from slen._modules.customers._async import AsyncCustomers
|
|
34
|
+
from slen._modules.customers._sync import Customers
|
|
35
|
+
from slen._modules.customers._types import (
|
|
36
|
+
CreditEntry,
|
|
37
|
+
CreditEntryType,
|
|
38
|
+
CreditPrice,
|
|
39
|
+
CreditPriceList,
|
|
40
|
+
Customer,
|
|
41
|
+
CustomerAccount,
|
|
42
|
+
CustomerStatus,
|
|
43
|
+
)
|
|
44
|
+
from slen._modules.executions._async import AsyncExecutions
|
|
45
|
+
from slen._modules.executions._sync import Executions
|
|
46
|
+
from slen._modules.executions._types import (
|
|
47
|
+
AttemptSummary,
|
|
48
|
+
ExecutionCache,
|
|
49
|
+
ExecutionCost,
|
|
50
|
+
ExecutionError,
|
|
51
|
+
ExecutionStatus,
|
|
52
|
+
ExecutionStatusView,
|
|
53
|
+
ExecutionTrace,
|
|
54
|
+
FlowExecutionEvent,
|
|
55
|
+
FlowExecutionNode,
|
|
56
|
+
WaitingOn,
|
|
57
|
+
)
|
|
58
|
+
from slen._modules.executions._wait import AsyncPollErrorListener, PollErrorListener
|
|
59
|
+
from slen._modules.operations._async import AsyncOperations
|
|
60
|
+
from slen._modules.operations._calls import ContextValue
|
|
61
|
+
from slen._modules.operations._sync import Operations
|
|
62
|
+
from slen._modules.operations._types import ExecutionResult
|
|
63
|
+
from slen._modules.webhooks._types import (
|
|
64
|
+
ApprovalDecidedEvent,
|
|
65
|
+
ApprovalDecidedEventData,
|
|
66
|
+
ApprovalRequestedEvent,
|
|
67
|
+
ApprovalRequestedEventData,
|
|
68
|
+
CustomerBalanceLowEvent,
|
|
69
|
+
CustomerBalanceLowEventData,
|
|
70
|
+
ExecutionFinishedEvent,
|
|
71
|
+
ExecutionFinishedEventData,
|
|
72
|
+
UnknownWebhookEvent,
|
|
73
|
+
WebhookEvent,
|
|
74
|
+
WebhookEventType,
|
|
75
|
+
WebhookPayload,
|
|
76
|
+
)
|
|
77
|
+
from slen._modules.webhooks._webhooks import Webhooks
|
|
78
|
+
from slen._observability._recorders import LoggerSpanRecorder, SpanRecorder
|
|
79
|
+
from slen._observability._span import SlenSpan
|
|
80
|
+
from slen._page import Page
|
|
81
|
+
from slen._version import __version__
|
|
82
|
+
|
|
83
|
+
__all__ = (
|
|
84
|
+
"LOG_EVENTS",
|
|
85
|
+
"ApprovalDecidedEvent",
|
|
86
|
+
"ApprovalDecidedEventData",
|
|
87
|
+
"ApprovalRequestedEvent",
|
|
88
|
+
"ApprovalRequestedEventData",
|
|
89
|
+
"AsyncCustomers",
|
|
90
|
+
"AsyncExecutions",
|
|
91
|
+
"AsyncOperations",
|
|
92
|
+
"AsyncPollErrorListener",
|
|
93
|
+
"AsyncSlen",
|
|
94
|
+
"AttemptSummary",
|
|
95
|
+
"AuthenticationError",
|
|
96
|
+
"BudgetExceededError",
|
|
97
|
+
"ConcurrencyLimitError",
|
|
98
|
+
"ConnectionError",
|
|
99
|
+
"ContextValue",
|
|
100
|
+
"CreditEntry",
|
|
101
|
+
"CreditEntryType",
|
|
102
|
+
"CreditPrice",
|
|
103
|
+
"CreditPriceList",
|
|
104
|
+
"Customer",
|
|
105
|
+
"CustomerAccount",
|
|
106
|
+
"CustomerBalanceLowEvent",
|
|
107
|
+
"CustomerBalanceLowEventData",
|
|
108
|
+
"CustomerBlockedError",
|
|
109
|
+
"CustomerNotFoundError",
|
|
110
|
+
"CustomerStatus",
|
|
111
|
+
"Customers",
|
|
112
|
+
"ExecutionCache",
|
|
113
|
+
"ExecutionCost",
|
|
114
|
+
"ExecutionError",
|
|
115
|
+
"ExecutionFinishedEvent",
|
|
116
|
+
"ExecutionFinishedEventData",
|
|
117
|
+
"ExecutionResult",
|
|
118
|
+
"ExecutionStatus",
|
|
119
|
+
"ExecutionStatusView",
|
|
120
|
+
"ExecutionTrace",
|
|
121
|
+
"Executions",
|
|
122
|
+
"FlowExecutionEvent",
|
|
123
|
+
"FlowExecutionNode",
|
|
124
|
+
"FlowRuntimeError",
|
|
125
|
+
"Health",
|
|
126
|
+
"IdempotencyConflictError",
|
|
127
|
+
"InsufficientCreditsError",
|
|
128
|
+
"InvalidRequestError",
|
|
129
|
+
"InvalidResponseError",
|
|
130
|
+
"LoggerSpanRecorder",
|
|
131
|
+
"OperationLimitExceededError",
|
|
132
|
+
"OperationUncertainError",
|
|
133
|
+
"Operations",
|
|
134
|
+
"OverrunApprovalRequiredError",
|
|
135
|
+
"OverrunStoppedError",
|
|
136
|
+
"Page",
|
|
137
|
+
"PolicyDeniedError",
|
|
138
|
+
"PollErrorListener",
|
|
139
|
+
"ProviderError",
|
|
140
|
+
"RateLimitError",
|
|
141
|
+
"ServiceUnavailableError",
|
|
142
|
+
"Slen",
|
|
143
|
+
"SlenError",
|
|
144
|
+
"SlenLogger",
|
|
145
|
+
"SlenSpan",
|
|
146
|
+
"SpanRecorder",
|
|
147
|
+
"SpendRefusedError",
|
|
148
|
+
"TimeoutError",
|
|
149
|
+
"UnknownWebhookEvent",
|
|
150
|
+
"WaitingOn",
|
|
151
|
+
"WebhookEvent",
|
|
152
|
+
"WebhookEventType",
|
|
153
|
+
"WebhookPayload",
|
|
154
|
+
"WebhookSignatureError",
|
|
155
|
+
"WebhookSignatureReason",
|
|
156
|
+
"Webhooks",
|
|
157
|
+
"__version__",
|
|
158
|
+
"generate_idempotency_key",
|
|
159
|
+
"idempotency_key_for_job",
|
|
160
|
+
)
|
slen/_client.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Generic, NoReturn, Self, TypeVar
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from slen._config import DEFAULT_BASE_URL, ClientConfig, ClientOptions, logger_of, resolve_config
|
|
9
|
+
from slen._health import Health, health_call
|
|
10
|
+
from slen._http import _clock, _pool
|
|
11
|
+
from slen._http._async import AsyncTransport
|
|
12
|
+
from slen._http._clock import Clock
|
|
13
|
+
from slen._http._request import Sending
|
|
14
|
+
from slen._http._sync import SyncTransport
|
|
15
|
+
from slen._logger import SlenLogger
|
|
16
|
+
from slen._modules._context import AsyncContext, SyncContext
|
|
17
|
+
from slen._modules.customers._async import AsyncCustomers
|
|
18
|
+
from slen._modules.customers._sync import Customers
|
|
19
|
+
from slen._modules.executions._async import AsyncExecutions
|
|
20
|
+
from slen._modules.executions._sync import Executions
|
|
21
|
+
from slen._modules.operations._async import AsyncOperations
|
|
22
|
+
from slen._modules.operations._sync import Operations
|
|
23
|
+
from slen._modules.webhooks._webhooks import Webhooks
|
|
24
|
+
from slen._observability._recorders import NullSpanRecorder, SpanRecorder
|
|
25
|
+
|
|
26
|
+
C = TypeVar("C", httpx.Client, httpx.AsyncClient)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# What one client is made of; a copy made by with_logger() shares all of it but the logger, and owns no pool.
|
|
30
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
31
|
+
class Parts(Generic[C]):
|
|
32
|
+
config: ClientConfig
|
|
33
|
+
http: C
|
|
34
|
+
owns: bool
|
|
35
|
+
spans: SpanRecorder
|
|
36
|
+
clock: Clock
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def recorder_of(given: SpanRecorder | None) -> SpanRecorder:
|
|
40
|
+
if given is not None and not callable(getattr(given, "record", None)):
|
|
41
|
+
raise TypeError("Slen: span_recorder has a record(span) method.")
|
|
42
|
+
|
|
43
|
+
return NullSpanRecorder() if given is None else given
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def webhooks_of(config: ClientConfig) -> Webhooks:
|
|
47
|
+
return Webhooks(
|
|
48
|
+
secret=config.webhook_secret,
|
|
49
|
+
tolerance_seconds=config.webhook_tolerance_seconds,
|
|
50
|
+
logger=config.logger,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def refuse_to_travel() -> NoReturn:
|
|
55
|
+
raise TypeError(
|
|
56
|
+
"Slen: a client holds connections and cannot be pickled or copied; build a client in each process, "
|
|
57
|
+
"and use with_logger() for a copy within one."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Slen:
|
|
62
|
+
"""The Slen client: runs flows, reads their runs, keeps end customers and their credits."""
|
|
63
|
+
|
|
64
|
+
__slots__ = ("_parts", "_transport", "customers", "executions", "operations", "webhooks")
|
|
65
|
+
|
|
66
|
+
operations: Operations
|
|
67
|
+
executions: Executions
|
|
68
|
+
customers: Customers
|
|
69
|
+
webhooks: Webhooks
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
*,
|
|
74
|
+
api_key: str | None = None,
|
|
75
|
+
timeout: float = 65.0,
|
|
76
|
+
connect_timeout: float = 5.0,
|
|
77
|
+
retries: int = 2,
|
|
78
|
+
logger: SlenLogger | None = None,
|
|
79
|
+
span_recorder: SpanRecorder | None = None,
|
|
80
|
+
webhook_secret: str | None = None,
|
|
81
|
+
webhook_tolerance_seconds: int = 300,
|
|
82
|
+
http_client: httpx.Client | None = None,
|
|
83
|
+
headers: Mapping[str, str] | None = None,
|
|
84
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
85
|
+
) -> None:
|
|
86
|
+
options = ClientOptions(
|
|
87
|
+
api_key=api_key,
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
connect_timeout=connect_timeout,
|
|
90
|
+
retries=retries,
|
|
91
|
+
logger=logger,
|
|
92
|
+
webhook_secret=webhook_secret,
|
|
93
|
+
webhook_tolerance_seconds=webhook_tolerance_seconds,
|
|
94
|
+
headers=headers,
|
|
95
|
+
base_url=base_url,
|
|
96
|
+
)
|
|
97
|
+
config = resolve_config(options)
|
|
98
|
+
spans = recorder_of(span_recorder)
|
|
99
|
+
|
|
100
|
+
if http_client is not None and not isinstance(http_client, httpx.Client):
|
|
101
|
+
raise TypeError("Slen: http_client is an httpx.Client; AsyncSlen takes an httpx.AsyncClient.")
|
|
102
|
+
|
|
103
|
+
http = _pool.sync_pool(config) if http_client is None else http_client
|
|
104
|
+
self._build(Parts(config=config, http=http, owns=http_client is None, spans=spans, clock=_clock.SYSTEM_CLOCK))
|
|
105
|
+
|
|
106
|
+
def _build(self, parts: Parts[httpx.Client]) -> None:
|
|
107
|
+
self._parts = parts
|
|
108
|
+
self._transport = SyncTransport(parts.config, parts.http, parts.clock)
|
|
109
|
+
context = SyncContext(self._transport, parts.spans)
|
|
110
|
+
self.operations = Operations(context)
|
|
111
|
+
self.executions = Executions(context)
|
|
112
|
+
self.customers = Customers(context)
|
|
113
|
+
self.webhooks = webhooks_of(parts.config)
|
|
114
|
+
|
|
115
|
+
def with_logger(self, logger: SlenLogger) -> "Slen":
|
|
116
|
+
"""A client like this one that writes its journal to logger, over the same connections and span recorder."""
|
|
117
|
+
parts = self._parts
|
|
118
|
+
copy = type(self).__new__(type(self))
|
|
119
|
+
config = dataclasses.replace(parts.config, logger=logger_of(logger))
|
|
120
|
+
copy._build(dataclasses.replace(parts, config=config, owns=False))
|
|
121
|
+
|
|
122
|
+
return copy
|
|
123
|
+
|
|
124
|
+
def health(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
timeout: float | None = None,
|
|
128
|
+
retries: int | None = None,
|
|
129
|
+
request_id: str | None = None,
|
|
130
|
+
) -> Health:
|
|
131
|
+
"""Liveness of the API: a dependency that is down is named in the body, not in the status."""
|
|
132
|
+
sending = Sending(timeout=timeout, retries=retries, request_id=request_id)
|
|
133
|
+
|
|
134
|
+
return self._transport.call(health_call(sending))
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
"""Closes the connections of the HTTP client the SDK built; one passed as http_client is left to its owner."""
|
|
138
|
+
if self._parts.owns:
|
|
139
|
+
self._parts.http.close()
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> Self:
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *exc: object) -> None:
|
|
145
|
+
self.close()
|
|
146
|
+
|
|
147
|
+
def __repr__(self) -> str:
|
|
148
|
+
return f"Slen(timeout={self._parts.config.timeout!r}, retries={self._parts.config.retries!r})"
|
|
149
|
+
|
|
150
|
+
def __reduce__(self) -> NoReturn:
|
|
151
|
+
refuse_to_travel()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class AsyncSlen:
|
|
155
|
+
"""The Slen client for asyncio: runs flows, reads their runs, keeps end customers and their credits."""
|
|
156
|
+
|
|
157
|
+
__slots__ = ("_parts", "_transport", "customers", "executions", "operations", "webhooks")
|
|
158
|
+
|
|
159
|
+
operations: AsyncOperations
|
|
160
|
+
executions: AsyncExecutions
|
|
161
|
+
customers: AsyncCustomers
|
|
162
|
+
webhooks: Webhooks
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
api_key: str | None = None,
|
|
168
|
+
timeout: float = 65.0,
|
|
169
|
+
connect_timeout: float = 5.0,
|
|
170
|
+
retries: int = 2,
|
|
171
|
+
logger: SlenLogger | None = None,
|
|
172
|
+
span_recorder: SpanRecorder | None = None,
|
|
173
|
+
webhook_secret: str | None = None,
|
|
174
|
+
webhook_tolerance_seconds: int = 300,
|
|
175
|
+
http_client: httpx.AsyncClient | None = None,
|
|
176
|
+
headers: Mapping[str, str] | None = None,
|
|
177
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
178
|
+
) -> None:
|
|
179
|
+
options = ClientOptions(
|
|
180
|
+
api_key=api_key,
|
|
181
|
+
timeout=timeout,
|
|
182
|
+
connect_timeout=connect_timeout,
|
|
183
|
+
retries=retries,
|
|
184
|
+
logger=logger,
|
|
185
|
+
webhook_secret=webhook_secret,
|
|
186
|
+
webhook_tolerance_seconds=webhook_tolerance_seconds,
|
|
187
|
+
headers=headers,
|
|
188
|
+
base_url=base_url,
|
|
189
|
+
)
|
|
190
|
+
config = resolve_config(options)
|
|
191
|
+
spans = recorder_of(span_recorder)
|
|
192
|
+
|
|
193
|
+
if http_client is not None and not isinstance(http_client, httpx.AsyncClient):
|
|
194
|
+
raise TypeError("Slen: http_client is an httpx.AsyncClient; Slen takes an httpx.Client.")
|
|
195
|
+
|
|
196
|
+
http = _pool.async_pool(config) if http_client is None else http_client
|
|
197
|
+
self._build(Parts(config=config, http=http, owns=http_client is None, spans=spans, clock=_clock.SYSTEM_CLOCK))
|
|
198
|
+
|
|
199
|
+
def _build(self, parts: Parts[httpx.AsyncClient]) -> None:
|
|
200
|
+
self._parts = parts
|
|
201
|
+
self._transport = AsyncTransport(parts.config, parts.http, parts.clock)
|
|
202
|
+
context = AsyncContext(self._transport, parts.spans)
|
|
203
|
+
self.operations = AsyncOperations(context)
|
|
204
|
+
self.executions = AsyncExecutions(context)
|
|
205
|
+
self.customers = AsyncCustomers(context)
|
|
206
|
+
self.webhooks = webhooks_of(parts.config)
|
|
207
|
+
|
|
208
|
+
def with_logger(self, logger: SlenLogger) -> "AsyncSlen":
|
|
209
|
+
"""A client like this one that writes its journal to logger, over the same connections and span recorder."""
|
|
210
|
+
parts = self._parts
|
|
211
|
+
copy = type(self).__new__(type(self))
|
|
212
|
+
config = dataclasses.replace(parts.config, logger=logger_of(logger))
|
|
213
|
+
copy._build(dataclasses.replace(parts, config=config, owns=False))
|
|
214
|
+
|
|
215
|
+
return copy
|
|
216
|
+
|
|
217
|
+
async def health(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
timeout: float | None = None,
|
|
221
|
+
retries: int | None = None,
|
|
222
|
+
request_id: str | None = None,
|
|
223
|
+
) -> Health:
|
|
224
|
+
"""Liveness of the API: a dependency that is down is named in the body, not in the status."""
|
|
225
|
+
sending = Sending(timeout=timeout, retries=retries, request_id=request_id)
|
|
226
|
+
|
|
227
|
+
return await self._transport.call(health_call(sending))
|
|
228
|
+
|
|
229
|
+
async def close(self) -> None:
|
|
230
|
+
"""Closes the connections of the HTTP client the SDK built; one passed as http_client is left to its owner."""
|
|
231
|
+
if self._parts.owns:
|
|
232
|
+
await self._parts.http.aclose()
|
|
233
|
+
|
|
234
|
+
async def __aenter__(self) -> Self:
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
238
|
+
await self.close()
|
|
239
|
+
|
|
240
|
+
def __repr__(self) -> str:
|
|
241
|
+
return f"AsyncSlen(timeout={self._parts.config.timeout!r}, retries={self._parts.config.retries!r})"
|
|
242
|
+
|
|
243
|
+
def __reduce__(self) -> NoReturn:
|
|
244
|
+
refuse_to_travel()
|
slen/_config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from slen._logger import SlenLogger, default_logger, is_logger
|
|
8
|
+
|
|
9
|
+
# The hosted API, `servers[0]` of the spec. Another address is for tests and a local stack only.
|
|
10
|
+
DEFAULT_BASE_URL: Final = "https://api.slen.ai"
|
|
11
|
+
|
|
12
|
+
# The one variable the SDK reads.
|
|
13
|
+
KEY_VARIABLE: Final = "SLEN_API_KEY"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
17
|
+
class ClientOptions:
|
|
18
|
+
api_key: str | None = field(repr=False)
|
|
19
|
+
timeout: float
|
|
20
|
+
connect_timeout: float
|
|
21
|
+
retries: int
|
|
22
|
+
logger: SlenLogger | None
|
|
23
|
+
webhook_secret: str | None = field(repr=False)
|
|
24
|
+
webhook_tolerance_seconds: int
|
|
25
|
+
headers: Mapping[str, str] | None
|
|
26
|
+
base_url: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
30
|
+
class ClientConfig:
|
|
31
|
+
api_key: str = field(repr=False)
|
|
32
|
+
base_url: str
|
|
33
|
+
timeout: float
|
|
34
|
+
connect_timeout: float
|
|
35
|
+
retries: int
|
|
36
|
+
headers: Mapping[str, str]
|
|
37
|
+
logger: SlenLogger
|
|
38
|
+
webhook_secret: str | None = field(repr=False)
|
|
39
|
+
webhook_tolerance_seconds: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_config(options: ClientOptions) -> ClientConfig:
|
|
43
|
+
return ClientConfig(
|
|
44
|
+
api_key=api_key_of(options.api_key),
|
|
45
|
+
base_url=base_url_of(options.base_url),
|
|
46
|
+
timeout=seconds_of("timeout", options.timeout),
|
|
47
|
+
connect_timeout=seconds_of("connect_timeout", options.connect_timeout),
|
|
48
|
+
retries=whole("retries", options.retries, 0),
|
|
49
|
+
headers=headers_of(options.headers),
|
|
50
|
+
logger=logger_of(options.logger),
|
|
51
|
+
webhook_secret=secret_of(options.webhook_secret),
|
|
52
|
+
webhook_tolerance_seconds=whole("webhook_tolerance_seconds", options.webhook_tolerance_seconds, 1),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def api_key_of(given: str | None) -> str:
|
|
57
|
+
key: object = os.environ.get(KEY_VARIABLE) if given is None else given
|
|
58
|
+
|
|
59
|
+
if key is not None and not isinstance(key, str):
|
|
60
|
+
raise TypeError("Slen: api_key is a string.")
|
|
61
|
+
|
|
62
|
+
if key is None or not key.strip():
|
|
63
|
+
raise ValueError(f"Slen: an api_key is required (pass api_key or set {KEY_VARIABLE}).")
|
|
64
|
+
|
|
65
|
+
return key
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def base_url_of(given: str) -> str:
|
|
69
|
+
if not isinstance(given, str) or not given.strip():
|
|
70
|
+
raise ValueError("Slen: the address of the API is a URL that is not empty.")
|
|
71
|
+
|
|
72
|
+
return given.rstrip("/")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def seconds_of(name: str, value: object) -> float:
|
|
76
|
+
if not isinstance(value, int | float) or isinstance(value, bool):
|
|
77
|
+
raise TypeError(f"Slen: {name} is a number of seconds, got {type(value).__name__}.")
|
|
78
|
+
|
|
79
|
+
# Negated on purpose: NaN is not above 0 either.
|
|
80
|
+
if not value > 0:
|
|
81
|
+
raise ValueError(f"Slen: {name} must be a number above 0, got {value!r}.")
|
|
82
|
+
|
|
83
|
+
return float(value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def whole(name: str, value: object, least: int) -> int:
|
|
87
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
88
|
+
raise TypeError(f"Slen: {name} is an integer, got {type(value).__name__}.")
|
|
89
|
+
|
|
90
|
+
if value < least:
|
|
91
|
+
raise ValueError(f"Slen: {name} must be an integer of at least {least}, got {value!r}.")
|
|
92
|
+
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def headers_of(given: Mapping[str, str] | None) -> Mapping[str, str]:
|
|
97
|
+
headers = dict(given or {})
|
|
98
|
+
|
|
99
|
+
if not all(isinstance(name, str) and isinstance(value, str) for name, value in headers.items()):
|
|
100
|
+
raise TypeError("Slen: headers is a mapping of strings to strings.")
|
|
101
|
+
|
|
102
|
+
return MappingProxyType(headers)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def logger_of(given: SlenLogger | None) -> SlenLogger:
|
|
106
|
+
if given is not None and not is_logger(given):
|
|
107
|
+
raise TypeError("Slen: logger has debug, info, warning and error, as logging.Logger has.")
|
|
108
|
+
|
|
109
|
+
return default_logger() if given is None else given
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# An empty or blank secret is a variable that was not set, not a secret.
|
|
113
|
+
def secret_of(given: str | None) -> str | None:
|
|
114
|
+
if given is not None and not isinstance(given, str):
|
|
115
|
+
raise TypeError("Slen: webhook_secret is a string.")
|
|
116
|
+
|
|
117
|
+
return None if given is None or not given.strip() else given
|
slen/_errors.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
from collections.abc import Callable, Mapping
|
|
3
|
+
from types import MappingProxyType
|
|
4
|
+
from typing import Any, Final, Literal, TypeAlias
|
|
5
|
+
|
|
6
|
+
INVALID_REQUEST: Final = "invalid_request"
|
|
7
|
+
INVALID_RESPONSE: Final = "invalid_response"
|
|
8
|
+
WEBHOOK_SIGNATURE_INVALID: Final = "webhook_signature_invalid"
|
|
9
|
+
WEBHOOK_PAYLOAD_INVALID: Final = "webhook_payload_invalid"
|
|
10
|
+
|
|
11
|
+
WebhookSignatureReason: TypeAlias = Literal[
|
|
12
|
+
"missing_header", "malformed_header", "outside_tolerance", "no_matching_signature"
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
_STATE: Final = ("code", "status", "details", "request_id", "retry_after_seconds")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SlenError(Exception):
|
|
19
|
+
"""The root of every error the SDK raises."""
|
|
20
|
+
|
|
21
|
+
code: str | None
|
|
22
|
+
status: int | None
|
|
23
|
+
details: Mapping[str, Any]
|
|
24
|
+
request_id: str | None
|
|
25
|
+
retry_after_seconds: int | None
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
message: str,
|
|
30
|
+
*,
|
|
31
|
+
code: str | None = None,
|
|
32
|
+
status: int | None = None,
|
|
33
|
+
details: Mapping[str, Any] | None = None,
|
|
34
|
+
request_id: str | None = None,
|
|
35
|
+
retry_after_seconds: int | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.code = code
|
|
39
|
+
self.status = status
|
|
40
|
+
self.details = MappingProxyType(dict(details or {}))
|
|
41
|
+
self.request_id = request_id
|
|
42
|
+
self.retry_after_seconds = retry_after_seconds
|
|
43
|
+
|
|
44
|
+
def __reduce__(self) -> tuple[Callable[..., "SlenError"], tuple[object, ...]]:
|
|
45
|
+
state = {name: getattr(self, name) for name in _STATE}
|
|
46
|
+
state["details"] = dict(self.details)
|
|
47
|
+
state.update({name: value for name, value in vars(self).items() if name not in _STATE})
|
|
48
|
+
|
|
49
|
+
return _rebuilt, (type(self), str(self), state)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rebuilt(kind: type[SlenError], message: str, state: dict[str, Any]) -> SlenError:
|
|
53
|
+
error = kind.__new__(kind, message)
|
|
54
|
+
Exception.__init__(error, message)
|
|
55
|
+
vars(error).update({**state, "details": MappingProxyType(dict(state["details"]))})
|
|
56
|
+
|
|
57
|
+
return error
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AuthenticationError(SlenError):
|
|
61
|
+
"""401 or 403: the key, its scope or the tenant."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class InvalidRequestError(SlenError):
|
|
65
|
+
"""The request was refused for its shape: 422, an unknown code of a 4xx, or a check before the wire."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SpendRefusedError(SlenError):
|
|
69
|
+
"""Money said no (402); each ceiling has a subtype."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BudgetExceededError(SpendRefusedError):
|
|
73
|
+
"""The project's or the organization's budget."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class InsufficientCreditsError(SpendRefusedError):
|
|
77
|
+
"""The end customer is out of credits."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OverrunStoppedError(SpendRefusedError):
|
|
81
|
+
"""An overrun of a budget that stops the run."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OverrunApprovalRequiredError(SpendRefusedError):
|
|
85
|
+
"""An overrun of a budget that waits for somebody to approve it."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class OperationLimitExceededError(SpendRefusedError):
|
|
89
|
+
"""One operation would cost more than a per-operation budget allows."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConcurrencyLimitError(SpendRefusedError):
|
|
93
|
+
"""A concurrency budget has every slot taken; passes when one is freed."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CustomerNotFoundError(SlenError):
|
|
97
|
+
"""The project has never seen this end customer."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RateLimitError(SlenError):
|
|
101
|
+
"""429: the key sent more requests than its rate limit allows."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PolicyDeniedError(SlenError):
|
|
105
|
+
"""A policy of the project refused the run."""
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class CustomerBlockedError(PolicyDeniedError):
|
|
109
|
+
"""The end customer of the run is blocked, so its new runs are refused; nothing ran."""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class IdempotencyConflictError(SlenError):
|
|
113
|
+
"""The idempotency key was used with another body, or its first request is still running."""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ProviderError(SlenError):
|
|
117
|
+
"""A model provider failed or could not be called; any 5xx without a known code lands here too — read code."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TimeoutError(SlenError, builtins.TimeoutError):
|
|
121
|
+
"""No answer in time (no code), or a run that ran out of time on the server."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class OperationUncertainError(SlenError):
|
|
125
|
+
"""A provider was called and nobody knows what became of the call: the money may be spent."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ServiceUnavailableError(SlenError):
|
|
129
|
+
"""503 from Slen itself: nothing was done, the request may be sent again."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class FlowRuntimeError(SlenError):
|
|
133
|
+
"""The flow broke while it was walked; the model calls before the break were paid for."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ConnectionError(SlenError, builtins.ConnectionError):
|
|
137
|
+
"""Slen could not be reached, or the connection broke before the answer was read; no code, no status."""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class InvalidResponseError(SlenError):
|
|
141
|
+
"""A 2xx whose body cannot be read as the answer; the call may have gone through."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class WebhookSignatureError(SlenError):
|
|
145
|
+
"""A webhook whose Slen-Signature was not accepted: do not act on its body; reason says why."""
|
|
146
|
+
|
|
147
|
+
reason: WebhookSignatureReason
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
message: str,
|
|
152
|
+
*,
|
|
153
|
+
reason: WebhookSignatureReason,
|
|
154
|
+
code: str | None = WEBHOOK_SIGNATURE_INVALID,
|
|
155
|
+
status: int | None = None,
|
|
156
|
+
details: Mapping[str, Any] | None = None,
|
|
157
|
+
request_id: str | None = None,
|
|
158
|
+
retry_after_seconds: int | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
super().__init__(
|
|
161
|
+
message,
|
|
162
|
+
code=code,
|
|
163
|
+
status=status,
|
|
164
|
+
details=details,
|
|
165
|
+
request_id=request_id,
|
|
166
|
+
retry_after_seconds=retry_after_seconds,
|
|
167
|
+
)
|
|
168
|
+
self.reason = reason
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def contract_type_of(error: SlenError) -> str:
|
|
172
|
+
return type(error).__name__.removesuffix("Error")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def refused(message: str) -> InvalidRequestError:
|
|
176
|
+
return InvalidRequestError(message, code=INVALID_REQUEST)
|