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.
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/PKG-INFO +2 -1
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/pyproject.toml +2 -1
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/__about__.py +1 -1
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/__init__.py +9 -1
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/base.py +7 -1
- fastapi_authly-0.1.30/src/fastapi_authly/schedule/__init__.py +13 -0
- fastapi_authly-0.1.30/src/fastapi_authly/schedule/config.py +80 -0
- fastapi_authly-0.1.30/src/fastapi_authly/schedule/manager.py +277 -0
- fastapi_authly-0.1.30/src/fastapi_authly/schedule.py +202 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/uv.lock +1195 -17
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/.gitignore +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/LICENSE +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/README.md +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/README.zh.md +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/ScienceClaw-master.zip +0 -0
- {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
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/examples/chart_tools.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/examples/correct_usage.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/embeddings.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/output_parsers.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/rerank.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/ai/vectorstores.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/auth.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/charts.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/contrib/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/contrib/tortoise_pg.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/auth.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/config/database.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/error_codes.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/exception_handlers.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/exceptions.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/logging.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/pagination.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/response.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/core/security.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/deps.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/docs.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/interfaces.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/config.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/exceptions.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/factory.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/llm/providers.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/models/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/models/user.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/repo.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/schemas/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/schemas/user.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/__init__.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/langchain_adapter.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/models.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/registry.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/skill_runtime/runtime.py +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/static/echarts.min.js +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/static/favicon.svg +0 -0
- {fastapi_authly-0.1.28 → fastapi_authly-0.1.30}/src/fastapi_authly/static/scalar/standalone.js +0 -0
- {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.
|
|
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.
|
|
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,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__}")
|