encore-dev 2.0.0a7__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.
- encore/__init__.py +43 -0
- encore/cron/__init__.py +50 -0
- encore/internal/__init__.py +1 -0
- encore/internal/_loader.py +79 -0
- encore/internal/_native.pyi +734 -0
- encore/internal/lazy.py +35 -0
- encore/internal/runtime.py +133 -0
- encore/internal/startup.py +24 -0
- encore/logs/__init__.py +19 -0
- encore/logs/_console.py +155 -0
- encore/logs/_runtime.py +56 -0
- encore/meta/__init__.py +228 -0
- encore/metrics/__init__.py +390 -0
- encore/pubsub/__init__.py +425 -0
- encore/py.typed +0 -0
- encore/secrets/__init__.py +139 -0
- encore/shutdown/__init__.py +164 -0
- encore/storage/__init__.py +5 -0
- encore/storage/objects/__init__.py +396 -0
- encore/storage/postgres/__init__.py +737 -0
- encore/storage/redis/__init__.py +200 -0
- encore/storage/redis/_base.py +42 -0
- encore/storage/redis/_responses.py +150 -0
- encore/storage/redis/_values.py +189 -0
- encore/storage/redis/bitmap.py +142 -0
- encore/storage/redis/generic.py +367 -0
- encore/storage/redis/geo.py +508 -0
- encore/storage/redis/hash.py +504 -0
- encore/storage/redis/json.py +614 -0
- encore/storage/redis/list.py +511 -0
- encore/storage/redis/pubsub.py +276 -0
- encore/storage/redis/set.py +570 -0
- encore/storage/redis/sorted_set.py +753 -0
- encore/storage/redis/stream.py +773 -0
- encore/storage/redis/string.py +654 -0
- encore/tracing/__init__.py +19 -0
- encore/tracing/_context.py +27 -0
- encore/tracing/_encode.py +351 -0
- encore/tracing/_provider.py +62 -0
- encore/tracing/_span.py +226 -0
- encore/tracing/_tracer.py +93 -0
- encore_dev-2.0.0a7.dist-info/METADATA +791 -0
- encore_dev-2.0.0a7.dist-info/RECORD +44 -0
- encore_dev-2.0.0a7.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Pub/Sub topics and subscriptions.
|
|
2
|
+
|
|
3
|
+
Typical usage example:
|
|
4
|
+
|
|
5
|
+
from encore import pubsub
|
|
6
|
+
|
|
7
|
+
orders = pubsub.Topic(
|
|
8
|
+
"orders",
|
|
9
|
+
exactly_once=True,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
async def place_order(order):
|
|
13
|
+
await orders.publish({"id": order.id})
|
|
14
|
+
|
|
15
|
+
ordered = pubsub.OrderedTopic(
|
|
16
|
+
"ordered-orders",
|
|
17
|
+
exactly_once=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
async def place_order_in_sequence(order):
|
|
21
|
+
await ordered.publish({"id": order.id}, ordering_key=str(order.id))
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import datetime
|
|
29
|
+
import json
|
|
30
|
+
import types
|
|
31
|
+
from typing import (
|
|
32
|
+
Any,
|
|
33
|
+
Awaitable,
|
|
34
|
+
Callable,
|
|
35
|
+
Generic,
|
|
36
|
+
ForwardRef,
|
|
37
|
+
TYPE_CHECKING,
|
|
38
|
+
TypeVar,
|
|
39
|
+
Union,
|
|
40
|
+
get_args,
|
|
41
|
+
get_origin,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
from ..internal.lazy import Lazy
|
|
45
|
+
from ..internal.runtime import _native, runtime
|
|
46
|
+
from ..tracing._context import active_ctx_bytes
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
from pydantic import BaseModel
|
|
50
|
+
except ImportError:
|
|
51
|
+
BaseModel = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_UNION_ORIGINS = (Union, getattr(types, "UnionType", Union))
|
|
55
|
+
_json_ref = ForwardRef("JSON", module=__name__)
|
|
56
|
+
# Union syntax keeps these aliases compatible with Python 3.9.
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
JSON = Union[dict[str, "JSON"], list["JSON"], str, int, float, bool, None]
|
|
60
|
+
|
|
61
|
+
from pydantic import BaseModel as _PydanticBaseModel
|
|
62
|
+
|
|
63
|
+
Payload = Union[_PydanticBaseModel, JSON, bytes]
|
|
64
|
+
else:
|
|
65
|
+
# Resolve the recursive alias in this module, even when a handler imports
|
|
66
|
+
# pubsub under an alias or imports Message without importing JSON directly.
|
|
67
|
+
JSON = Union[dict[str, _json_ref], list[_json_ref], str, int, float, bool, None]
|
|
68
|
+
Payload = (
|
|
69
|
+
Union[BaseModel, JSON, bytes] if BaseModel is not None else Union[JSON, bytes]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_T = TypeVar("_T", bound=Payload)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _validate_payload_type(payload_type: Any) -> None:
|
|
76
|
+
"""Check decoder support, without validating individual JSON values."""
|
|
77
|
+
if payload_type is bytes or (
|
|
78
|
+
BaseModel is not None
|
|
79
|
+
and isinstance(payload_type, type)
|
|
80
|
+
and issubclass(payload_type, BaseModel)
|
|
81
|
+
):
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
pending = [payload_type]
|
|
85
|
+
seen: set[int] = set()
|
|
86
|
+
while pending:
|
|
87
|
+
current = pending.pop()
|
|
88
|
+
if id(current) in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(id(current))
|
|
91
|
+
if current in (Any, str, int, float, bool, type(None), None, list, dict):
|
|
92
|
+
continue
|
|
93
|
+
# Only our recursive JSON alias is supported, not arbitrary forward refs.
|
|
94
|
+
if current is _json_ref:
|
|
95
|
+
continue
|
|
96
|
+
origin, args = get_origin(current), get_args(current)
|
|
97
|
+
if origin in _UNION_ORIGINS or origin is list:
|
|
98
|
+
pending.extend(args)
|
|
99
|
+
elif origin is dict and (not args or args[0] in (str, Any)):
|
|
100
|
+
pending.extend(args[1:])
|
|
101
|
+
else:
|
|
102
|
+
raise TypeError(
|
|
103
|
+
f"Unsupported Pub/Sub payload type {payload_type!r}. Use JSON "
|
|
104
|
+
"types, bytes, or a top-level Pydantic v2 model. "
|
|
105
|
+
"Use Subscription[bytes] for custom decoding. "
|
|
106
|
+
"Pass types directly rather than as string forward references."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _decode_message_data(payload_type: Any, data: bytes) -> Payload:
|
|
111
|
+
"""Decode using the subscription's validated type argument.
|
|
112
|
+
|
|
113
|
+
Bytes pass through unchanged. Pydantic v2 models validate their own JSON.
|
|
114
|
+
Other supported annotations use ordinary JSON decoding; nested annotations
|
|
115
|
+
do not coerce or validate JSON values. Use a model for field validation.
|
|
116
|
+
"""
|
|
117
|
+
if payload_type is bytes:
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
BaseModel is not None
|
|
122
|
+
and isinstance(payload_type, type)
|
|
123
|
+
and issubclass(payload_type, BaseModel)
|
|
124
|
+
):
|
|
125
|
+
try:
|
|
126
|
+
return payload_type.model_validate_json(data)
|
|
127
|
+
except ValueError as error:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"Cannot decode Pub/Sub message as {payload_type.__name__}. "
|
|
130
|
+
f"Update the published payload or the model fields. {error}"
|
|
131
|
+
) from error
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
value = json.loads(data)
|
|
135
|
+
except json.JSONDecodeError as error:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Cannot decode Pub/Sub message as JSON at line {error.lineno}, "
|
|
138
|
+
f"column {error.colno}: {error.msg}. Publish valid JSON or use "
|
|
139
|
+
"Subscription[bytes] to handle the raw payload."
|
|
140
|
+
) from error
|
|
141
|
+
except UnicodeDecodeError as error:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"Cannot decode Pub/Sub message: invalid text encoding. "
|
|
144
|
+
"Publish JSON encoded as UTF-8, UTF-16, or UTF-32, or use "
|
|
145
|
+
"Subscription[bytes] to handle the raw payload."
|
|
146
|
+
) from error
|
|
147
|
+
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class Message(Generic[_T]):
|
|
153
|
+
"""A delivered message with its decoded payload and provider metadata.
|
|
154
|
+
|
|
155
|
+
Attributes:
|
|
156
|
+
data: The payload decoded according to the subscription's type argument.
|
|
157
|
+
raw_data: The original payload bytes.
|
|
158
|
+
attributes: Message attributes supplied alongside the payload.
|
|
159
|
+
id: The provider-assigned message identifier.
|
|
160
|
+
delivery_attempt: The delivery attempt number, starting at one.
|
|
161
|
+
publish_time: The publication time as a UTC datetime.
|
|
162
|
+
ordering_key: The ordering key, if supplied.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
data: _T
|
|
166
|
+
raw_data: bytes
|
|
167
|
+
attributes: dict[str, str]
|
|
168
|
+
id: str
|
|
169
|
+
delivery_attempt: int
|
|
170
|
+
publish_time: datetime
|
|
171
|
+
ordering_key: str | None
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def _from_native(cls, message: _native.PubSubMessage, data: _T) -> Message:
|
|
175
|
+
return cls(
|
|
176
|
+
data=data,
|
|
177
|
+
raw_data=message.data,
|
|
178
|
+
attributes=message.attributes,
|
|
179
|
+
id=message.id,
|
|
180
|
+
delivery_attempt=message.delivery_attempt,
|
|
181
|
+
publish_time=message.publish_time,
|
|
182
|
+
ordering_key=message.ordering_key,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _serialize_payload(value: Any) -> bytes:
|
|
187
|
+
if isinstance(value, bytes):
|
|
188
|
+
return value
|
|
189
|
+
|
|
190
|
+
if BaseModel is not None and isinstance(value, BaseModel):
|
|
191
|
+
return value.model_dump_json().encode("utf-8")
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
return json.dumps(value).encode("utf-8")
|
|
195
|
+
except (TypeError, ValueError) as error:
|
|
196
|
+
raise TypeError(
|
|
197
|
+
"Cannot publish Pub/Sub message. Use JSON values, bytes, or a top-level "
|
|
198
|
+
"Pydantic v2 model. Dataclasses and models nested in plain JSON containers "
|
|
199
|
+
"are not supported."
|
|
200
|
+
) from error
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Topic(Generic[_T]):
|
|
204
|
+
"""A pub/sub topic declared at module scope."""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
name: str,
|
|
209
|
+
*,
|
|
210
|
+
exactly_once: bool = False,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Declare a pub/sub topic.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
name: The topic's resource name.
|
|
216
|
+
exactly_once: Whether to request exactly-once delivery instead of
|
|
217
|
+
the default at-least-once delivery.
|
|
218
|
+
"""
|
|
219
|
+
self._name = name
|
|
220
|
+
self._exactly_once = exactly_once
|
|
221
|
+
self._handle = Lazy(lambda: runtime().pubsub_topic(name))
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def _impl(self) -> _native.PubSubTopic:
|
|
225
|
+
return self._handle.get()
|
|
226
|
+
|
|
227
|
+
@classmethod
|
|
228
|
+
def named(cls, name: str) -> Topic[_T]:
|
|
229
|
+
"""Reference an existing topic declared by another service.
|
|
230
|
+
|
|
231
|
+
The declaring service may use another language. Construct Topic
|
|
232
|
+
directly to declare a new topic.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
name: The topic name as a string literal for static resolution.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
A reference to the configured topic.
|
|
239
|
+
"""
|
|
240
|
+
return cls(name)
|
|
241
|
+
|
|
242
|
+
async def publish(
|
|
243
|
+
self, message: _T, attributes: dict[str, str] | None = None
|
|
244
|
+
) -> str:
|
|
245
|
+
"""Publish a message and return its provider-assigned identifier.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
message: A JSON value, bytes, or a top-level Pydantic v2 model.
|
|
249
|
+
attributes: Optional string metadata to send alongside the payload.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The published message identifier.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
TypeError: The payload cannot be serialized as a supported type.
|
|
256
|
+
"""
|
|
257
|
+
return await self._impl.publish(
|
|
258
|
+
_serialize_payload(message), attributes, source=active_ctx_bytes()
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class OrderedTopic(Generic[_T]):
|
|
263
|
+
"""A ordered pub/sub topic declared at module scope."""
|
|
264
|
+
|
|
265
|
+
def __init__(
|
|
266
|
+
self,
|
|
267
|
+
name: str,
|
|
268
|
+
*,
|
|
269
|
+
exactly_once: bool = False,
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Declare a ordered pub/sub topic.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: The topic's resource name.
|
|
275
|
+
exactly_once: Whether to request exactly-once delivery instead of
|
|
276
|
+
the default at-least-once delivery.
|
|
277
|
+
"""
|
|
278
|
+
self._name = name
|
|
279
|
+
self._exactly_once = exactly_once
|
|
280
|
+
self._handle = Lazy(lambda: runtime().pubsub_topic(name))
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def _impl(self) -> _native.PubSubTopic:
|
|
284
|
+
return self._handle.get()
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def named(cls, name: str) -> OrderedTopic[_T]:
|
|
288
|
+
"""Reference an existing topic declared by another service.
|
|
289
|
+
|
|
290
|
+
The declaring service may use another language. Construct OrderedTopic
|
|
291
|
+
directly to declare a new topic.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
name: The topic name as a string literal for static resolution.
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
A reference to the configured topic.
|
|
298
|
+
"""
|
|
299
|
+
return cls(name)
|
|
300
|
+
|
|
301
|
+
async def publish(
|
|
302
|
+
self, message: _T, ordering_key: str, attributes: dict[str, str] | None = None
|
|
303
|
+
) -> str:
|
|
304
|
+
"""Publish a message and return its provider-assigned identifier.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
message: A JSON value, bytes, or a top-level Pydantic v2 model.
|
|
308
|
+
ordering_key: The key grouping messages for ordered delivery.
|
|
309
|
+
attributes: Optional string metadata to send alongside the payload.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
The published message identifier.
|
|
313
|
+
|
|
314
|
+
Raises:
|
|
315
|
+
TypeError: The payload cannot be serialized as a supported type.
|
|
316
|
+
"""
|
|
317
|
+
return await self._impl.publish(
|
|
318
|
+
_serialize_payload(message),
|
|
319
|
+
attributes,
|
|
320
|
+
source=active_ctx_bytes(),
|
|
321
|
+
ordering_key=ordering_key,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
_subscriptions: list[Subscription] = []
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class SubscriptionLoopError(RuntimeError):
|
|
329
|
+
"""The subscription belongs to another asyncio loop."""
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class Subscription(Generic[_T]):
|
|
333
|
+
"""A subscription and async handler declared at module scope.
|
|
334
|
+
|
|
335
|
+
Await encore.start() to begin consumption on the application's asyncio
|
|
336
|
+
loop. The explicit type argument selects payload decoding; omitting it
|
|
337
|
+
uses ordinary JSON decoding.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def __init__(
|
|
341
|
+
self,
|
|
342
|
+
topic: Topic[_T] | OrderedTopic[_T],
|
|
343
|
+
name: str,
|
|
344
|
+
*,
|
|
345
|
+
handler: Callable[[Message[_T]], Awaitable[None]],
|
|
346
|
+
ack_deadline: str | None = None,
|
|
347
|
+
message_retention: str | None = None,
|
|
348
|
+
min_backoff: str | None = None,
|
|
349
|
+
max_backoff: str | None = None,
|
|
350
|
+
max_retries: int | None = None,
|
|
351
|
+
max_concurrency: int | None = None,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Declare a subscription and register its handler for startup.
|
|
354
|
+
|
|
355
|
+
Timing and retry options configure provisioning through Encore's parser.
|
|
356
|
+
None leaves an option at its platform default.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
topic: The topic to subscribe to.
|
|
360
|
+
name: The subscription name within the topic.
|
|
361
|
+
handler: An async callable receiving each decoded Message.
|
|
362
|
+
ack_deadline: The acknowledgement deadline as a duration string.
|
|
363
|
+
message_retention: The message retention period as a duration string.
|
|
364
|
+
min_backoff: The minimum retry delay as a duration string.
|
|
365
|
+
max_backoff: The maximum retry delay as a duration string.
|
|
366
|
+
max_retries: The maximum number of delivery retries.
|
|
367
|
+
max_concurrency: The maximum number of concurrent handler calls.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
TypeError: The handler is not callable.
|
|
371
|
+
"""
|
|
372
|
+
if not callable(handler):
|
|
373
|
+
raise TypeError(
|
|
374
|
+
"Subscription handler must be callable. "
|
|
375
|
+
"Pass an async function, for example handler=handle_order, "
|
|
376
|
+
"rather than calling it with handler=handle_order()."
|
|
377
|
+
)
|
|
378
|
+
self._topic = topic
|
|
379
|
+
self._name = name
|
|
380
|
+
self._handler = handler
|
|
381
|
+
self._handle = Lazy(self._resolve)
|
|
382
|
+
self._consumer: asyncio.Future[None] | None = None
|
|
383
|
+
_subscriptions.append(self)
|
|
384
|
+
|
|
385
|
+
def _resolve(self) -> _native.PubSubSubscription:
|
|
386
|
+
# Generic instance metadata is available after construction, at startup.
|
|
387
|
+
args = get_args(getattr(self, "__orig_class__", None))
|
|
388
|
+
tp = args[0] if args else JSON
|
|
389
|
+
_validate_payload_type(tp)
|
|
390
|
+
|
|
391
|
+
async def dispatch(message: _native.PubSubMessage) -> None:
|
|
392
|
+
data = _decode_message_data(tp, message.data)
|
|
393
|
+
await self._handler(Message._from_native(message, data))
|
|
394
|
+
|
|
395
|
+
return runtime().pubsub_subscription(
|
|
396
|
+
_native.PubSubSubscriptionConfig(
|
|
397
|
+
topic_name=self._topic._name,
|
|
398
|
+
subscription_name=self._name,
|
|
399
|
+
handler=dispatch,
|
|
400
|
+
)
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def _check_loop(self) -> None:
|
|
404
|
+
if self._consumer is not None:
|
|
405
|
+
if self._consumer.get_loop() is not asyncio.get_running_loop():
|
|
406
|
+
raise SubscriptionLoopError(
|
|
407
|
+
"subscription already started on a different asyncio loop"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _start(self) -> asyncio.Future[None]:
|
|
411
|
+
self._check_loop()
|
|
412
|
+
if self._consumer is None:
|
|
413
|
+
self._consumer = asyncio.ensure_future(self._handle.get().subscribe())
|
|
414
|
+
self._consumer.add_done_callback(self._consumer_done)
|
|
415
|
+
return self._consumer
|
|
416
|
+
|
|
417
|
+
def _consumer_done(self, consumer: asyncio.Future[None]) -> None:
|
|
418
|
+
if not consumer.cancelled() and (error := consumer.exception()) is not None:
|
|
419
|
+
consumer.get_loop().call_exception_handler(
|
|
420
|
+
{
|
|
421
|
+
"message": f"Encore subscription {self._topic._name}/{self._name} failed",
|
|
422
|
+
"exception": error,
|
|
423
|
+
"future": consumer,
|
|
424
|
+
}
|
|
425
|
+
)
|
encore/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Typed application secrets declared at module scope.
|
|
2
|
+
|
|
3
|
+
Secret[T] belongs to its declaring service. SharedSecret[T] declares a
|
|
4
|
+
shared secret with tracked usage. GlobalSecret[T] is available to every
|
|
5
|
+
service without usage tracking. Each exposes its current value through
|
|
6
|
+
value and requires an explicit str or bytes type argument.
|
|
7
|
+
|
|
8
|
+
Service-private secrets have a separate namespace from shared and global
|
|
9
|
+
secrets. SharedSecret and GlobalSecret use the same namespace.
|
|
10
|
+
|
|
11
|
+
Typical usage example:
|
|
12
|
+
from encore.secrets import Secret, SharedSecret
|
|
13
|
+
|
|
14
|
+
shared_api_key = SharedSecret[str]("api_key")
|
|
15
|
+
api_key = Secret[str]("api_key")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
from typing import Callable, Generic, Optional, TypeVar, cast, get_args
|
|
20
|
+
|
|
21
|
+
from ..internal.lazy import Lazy
|
|
22
|
+
from ..internal.runtime import _native, runtime
|
|
23
|
+
|
|
24
|
+
_T = TypeVar("_T", str, bytes)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _SecretValue(Generic[_T]):
|
|
28
|
+
def __init__(
|
|
29
|
+
self, name: str, load: Callable[[str], Optional[_native.Secret]]
|
|
30
|
+
) -> None:
|
|
31
|
+
self._name = name
|
|
32
|
+
self._handle = Lazy(lambda: load(name))
|
|
33
|
+
|
|
34
|
+
def read(self, value_type: type[str] | type[bytes]) -> _T:
|
|
35
|
+
if value_type is not str and value_type is not bytes:
|
|
36
|
+
raise TypeError(
|
|
37
|
+
f"For {type(self).__name__}[T](name) T must be str or bytes"
|
|
38
|
+
)
|
|
39
|
+
secret = self._handle.get()
|
|
40
|
+
if secret is None:
|
|
41
|
+
raise KeyError(f"secret {self._name!r} is not configured")
|
|
42
|
+
raw = secret.get_bytes()
|
|
43
|
+
|
|
44
|
+
if value_type is bytes:
|
|
45
|
+
return cast(_T, raw)
|
|
46
|
+
|
|
47
|
+
return cast(_T, raw.decode("utf-8"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Secret(Generic[_T]):
|
|
51
|
+
"""Common public behavior, backed by a scope-specific lookup."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self, name: str, load: Callable[[str], Optional[_native.Secret]]
|
|
55
|
+
) -> None:
|
|
56
|
+
self._name = name
|
|
57
|
+
self._value: _SecretValue[_T] = _SecretValue(name, load)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def name(self) -> str:
|
|
61
|
+
"""The original name, without an internal namespace prefix."""
|
|
62
|
+
return self._name
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def value(self) -> _T:
|
|
66
|
+
"""The secret value decoded using the constructor type argument.
|
|
67
|
+
|
|
68
|
+
A variable annotation alone does not supply the runtime type.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
TypeError: The constructor type argument is missing or is neither
|
|
72
|
+
str nor bytes.
|
|
73
|
+
KeyError: The secret is not configured.
|
|
74
|
+
UnicodeDecodeError: A str secret contains invalid UTF-8.
|
|
75
|
+
"""
|
|
76
|
+
args = get_args(getattr(self, "__orig_class__", None))
|
|
77
|
+
if not args:
|
|
78
|
+
raise TypeError(f"Use {type(self).__name__}[T](name) with an explicit type")
|
|
79
|
+
return self._value.read(args[0])
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
return f"{type(self).__name__}({self._name!r}: *********)"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Secret(_Secret[_T]):
|
|
86
|
+
"""A secret belonging only to the service that declares it.
|
|
87
|
+
|
|
88
|
+
The type parameter must be specified explicitly.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
secret = Secret[str]("ApiKey")
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, name: str) -> None:
|
|
95
|
+
"""Declare a service-private secret at module scope.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name: The configured secret name. Instantiate with an explicit str
|
|
99
|
+
or bytes type argument, such as Secret[str]("ApiKey").
|
|
100
|
+
"""
|
|
101
|
+
super().__init__(name, lambda key: runtime().secret(key))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SharedSecret(_Secret[_T]):
|
|
105
|
+
"""A shared secret with tracked usage; may be declared outside services.
|
|
106
|
+
|
|
107
|
+
The type parameter must be specified explicitly.
|
|
108
|
+
|
|
109
|
+
Example:
|
|
110
|
+
secret = SharedSecret[str]("ApiKey")
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(self, name: str) -> None:
|
|
114
|
+
"""Declare a shared secret at module scope.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
name: The configured secret name. Instantiate with an explicit str
|
|
118
|
+
or bytes type argument, such as SharedSecret[str]("ApiKey").
|
|
119
|
+
"""
|
|
120
|
+
super().__init__(name, lambda key: runtime().shared_secret(key))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class GlobalSecret(_Secret[_T]):
|
|
124
|
+
"""A global secret available to every service without usage tracking.
|
|
125
|
+
|
|
126
|
+
The type parameter must be specified explicitly.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
secret = GlobalSecret[str]("ApiKey")
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, name: str) -> None:
|
|
133
|
+
"""Declare a global secret at module scope.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
name: The configured secret name. Instantiate with an explicit str
|
|
137
|
+
or bytes type argument, such as GlobalSecret[str]("ApiKey").
|
|
138
|
+
"""
|
|
139
|
+
super().__init__(name, lambda key: runtime().shared_secret(key))
|