aury-boot 0.0.2__py3-none-any.whl → 0.0.3__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.
Files changed (138) hide show
  1. aury/boot/__init__.py +66 -0
  2. aury/boot/_version.py +2 -2
  3. aury/boot/application/__init__.py +120 -0
  4. aury/boot/application/app/__init__.py +39 -0
  5. aury/boot/application/app/base.py +511 -0
  6. aury/boot/application/app/components.py +434 -0
  7. aury/boot/application/app/middlewares.py +101 -0
  8. aury/boot/application/config/__init__.py +44 -0
  9. aury/boot/application/config/settings.py +663 -0
  10. aury/boot/application/constants/__init__.py +19 -0
  11. aury/boot/application/constants/components.py +50 -0
  12. aury/boot/application/constants/scheduler.py +28 -0
  13. aury/boot/application/constants/service.py +29 -0
  14. aury/boot/application/errors/__init__.py +55 -0
  15. aury/boot/application/errors/chain.py +80 -0
  16. aury/boot/application/errors/codes.py +67 -0
  17. aury/boot/application/errors/exceptions.py +238 -0
  18. aury/boot/application/errors/handlers.py +320 -0
  19. aury/boot/application/errors/response.py +120 -0
  20. aury/boot/application/interfaces/__init__.py +76 -0
  21. aury/boot/application/interfaces/egress.py +224 -0
  22. aury/boot/application/interfaces/ingress.py +98 -0
  23. aury/boot/application/middleware/__init__.py +22 -0
  24. aury/boot/application/middleware/logging.py +451 -0
  25. aury/boot/application/migrations/__init__.py +13 -0
  26. aury/boot/application/migrations/manager.py +685 -0
  27. aury/boot/application/migrations/setup.py +237 -0
  28. aury/boot/application/rpc/__init__.py +63 -0
  29. aury/boot/application/rpc/base.py +108 -0
  30. aury/boot/application/rpc/client.py +294 -0
  31. aury/boot/application/rpc/discovery.py +218 -0
  32. aury/boot/application/scheduler/__init__.py +13 -0
  33. aury/boot/application/scheduler/runner.py +123 -0
  34. aury/boot/application/server/__init__.py +296 -0
  35. aury/boot/commands/__init__.py +30 -0
  36. aury/boot/commands/add.py +76 -0
  37. aury/boot/commands/app.py +105 -0
  38. aury/boot/commands/config.py +177 -0
  39. aury/boot/commands/docker.py +367 -0
  40. aury/boot/commands/docs.py +284 -0
  41. aury/boot/commands/generate.py +1277 -0
  42. aury/boot/commands/init.py +890 -0
  43. aury/boot/commands/migrate/__init__.py +37 -0
  44. aury/boot/commands/migrate/app.py +54 -0
  45. aury/boot/commands/migrate/commands.py +303 -0
  46. aury/boot/commands/scheduler.py +124 -0
  47. aury/boot/commands/server/__init__.py +21 -0
  48. aury/boot/commands/server/app.py +541 -0
  49. aury/boot/commands/templates/generate/api.py.tpl +105 -0
  50. aury/boot/commands/templates/generate/model.py.tpl +17 -0
  51. aury/boot/commands/templates/generate/repository.py.tpl +19 -0
  52. aury/boot/commands/templates/generate/schema.py.tpl +29 -0
  53. aury/boot/commands/templates/generate/service.py.tpl +48 -0
  54. aury/boot/commands/templates/project/CLI.md.tpl +92 -0
  55. aury/boot/commands/templates/project/DEVELOPMENT.md.tpl +1397 -0
  56. aury/boot/commands/templates/project/README.md.tpl +111 -0
  57. aury/boot/commands/templates/project/admin_console_init.py.tpl +50 -0
  58. aury/boot/commands/templates/project/config.py.tpl +30 -0
  59. aury/boot/commands/templates/project/conftest.py.tpl +26 -0
  60. aury/boot/commands/templates/project/env.example.tpl +213 -0
  61. aury/boot/commands/templates/project/gitignore.tpl +128 -0
  62. aury/boot/commands/templates/project/main.py.tpl +41 -0
  63. aury/boot/commands/templates/project/modules/api.py.tpl +19 -0
  64. aury/boot/commands/templates/project/modules/exceptions.py.tpl +84 -0
  65. aury/boot/commands/templates/project/modules/schedules.py.tpl +18 -0
  66. aury/boot/commands/templates/project/modules/tasks.py.tpl +20 -0
  67. aury/boot/commands/worker.py +143 -0
  68. aury/boot/common/__init__.py +35 -0
  69. aury/boot/common/exceptions/__init__.py +114 -0
  70. aury/boot/common/i18n/__init__.py +16 -0
  71. aury/boot/common/i18n/translator.py +272 -0
  72. aury/boot/common/logging/__init__.py +716 -0
  73. aury/boot/contrib/__init__.py +10 -0
  74. aury/boot/contrib/admin_console/__init__.py +18 -0
  75. aury/boot/contrib/admin_console/auth.py +137 -0
  76. aury/boot/contrib/admin_console/discovery.py +69 -0
  77. aury/boot/contrib/admin_console/install.py +172 -0
  78. aury/boot/contrib/admin_console/utils.py +44 -0
  79. aury/boot/domain/__init__.py +79 -0
  80. aury/boot/domain/exceptions/__init__.py +132 -0
  81. aury/boot/domain/models/__init__.py +51 -0
  82. aury/boot/domain/models/base.py +69 -0
  83. aury/boot/domain/models/mixins.py +135 -0
  84. aury/boot/domain/models/models.py +96 -0
  85. aury/boot/domain/pagination/__init__.py +279 -0
  86. aury/boot/domain/repository/__init__.py +23 -0
  87. aury/boot/domain/repository/impl.py +423 -0
  88. aury/boot/domain/repository/interceptors.py +47 -0
  89. aury/boot/domain/repository/interface.py +106 -0
  90. aury/boot/domain/repository/query_builder.py +348 -0
  91. aury/boot/domain/service/__init__.py +11 -0
  92. aury/boot/domain/service/base.py +73 -0
  93. aury/boot/domain/transaction/__init__.py +404 -0
  94. aury/boot/infrastructure/__init__.py +104 -0
  95. aury/boot/infrastructure/cache/__init__.py +31 -0
  96. aury/boot/infrastructure/cache/backends.py +348 -0
  97. aury/boot/infrastructure/cache/base.py +68 -0
  98. aury/boot/infrastructure/cache/exceptions.py +37 -0
  99. aury/boot/infrastructure/cache/factory.py +94 -0
  100. aury/boot/infrastructure/cache/manager.py +274 -0
  101. aury/boot/infrastructure/database/__init__.py +39 -0
  102. aury/boot/infrastructure/database/config.py +71 -0
  103. aury/boot/infrastructure/database/exceptions.py +44 -0
  104. aury/boot/infrastructure/database/manager.py +317 -0
  105. aury/boot/infrastructure/database/query_tools/__init__.py +164 -0
  106. aury/boot/infrastructure/database/strategies/__init__.py +198 -0
  107. aury/boot/infrastructure/di/__init__.py +15 -0
  108. aury/boot/infrastructure/di/container.py +393 -0
  109. aury/boot/infrastructure/events/__init__.py +33 -0
  110. aury/boot/infrastructure/events/bus.py +362 -0
  111. aury/boot/infrastructure/events/config.py +52 -0
  112. aury/boot/infrastructure/events/consumer.py +134 -0
  113. aury/boot/infrastructure/events/middleware.py +51 -0
  114. aury/boot/infrastructure/events/models.py +63 -0
  115. aury/boot/infrastructure/monitoring/__init__.py +529 -0
  116. aury/boot/infrastructure/scheduler/__init__.py +19 -0
  117. aury/boot/infrastructure/scheduler/exceptions.py +37 -0
  118. aury/boot/infrastructure/scheduler/manager.py +478 -0
  119. aury/boot/infrastructure/storage/__init__.py +38 -0
  120. aury/boot/infrastructure/storage/base.py +164 -0
  121. aury/boot/infrastructure/storage/exceptions.py +37 -0
  122. aury/boot/infrastructure/storage/factory.py +88 -0
  123. aury/boot/infrastructure/tasks/__init__.py +24 -0
  124. aury/boot/infrastructure/tasks/config.py +45 -0
  125. aury/boot/infrastructure/tasks/constants.py +37 -0
  126. aury/boot/infrastructure/tasks/exceptions.py +37 -0
  127. aury/boot/infrastructure/tasks/manager.py +490 -0
  128. aury/boot/testing/__init__.py +24 -0
  129. aury/boot/testing/base.py +122 -0
  130. aury/boot/testing/client.py +163 -0
  131. aury/boot/testing/factory.py +154 -0
  132. aury/boot/toolkit/__init__.py +21 -0
  133. aury/boot/toolkit/http/__init__.py +367 -0
  134. {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/METADATA +3 -2
  135. aury_boot-0.0.3.dist-info/RECORD +137 -0
  136. aury_boot-0.0.2.dist-info/RECORD +0 -5
  137. {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/WHEEL +0 -0
  138. {aury_boot-0.0.2.dist-info → aury_boot-0.0.3.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,490 @@
1
+ """异步任务管理器 - 统一的任务队列接口。
2
+
3
+ 提供:
4
+ - 统一的任务队列管理
5
+ - 任务注册和执行
6
+ - 错误处理和重试
7
+ - 条件注册(避免 API 模式下重复注册)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from aury.boot.common.logging import logger
16
+ from aury.boot.infrastructure.tasks.config import TaskConfig
17
+ from aury.boot.infrastructure.tasks.constants import TaskQueueName, TaskRunMode
18
+
19
+ # 延迟导入 dramatiq(可选依赖)
20
+ try:
21
+ import dramatiq
22
+ from dramatiq import Message
23
+ from dramatiq.middleware import AsyncIO, CurrentMessage, TimeLimit
24
+ _DRAMATIQ_AVAILABLE = True
25
+ except ImportError:
26
+ _DRAMATIQ_AVAILABLE = False
27
+ # 创建占位符类型,避免类型检查错误
28
+ if TYPE_CHECKING:
29
+ from dramatiq import Message
30
+ from dramatiq.middleware import AsyncIO, CurrentMessage, TimeLimit
31
+ else:
32
+ Message = None
33
+ AsyncIO = None
34
+ CurrentMessage = None
35
+ TimeLimit = None
36
+
37
+ # 延迟导入 kombu broker(可选依赖)
38
+ try:
39
+ from dramatiq_kombu_broker import KombuBroker
40
+ _KOMBU_BROKER_AVAILABLE = True
41
+ except ImportError:
42
+ _KOMBU_BROKER_AVAILABLE = False
43
+ if TYPE_CHECKING:
44
+ from dramatiq_kombu_broker import KombuBroker
45
+ else:
46
+ KombuBroker = None
47
+
48
+
49
+ class TaskProxy:
50
+ """任务代理类,用于在 API 模式下发送消息而不注册 actor。
51
+
52
+ 在 API 服务中,我们不希望注册 actor(因为 worker 已经注册了),
53
+ 但仍然需要能够发送任务消息。TaskProxy 提供了这个功能。
54
+
55
+ 使用示例:
56
+ @conditional_actor(queue_name="default")
57
+ async def send_email(to: str, subject: str):
58
+ # 在 worker 中会注册为 actor
59
+ # 在 API 中会返回 TaskProxy
60
+ pass
61
+
62
+ # API 模式下发送任务
63
+ send_email.send("user@example.com", "Hello")
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ func: Callable,
69
+ queue_name: str,
70
+ actor_name: str,
71
+ broker: Any, # RedisBroker | None,但使用 Any 避免类型检查错误
72
+ **actor_kwargs: Any,
73
+ ) -> None:
74
+ """初始化任务代理。
75
+
76
+ Args:
77
+ func: 任务函数
78
+ queue_name: 队列名称
79
+ actor_name: Actor 名称(完整模块路径)
80
+ broker: Broker 实例
81
+ **actor_kwargs: Actor 参数
82
+ """
83
+ self.func = func
84
+ self.queue_name = queue_name
85
+ self.actor_name = actor_name
86
+ self.actor_kwargs = actor_kwargs
87
+ self._broker = broker
88
+
89
+ def send(self, *args: Any, **kwargs: Any) -> Message | None:
90
+ """发送任务消息,直接通过 broker 发送,不注册 actor。
91
+
92
+ Args:
93
+ *args: 位置参数
94
+ **kwargs: 关键字参数
95
+
96
+ Returns:
97
+ Message | None: 发送的消息对象
98
+ """
99
+ if not self._broker:
100
+ raise RuntimeError("Broker 未初始化,无法发送任务")
101
+
102
+ # 使用 dramatiq 的 Message 类创建消息
103
+ # actor_name 必须是 worker 中注册的完整函数路径
104
+ message = Message(
105
+ queue_name=self.queue_name,
106
+ actor_name=self.actor_name,
107
+ args=args,
108
+ kwargs=kwargs,
109
+ options={
110
+ "max_retries": self.actor_kwargs.get("max_retries", 0),
111
+ "time_limit": self.actor_kwargs.get("time_limit"),
112
+ },
113
+ )
114
+
115
+ result = self._broker.enqueue(message)
116
+ logger.debug(f"任务消息已发送: {self.actor_name}")
117
+ return result
118
+
119
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
120
+ """直接调用函数(用于测试等场景)。
121
+
122
+ Args:
123
+ *args: 位置参数
124
+ **kwargs: 关键字参数
125
+
126
+ Returns:
127
+ Any: 函数返回值
128
+ """
129
+ return self.func(*args, **kwargs)
130
+
131
+ def __repr__(self) -> str:
132
+ """字符串表示。"""
133
+ return f"<TaskProxy actor_name={self.actor_name} queue={self.queue_name}>"
134
+
135
+
136
+ class TaskManager:
137
+ """任务管理器(命名多实例)。
138
+
139
+ 职责:
140
+ 1. 管理任务队列broker
141
+ 2. 注册任务
142
+ 3. 任务执行和监控
143
+ 4. 支持多个命名实例,如不同的消息队列
144
+
145
+ 使用示例:
146
+ # 默认实例
147
+ task_manager = TaskManager.get_instance()
148
+ await task_manager.initialize()
149
+
150
+ # 命名实例
151
+ email_tasks = TaskManager.get_instance("email")
152
+ report_tasks = TaskManager.get_instance("report")
153
+
154
+ # 注册任务
155
+ @task_manager.task
156
+ async def send_email(to: str, subject: str):
157
+ # 发送邮件
158
+ pass
159
+
160
+ # 执行任务
161
+ send_email.send("user@example.com", "Hello")
162
+ """
163
+
164
+ _instances: dict[str, TaskManager] = {}
165
+
166
+ def __init__(self, name: str = "default") -> None:
167
+ """初始化任务管理器。
168
+
169
+ Args:
170
+ name: 实例名称
171
+ """
172
+ self.name = name
173
+ self._broker: Any = None # KombuBroker | None
174
+ self._initialized: bool = False
175
+ self._task_config: TaskConfig | None = None
176
+ self._run_mode: TaskRunMode = TaskRunMode.WORKER # 默认 Worker 模式(调度者)
177
+
178
+ @classmethod
179
+ def get_instance(cls, name: str = "default") -> TaskManager:
180
+ """获取指定名称的实例。
181
+
182
+ Args:
183
+ name: 实例名称,默认为 "default"
184
+
185
+ Returns:
186
+ TaskManager: 任务管理器实例
187
+ """
188
+ if name not in cls._instances:
189
+ cls._instances[name] = cls(name)
190
+ return cls._instances[name]
191
+
192
+ @classmethod
193
+ def reset_instance(cls, name: str | None = None) -> None:
194
+ """重置实例(仅用于测试)。
195
+
196
+ Args:
197
+ name: 要重置的实例名称。如果为 None,则重置所有实例。
198
+
199
+ 注意:调用此方法前应先调用 cleanup() 释放资源。
200
+ """
201
+ if name is None:
202
+ cls._instances.clear()
203
+ elif name in cls._instances:
204
+ del cls._instances[name]
205
+
206
+ async def initialize(
207
+ self,
208
+ task_config: TaskConfig | None = None,
209
+ run_mode: TaskRunMode | str | None = None,
210
+ broker_url: str | None = None,
211
+ *,
212
+ middleware: list | None = None,
213
+ ) -> None:
214
+ """初始化任务队列。
215
+
216
+ Args:
217
+ task_config: 任务配置(TaskConfig)
218
+ run_mode: 运行模式(TaskRunMode 或字符串,如 "api", "worker")
219
+ broker_url: Broker连接URL(可选,优先使用 config)
220
+ middleware: 中间件列表
221
+ """
222
+ if self._initialized:
223
+ logger.warning("任务管理器已初始化,跳过")
224
+ return
225
+
226
+ self._task_config = task_config or TaskConfig()
227
+
228
+ # 处理 run_mode 参数
229
+ if run_mode is None:
230
+ self._run_mode = TaskRunMode.WORKER # 默认 Worker 模式(调度者)
231
+ elif isinstance(run_mode, str):
232
+ try:
233
+ self._run_mode = TaskRunMode(run_mode.lower())
234
+ except ValueError:
235
+ logger.warning(f"无效的运行模式: {run_mode},使用默认值: {TaskRunMode.WORKER.value}")
236
+ self._run_mode = TaskRunMode.WORKER
237
+ else:
238
+ self._run_mode = run_mode
239
+
240
+ # 获取 broker URL(优先级:参数 > 配置 > 环境变量)
241
+ url = broker_url or self._task_config.broker_url
242
+ if not url:
243
+ logger.warning("未配置任务队列URL,任务功能将被禁用")
244
+ return
245
+
246
+ if not _DRAMATIQ_AVAILABLE:
247
+ raise ImportError(
248
+ "dramatiq 未安装。请安装可选依赖: pip install 'aury-boot[queue-dramatiq]'"
249
+ )
250
+
251
+ if not _KOMBU_BROKER_AVAILABLE:
252
+ raise ImportError(
253
+ "dramatiq-kombu-broker 未安装。请安装可选依赖: pip install 'aury-boot[queue-dramatiq-kombu]'"
254
+ )
255
+
256
+ try:
257
+ # 使用函数式编程创建默认中间件(如果未提供)
258
+ def create_default_middleware() -> list:
259
+ """创建默认中间件列表。"""
260
+ return [AsyncIO(), CurrentMessage(), TimeLimit()]
261
+
262
+ middleware_list = middleware if middleware is not None else create_default_middleware()
263
+
264
+ # 使用 KombuBroker,支持多种后端(Redis、RabbitMQ、SQS 等)
265
+ self._broker = KombuBroker(url=url, middleware=middleware_list)
266
+ dramatiq.set_broker(self._broker)
267
+ self._initialized = True
268
+ logger.info(f"任务管理器初始化完成(使用 Kombu Broker: {url})")
269
+ except Exception as exc:
270
+ logger.error(f"任务队列初始化失败: {exc}")
271
+ raise
272
+
273
+ def task(
274
+ self,
275
+ func: Callable | None = None,
276
+ *,
277
+ actor_name: str | None = None,
278
+ max_retries: int = 3,
279
+ time_limit: int | None = None,
280
+ queue_name: str = TaskQueueName.DEFAULT.value,
281
+ **kwargs: Any,
282
+ ) -> Any:
283
+ """任务装饰器(始终注册)。
284
+
285
+ Args:
286
+ func: 任务函数
287
+ actor_name: Actor名称
288
+ max_retries: 最大重试次数
289
+ time_limit: 时间限制(毫秒)
290
+ queue_name: 队列名称
291
+ **kwargs: 其他参数
292
+
293
+ 使用示例:
294
+ @task_manager.task(max_retries=3)
295
+ async def send_email(to: str, subject: str):
296
+ # 发送邮件
297
+ pass
298
+ """
299
+ if not _DRAMATIQ_AVAILABLE:
300
+ raise ImportError(
301
+ "dramatiq 未安装。请安装可选依赖: pip install 'aury-boot[queue-dramatiq]'"
302
+ )
303
+
304
+ def decorator(f: Callable) -> Callable:
305
+ actor = dramatiq.actor(
306
+ f,
307
+ actor_name=actor_name or f.__name__,
308
+ max_retries=max_retries,
309
+ time_limit=time_limit,
310
+ queue_name=queue_name,
311
+ **kwargs,
312
+ )
313
+ logger.debug(f"任务已注册: {actor_name or f.__name__}")
314
+ return actor
315
+
316
+ if func is None:
317
+ return decorator
318
+ return decorator(func)
319
+
320
+ def conditional_task(
321
+ self,
322
+ func: Callable | None = None,
323
+ *,
324
+ actor_name: str | None = None,
325
+ max_retries: int = 3,
326
+ time_limit: int | None = None,
327
+ queue_name: str = TaskQueueName.DEFAULT.value,
328
+ **kwargs: Any,
329
+ ) -> Any:
330
+ """条件注册的任务装饰器。
331
+
332
+ 根据 SERVICE_TYPE 环境变量决定:
333
+ - worker 模式:正常注册为 actor
334
+ - api 模式:返回 TaskProxy,可以发送消息但不注册
335
+
336
+ Args:
337
+ func: 任务函数
338
+ actor_name: Actor名称
339
+ max_retries: 最大重试次数
340
+ time_limit: 时间限制(毫秒)
341
+ queue_name: 队列名称
342
+ **kwargs: 其他参数
343
+
344
+ 使用示例:
345
+ @task_manager.conditional_task(max_retries=3)
346
+ async def send_email(to: str, subject: str):
347
+ # 在 worker 中会注册为 actor
348
+ # 在 API 中会返回 TaskProxy
349
+ pass
350
+ """
351
+ if not _DRAMATIQ_AVAILABLE:
352
+ raise ImportError(
353
+ "dramatiq 未安装。请安装可选依赖: pip install 'aury-boot[queue-dramatiq]'"
354
+ )
355
+
356
+ def decorator(f: Callable) -> Any:
357
+ # 从配置获取运行模式,默认为 API
358
+ run_mode = self._run_mode
359
+
360
+ if run_mode == TaskRunMode.WORKER:
361
+ # Worker 模式下正常注册(执行者)
362
+ actor = dramatiq.actor(
363
+ f,
364
+ actor_name=actor_name or f.__name__,
365
+ max_retries=max_retries,
366
+ time_limit=time_limit,
367
+ queue_name=queue_name,
368
+ **kwargs,
369
+ )
370
+ logger.debug(f"任务已注册(worker 模式): {actor_name or f.__name__}")
371
+ return actor
372
+ else:
373
+ # Producer 模式下返回代理对象,不注册但可以发送消息
374
+ # 获取函数的完整模块路径作为 actor_name
375
+ module_name = f.__module__
376
+ func_name = f.__name__
377
+ full_actor_name = actor_name or f"{module_name}.{func_name}"
378
+
379
+ proxy = TaskProxy(
380
+ func=f,
381
+ queue_name=queue_name,
382
+ actor_name=full_actor_name,
383
+ broker=self._broker,
384
+ max_retries=max_retries,
385
+ time_limit=time_limit,
386
+ **kwargs,
387
+ )
388
+ logger.debug(f"任务代理已创建(producer 模式): {full_actor_name}")
389
+ return proxy
390
+
391
+ if func is None:
392
+ return decorator
393
+ return decorator(func)
394
+
395
+ @property
396
+ def broker(self) -> Any: # KombuBroker | None
397
+ """获取broker实例。"""
398
+ return self._broker
399
+
400
+ def is_initialized(self) -> bool:
401
+ """检查是否已初始化。"""
402
+ return self._initialized
403
+
404
+ async def cleanup(self) -> None:
405
+ """清理资源。"""
406
+ if self._broker:
407
+ # Dramatiq会自动管理broker
408
+ pass
409
+ self._initialized = False
410
+ logger.info("任务管理器已清理")
411
+
412
+ def __repr__(self) -> str:
413
+ """字符串表示。"""
414
+ status = "initialized" if self._initialized else "not initialized"
415
+ return f"<TaskManager status={status}>"
416
+
417
+
418
+ def conditional_actor(
419
+ queue_name: str = TaskQueueName.DEFAULT.value,
420
+ run_mode: TaskRunMode | str | None = None,
421
+ **kwargs: Any,
422
+ ) -> Callable[[Callable], Any]:
423
+ """条件注册的 actor 装饰器(独立函数版本)。
424
+
425
+ 在 worker 模式下正常注册为 actor(执行者)
426
+ 在 producer 模式下返回 TaskProxy,可以发送消息但不注册(生产者)
427
+
428
+ Args:
429
+ queue_name: 队列名称
430
+ run_mode: 运行模式(TaskRunMode 或字符串),默认为 WORKER
431
+ **kwargs: 其他参数
432
+
433
+ 使用示例:
434
+ @conditional_actor(queue_name="default", max_retries=3)
435
+ async def send_email(to: str, subject: str):
436
+ # 在 worker 中会注册为 actor
437
+ # 在 producer 中会返回 TaskProxy
438
+ pass
439
+
440
+ # Producer 模式下发送任务
441
+ send_email.send("user@example.com", "Hello")
442
+ """
443
+ if not _DRAMATIQ_AVAILABLE:
444
+ raise ImportError(
445
+ "dramatiq 未安装。请安装可选依赖: pip install 'aury-boot[queue-dramatiq]'"
446
+ )
447
+
448
+ def decorator(func: Callable) -> Any:
449
+ # 处理 run_mode 参数,默认为 WORKER
450
+ if run_mode is None:
451
+ mode = TaskRunMode.WORKER
452
+ elif isinstance(run_mode, str):
453
+ try:
454
+ mode = TaskRunMode(run_mode.lower())
455
+ except ValueError:
456
+ logger.warning(f"无效的运行模式: {run_mode},使用默认值: {TaskRunMode.WORKER.value}")
457
+ mode = TaskRunMode.WORKER
458
+ else:
459
+ mode = run_mode
460
+
461
+ if mode == TaskRunMode.WORKER:
462
+ # Worker 模式下正常注册(执行者)
463
+ return dramatiq.actor(queue_name=queue_name, **kwargs)(func)
464
+ else:
465
+ # Producer 模式下返回代理对象,不注册但可以发送消息
466
+ # 获取函数的完整模块路径作为 actor_name
467
+ module_name = func.__module__
468
+ func_name = func.__name__
469
+ actor_name = f"{module_name}.{func_name}"
470
+
471
+ # 获取全局 broker(如果已设置)
472
+ broker = dramatiq.get_broker() if hasattr(dramatiq, "get_broker") else None
473
+
474
+ return TaskProxy(
475
+ func=func,
476
+ queue_name=queue_name,
477
+ actor_name=actor_name,
478
+ broker=broker,
479
+ **kwargs,
480
+ )
481
+
482
+ return decorator
483
+
484
+
485
+ __all__ = [
486
+ "TaskManager",
487
+ "TaskProxy",
488
+ "conditional_actor",
489
+ ]
490
+
@@ -0,0 +1,24 @@
1
+ """测试框架模块。
2
+
3
+ 提供便捷的测试工具,包括测试基类、测试客户端、数据工厂等。
4
+
5
+ 注意:此模块需要 pytest 作为依赖,仅在开发环境可用。
6
+ 生产环境不会安装此模块的依赖,导入时会静默失败。
7
+ """
8
+
9
+ # 检查 pytest 是否可用,如果不可用则静默失败(让外层捕获)
10
+ try:
11
+ import pytest
12
+ except ImportError:
13
+ # 在生产环境,pytest 不存在,让导入失败以便外层捕获
14
+ raise ImportError("testing 模块需要 pytest,仅在开发环境可用")
15
+
16
+ from .base import TestCase
17
+ from .client import TestClient
18
+ from .factory import Factory
19
+
20
+ __all__ = [
21
+ "Factory",
22
+ "TestCase",
23
+ "TestClient",
24
+ ]
@@ -0,0 +1,122 @@
1
+ """测试基类。
2
+
3
+ 提供类似 Django TestCase 的功能,自动处理数据库事务回滚。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC
9
+
10
+ import pytest
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from aury.boot.common.logging import logger
14
+ from aury.boot.infrastructure.database.manager import DatabaseManager
15
+
16
+
17
+ class TestCase(ABC): # noqa: B024
18
+ """测试基类。
19
+
20
+ 提供类似 Django TestCase 的功能:
21
+ - 自动数据库事务回滚
22
+ - setUp/tearDown 钩子
23
+ - 测试客户端支持
24
+ - Fixtures 支持
25
+
26
+ 使用示例:
27
+ class UserServiceTest(TestCase):
28
+ async def setUp(self):
29
+ self.db = await DatabaseManager.get_session()
30
+ self.user_repo = UserRepository(self.db)
31
+
32
+ async def test_create_user(self):
33
+ user = await self.user_repo.create({"name": "张三"})
34
+ assert user.name == "张三"
35
+ """
36
+
37
+ def __init__(self) -> None:
38
+ """初始化测试用例。"""
39
+ self._db_session: AsyncSession | None = None
40
+ self._db_manager: DatabaseManager | None = None
41
+
42
+ @property
43
+ def db(self) -> AsyncSession:
44
+ """获取数据库会话。
45
+
46
+ Returns:
47
+ AsyncSession: 数据库会话
48
+
49
+ Raises:
50
+ RuntimeError: 如果会话未初始化
51
+ """
52
+ if self._db_session is None:
53
+ raise RuntimeError("数据库会话未初始化,请在 setUp 中调用 await self.setup_db()")
54
+ return self._db_session
55
+
56
+ async def setup_db(self) -> AsyncSession:
57
+ """设置数据库会话(在事务中)。
58
+
59
+ Returns:
60
+ AsyncSession: 数据库会话
61
+ """
62
+ if self._db_manager is None:
63
+ self._db_manager = DatabaseManager.get_instance()
64
+ if not hasattr(self._db_manager, '_initialized') or not self._db_manager._initialized:
65
+ await self._db_manager.initialize()
66
+
67
+ # 使用 session_factory 创建会话
68
+ self._db_session = self._db_manager.session_factory()
69
+ # 开始事务(测试结束后会自动回滚)
70
+ await self._db_session.begin()
71
+ logger.debug("测试数据库会话已创建(事务模式)")
72
+ return self._db_session
73
+
74
+ async def setUp(self) -> None: # noqa: B027
75
+ """测试前准备(子类可重写)。
76
+
77
+ 在此方法中初始化测试所需的资源。
78
+ """
79
+ pass
80
+
81
+ async def tearDown(self) -> None: # noqa: B027
82
+ """测试后清理(子类可重写)。
83
+
84
+ 在此方法中清理测试资源。
85
+ """
86
+ pass
87
+
88
+ async def _cleanup(self) -> None:
89
+ """内部清理方法,自动回滚事务。"""
90
+ if self._db_session:
91
+ try:
92
+ await self._db_session.rollback()
93
+ logger.debug("测试事务已回滚")
94
+ except Exception as e:
95
+ logger.error(f"回滚测试事务失败: {e}")
96
+ finally:
97
+ await self._db_session.close()
98
+ self._db_session = None
99
+
100
+ if self._db_manager:
101
+ await self._db_manager.cleanup()
102
+ self._db_manager = None
103
+
104
+
105
+ # Pytest fixtures 支持
106
+ @pytest.fixture()
107
+ async def test_case():
108
+ """Pytest fixture,提供测试用例实例。"""
109
+ case = TestCase()
110
+ await case.setUp()
111
+ try:
112
+ yield case
113
+ finally:
114
+ await case.tearDown()
115
+ await case._cleanup()
116
+
117
+
118
+ @pytest.fixture()
119
+ async def db_session(test_case: TestCase):
120
+ """Pytest fixture,提供数据库会话。"""
121
+ await test_case.setup_db()
122
+ return test_case.db