hikari-risa 1.0.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.
risa/client.py ADDED
@@ -0,0 +1,657 @@
1
+ # Copyright (c) 2025 Rayakame
2
+ #
3
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ # of this software and associated documentation files (the "Software"), to deal
5
+ # in the Software without restriction, including without limitation the rights
6
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ # copies of the Software, and to permit persons to whom the Software is
8
+ # furnished to do so, subject to the following conditions:
9
+ #
10
+ # The above copyright notice and this permission notice shall be included in all
11
+ # copies or substantial portions of the Software.
12
+ #
13
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ # SOFTWARE.
20
+ """The client that routes clicks to views, and the functions that make one."""
21
+
22
+ from __future__ import annotations
23
+
24
+ import abc
25
+ import asyncio
26
+ import collections.abc
27
+ import logging
28
+ import typing
29
+
30
+ import hikari
31
+ import linkd
32
+ import linkd.solver
33
+
34
+ from risa import context
35
+ from risa import di as di_
36
+ from risa import dispatch as dispatch_
37
+ from risa import errors
38
+ from risa import view as view_
39
+ from risa.internal import constants
40
+ from risa.internal import registry
41
+ from risa.state import message
42
+ from risa.state import placement as placement_
43
+ from risa.state import stateless
44
+ from risa.state import stored
45
+ from risa.store import memory
46
+ from risa.ui import build as build_
47
+
48
+ if typing.TYPE_CHECKING:
49
+ from hikari.api import interaction_server as interaction_server_
50
+
51
+ from risa.state import backend as backend_
52
+ from risa.store import protocol
53
+
54
+ __all__ = (
55
+ "Client",
56
+ "GatewayClientAppT",
57
+ "GatewayEnabledClient",
58
+ "LightbulbClient",
59
+ "RestClientAppT",
60
+ "RestEnabledClient",
61
+ "client_from_app",
62
+ "client_from_lightbulb",
63
+ )
64
+
65
+ _LOGGER: typing.Final[logging.Logger] = logging.getLogger("risa.client")
66
+
67
+ type _ComponentResponse = (
68
+ hikari.api.InteractionDeferredBuilder | hikari.api.InteractionMessageBuilder | hikari.api.InteractionModalBuilder
69
+ )
70
+ type _ComponentListener = interaction_server_.ListenerT[hikari.ComponentInteraction, _ComponentResponse]
71
+
72
+
73
+ class _ErrorHandlerDecorator(typing.Protocol):
74
+ def __call__[T: context.ErrorHandler](self, func: T, /) -> T: ...
75
+
76
+
77
+ @typing.runtime_checkable
78
+ class GatewayClientAppT(hikari.EventManagerAware, hikari.RESTAware, typing.Protocol):
79
+ """A gateway app, with an event manager and a REST client, such as `hikari.GatewayBot`."""
80
+
81
+
82
+ @typing.runtime_checkable
83
+ class RestClientAppT(hikari.InteractionServerAware, hikari.RESTAware, typing.Protocol):
84
+ """A REST app, with an interaction server and a REST client, such as `hikari.RESTBot`."""
85
+
86
+
87
+ @typing.runtime_checkable
88
+ class LightbulbClient(typing.Protocol):
89
+ """The part of a lightbulb client risa uses."""
90
+
91
+ @property
92
+ def app(self) -> hikari.RESTAware:
93
+ """The hikari app the lightbulb client runs on."""
94
+ ...
95
+
96
+ @property
97
+ def di(self) -> linkd.solver.DependencyInjectionManager:
98
+ """The lightbulb client's dependency manager."""
99
+ ...
100
+
101
+
102
+ class _LightbulbOptions(typing.TypedDict, total=False):
103
+ use_global: bool
104
+ auto_defer: view_.AutoDefer
105
+ auto_defer_delay: float
106
+ store: protocol.Store | None
107
+ stores: collections.abc.Mapping[str, protocol.Store]
108
+ key_prefix: str | None
109
+
110
+
111
+ class _ClientOptions(_LightbulbOptions, total=False):
112
+ di: linkd.solver.DependencyInjectionManager | None
113
+
114
+
115
+ class Client(abc.ABC):
116
+ """Routes clicks on registered views to their handlers.
117
+
118
+ Make one with `client_from_app` or `client_from_lightbulb`, add views with
119
+ `add_view`, and send a view with `build`. A client answers only `custom_id`s risa
120
+ wrote, so it can share a bot with other component handlers.
121
+
122
+ Parameters
123
+ ----------
124
+ rest
125
+ The REST client risa answers interactions through.
126
+ use_global
127
+ Also route views registered with `@risa.register` but never added to this client.
128
+ di
129
+ The linkd manager handlers are injected from; a new one by default.
130
+ register_app_dependencies
131
+ Register the app, its REST client, and its event manager or interaction server
132
+ for injection.
133
+ auto_defer
134
+ The `AutoDefer` of views and handlers that set none, `OFF` by default.
135
+ auto_defer_delay
136
+ Seconds after which auto-defer answers a click in its handler's place.
137
+ store
138
+ The store `InStore` views use unless they name another.
139
+ stores
140
+ Further stores, by the name `InStore(store=...)` refers to them by.
141
+ key_prefix
142
+ The prefix of every store key, `risa:{application id}` by default. Set it to
143
+ fit a Redis ACL pattern, or to separate bots that share one store.
144
+
145
+ Raises
146
+ ------
147
+ ValueError
148
+ If `key_prefix` is blank, or the default store is given both as `store` and in
149
+ `stores`.
150
+ """
151
+
152
+ __slots__ = (
153
+ "_auto_defer",
154
+ "_auto_defer_delay",
155
+ "_di",
156
+ "_dispatcher",
157
+ "_in_message",
158
+ "_memory_warned",
159
+ "_registry",
160
+ "_rest",
161
+ "_stateless",
162
+ "_stored",
163
+ "_unanswered_warned",
164
+ "_use_global",
165
+ )
166
+
167
+ def __init__( # ruff:ignore[too-many-arguments]
168
+ self,
169
+ rest: hikari.api.RESTClient,
170
+ *,
171
+ use_global: bool = False,
172
+ di: linkd.solver.DependencyInjectionManager | None = None,
173
+ register_app_dependencies: bool = True,
174
+ auto_defer: view_.AutoDefer = view_.AutoDefer.OFF,
175
+ auto_defer_delay: float = constants.AUTO_DEFER_DELAY,
176
+ store: protocol.Store | None = None,
177
+ stores: collections.abc.Mapping[str, protocol.Store] | None = None,
178
+ key_prefix: str | None = None,
179
+ ) -> None:
180
+ if key_prefix is not None and not key_prefix.strip():
181
+ msg = "key_prefix must not be blank; leave it out to namespace store keys by the application id"
182
+ raise ValueError(msg)
183
+ self._di = di if di is not None else linkd.DependencyInjectionManager()
184
+ self._registry = registry.Registry()
185
+ self._rest = rest
186
+ self._use_global = use_global
187
+ self._auto_defer = auto_defer
188
+ self._auto_defer_delay = auto_defer_delay
189
+ namespace = stored.KeyNamespace(rest, key_prefix=key_prefix)
190
+ self._stored = {
191
+ name: stored.StoredBackend(instance, namespace=namespace)
192
+ for name, instance in _store_slots(store, stores or {}).items()
193
+ }
194
+ self._in_message = message.MessageBackend()
195
+ self._stateless = stateless.StatelessBackend()
196
+ self._memory_warned: set[str] = set()
197
+ self._unanswered_warned: set[str] = set()
198
+
199
+ if Client in self._di.registry_for(di_.Contexts.DEFAULT):
200
+ _LOGGER.warning(
201
+ "another risa client is already registered on this dependency manager;"
202
+ " `client: risa.Client` injections will resolve to this newest one",
203
+ )
204
+ self._register_dependency(Client, self)
205
+ if register_app_dependencies:
206
+ self._register_dependency(hikari.api.RESTClient, rest)
207
+ self._dispatcher = dispatch_.Dispatcher(
208
+ rest,
209
+ self._di,
210
+ self._resolve,
211
+ self._require_backend,
212
+ auto_defer=auto_defer,
213
+ auto_defer_delay=auto_defer_delay,
214
+ build=self._build_over,
215
+ )
216
+
217
+ @property
218
+ @abc.abstractmethod
219
+ def app(self) -> hikari.RESTAware:
220
+ """The hikari app the client receives interactions from."""
221
+
222
+ @property
223
+ def di(self) -> linkd.solver.DependencyInjectionManager:
224
+ """The dependency manager handlers are injected from."""
225
+ return self._di
226
+
227
+ @property
228
+ def rest(self) -> hikari.api.RESTClient:
229
+ """The REST client risa answers interactions through."""
230
+ return self._rest
231
+
232
+ async def build(self, view: view_.View) -> build_.Rendered:
233
+ """Render `view` into the components of a new message, ready to send.
234
+
235
+ ```python
236
+ await ctx.respond(**await client.build(Poll(question="Ship it?")))
237
+ ```
238
+
239
+ It runs the view's `load`, renders it, and writes an `InStore` view's first record.
240
+ The result spreads into any hikari or lightbulb call that takes `components=`. If
241
+ that send fails, the record stays behind until its TTL reaps it, or for good under
242
+ `InStore(ttl=None)`.
243
+
244
+ Parameters
245
+ ----------
246
+ view
247
+ The view to render, an instance of a registered class.
248
+
249
+ Returns
250
+ -------
251
+ :
252
+ The components, as a mapping that spreads into a send.
253
+
254
+ Raises
255
+ ------
256
+ NotAViewError
257
+ If the view's class was never registered.
258
+ ViewDeclarationError
259
+ If the view keeps its state in a store this client does not have.
260
+ StateOverflowError
261
+ If an `InMessage` view's state does not fit its components.
262
+ """
263
+ meta = registry.require_meta(view)
264
+ if meta.loader is not None:
265
+ async with self._di.enter_context(di_.Contexts.DEFAULT):
266
+ await meta.loader(view)
267
+ plan = build_.plan(view.render(), meta)
268
+ if not plan.interactive:
269
+ return build_.Rendered(plan.emit(b""))
270
+ self._warn_if_unanswered(meta)
271
+ state = self._require_backend(meta)
272
+ anchor = state.first_anchor(meta=meta, view=view)
273
+ builders = plan.emit(anchor)
274
+ await state.commit_first(meta=meta, view=view, anchor=anchor)
275
+ return build_.Rendered(builders)
276
+
277
+ @typing.overload
278
+ def error_handler[T: context.ErrorHandler](self, func: T, /, *, priority: int = 0) -> T: ...
279
+
280
+ @typing.overload
281
+ def error_handler(self, /, *, priority: int = 0) -> _ErrorHandlerDecorator: ...
282
+
283
+ def error_handler(
284
+ self,
285
+ func: context.ErrorHandler | None = None,
286
+ /,
287
+ *,
288
+ priority: int = 0,
289
+ ) -> context.ErrorHandler | _ErrorHandlerDecorator:
290
+ """Register a function that answers clicks that failed.
291
+
292
+ ```python
293
+ @client.error_handler
294
+ async def on_error(failure: risa.ClickFailure) -> bool:
295
+ if isinstance(failure.error, risa.StoreUnavailableError):
296
+ await failure.ctx.respond("Try again in a moment.", ephemeral=True)
297
+ return True
298
+ return False
299
+ ```
300
+
301
+ It runs when a handler, `load`, `View.on_outdated` or `View.on_state_missing` raises,
302
+ or when the view's state cannot be reached before them; `ClickFailure.stage` says
303
+ which. It returns `True` once it has dealt with the failure, and risa then logs
304
+ nothing. Otherwise the next one runs, and if none returns `True`, risa logs the
305
+ failure as it would without them. One that raises is logged and counts as `False`.
306
+ Its parameters after the first are injected, as a handler's are. It runs without
307
+ auto-defer, and Discord's three seconds count from the click, so a failure that comes
308
+ late, such as a lock timeout, may leave it no time unless auto-defer answered first.
309
+
310
+ Parameters
311
+ ----------
312
+ func
313
+ The function to register. Left out, the call takes `priority` and returns the
314
+ decorator.
315
+ priority
316
+ Higher runs first; functions of one priority run in the order they were
317
+ registered.
318
+
319
+ Returns
320
+ -------
321
+ :
322
+ The function, unchanged, or the decorator that registers one.
323
+ """
324
+
325
+ def register[T: context.ErrorHandler](handler: T) -> T:
326
+ self._dispatcher.add_error_handler(handler, priority=priority)
327
+ return handler
328
+
329
+ return register(func) if func is not None else register
330
+
331
+ def add_view[T: view_.View](self, cls: type[T]) -> type[T]:
332
+ """Route clicks on the components of `cls` through this client.
333
+
334
+ Parameters
335
+ ----------
336
+ cls
337
+ The view class, already registered with `@risa.register`.
338
+
339
+ Returns
340
+ -------
341
+ :
342
+ The class, so that this also works as a decorator above `@risa.register`.
343
+
344
+ Raises
345
+ ------
346
+ NotAViewError
347
+ If the class was never registered.
348
+ ViewDeclarationError
349
+ If the view keeps its state in a store this client does not have.
350
+ """
351
+ meta = registry.require_meta(cls)
352
+ self._check_store(meta)
353
+ _LOGGER.debug("registering view %s to client", meta.name)
354
+ self._registry.register(meta)
355
+ return cls
356
+
357
+ async def _build_over(self, view: view_.View, message_id: hikari.Snowflake) -> build_.Rendered:
358
+ rendered = await self.build(view)
359
+ # The new state starts at sequence 0, so a later commit remembered for the message would win over it.
360
+ self._in_message.forget(message_id)
361
+ return rendered
362
+
363
+ def _warn_if_unanswered(self, meta: registry.ViewMeta) -> None:
364
+ if self._resolve(meta.cookie) is None and meta.cookie not in self._unanswered_warned:
365
+ self._unanswered_warned.add(meta.cookie)
366
+ _LOGGER.warning(
367
+ "view %s was built, but this client does not answer its clicks: pass the class to"
368
+ " client.add_view, or make the client with use_global=True",
369
+ meta.name,
370
+ )
371
+
372
+ def _check_store(self, meta: registry.ViewMeta) -> None:
373
+ state = meta.state
374
+ if not isinstance(state, placement_.InStore):
375
+ return
376
+ slot = self._stored.get(state.store)
377
+ if slot is None:
378
+ raise errors.ViewDeclarationError(meta.name, _missing_store(state.store))
379
+ if isinstance(slot.store, memory.MemoryStore) and state.store not in self._memory_warned:
380
+ self._memory_warned.add(state.store)
381
+ _LOGGER.warning(
382
+ "view %s keeps its state in store %r, which is a MemoryStore: the state lives in this"
383
+ " process only, so it is lost on every restart, and on a REST bot with more than one"
384
+ " process most clicks will not find it - use it for development only",
385
+ meta.name,
386
+ state.store,
387
+ )
388
+
389
+ def _require_backend(self, meta: registry.ViewMeta) -> backend_.StateBackend:
390
+ found = self._backend_for(meta)
391
+ if found is None:
392
+ name = meta.state.store if isinstance(meta.state, placement_.InStore) else placement_.DEFAULT_STORE
393
+ raise errors.ViewDeclarationError(meta.name, _missing_store(name))
394
+ return found
395
+
396
+ def _backend_for(self, meta: registry.ViewMeta) -> backend_.StateBackend | None:
397
+ if not meta.schema.durable:
398
+ return self._stateless
399
+ if isinstance(meta.state, placement_.InStore):
400
+ return self._stored.get(meta.state.store)
401
+ return self._in_message
402
+
403
+ def _register_dependency[T](self, dependency_type: type[T], value: T) -> None:
404
+ try:
405
+ self._di.registry_for(di_.Contexts.DEFAULT).register_value(dependency_type, value)
406
+ except linkd.RegistryFrozenException:
407
+ _LOGGER.warning(
408
+ "could not register %s: the dependency manager is already in use. Build the client before"
409
+ " the manager opens its first container, or pass register_app_dependencies=False if its"
410
+ " owner registers these already.",
411
+ dependency_type,
412
+ )
413
+
414
+ def _resolve(self, cookie: str) -> registry.ViewMeta | None:
415
+ meta = self._registry.get(cookie)
416
+ if meta is None and self._use_global:
417
+ meta = registry.global_registry().get(cookie)
418
+ return meta
419
+
420
+ async def _process_interaction(
421
+ self,
422
+ interaction: hikari.ComponentInteraction,
423
+ gate: context.ResponseGate | None = None,
424
+ ) -> None:
425
+ await self._dispatcher.process(interaction, gate)
426
+
427
+
428
+ class GatewayEnabledClient(Client):
429
+ """A client that receives clicks as gateway events. `client_from_app` or `client_from_lightbulb` makes one."""
430
+
431
+ __slots__ = ("_app",)
432
+
433
+ def __init__(
434
+ self,
435
+ app: GatewayClientAppT,
436
+ *,
437
+ register_app_dependencies: bool = True,
438
+ **options: typing.Unpack[_ClientOptions],
439
+ ) -> None:
440
+ super().__init__(app.rest, register_app_dependencies=register_app_dependencies, **options)
441
+ self._app = app
442
+
443
+ if register_app_dependencies:
444
+ self._register_dependency(hikari.api.EventManager, app.event_manager)
445
+ if isinstance(app, hikari.GatewayBot):
446
+ self._register_dependency(hikari.GatewayBot, app)
447
+
448
+ app.event_manager.subscribe(hikari.ComponentInteractionCreateEvent, self._on_interaction_create)
449
+
450
+ @property
451
+ @typing.override
452
+ def app(self) -> GatewayClientAppT:
453
+ """The gateway app the client receives interactions from."""
454
+ return self._app
455
+
456
+ async def _on_interaction_create(self, event: hikari.ComponentInteractionCreateEvent) -> None:
457
+ await self._process_interaction(event.interaction)
458
+
459
+
460
+ class RestEnabledClient(Client):
461
+ """A client that receives clicks from hikari's interaction server.
462
+
463
+ The interaction server has a single component listener. The client takes it over and
464
+ passes every interaction it does not handle on to the listener it replaced, and it
465
+ logs a warning naming that listener unless `warn_shared_listener=False`.
466
+ `client_from_app` or `client_from_lightbulb` makes one.
467
+ """
468
+
469
+ __slots__ = ("_app", "_replaced")
470
+
471
+ def __init__(
472
+ self,
473
+ app: RestClientAppT,
474
+ *,
475
+ register_app_dependencies: bool = True,
476
+ warn_shared_listener: bool = True,
477
+ **options: typing.Unpack[_ClientOptions],
478
+ ) -> None:
479
+ super().__init__(app.rest, register_app_dependencies=register_app_dependencies, **options)
480
+ self._app = app
481
+
482
+ if register_app_dependencies:
483
+ self._register_dependency(hikari.api.InteractionServer, app.interaction_server)
484
+ if isinstance(app, hikari.RESTBot):
485
+ self._register_dependency(hikari.RESTBot, app)
486
+
487
+ self._replaced = typing.cast(
488
+ "_ComponentListener | None",
489
+ app.interaction_server.get_listener(hikari.ComponentInteraction),
490
+ )
491
+ if self._replaced is not None and warn_shared_listener:
492
+ _LOGGER.warning(
493
+ "the interaction server already had a component listener, %r; risa now receives every"
494
+ " component interaction first and passes each one it does not handle on to that listener."
495
+ " Pass warn_shared_listener=False to the risa client once this is intended",
496
+ self._replaced,
497
+ )
498
+ app.interaction_server.set_listener(hikari.ComponentInteraction, self._on_interaction, replace=True)
499
+
500
+ @property
501
+ @typing.override
502
+ def app(self) -> RestClientAppT:
503
+ """The REST app the client receives interactions from."""
504
+ return self._app
505
+
506
+ async def _on_interaction(
507
+ self,
508
+ interaction: hikari.ComponentInteraction,
509
+ ) -> collections.abc.AsyncGenerator[_ComponentResponse | None, None]:
510
+ gate = context.ResponseGate()
511
+ task = asyncio.create_task(self._process_interaction(interaction, gate))
512
+ try:
513
+ await asyncio.wait_for(gate.acknowledged.wait(), timeout=constants.INTERACTION_WINDOW)
514
+ except TimeoutError:
515
+ _LOGGER.error( # ruff:ignore[error-instead-of-exception]
516
+ "interaction %s received no initial response within %.1fs on the REST transport; the"
517
+ " interaction is lost - respond, defer, or enable auto_defer. The handler was not"
518
+ " cancelled and continues in the background.",
519
+ interaction.id,
520
+ constants.INTERACTION_WINDOW,
521
+ )
522
+ if not gate.adopted and self._replaced is not None:
523
+ await task
524
+ call = self._replaced(interaction)
525
+ if isinstance(call, collections.abc.AsyncGenerator):
526
+ async for response in call:
527
+ yield response
528
+ else:
529
+ yield await call
530
+ return
531
+ yield None
532
+ await task
533
+ if gate.adopted and not gate.responded:
534
+ _LOGGER.debug(
535
+ "interaction %s was answered with 204 and no response was sent through risa;"
536
+ " Discord shows it as failed unless the handler answered it another way",
537
+ interaction.id,
538
+ )
539
+
540
+
541
+ @typing.overload
542
+ def client_from_app(
543
+ app: GatewayClientAppT,
544
+ **options: typing.Unpack[_ClientOptions],
545
+ ) -> GatewayEnabledClient: ...
546
+
547
+
548
+ @typing.overload
549
+ def client_from_app(
550
+ app: RestClientAppT,
551
+ *,
552
+ warn_shared_listener: bool = True,
553
+ **options: typing.Unpack[_ClientOptions],
554
+ ) -> RestEnabledClient: ...
555
+
556
+
557
+ def client_from_app(
558
+ app: GatewayClientAppT | RestClientAppT,
559
+ *,
560
+ warn_shared_listener: bool = True,
561
+ **options: typing.Unpack[_ClientOptions],
562
+ ) -> Client:
563
+ """Make a client for a hikari app, gateway or REST.
564
+
565
+ ```python
566
+ bot = hikari.GatewayBot(TOKEN)
567
+ client = risa.client_from_app(bot, store=risa.MemoryStore())
568
+ ```
569
+
570
+ Parameters
571
+ ----------
572
+ app
573
+ The app to receive interactions from.
574
+ warn_shared_listener
575
+ REST only: warn when the client takes over a component listener another library
576
+ set.
577
+ **options
578
+ The options of `Client`.
579
+
580
+ Returns
581
+ -------
582
+ :
583
+ A `GatewayEnabledClient` or a `RestEnabledClient`, depending on the app.
584
+ """
585
+ if isinstance(app, GatewayClientAppT):
586
+ return GatewayEnabledClient(app, **options)
587
+ return RestEnabledClient(app, warn_shared_listener=warn_shared_listener, **options)
588
+
589
+
590
+ def client_from_lightbulb(
591
+ lightbulb_client: LightbulbClient,
592
+ *,
593
+ warn_shared_listener: bool = True,
594
+ **options: typing.Unpack[_LightbulbOptions],
595
+ ) -> Client:
596
+ """Make a client that shares a lightbulb client's app and dependency manager.
597
+
598
+ Handlers can then be injected with anything registered with lightbulb. The app's own
599
+ dependencies are left to lightbulb, which registers them already.
600
+
601
+ Parameters
602
+ ----------
603
+ lightbulb_client
604
+ The lightbulb client to share.
605
+ warn_shared_listener
606
+ REST only: warn when the client takes over lightbulb's component listener.
607
+ **options
608
+ The options of `Client`, apart from `di`.
609
+
610
+ Returns
611
+ -------
612
+ :
613
+ A `GatewayEnabledClient` or a `RestEnabledClient`, depending on the app.
614
+
615
+ Raises
616
+ ------
617
+ TypeError
618
+ If the lightbulb client's app is neither a gateway nor a REST app.
619
+ """
620
+ app = lightbulb_client.app
621
+ if isinstance(app, GatewayClientAppT):
622
+ return GatewayEnabledClient(app, di=lightbulb_client.di, register_app_dependencies=False, **options)
623
+ if isinstance(app, RestClientAppT):
624
+ return RestEnabledClient(
625
+ app,
626
+ di=lightbulb_client.di,
627
+ register_app_dependencies=False,
628
+ warn_shared_listener=warn_shared_listener,
629
+ **options,
630
+ )
631
+
632
+ msg = "the lightbulb client's app has neither an event manager nor an interaction server"
633
+ raise TypeError(msg)
634
+
635
+
636
+ def _store_slots(
637
+ store: protocol.Store | None,
638
+ stores: collections.abc.Mapping[str, protocol.Store],
639
+ ) -> dict[str, protocol.Store]:
640
+ if store is None:
641
+ return dict(stores)
642
+ if placement_.DEFAULT_STORE in stores:
643
+ msg = f"the {placement_.DEFAULT_STORE!r} store was given twice, as store= and in stores=; pass it one way"
644
+ raise ValueError(msg)
645
+ return {placement_.DEFAULT_STORE: store, **stores}
646
+
647
+
648
+ def _missing_store(name: str) -> str:
649
+ if name == placement_.DEFAULT_STORE:
650
+ return (
651
+ "it keeps its state in the default store, but this client has none; pass store=... to the"
652
+ " client - store=risa.MemoryStore() for development"
653
+ )
654
+ return (
655
+ f"it keeps its state in store {name!r}, but this client has no store of that name; pass"
656
+ f" stores={{{name!r}: ...}} to the client"
657
+ )