fastapi-authly 0.1.28__tar.gz → 0.1.30__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 (60) hide show
  1. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/PKG-INFO +2 -1
  2. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/pyproject.toml +2 -1
  3. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/__about__.py +1 -1
  4. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/__init__.py +9 -1
  5. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/base.py +7 -1
  6. fastapi_authly-0.1.30/src/fastapi_authly/schedule/__init__.py +13 -0
  7. fastapi_authly-0.1.30/src/fastapi_authly/schedule/config.py +80 -0
  8. fastapi_authly-0.1.30/src/fastapi_authly/schedule/manager.py +277 -0
  9. fastapi_authly-0.1.30/src/fastapi_authly/schedule.py +202 -0
  10. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/uv.lock +1195 -17
  11. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/.gitignore +0 -0
  12. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/LICENSE +0 -0
  13. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/README.md +0 -0
  14. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/README.zh.md +0 -0
  15. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/ScienceClaw-master.zip +0 -0
  16. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/docs//345/207/275/346/225/260/345/274/217/347/274/226/347/250/213/344/270/203/346/255/246/345/231/250.md" +0 -0
  17. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/examples/chart_tools.py +0 -0
  18. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/examples/correct_usage.py +0 -0
  19. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/__init__.py +0 -0
  20. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/__init__.py +0 -0
  21. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/embeddings.py +0 -0
  22. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/output_parsers.py +0 -0
  23. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/rerank.py +0 -0
  24. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/vectorstores.py +0 -0
  25. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/auth.py +0 -0
  26. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/charts.py +0 -0
  27. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/contrib/__init__.py +0 -0
  28. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/contrib/tortoise_pg.py +0 -0
  29. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/__init__.py +0 -0
  30. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/auth.py +0 -0
  31. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/database.py +0 -0
  32. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/error_codes.py +0 -0
  33. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/exception_handlers.py +0 -0
  34. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/exceptions.py +0 -0
  35. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/logging.py +0 -0
  36. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/pagination.py +0 -0
  37. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/response.py +0 -0
  38. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/security.py +0 -0
  39. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/deps.py +0 -0
  40. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/docs.py +0 -0
  41. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/interfaces.py +0 -0
  42. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/__init__.py +0 -0
  43. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/config.py +0 -0
  44. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/exceptions.py +0 -0
  45. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/factory.py +0 -0
  46. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/providers.py +0 -0
  47. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/models/__init__.py +0 -0
  48. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/models/user.py +0 -0
  49. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/repo.py +0 -0
  50. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/schemas/__init__.py +0 -0
  51. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/schemas/user.py +0 -0
  52. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/__init__.py +0 -0
  53. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/langchain_adapter.py +0 -0
  54. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/models.py +0 -0
  55. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/registry.py +0 -0
  56. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/runtime.py +0 -0
  57. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/static/echarts.min.js +0 -0
  58. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/static/favicon.svg +0 -0
  59. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/static/scalar/standalone.js +0 -0
  60. {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/static/scalar/style.css +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastapi-authly
3
- Version: 0.1.28
3
+ Version: 0.1.30
4
4
  Summary: A modular authentication system for FastAPI with OAuth2, JWT, and password recovery
5
5
  Project-URL: Homepage, https://github.com/yourusername/fastapi-auth-module
6
6
  Project-URL: Documentation, https://yourusername.github.io/fastapi-auth-module/
@@ -32,6 +32,7 @@ Requires-Dist: fastapi>=0.100.0
32
32
  Requires-Dist: jinja2>=3.0.0
33
33
  Requires-Dist: loguru>=0.7.3
34
34
  Requires-Dist: passlib[bcrypt]>=1.7.4
35
+ Requires-Dist: prefect>=3.8.6
35
36
  Requires-Dist: pydantic-settings>=2.1.0
36
37
  Requires-Dist: pydantic[email]>=2.0.0
37
38
  Requires-Dist: pyjwt>=2.12.1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-authly"
7
- version = "0.1.28"
7
+ version = "0.1.30"
8
8
  description = "A modular authentication system for FastAPI with OAuth2, JWT, and password recovery"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -57,6 +57,7 @@ dependencies = [
57
57
  "loguru>=0.7.3",
58
58
  "tenacity>=9.1.4",
59
59
  "rich>=15.0.0",
60
+ "prefect>=3.8.6",
60
61
  ]
61
62
 
62
63
  [project.optional-dependencies]
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.1.28"
3
+ __version__ = "0.1.30"
@@ -1,6 +1,7 @@
1
1
  from .database import DatabaseConfig
2
2
  from .auth import AuthConfig, AuthDependencyConfig, JwtConfig
3
- from .base import BaseBackendModel, EmbeddingConfig, RedisConfig, MinioConfig, LoggingConfig
3
+ from .base import BaseBackendModel, EmbeddingConfig, RedisConfig, MinioConfig, LoggingConfig, ServerConfig
4
+ from fastapi_authly.schedule import ScheduleConfig
4
5
  from pathlib import Path
5
6
  from typing import Any, Iterable
6
7
 
@@ -38,6 +39,13 @@ class BaseAppConfig(BaseModel):
38
39
  redis: RedisConfig | None = None
39
40
  minio: MinioConfig | None = None
40
41
  logging: LoggingConfig = Field(default_factory=LoggingConfig)
42
+ server: ServerConfig = Field(default_factory=ServerConfig)
43
+
44
+ schedules: dict[str, ScheduleConfig] = Field(
45
+ default_factory=dict,
46
+ description="任务标识与调度配置",
47
+ )
48
+
41
49
 
42
50
 
43
51
  @staticmethod
@@ -41,4 +41,10 @@ class LoggingFileConfig(BaseModel):
41
41
  class LoggingConfig(BaseModel):
42
42
  level: Literal["DEBUG","INFO","WARNING","ERROR","CRITICAL"] = "INFO"
43
43
  format: Literal["console", "rich"] = "console"
44
- file: LoggingFileConfig = Field(default_factory=LoggingFileConfig)
44
+ file: LoggingFileConfig = Field(default_factory=LoggingFileConfig)
45
+
46
+
47
+ class ServerConfig(BaseModel):
48
+ host: str = "0.0.0.0"
49
+ port: int = Field(default=4040, ge=1, le=65535)
50
+ reload: bool = False
@@ -0,0 +1,13 @@
1
+ from .config import (
2
+ CronScheduleConfig,
3
+ IntervalScheduleConfig,
4
+ ScheduleConfig,
5
+ )
6
+ from .manager import PrefectDeploymentManager
7
+
8
+ __all__ = [
9
+ "CronScheduleConfig",
10
+ "IntervalScheduleConfig",
11
+ "ScheduleConfig",
12
+ "PrefectDeploymentManager",
13
+ ]
@@ -0,0 +1,80 @@
1
+ from typing import Annotated, Literal
2
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
3
+
4
+ from prefect.client.schemas.schedules import CronSchedule
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
6
+
7
+ class CronScheduleConfig(BaseModel):
8
+ """按五段 Cron 表达式调度。
9
+
10
+ Attributes:
11
+ type: 固定为 cron。
12
+ cron: 分钟、小时、日、月、星期组成的表达式。
13
+ timezone: 调度时区,默认 Asia/Shanghai。
14
+ """
15
+
16
+ model_config = ConfigDict(extra="forbid", validate_default=True)
17
+
18
+ type: Literal["cron"]
19
+ cron: str = Field(min_length=1, description="五段 Cron 表达式")
20
+ timezone: str = Field(
21
+ default="Asia/Shanghai",
22
+ description="调度时区",
23
+ )
24
+
25
+ max_instances: int = Field(
26
+ default=1,
27
+ gt=0,
28
+ strict=True,
29
+ description="同一部署的最大并发实例数,满额时取消新运行",
30
+ )
31
+
32
+ @field_validator("cron")
33
+ @classmethod
34
+ def validate_cron(cls, value: str) -> str:
35
+ """规范化表达式,并使用 Prefect 校验合法性。"""
36
+ parts = value.split()
37
+ if len(parts) != 5:
38
+ raise ValueError("cron 必须是五段表达式:分钟 小时 日 月 星期")
39
+
40
+ expression = " ".join(parts)
41
+ CronSchedule(cron=expression, timezone="UTC")
42
+ return expression
43
+
44
+ @field_validator("timezone")
45
+ @classmethod
46
+ def validate_timezone(cls, value: str) -> str:
47
+ """检查时区名称是否有效。"""
48
+ try:
49
+ ZoneInfo(value)
50
+ except (ZoneInfoNotFoundError, ValueError) as exc:
51
+ raise ValueError(f"无效时区:{value}") from exc
52
+ return value
53
+
54
+ class IntervalScheduleConfig(BaseModel):
55
+ """按固定时间间隔调度。
56
+
57
+ Attributes:
58
+ type: 固定为 interval。
59
+ seconds: 间隔秒数,必须是正整数。
60
+ """
61
+
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ type: Literal["interval"]
65
+ seconds: int = Field(
66
+ gt=0,
67
+ strict=True,
68
+ description="执行间隔,单位为秒",
69
+ )
70
+ max_instances: int = Field(
71
+ default=1,
72
+ gt=0,
73
+ strict=True,
74
+ description="同一部署的最大并发实例数,满额时取消新运行",
75
+ )
76
+
77
+ ScheduleConfig = Annotated[
78
+ CronScheduleConfig | IntervalScheduleConfig,
79
+ Field(discriminator="type"),
80
+ ]
@@ -0,0 +1,277 @@
1
+ from dataclasses import dataclass
2
+ from datetime import timedelta
3
+
4
+ from prefect import Flow, aserve
5
+ from prefect.client.schemas.schedules import CronSchedule, IntervalSchedule
6
+ from prefect.deployments.runner import RunnerDeployment
7
+ from prefect.client.schemas.objects import (
8
+ ConcurrencyLimitConfig,
9
+ ConcurrencyLimitStrategy,
10
+ )
11
+ from .config import (
12
+ CronScheduleConfig,
13
+ IntervalScheduleConfig,
14
+ ScheduleConfig,
15
+ )
16
+ from rich import box
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class _DeploymentBinding:
25
+ key: str
26
+ flow: Flow
27
+ config: ScheduleConfig
28
+
29
+
30
+ class PrefectDeploymentManager:
31
+ """管理 Flow 与调度配置的绑定、部署构建及运行服务。
32
+
33
+ Args:
34
+ namespace: 项目或环境命名空间,例如 complaint-hb。
35
+
36
+ Examples:
37
+ manager = PrefectDeploymentManager(namespace="complaint-hb")
38
+ manager.add(
39
+ key="cluster",
40
+ flow=cluster_flow,
41
+ config=app_config.schedules["cluster"],
42
+ )
43
+ await manager.serve()
44
+ """
45
+
46
+ def __init__(self, namespace: str) -> None:
47
+ self.namespace = self._validate_name(namespace, "namespace")
48
+ self._bindings: dict[str, _DeploymentBinding] = {}
49
+ self._serving = False
50
+
51
+ def add(
52
+ self,
53
+ key: str,
54
+ flow: Flow,
55
+ config: ScheduleConfig,
56
+ ) -> None:
57
+ """绑定一个 Flow 与调度配置,不连接 Prefect 服务端。
58
+
59
+ Args:
60
+ key: 当前管理实例中唯一的任务标识,例如 cluster。
61
+ flow: 使用 Prefect @flow 装饰的工作流对象。
62
+ config: Cron 或固定间隔调度配置。
63
+
64
+ Raises:
65
+ ValueError: 任务标识非法或重复。
66
+ TypeError: flow 或 config 类型不正确。
67
+ RuntimeError: 服务运行期间尝试添加绑定。
68
+
69
+ Examples:
70
+ manager.add(
71
+ key="cluster",
72
+ flow=cluster_flow,
73
+ config=CronScheduleConfig(
74
+ type="cron",
75
+ cron="30 0 1,7,14,21 * *",
76
+ timezone="Asia/Shanghai",
77
+ ),
78
+ )
79
+ """
80
+ if self._serving:
81
+ raise RuntimeError("服务运行期间不能添加任务")
82
+
83
+ key = self._validate_name(key, "key")
84
+
85
+ if key in self._bindings:
86
+ raise ValueError(f"任务标识重复:{key}")
87
+
88
+ if not isinstance(flow, Flow):
89
+ raise TypeError("flow 必须是使用 @flow 装饰的 Prefect Flow")
90
+
91
+ if not isinstance(
92
+ config,
93
+ (CronScheduleConfig, IntervalScheduleConfig),
94
+ ):
95
+ raise TypeError(
96
+ "config 必须是 CronScheduleConfig 或 IntervalScheduleConfig"
97
+ )
98
+
99
+ config_copy = type(config).model_validate(
100
+ config.model_dump(mode="python")
101
+ )
102
+
103
+ self._bindings[key] = _DeploymentBinding(
104
+ key=key,
105
+ flow=flow,
106
+ config=config_copy,
107
+ )
108
+
109
+ async def build_deployments(self) -> list[RunnerDeployment]:
110
+ """校验所有调度并构建 Deployment 对象,不启动运行服务。
111
+
112
+ Returns:
113
+ 按添加顺序排列的 Deployment 对象列表。
114
+
115
+ Raises:
116
+ ValueError: 未添加任务或调度配置无效。
117
+ Exception: Prefect 部署构建异常原样向上传播。
118
+
119
+ Examples:
120
+ deployments = await manager.build_deployments()
121
+ await aserve(*deployments)
122
+
123
+ Notes:
124
+ 部署名称为 namespace-key,不包含调度表达式或运行时间。
125
+ 不设置业务参数,定时运行使用 Flow 自身的默认参数。
126
+ """
127
+ bindings = tuple(self._bindings.values())
128
+
129
+ if not bindings:
130
+ raise ValueError("没有可构建的任务,请先调用 add()")
131
+
132
+ schedules = [
133
+ self._build_schedule(binding.config)
134
+ for binding in bindings
135
+ ]
136
+
137
+ deployments: list[RunnerDeployment] = []
138
+
139
+ for binding, schedule in zip(bindings, schedules, strict=True):
140
+ deployment = await binding.flow.ato_deployment(
141
+ name=f"{self.namespace}-{binding.key}",
142
+ schedules=[schedule],
143
+ concurrency_limit=ConcurrencyLimitConfig(
144
+ limit=binding.config.max_instances,
145
+ collision_strategy=ConcurrencyLimitStrategy.CANCEL_NEW,
146
+ ),
147
+ )
148
+ deployments.append(deployment)
149
+
150
+ return deployments
151
+
152
+ async def serve(self) -> None:
153
+ """打印部署配置,注册部署并持续运行本地 Runner。
154
+
155
+ Raises:
156
+ RuntimeError: 同一管理实例已经在运行服务。
157
+ ValueError: 未添加任务。
158
+ Exception: 构建、注册或运行服务的异常原样向上传播。
159
+
160
+ Examples:
161
+ await manager.serve()
162
+
163
+ Notes:
164
+ 表格展示的是待注册配置,不代表远程注册成功。
165
+ 此方法持续运行,正常停止后才返回。
166
+ 正常关闭时请求暂停所服务部署的调度,不删除部署。
167
+ 强制终止或网络故障时,暂停操作可能无法完成。
168
+ """
169
+ if self._serving:
170
+ raise RuntimeError("当前管理实例已经在运行服务")
171
+
172
+ self._serving = True
173
+
174
+ try:
175
+ deployments = await self.build_deployments()
176
+
177
+ self._print_task_table()
178
+
179
+ await aserve(
180
+ *deployments,
181
+ pause_on_shutdown=True,
182
+ print_starting_message=True,
183
+ )
184
+ finally:
185
+ self._serving = False
186
+
187
+ @staticmethod
188
+ def _validate_name(value: str, field: str) -> str:
189
+ if not isinstance(value, str):
190
+ raise TypeError(f"{field} 必须是字符串")
191
+
192
+ if not value or value != value.strip():
193
+ raise ValueError(f"{field} 不能为空或包含首尾空白")
194
+
195
+ if not all(
196
+ character.isascii()
197
+ and (character.isalnum() or character in "-_")
198
+ for character in value
199
+ ):
200
+ raise ValueError(
201
+ f"{field} 只能包含英文字母、数字、短横线和下划线"
202
+ )
203
+
204
+ return value
205
+
206
+ @staticmethod
207
+ def _build_schedule(
208
+ config: ScheduleConfig,
209
+ ) -> CronSchedule | IntervalSchedule:
210
+ if isinstance(config, CronScheduleConfig):
211
+ return CronSchedule(
212
+ cron=config.cron,
213
+ timezone=config.timezone,
214
+ )
215
+
216
+ if isinstance(config, IntervalScheduleConfig):
217
+ return IntervalSchedule(
218
+ interval=timedelta(seconds=config.seconds),
219
+ )
220
+
221
+ raise TypeError(f"不支持的调度配置类型:{type(config).__name__}")
222
+
223
+
224
+ def _print_task_table(self) -> None:
225
+ """打印已构建、待注册的部署配置。
226
+
227
+ Notes:
228
+ 本表不代表服务端注册成功,也不代表 Runner 已启动。
229
+ 满额策略统一为 CANCEL_NEW。
230
+ """
231
+ if not self._bindings:
232
+ return
233
+
234
+ table = Table(
235
+ box=box.SIMPLE,
236
+ show_header=True,
237
+ header_style="bold",
238
+ expand=False,
239
+ padding=(0, 1),
240
+ )
241
+ table.add_column("部署名称", style="cyan")
242
+ table.add_column("Flow")
243
+ table.add_column("调度计划", style="green")
244
+ table.add_column("时区")
245
+ table.add_column("最大实例", justify="right")
246
+ table.add_column("满额策略", style="yellow")
247
+
248
+ for binding in self._bindings.values():
249
+ config = binding.config
250
+
251
+ if isinstance(config, CronScheduleConfig):
252
+ schedule_text = f"Cron:{config.cron}"
253
+ timezone_text = config.timezone
254
+ else:
255
+ schedule_text = f"间隔:{config.seconds} 秒"
256
+ timezone_text = "—"
257
+
258
+ table.add_row(
259
+ Text(f"{self.namespace}-{binding.key}"),
260
+ Text(binding.flow.name),
261
+ Text(schedule_text),
262
+ Text(timezone_text),
263
+ str(config.max_instances),
264
+ "取消新运行",
265
+ )
266
+
267
+ panel = Panel(
268
+ table,
269
+ title=Text(f"待注册部署 · {len(self._bindings)} 个"),
270
+ subtitle=Text("配置构建完成,尚未确认远程注册成功"),
271
+ border_style="cyan",
272
+ title_align="left",
273
+ subtitle_align="left",
274
+ expand=False,
275
+ )
276
+
277
+ Console().print(panel)
@@ -0,0 +1,202 @@
1
+ from dataclasses import dataclass
2
+ from datetime import timedelta
3
+
4
+ from prefect import Flow, aserve
5
+ from prefect.client.schemas.schedules import CronSchedule, IntervalSchedule
6
+ from prefect.deployments.runner import RunnerDeployment
7
+
8
+ from .config import (
9
+ CronScheduleConfig,
10
+ IntervalScheduleConfig,
11
+ ScheduleConfig,
12
+ )
13
+
14
+ @dataclass(frozen=True)
15
+ class _DeploymentBinding:
16
+ key: str
17
+ flow: Flow
18
+ config: ScheduleConfig
19
+
20
+ class PrefectDeploymentManager:
21
+ """管理 Flow 与调度配置的绑定、部署构建及运行服务。
22
+
23
+ Args:
24
+ namespace: 项目或环境命名空间,例如 complaint-hb。
25
+
26
+ Examples:
27
+ manager = PrefectDeploymentManager(namespace="complaint-hb")
28
+ manager.add(
29
+ key="cluster",
30
+ flow=cluster_flow,
31
+ config=app_config.schedules["cluster"],
32
+ )
33
+ await manager.serve()
34
+ """
35
+
36
+ def __init__(self, namespace: str) -> None:
37
+ self.namespace = self._validate_name(namespace, "namespace")
38
+ self._bindings: dict[str, _DeploymentBinding] = {}
39
+ self._serving = False
40
+
41
+ def add(
42
+ self,
43
+ key: str,
44
+ flow: Flow,
45
+ config: ScheduleConfig,
46
+ ) -> None:
47
+ """绑定一个 Flow 与调度配置,不连接 Prefect 服务端。
48
+
49
+ Args:
50
+ key: 当前管理实例中唯一的任务标识,例如 cluster。
51
+ flow: 使用 Prefect @flow 装饰的工作流对象。
52
+ config: Cron 或固定间隔调度配置。
53
+
54
+ Raises:
55
+ ValueError: 任务标识非法或重复。
56
+ TypeError: flow 或 config 类型不正确。
57
+ RuntimeError: 服务运行期间尝试添加绑定。
58
+
59
+ Examples:
60
+ manager.add(
61
+ key="cluster",
62
+ flow=cluster_flow,
63
+ config=CronScheduleConfig(
64
+ type="cron",
65
+ cron="30 0 1,7,14,21 * *",
66
+ timezone="Asia/Shanghai",
67
+ ),
68
+ )
69
+ """
70
+ if self._serving:
71
+ raise RuntimeError("服务运行期间不能添加任务")
72
+
73
+ key = self._validate_name(key, "key")
74
+
75
+ if key in self._bindings:
76
+ raise ValueError(f"任务标识重复:{key}")
77
+
78
+ if not isinstance(flow, Flow):
79
+ raise TypeError("flow 必须是使用 @flow 装饰的 Prefect Flow")
80
+
81
+ if not isinstance(
82
+ config,
83
+ (CronScheduleConfig, IntervalScheduleConfig),
84
+ ):
85
+ raise TypeError(
86
+ "config 必须是 CronScheduleConfig 或 IntervalScheduleConfig"
87
+ )
88
+
89
+ config_copy = type(config).model_validate(
90
+ config.model_dump(mode="python")
91
+ )
92
+
93
+ self._bindings[key] = _DeploymentBinding(
94
+ key=key,
95
+ flow=flow,
96
+ config=config_copy,
97
+ )
98
+
99
+ async def build_deployments(self) -> list[RunnerDeployment]:
100
+ """校验所有调度并构建 Deployment 对象,不启动运行服务。
101
+
102
+ Returns:
103
+ 按添加顺序排列的 Deployment 对象列表。
104
+
105
+ Raises:
106
+ ValueError: 未添加任务或调度配置无效。
107
+ Exception: Prefect 部署构建异常原样向上传播。
108
+
109
+ Examples:
110
+ deployments = await manager.build_deployments()
111
+ await aserve(*deployments)
112
+
113
+ Notes:
114
+ 部署名称为 namespace-key,不包含调度表达式或运行时间。
115
+ 不设置业务参数,定时运行使用 Flow 自身的默认参数。
116
+ """
117
+ bindings = tuple(self._bindings.values())
118
+
119
+ if not bindings:
120
+ raise ValueError("没有可构建的任务,请先调用 add()")
121
+
122
+ schedules = [
123
+ self._build_schedule(binding.config)
124
+ for binding in bindings
125
+ ]
126
+
127
+ deployments: list[RunnerDeployment] = []
128
+
129
+ for binding, schedule in zip(bindings, schedules, strict=True):
130
+ deployment = await binding.flow.ato_deployment(
131
+ name=f"{self.namespace}-{binding.key}",
132
+ schedules=[schedule],
133
+ )
134
+ deployments.append(deployment)
135
+
136
+ return deployments
137
+
138
+ async def serve(self) -> None:
139
+ """注册部署并持续运行本地 Runner。
140
+
141
+ Raises:
142
+ RuntimeError: 同一管理实例已经在运行服务。
143
+ ValueError: 未添加任务。
144
+ Exception: 构建、注册或运行服务的异常原样向上传播。
145
+
146
+ Examples:
147
+ await manager.serve()
148
+
149
+ Notes:
150
+ 此方法持续运行,正常停止后才返回。
151
+ 正常关闭时请求暂停所服务部署的调度,不删除部署。
152
+ 强制终止或网络故障时,暂停操作可能无法完成。
153
+ """
154
+ if self._serving:
155
+ raise RuntimeError("当前管理实例已经在运行服务")
156
+
157
+ self._serving = True
158
+
159
+ try:
160
+ deployments = await self.build_deployments()
161
+ await aserve(
162
+ *deployments,
163
+ pause_on_shutdown=True,
164
+ )
165
+ finally:
166
+ self._serving = False
167
+
168
+ @staticmethod
169
+ def _validate_name(value: str, field: str) -> str:
170
+ if not isinstance(value, str):
171
+ raise TypeError(f"{field} 必须是字符串")
172
+
173
+ if not value or value != value.strip():
174
+ raise ValueError(f"{field} 不能为空或包含首尾空白")
175
+
176
+ if not all(
177
+ character.isascii()
178
+ and (character.isalnum() or character in "-_")
179
+ for character in value
180
+ ):
181
+ raise ValueError(
182
+ f"{field} 只能包含英文字母、数字、短横线和下划线"
183
+ )
184
+
185
+ return value
186
+
187
+ @staticmethod
188
+ def _build_schedule(
189
+ config: ScheduleConfig,
190
+ ) -> CronSchedule | IntervalSchedule:
191
+ if isinstance(config, CronScheduleConfig):
192
+ return CronSchedule(
193
+ cron=config.cron,
194
+ timezone=config.timezone,
195
+ )
196
+
197
+ if isinstance(config, IntervalScheduleConfig):
198
+ return IntervalSchedule(
199
+ interval=timedelta(seconds=config.seconds),
200
+ )
201
+
202
+ raise TypeError(f"不支持的调度配置类型:{type(config).__name__}")