qena-shared-lib 0.1.1__tar.gz → 0.1.3__tar.gz

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.
Files changed (43) hide show
  1. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/PKG-INFO +43 -7
  2. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/README.md +42 -6
  3. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/pyproject.toml +1 -1
  4. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_base.py +50 -79
  5. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_exception_handlers.py +1 -0
  6. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/scheduler.py +23 -23
  7. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/security.py +11 -5
  8. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/test_rabbitmq.py +67 -82
  9. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/test_scheduler.py +8 -9
  10. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/test_security.py +45 -45
  11. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/uv.lock +1 -1
  12. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/.gitignore +0 -0
  13. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/.pre-commit-config.yaml +0 -0
  14. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/requirements.txt +0 -0
  15. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/__init__.py +0 -0
  16. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/application.py +0 -0
  17. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/background.py +0 -0
  18. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/dependencies/__init__.py +0 -0
  19. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/dependencies/http.py +0 -0
  20. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/dependencies/miscellaneous.py +0 -0
  21. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/exception_handlers.py +0 -0
  22. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/exceptions.py +0 -0
  23. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/http.py +0 -0
  24. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/logging.py +0 -0
  25. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/logstash/__init__.py +0 -0
  26. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/logstash/_base.py +0 -0
  27. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/logstash/_http_sender.py +0 -0
  28. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/logstash/_tcp_sender.py +0 -0
  29. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/py.typed +0 -0
  30. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/__init__.py +0 -0
  31. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_channel.py +0 -0
  32. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_exceptions.py +0 -0
  33. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_listener.py +0 -0
  34. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_pool.py +0 -0
  35. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_publisher.py +0 -0
  36. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_rpc_client.py +0 -0
  37. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/rabbitmq/_utils.py +0 -0
  38. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/src/qena_shared_lib/utils.py +0 -0
  39. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/conftest.py +0 -0
  40. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/test_application.py +0 -0
  41. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/test_background.py +0 -0
  42. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/test_dependencies.py +0 -0
  43. {qena_shared_lib-0.1.1 → qena_shared_lib-0.1.3}/tests/test_logstash.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qena-shared-lib
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: A shared tools for other services
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: cronsim~=2.0
@@ -30,6 +30,12 @@ A shared tools for other services. It includes.
30
30
 
31
31
  # Usage
32
32
 
33
+ ## Environment variables
34
+
35
+ - `LOGGER_NAME` root logger name.
36
+ - `UNAUTHORIZED_RESPONSE_CODE` an integer response on an authorized access of resource.
37
+ - `TOKEN_HEADER` to header key for jwt token.
38
+
33
39
  ## Http
34
40
 
35
41
  To create fastapi app.
@@ -224,13 +230,11 @@ def main() -> FastAPI:
224
230
  ...
225
231
 
226
232
  rabbitmq = RabbitMqManager(
227
- listeners=[
228
- UserConsumer
229
- ],
230
233
  logstash=logstash,
231
234
  container=builder.container,
232
235
  )
233
236
 
237
+ rabbitmq.include_listener(UserConsumer)
234
238
  builder.add_singleton(
235
239
  service=RabbitMqManager,
236
240
  instance=rabbitmq,
@@ -275,6 +279,40 @@ async def get_user(
275
279
  return user
276
280
  ```
277
281
 
282
+ ### Flow control
283
+
284
+ ``` py
285
+ @Consumer("UserQueue")
286
+ class UserConsumer(ListenerBase):
287
+
288
+ @consume()
289
+ async def store_user(self, ctx: ListenerContext, user: User):
290
+ ...
291
+
292
+ await ctx.flow_control.request(10)
293
+
294
+ ...
295
+
296
+ ```
297
+
298
+ ### Rpc reply
299
+
300
+ Optionally it is possible to reply to rpc calls, through.
301
+
302
+ ``` py
303
+ @RpcWorker("UserQueue")
304
+ class UserWorker(ListenerBase):
305
+
306
+ @execute()
307
+ async def store_user(self, ctx: ListenerContext, user: User):
308
+ ...
309
+
310
+ await ctx.rpc_reply.reply("Done")
311
+
312
+ ...
313
+ ```
314
+
315
+
278
316
  ## Scheduler
279
317
 
280
318
  ``` py
@@ -314,13 +352,11 @@ def main() -> FastAPI:
314
352
  ...
315
353
 
316
354
  schedule_manager = ScheduleManager(
317
- schedulers=[
318
- TaskScheduler
319
- ],
320
355
  logstash=logstash,
321
356
  container=builder.container
322
357
  )
323
358
 
359
+ schedule_manager.include_scheduler(TaskScheduler)
324
360
  builder.with_singleton(
325
361
  service=ScheduleManager,
326
362
  instance=schedule_manager,
@@ -13,6 +13,12 @@ A shared tools for other services. It includes.
13
13
 
14
14
  # Usage
15
15
 
16
+ ## Environment variables
17
+
18
+ - `LOGGER_NAME` root logger name.
19
+ - `UNAUTHORIZED_RESPONSE_CODE` an integer response on an authorized access of resource.
20
+ - `TOKEN_HEADER` to header key for jwt token.
21
+
16
22
  ## Http
17
23
 
18
24
  To create fastapi app.
@@ -207,13 +213,11 @@ def main() -> FastAPI:
207
213
  ...
208
214
 
209
215
  rabbitmq = RabbitMqManager(
210
- listeners=[
211
- UserConsumer
212
- ],
213
216
  logstash=logstash,
214
217
  container=builder.container,
215
218
  )
216
219
 
220
+ rabbitmq.include_listener(UserConsumer)
217
221
  builder.add_singleton(
218
222
  service=RabbitMqManager,
219
223
  instance=rabbitmq,
@@ -258,6 +262,40 @@ async def get_user(
258
262
  return user
259
263
  ```
260
264
 
265
+ ### Flow control
266
+
267
+ ``` py
268
+ @Consumer("UserQueue")
269
+ class UserConsumer(ListenerBase):
270
+
271
+ @consume()
272
+ async def store_user(self, ctx: ListenerContext, user: User):
273
+ ...
274
+
275
+ await ctx.flow_control.request(10)
276
+
277
+ ...
278
+
279
+ ```
280
+
281
+ ### Rpc reply
282
+
283
+ Optionally it is possible to reply to rpc calls, through.
284
+
285
+ ``` py
286
+ @RpcWorker("UserQueue")
287
+ class UserWorker(ListenerBase):
288
+
289
+ @execute()
290
+ async def store_user(self, ctx: ListenerContext, user: User):
291
+ ...
292
+
293
+ await ctx.rpc_reply.reply("Done")
294
+
295
+ ...
296
+ ```
297
+
298
+
261
299
  ## Scheduler
262
300
 
263
301
  ``` py
@@ -297,13 +335,11 @@ def main() -> FastAPI:
297
335
  ...
298
336
 
299
337
  schedule_manager = ScheduleManager(
300
- schedulers=[
301
- TaskScheduler
302
- ],
303
338
  logstash=logstash,
304
339
  container=builder.container
305
340
  )
306
341
 
342
+ schedule_manager.include_scheduler(TaskScheduler)
307
343
  builder.with_singleton(
308
344
  service=ScheduleManager,
309
345
  instance=schedule_manager,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qena-shared-lib"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "A shared tools for other services"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -94,7 +94,6 @@ class RabbitMqManager(AsyncEventLoopMixin):
94
94
 
95
95
  def __init__(
96
96
  self,
97
- listeners: list[Listener | type[ListenerBase]],
98
97
  logstash: BaseLogstashSender,
99
98
  parameters: Parameters | str | None = None,
100
99
  reconnect_delay: float = 5.0,
@@ -102,24 +101,7 @@ class RabbitMqManager(AsyncEventLoopMixin):
102
101
  listener_global_retry_policy: RetryPolicy | None = None,
103
102
  container: Container | None = None,
104
103
  ):
105
- for index, listener in enumerate(listeners):
106
- if not isinstance(listener, Listener) and (
107
- not isinstance(listener, type)
108
- or not issubclass(listener, ListenerBase)
109
- ):
110
- raise TypeError(
111
- f"listener {index} is {type(listener)}, expected instance of type or subclass of `Listener` or `type[ListenerBase]`"
112
- )
113
-
114
- self._listener_classes = [
115
- listener_class
116
- for listener_class in listeners
117
- if isinstance(listener_class, type)
118
- and issubclass(listener_class, ListenerBase)
119
- ]
120
- self._listeners = [
121
- listener for listener in listeners if isinstance(listener, Listener)
122
- ]
104
+ self._listeners: list[Listener] = []
123
105
 
124
106
  if isinstance(parameters, str):
125
107
  self._parameters = URLParameters(parameters)
@@ -139,8 +121,6 @@ class RabbitMqManager(AsyncEventLoopMixin):
139
121
  type[Exception], ExceptionHandlerContainer
140
122
  ] = {}
141
123
 
142
- self._register_listener_classes()
143
-
144
124
  self.exception_handler(RabbitMQException)(handle_rabbitmq_exception)
145
125
  self.exception_handler(ValidationError)(handle_validation_error)
146
126
  self.exception_handler(ServiceException)(handle_microservice_exception)
@@ -150,26 +130,6 @@ class RabbitMqManager(AsyncEventLoopMixin):
150
130
  self._logstash = logstash
151
131
  self._logger = LoggerProvider.default().get_logger("rabbitmq")
152
132
 
153
- def _register_listener_classes(self):
154
- for listener_class in self._listener_classes:
155
- inner_listener = getattr(listener_class, LISTENER_ATTRIBUTE, None)
156
-
157
- if inner_listener is None:
158
- raise AttributeError(
159
- "listener is possibly not with `Consumer` or `RpcWorker`"
160
- )
161
-
162
- if not isinstance(inner_listener, Listener):
163
- raise TypeError(
164
- f"listener class {type(listener_class)} is not a type `Listener`, posibilly not decorated with `Consumer` or `RpcWorker`"
165
- )
166
-
167
- self._container.register(
168
- service=ListenerBase,
169
- factory=listener_class,
170
- scope=Scope.singleton,
171
- )
172
-
173
133
  @property
174
134
  def container(self) -> Container:
175
135
  return self._container
@@ -239,6 +199,40 @@ class RabbitMqManager(AsyncEventLoopMixin):
239
199
 
240
200
  return True, None
241
201
 
202
+ def include_listener(self, listener: Listener | type[ListenerBase]):
203
+ if isinstance(listener, Listener):
204
+ self._listeners.append(listener)
205
+
206
+ return
207
+
208
+ if isinstance(listener, type) and issubclass(listener, ListenerBase):
209
+ self._register_listener_classes(listener)
210
+
211
+ return
212
+
213
+ raise TypeError(
214
+ f"listener is {type(listener)}, expected instance of type or subclass of `Listener` or `type[ListenerBase]`"
215
+ )
216
+
217
+ def _register_listener_classes(self, listener_class: type):
218
+ inner_listener = getattr(listener_class, LISTENER_ATTRIBUTE, None)
219
+
220
+ if inner_listener is None:
221
+ raise AttributeError(
222
+ "listener is possibly not with `Consumer` or `RpcWorker`"
223
+ )
224
+
225
+ if not isinstance(inner_listener, Listener):
226
+ raise TypeError(
227
+ f"listener class {type(listener_class)} is not a type `Listener`, posibilly not decorated with `Consumer` or `RpcWorker`"
228
+ )
229
+
230
+ self._container.register(
231
+ service=ListenerBase,
232
+ factory=listener_class,
233
+ scope=Scope.singleton,
234
+ )
235
+
242
236
  def include_service(
243
237
  self,
244
238
  rabbit_mq_service: AbstractRabbitMQService
@@ -407,9 +401,7 @@ class RabbitMqManager(AsyncEventLoopMixin):
407
401
  exception = task.exception()
408
402
 
409
403
  if exception is not None:
410
- if not self._disconnected and not isinstance(
411
- exception, (ConnectionClosedByClient, ChannelClosedByClient)
412
- ):
404
+ if self._can_reconnect(exception):
413
405
  self._logstash.error(
414
406
  message="couldn't drain the channel pool",
415
407
  exception=exception,
@@ -462,13 +454,7 @@ class RabbitMqManager(AsyncEventLoopMixin):
462
454
  if exception is not None:
463
455
  if not self._connected and not self._connected_future.done():
464
456
  self._connected_future.set_exception(exception)
465
- elif (
466
- self._connected
467
- and not self._disconnected
468
- and not isinstance(
469
- exception, (ConnectionClosedByClient, ChannelClosedByClient)
470
- )
471
- ):
457
+ elif self._can_reconnect(exception):
472
458
  self._logstash.error(
473
459
  message="couldn't fill the channel pool",
474
460
  exception=exception,
@@ -538,13 +524,7 @@ class RabbitMqManager(AsyncEventLoopMixin):
538
524
  if exception is not None:
539
525
  if not self._connected and not self._connected_future.done():
540
526
  self._connected_future.set_exception(exception)
541
- elif (
542
- self._connected
543
- and not self._disconnected
544
- and not isinstance(
545
- exception, (ConnectionClosedByClient, ChannelClosedByClient)
546
- )
547
- ):
527
+ elif self._can_reconnect(exception):
548
528
  self._logstash.error(
549
529
  message="couldn't configure and initialize all listeners and services",
550
530
  exception=exception,
@@ -671,13 +651,7 @@ class RabbitMqManager(AsyncEventLoopMixin):
671
651
 
672
652
  return
673
653
 
674
- if (
675
- self._connected
676
- and not self._disconnected
677
- and not isinstance(
678
- exception, (ConnectionClosedByClient, ChannelClosedByClient)
679
- )
680
- ):
654
+ if self._can_reconnect(exception):
681
655
  self._logstash.error(
682
656
  message="connection to rabbitmq closed unexpectedly, attempting to reconnect",
683
657
  exception=exception,
@@ -696,13 +670,7 @@ class RabbitMqManager(AsyncEventLoopMixin):
696
670
  try:
697
671
  connected_future = self.connect()
698
672
  except Exception as e:
699
- if (
700
- self._connected
701
- and not self._disconnected
702
- and not isinstance(
703
- e, (ConnectionClosedByClient, ChannelClosedByClient)
704
- )
705
- ):
673
+ if self._can_reconnect(e):
706
674
  if not self._connected_future.done():
707
675
  self._connected_future.set_result(None)
708
676
 
@@ -727,15 +695,18 @@ class RabbitMqManager(AsyncEventLoopMixin):
727
695
  if exception is None:
728
696
  return
729
697
 
730
- if (
731
- self._connected
732
- and not self._disconnected
733
- and not isinstance(
734
- exception, (ConnectionClosedByClient, ChannelClosedByClient)
735
- )
736
- ):
698
+ if self._can_reconnect(exception):
737
699
  self._logstash.error(
738
700
  message="couldn't reconnect to rabbitmq, attempting to reconnect",
739
701
  exception=exception,
740
702
  )
741
703
  self._reconnect()
704
+
705
+ def _can_reconnect(self, exception: BaseException) -> bool:
706
+ return (
707
+ self._connected
708
+ and not self._disconnected
709
+ and not isinstance(
710
+ exception, (ConnectionClosedByClient, ChannelClosedByClient)
711
+ )
712
+ )
@@ -49,6 +49,7 @@ def handle_rabbitmq_exception(
49
49
  "listenerName": context.listener_name,
50
50
  "exception": "RabbitMQException",
51
51
  },
52
+ exception=exception,
52
53
  )
53
54
 
54
55
 
@@ -181,33 +181,34 @@ class ScheduleManager(AsyncEventLoopMixin):
181
181
 
182
182
  def __init__(
183
183
  self,
184
- schedulers: list[Scheduler | type[SchedulerBase]],
185
184
  logstash: BaseLogstashSender,
186
185
  container: Container | None = None,
187
186
  ):
188
187
  self._container = container or Container()
189
188
  self._logstash = logstash
190
189
  self._scheduled_tasks: list[ScheduledTask] = []
191
-
192
- for index, scheduler in enumerate(schedulers):
193
- if isinstance(scheduler, Scheduler):
194
- self._scheduled_tasks.extend(scheduler.scheduled_tasks)
195
- elif isinstance(scheduler, type) and issubclass(
196
- scheduler, SchedulerBase
197
- ):
198
- self._container.register(
199
- service=SchedulerBase,
200
- factory=scheduler,
201
- scope=Scope.singleton,
202
- )
203
- else:
204
- raise TypeError(
205
- f"scheduler {index} is {type(scheduler)}, expected instance of type or subclass of `Scheduler` or `type[SchedulerBase]`"
206
- )
207
-
208
190
  self._next_run_in = None
209
191
  self._logger = LoggerProvider.default().get_logger("schedule_manager")
210
192
 
193
+ def include_scheduler(self, scheduler: Scheduler | type[SchedulerBase]):
194
+ if isinstance(scheduler, Scheduler):
195
+ self._scheduled_tasks.extend(scheduler.scheduled_tasks)
196
+
197
+ return
198
+
199
+ if isinstance(scheduler, type) and issubclass(scheduler, SchedulerBase):
200
+ self._container.register(
201
+ service=SchedulerBase,
202
+ factory=scheduler,
203
+ scope=Scope.singleton,
204
+ )
205
+
206
+ return
207
+
208
+ raise TypeError(
209
+ f"scheduler is {type(scheduler)}, expected instance of type or subclass of `Scheduler` or `type[SchedulerBase]`"
210
+ )
211
+
211
212
  @property
212
213
  def container(self) -> Container:
213
214
  return self._container
@@ -226,10 +227,7 @@ class ScheduleManager(AsyncEventLoopMixin):
226
227
  if not self._aquired_lock():
227
228
  return
228
229
 
229
- if (
230
- getattr(self, "_scheduler_task", None) is not None
231
- and not self._scheduler_task.done()
232
- ):
230
+ if self._scheduler_task is not None and not self._scheduler_task.done():
233
231
  raise RuntimeError("scheduler already running")
234
232
 
235
233
  self.use_schedulers()
@@ -239,6 +237,8 @@ class ScheduleManager(AsyncEventLoopMixin):
239
237
  "tasks" if self.scheduled_task_count > 1 else "task",
240
238
  )
241
239
 
240
+ self._scheduler_task = None
241
+
242
242
  if self.scheduled_task_count == 0:
243
243
  return
244
244
 
@@ -255,7 +255,7 @@ class ScheduleManager(AsyncEventLoopMixin):
255
255
  self._scheduled_tasks.extend(scheduler.scheduled_tasks)
256
256
 
257
257
  def stop(self):
258
- if not self._scheduler_task.done():
258
+ if self._scheduler_task is not None and not self._scheduler_task.done():
259
259
  self._scheduler_task.cancel()
260
260
 
261
261
  self.SCHEDULE_MANAGER_STATE.state("stopped")
@@ -26,14 +26,14 @@ __all__ = [
26
26
  ]
27
27
 
28
28
 
29
- MESSAGE = "you are not authorized to access request resouce"
29
+ MESSAGE = "you are not authorized to access requested resource"
30
30
  RESPONSE_CODE = int(environ.get("UNAUTHORIZED_RESPONSE_CODE") or 0)
31
31
 
32
32
 
33
33
  class PasswordHasher(AsyncEventLoopMixin):
34
- def __init__(self):
34
+ def __init__(self, schemes=None):
35
35
  self._crypt_context = CryptContext(
36
- schemes=["bcrypt"], deprecated="auto"
36
+ schemes=schemes or ["bcrypt"], deprecated="auto"
37
37
  )
38
38
 
39
39
  def hash(self, password: str) -> Future[str]:
@@ -92,9 +92,15 @@ class UserInfo(BaseModel):
92
92
  async def extract_user_info(
93
93
  jwt_adapter: Annotated[JwtAdapter, DependsOn(JwtAdapter)],
94
94
  token: Annotated[
95
- str | None, Header(alias=environ.get("TOKEN_HEADER") or "authorization")
95
+ str | None,
96
+ Header(
97
+ alias=environ.get("TOKEN_HEADER") or "authorization",
98
+ include_in_schema=False,
99
+ ),
100
+ ] = None,
101
+ user_agent: Annotated[
102
+ str | None, Header(alias="user-agent", include_in_schema=False)
96
103
  ] = None,
97
- user_agent: Annotated[str | None, Header(alias="user-agent")] = None,
98
104
  ) -> UserInfo:
99
105
  extra = {"userAgent": user_agent} if user_agent is not None else None
100
106