fastapi-authly 0.1.32__tar.gz → 0.1.34__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.32 → fastapi_authly-0.1.34}/PKG-INFO +1 -1
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/pyproject.toml +1 -1
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/__about__.py +1 -1
- fastapi_authly-0.1.34/src/fastapi_authly/ai/output_parsers.py +261 -0
- fastapi_authly-0.1.34/src/fastapi_authly/schedule/logging.py +193 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schedule/manager.py +54 -22
- fastapi_authly-0.1.32/src/fastapi_authly/ai/output_parsers.py +0 -24
- fastapi_authly-0.1.32/src/fastapi_authly/schedule.py +0 -202
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/.gitignore +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/LICENSE +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/README.md +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/README.zh.md +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/ScienceClaw-master.zip +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/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.32 → fastapi_authly-0.1.34}/examples/chart_tools.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/examples/correct_usage.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/embeddings.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/rerank.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/vectorstores.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/auth.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/charts.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/contrib/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/contrib/tortoise_pg.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/auth.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/base.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/database.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/error_codes.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/exception_handlers.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/exceptions.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/logging.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/pagination.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/response.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/security.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/deps.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/docs.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/interfaces.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/config.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/exceptions.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/factory.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/providers.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/models/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/models/user.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/repo.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schedule/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schedule/config.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schemas/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schemas/user.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/__init__.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/langchain_adapter.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/models.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/registry.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/runtime.py +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/echarts.min.js +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/favicon.svg +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/scalar/standalone.js +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/scalar/style.css +0 -0
- {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/uv.lock +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.34
|
|
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/
|
|
@@ -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.34"
|
|
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"}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
from json_repair import repair_json
|
|
8
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
9
|
+
from langchain.agents import create_agent
|
|
10
|
+
from langchain.agents.middleware import AgentMiddleware, ModelRequest, ModelResponse
|
|
11
|
+
from langchain.agents.structured_output import ToolStrategy
|
|
12
|
+
from langchain_core.language_models import BaseChatModel
|
|
13
|
+
from langchain_core.messages import BaseMessage, HumanMessage, convert_to_openai_messages
|
|
14
|
+
from langchain_core.tools import BaseTool
|
|
15
|
+
from langgraph.errors import GraphRecursionError
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
T = TypeVar("T", bound=BaseModel)
|
|
20
|
+
ResultValidator = Callable[[T, str], None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def jsonify(value: Any) -> Any:
|
|
24
|
+
content = value.content if hasattr(value, "content") else value
|
|
25
|
+
return repair_json(str(content).strip(), return_objects=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def create_json_chain(llm: BaseChatModel):
|
|
29
|
+
chat_prompt = ChatPromptTemplate.from_messages([
|
|
30
|
+
("human", "{content}")
|
|
31
|
+
])
|
|
32
|
+
return (
|
|
33
|
+
chat_prompt
|
|
34
|
+
| llm
|
|
35
|
+
| jsonify
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DebugModelMiddleware(AgentMiddleware):
|
|
40
|
+
def __init__(self, enabled: bool = True) -> None:
|
|
41
|
+
super().__init__()
|
|
42
|
+
self.model_calls = 0
|
|
43
|
+
self.enabled = enabled
|
|
44
|
+
|
|
45
|
+
async def awrap_model_call(self, request: ModelRequest, handler) -> ModelResponse:
|
|
46
|
+
self.model_calls += 1
|
|
47
|
+
if self.enabled and logger.isEnabledFor(logging.DEBUG):
|
|
48
|
+
self._log_request(request)
|
|
49
|
+
response = await handler(request)
|
|
50
|
+
if self.enabled and logger.isEnabledFor(logging.DEBUG):
|
|
51
|
+
self._log_response(response)
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
def wrap_model_call(self, request: ModelRequest, handler) -> ModelResponse:
|
|
55
|
+
self.model_calls += 1
|
|
56
|
+
if self.enabled and logger.isEnabledFor(logging.DEBUG):
|
|
57
|
+
self._log_request(request)
|
|
58
|
+
response = handler(request)
|
|
59
|
+
if self.enabled and logger.isEnabledFor(logging.DEBUG):
|
|
60
|
+
self._log_response(response)
|
|
61
|
+
return response
|
|
62
|
+
|
|
63
|
+
def _log_request(self, request: ModelRequest) -> None:
|
|
64
|
+
messages = list(request.messages)
|
|
65
|
+
if request.system_message is not None:
|
|
66
|
+
messages.insert(0, request.system_message)
|
|
67
|
+
logger.debug(
|
|
68
|
+
"第 %d 轮模型调用,发给模型的 messages:\n%s",
|
|
69
|
+
self.model_calls,
|
|
70
|
+
json.dumps(convert_to_openai_messages(messages), ensure_ascii=False, indent=2, default=str),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _log_response(response: ModelResponse) -> None:
|
|
75
|
+
for message in response.result:
|
|
76
|
+
logger.debug("模型返回:\n%s", message.pretty_repr())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StructuredAgent(Generic[T]):
|
|
80
|
+
"""使用工具调用获取经过 Pydantic 校验的结构化结果。
|
|
81
|
+
|
|
82
|
+
校验失败时,ToolStrategy(handle_errors=True) 会在同一次 Agent 调用中
|
|
83
|
+
将错误反馈给模型;达到图执行步数上限后抛出 GraphRecursionError,
|
|
84
|
+
不会重新发起一轮 Agent 调用。可选的结果校验在结构化输出后执行,
|
|
85
|
+
若抛出 ValueError,会带着原有对话和错误反馈再次调用 Agent。
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
model: 支持工具调用的聊天模型。
|
|
89
|
+
schema: 结果的 Pydantic BaseModel 类型。
|
|
90
|
+
system_prompt: Agent 的系统提示词。
|
|
91
|
+
tools: 可选业务工具;不传时只有结构化输出工具。
|
|
92
|
+
recursion_limit: 单次调用的图执行步数上限,默认 5;不是模型重试次数。
|
|
93
|
+
debug_model: 是否允许记录完整模型请求和响应;还需启用本模块的 DEBUG 日志。
|
|
94
|
+
result_validator: 可选的业务校验函数,接收结果与可信原文;失败时抛出 ValueError。
|
|
95
|
+
max_validation_attempts: 业务校验失败时最多调用 Agent 的次数,含首次调用。
|
|
96
|
+
|
|
97
|
+
Examples:
|
|
98
|
+
以下示例中的 model 由调用方提供,需支持工具调用。
|
|
99
|
+
|
|
100
|
+
>>> from pydantic import BaseModel
|
|
101
|
+
>>> class BaseModelSchema(BaseModel):
|
|
102
|
+
... person_name: str | None
|
|
103
|
+
... unit_name: str | None
|
|
104
|
+
|
|
105
|
+
>>> agent = StructuredAgent(
|
|
106
|
+
... model,
|
|
107
|
+
... BaseModelSchema,
|
|
108
|
+
... system_prompt="从诉求正文中抽取被投诉人和被投诉单位。",
|
|
109
|
+
... recursion_limit=10,
|
|
110
|
+
... )
|
|
111
|
+
>>> message = "请分析:<complaint_content>某某某处理不当</complaint_content>"
|
|
112
|
+
>>> result = agent.invoke(message)
|
|
113
|
+
>>> isinstance(result, BaseModelSchema)
|
|
114
|
+
True
|
|
115
|
+
|
|
116
|
+
在异步函数中也可调用 ``await agent.ainvoke(message, recursion_limit=20)``。
|
|
117
|
+
自定义原文校验示例:
|
|
118
|
+
|
|
119
|
+
>>> def validate_names(result: BaseModelSchema, source_text: str) -> None:
|
|
120
|
+
... for name in (result.person_name, result.unit_name):
|
|
121
|
+
... if name is not None and name not in source_text:
|
|
122
|
+
... raise ValueError(f"{name!r} 未出现在原文中,请改为原文中的名称或 null")
|
|
123
|
+
>>> checked = StructuredAgent(
|
|
124
|
+
... model, BaseModelSchema, system_prompt="抽取被投诉人和单位",
|
|
125
|
+
... result_validator=validate_names, max_validation_attempts=3,
|
|
126
|
+
... )
|
|
127
|
+
>>> result = checked.invoke(message, source_text="某某某处理不当")
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
model: BaseChatModel,
|
|
133
|
+
schema: type[T],
|
|
134
|
+
*,
|
|
135
|
+
system_prompt: str,
|
|
136
|
+
tools: Sequence[BaseTool | Callable[..., Any]] | None = None,
|
|
137
|
+
recursion_limit: int = 5,
|
|
138
|
+
debug_model: bool = True,
|
|
139
|
+
result_validator: ResultValidator[T] | None = None,
|
|
140
|
+
max_validation_attempts: int = 3,
|
|
141
|
+
) -> None:
|
|
142
|
+
self.model = model
|
|
143
|
+
self.schema = schema
|
|
144
|
+
self.system_prompt = system_prompt
|
|
145
|
+
self.tools = list(tools) if tools is not None else []
|
|
146
|
+
self.recursion_limit = self._positive(recursion_limit, "recursion_limit")
|
|
147
|
+
self.debug = debug_model
|
|
148
|
+
self.result_validator = result_validator
|
|
149
|
+
self.max_validation_attempts = self._positive(max_validation_attempts, "max_validation_attempts")
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _positive(value: int, name: str) -> int:
|
|
153
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
154
|
+
raise ValueError(f"{name} 必须是大于 0 的整数")
|
|
155
|
+
return value
|
|
156
|
+
|
|
157
|
+
def _create_agent(self, debug: DebugModelMiddleware):
|
|
158
|
+
return create_agent(
|
|
159
|
+
model=self.model,
|
|
160
|
+
tools=self.tools,
|
|
161
|
+
system_prompt=self.system_prompt,
|
|
162
|
+
response_format=ToolStrategy(self.schema, handle_errors=True),
|
|
163
|
+
middleware=[debug],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _result(self, output: dict) -> T:
|
|
167
|
+
structured = output.get("structured_response")
|
|
168
|
+
if structured is None:
|
|
169
|
+
raise ValueError("Agent 未返回 structured_response")
|
|
170
|
+
return self.schema.model_validate(structured)
|
|
171
|
+
|
|
172
|
+
def _prepare(
|
|
173
|
+
self, recursion_limit: int | None, max_validation_attempts: int | None, source_text: str | None
|
|
174
|
+
) -> tuple[int, int]:
|
|
175
|
+
limit = self.recursion_limit if recursion_limit is None else self._positive(recursion_limit, "recursion_limit")
|
|
176
|
+
attempts = (
|
|
177
|
+
self.max_validation_attempts
|
|
178
|
+
if max_validation_attempts is None
|
|
179
|
+
else self._positive(max_validation_attempts, "max_validation_attempts")
|
|
180
|
+
)
|
|
181
|
+
if self.result_validator is not None and not isinstance(source_text, str):
|
|
182
|
+
raise ValueError("设置 result_validator 时必须传入可信原文 source_text")
|
|
183
|
+
return limit, attempts
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _feedback(output: dict, error: ValueError) -> list[BaseMessage]:
|
|
187
|
+
messages = list(output.get("messages") or [])
|
|
188
|
+
if not messages:
|
|
189
|
+
raise ValueError("Agent 未返回消息历史,无法将业务校验错误反馈给模型") from error
|
|
190
|
+
messages.append(HumanMessage(
|
|
191
|
+
content=f"上一轮结构化结果未通过业务校验:{error}。请根据错误修正并重新提交结构化结果。"
|
|
192
|
+
))
|
|
193
|
+
return messages
|
|
194
|
+
|
|
195
|
+
def invoke(
|
|
196
|
+
self,
|
|
197
|
+
message: str,
|
|
198
|
+
*,
|
|
199
|
+
recursion_limit: int | None = None,
|
|
200
|
+
source_text: str | None = None,
|
|
201
|
+
max_validation_attempts: int | None = None,
|
|
202
|
+
) -> T:
|
|
203
|
+
limit, attempts = self._prepare(recursion_limit, max_validation_attempts, source_text)
|
|
204
|
+
debug = DebugModelMiddleware(self.debug)
|
|
205
|
+
agent = self._create_agent(debug)
|
|
206
|
+
messages: list[BaseMessage] = [HumanMessage(content=message)]
|
|
207
|
+
try:
|
|
208
|
+
for attempt in range(1, attempts + 1):
|
|
209
|
+
output = agent.invoke({"messages": messages}, config={"recursion_limit": limit})
|
|
210
|
+
result = self._result(output)
|
|
211
|
+
if self.result_validator is None:
|
|
212
|
+
return result
|
|
213
|
+
try:
|
|
214
|
+
self.result_validator(result, source_text)
|
|
215
|
+
except ValueError as exc:
|
|
216
|
+
logger.warning("业务校验未通过(第 %d/%d 次)", attempt, attempts)
|
|
217
|
+
if attempt == attempts:
|
|
218
|
+
raise ValueError(f"业务校验在 {attempts} 次调用后仍未通过:{exc}") from exc
|
|
219
|
+
messages = self._feedback(output, exc)
|
|
220
|
+
else:
|
|
221
|
+
return result
|
|
222
|
+
raise RuntimeError("未获得结构化结果")
|
|
223
|
+
except GraphRecursionError:
|
|
224
|
+
logger.warning("达到图步数上限 %d,本次 invoke 停止", limit)
|
|
225
|
+
raise
|
|
226
|
+
finally:
|
|
227
|
+
logger.debug("本次总模型调用:%d 轮", debug.model_calls)
|
|
228
|
+
|
|
229
|
+
async def ainvoke(
|
|
230
|
+
self,
|
|
231
|
+
message: str,
|
|
232
|
+
*,
|
|
233
|
+
recursion_limit: int | None = None,
|
|
234
|
+
source_text: str | None = None,
|
|
235
|
+
max_validation_attempts: int | None = None,
|
|
236
|
+
) -> T:
|
|
237
|
+
limit, attempts = self._prepare(recursion_limit, max_validation_attempts, source_text)
|
|
238
|
+
debug = DebugModelMiddleware(self.debug)
|
|
239
|
+
agent = self._create_agent(debug)
|
|
240
|
+
messages: list[BaseMessage] = [HumanMessage(content=message)]
|
|
241
|
+
try:
|
|
242
|
+
for attempt in range(1, attempts + 1):
|
|
243
|
+
output = await agent.ainvoke({"messages": messages}, config={"recursion_limit": limit})
|
|
244
|
+
result = self._result(output)
|
|
245
|
+
if self.result_validator is None:
|
|
246
|
+
return result
|
|
247
|
+
try:
|
|
248
|
+
self.result_validator(result, source_text)
|
|
249
|
+
except ValueError as exc:
|
|
250
|
+
logger.warning("业务校验未通过(第 %d/%d 次)", attempt, attempts)
|
|
251
|
+
if attempt == attempts:
|
|
252
|
+
raise ValueError(f"业务校验在 {attempts} 次调用后仍未通过:{exc}") from exc
|
|
253
|
+
messages = self._feedback(output, exc)
|
|
254
|
+
else:
|
|
255
|
+
return result
|
|
256
|
+
raise RuntimeError("未获得结构化结果")
|
|
257
|
+
except GraphRecursionError:
|
|
258
|
+
logger.warning("达到图步数上限 %d,本次 ainvoke 停止", limit)
|
|
259
|
+
raise
|
|
260
|
+
finally:
|
|
261
|
+
logger.debug("本次总模型调用:%d 轮", debug.model_calls)
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from copy import copy
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from tempfile import TemporaryDirectory
|
|
8
|
+
from threading import Lock, RLock
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from prefect.context import get_run_context
|
|
12
|
+
from prefect.exceptions import MissingContextError
|
|
13
|
+
from prefect.logging.configuration import DEFAULT_LOGGING_SETTINGS_PATH, setup_logging
|
|
14
|
+
from prefect.logging.handlers import APILogHandler, PrefectConsoleHandler
|
|
15
|
+
from prefect.logging.highlighters import PrefectConsoleHighlighter
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_INSTALL_LOCK = RLock()
|
|
20
|
+
_SERVE_LOCK = Lock()
|
|
21
|
+
_HANDLER_NAME = "fastapi_authly.schedule.run_api"
|
|
22
|
+
_PROJECT_ROOT_ENV = "FASTAPI_AUTHLY_SCHEDULE_PROJECT_ROOT"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BusinessConsoleHighlighter(PrefectConsoleHighlighter):
|
|
26
|
+
def highlight(self, text: Text) -> None:
|
|
27
|
+
super().highlight(text)
|
|
28
|
+
match = re.match(
|
|
29
|
+
r"\A\d{2}:\d{2}:\d{2}\.\d{3} \| [A-Z]+\s+\| (?P<module>(?!prefect(?:\.| - ))[\w-]+(?:\.[\w-]+)*) - ",
|
|
30
|
+
text.plain,
|
|
31
|
+
)
|
|
32
|
+
if match:
|
|
33
|
+
text.stylize("bright_cyan", *match.span("module"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_project_root(project_root: str | Path | None = None) -> Path:
|
|
37
|
+
return Path(project_root if project_root is not None else os.environ.get(_PROJECT_ROOT_ENV) or Path.cwd()).expanduser().resolve()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BusinessNameFilter(logging.Filter):
|
|
41
|
+
def __init__(self, project_root: str | Path | None = None):
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.project_root = resolve_project_root(project_root)
|
|
44
|
+
|
|
45
|
+
def filter(self, record: logging.LogRecord) -> logging.LogRecord:
|
|
46
|
+
local_record = copy(record)
|
|
47
|
+
original_name = getattr(record, "_schedule_original_name", record.name)
|
|
48
|
+
local_record._schedule_original_name = original_name
|
|
49
|
+
if original_name == "prefect" or original_name.startswith("prefect."):
|
|
50
|
+
return local_record
|
|
51
|
+
local_record.name = original_name
|
|
52
|
+
if not record.pathname.startswith("<"):
|
|
53
|
+
source = Path(record.pathname)
|
|
54
|
+
source = (source if source.is_absolute() else self.project_root / source).resolve()
|
|
55
|
+
try:
|
|
56
|
+
relative = source.relative_to(self.project_root).with_suffix("")
|
|
57
|
+
except ValueError:
|
|
58
|
+
return local_record
|
|
59
|
+
local_record.name = ".".join(relative.parts)
|
|
60
|
+
return local_record
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_log_level(level: int | str) -> int:
|
|
64
|
+
if isinstance(level, str):
|
|
65
|
+
resolved = logging.getLevelNamesMapping().get(level.strip().upper())
|
|
66
|
+
if resolved is None:
|
|
67
|
+
raise ValueError(f"不支持的日志级别:{level}")
|
|
68
|
+
elif type(level) is int:
|
|
69
|
+
resolved = level
|
|
70
|
+
else:
|
|
71
|
+
raise TypeError("log_level 必须是整数或日志级别名称")
|
|
72
|
+
if resolved <= logging.NOTSET:
|
|
73
|
+
raise ValueError("log_level 必须大于 NOTSET")
|
|
74
|
+
return resolved
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ActiveRunFilter(logging.Filter):
|
|
78
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
79
|
+
if not getattr(record, "send_to_api", True):
|
|
80
|
+
return False
|
|
81
|
+
try:
|
|
82
|
+
context = get_run_context()
|
|
83
|
+
except MissingContextError:
|
|
84
|
+
return False
|
|
85
|
+
flow_run = getattr(context, "flow_run", None)
|
|
86
|
+
task_run = getattr(context, "task_run", None)
|
|
87
|
+
flow_run_id = (
|
|
88
|
+
flow_run.id if flow_run is not None
|
|
89
|
+
else getattr(task_run, "flow_run_id", None)
|
|
90
|
+
)
|
|
91
|
+
if flow_run_id is None:
|
|
92
|
+
return False
|
|
93
|
+
root = logging.getLogger()
|
|
94
|
+
current = logging.getLogger(getattr(record, "_schedule_original_name", record.name))
|
|
95
|
+
while current is not root:
|
|
96
|
+
if any(isinstance(handler, APILogHandler) for handler in current.handlers):
|
|
97
|
+
return False
|
|
98
|
+
if not current.propagate or current.parent is None:
|
|
99
|
+
return False
|
|
100
|
+
current = current.parent
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def setup_prefect_logging(level: int | str = logging.INFO, project_root: str | Path | None = None) -> None:
|
|
105
|
+
resolved = resolve_log_level(level)
|
|
106
|
+
project_root = resolve_project_root(project_root)
|
|
107
|
+
with _INSTALL_LOCK:
|
|
108
|
+
root = logging.getLogger()
|
|
109
|
+
handlers = [handler for handler in root.handlers if isinstance(handler, APILogHandler)]
|
|
110
|
+
if handlers:
|
|
111
|
+
if len(handlers) != 1 or handlers[0].name != _HANDLER_NAME:
|
|
112
|
+
raise RuntimeError("root 已有其他 Prefect API handler,请先统一日志配置")
|
|
113
|
+
handler = handlers[0]
|
|
114
|
+
else:
|
|
115
|
+
handler = APILogHandler()
|
|
116
|
+
handler.set_name(_HANDLER_NAME)
|
|
117
|
+
handler.addFilter(ActiveRunFilter())
|
|
118
|
+
root.addHandler(handler)
|
|
119
|
+
for target in {handler, *root.handlers, logging.getHandlerByName("api"), logging.getHandlerByName("console")}:
|
|
120
|
+
if target is None or not isinstance(target, (APILogHandler, PrefectConsoleHandler)):
|
|
121
|
+
continue
|
|
122
|
+
for existing in tuple(target.filters):
|
|
123
|
+
if isinstance(existing, BusinessNameFilter):
|
|
124
|
+
target.removeFilter(existing)
|
|
125
|
+
target.addFilter(BusinessNameFilter(project_root))
|
|
126
|
+
if isinstance(target, PrefectConsoleHandler) and isinstance(target.console.highlighter, PrefectConsoleHighlighter):
|
|
127
|
+
target.console.highlighter = BusinessConsoleHighlighter()
|
|
128
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
129
|
+
handler.setLevel(resolved)
|
|
130
|
+
root.setLevel(resolved)
|
|
131
|
+
for name in ("httpx", "httpcore"):
|
|
132
|
+
logger = logging.getLogger(name)
|
|
133
|
+
if logger.level == logging.NOTSET:
|
|
134
|
+
logger.setLevel(logging.WARNING)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_logging_config(path: str | Path, level: int | str = logging.INFO, project_root: str | Path | None = None) -> Path:
|
|
138
|
+
resolved = resolve_log_level(level)
|
|
139
|
+
project_root = str(resolve_project_root(project_root))
|
|
140
|
+
config = yaml.safe_load(DEFAULT_LOGGING_SETTINGS_PATH.read_text(encoding="utf-8"))
|
|
141
|
+
config.setdefault("filters", {})["schedule_active_run"] = {
|
|
142
|
+
"()": "fastapi_authly.schedule.logging.ActiveRunFilter",
|
|
143
|
+
}
|
|
144
|
+
config["filters"]["schedule_business_name"] = {
|
|
145
|
+
"()": "fastapi_authly.schedule.logging.BusinessNameFilter",
|
|
146
|
+
"project_root": project_root,
|
|
147
|
+
}
|
|
148
|
+
for name in ("console", "api"):
|
|
149
|
+
config["handlers"][name].setdefault("filters", []).append("schedule_business_name")
|
|
150
|
+
config["handlers"]["console"]["highlighter"] = "ext://fastapi_authly.schedule.logging.BusinessConsoleHighlighter"
|
|
151
|
+
config["handlers"][_HANDLER_NAME] = {
|
|
152
|
+
"class": "prefect.logging.handlers.APILogHandler",
|
|
153
|
+
"level": resolved,
|
|
154
|
+
"filters": ["schedule_active_run", "schedule_business_name"],
|
|
155
|
+
}
|
|
156
|
+
config["root"]["level"] = resolved
|
|
157
|
+
config["root"]["handlers"].append(_HANDLER_NAME)
|
|
158
|
+
for name in ("httpx", "httpcore"):
|
|
159
|
+
config["loggers"].setdefault(name, {"level": "WARNING"})
|
|
160
|
+
target = Path(path).resolve()
|
|
161
|
+
target.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8")
|
|
162
|
+
return target
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@contextmanager
|
|
166
|
+
def prefect_logging_environment(level: int | str = logging.INFO, project_root: str | Path | None = None):
|
|
167
|
+
resolved = resolve_log_level(level)
|
|
168
|
+
project_root = resolve_project_root(project_root)
|
|
169
|
+
if not _SERVE_LOCK.acquire(blocking=False):
|
|
170
|
+
raise RuntimeError("同一进程不能同时运行多个调度日志环境")
|
|
171
|
+
key = "PREFECT_LOGGING_SETTINGS_PATH"
|
|
172
|
+
previous = os.environ.get(key)
|
|
173
|
+
previous_root = os.environ.get(_PROJECT_ROOT_ENV)
|
|
174
|
+
try:
|
|
175
|
+
with TemporaryDirectory(prefix="fastapi-authly-schedule-") as directory:
|
|
176
|
+
path = write_logging_config(Path(directory) / "logging.yml", resolved, project_root)
|
|
177
|
+
setup_logging()
|
|
178
|
+
setup_prefect_logging(resolved, project_root)
|
|
179
|
+
os.environ[key] = str(path)
|
|
180
|
+
os.environ[_PROJECT_ROOT_ENV] = str(project_root)
|
|
181
|
+
try:
|
|
182
|
+
yield path
|
|
183
|
+
finally:
|
|
184
|
+
if previous is None:
|
|
185
|
+
os.environ.pop(key, None)
|
|
186
|
+
else:
|
|
187
|
+
os.environ[key] = previous
|
|
188
|
+
if previous_root is None:
|
|
189
|
+
os.environ.pop(_PROJECT_ROOT_ENV, None)
|
|
190
|
+
else:
|
|
191
|
+
os.environ[_PROJECT_ROOT_ENV] = previous_root
|
|
192
|
+
finally:
|
|
193
|
+
_SERVE_LOCK.release()
|
|
@@ -24,7 +24,7 @@ from rich.text import Text
|
|
|
24
24
|
class _DeploymentBinding:
|
|
25
25
|
key: str
|
|
26
26
|
flow: Flow
|
|
27
|
-
config: ScheduleConfig
|
|
27
|
+
config: ScheduleConfig | None
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
class PrefectDeploymentManager:
|
|
@@ -43,8 +43,20 @@ class PrefectDeploymentManager:
|
|
|
43
43
|
await manager.serve()
|
|
44
44
|
"""
|
|
45
45
|
|
|
46
|
-
def __init__(
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
namespace: str,
|
|
49
|
+
*,
|
|
50
|
+
runner_limit: int | None = 5,
|
|
51
|
+
) -> None:
|
|
52
|
+
if runner_limit is not None:
|
|
53
|
+
if type(runner_limit) is not int:
|
|
54
|
+
raise TypeError("runner_limit 必须是正整数或 None")
|
|
55
|
+
if runner_limit < 1:
|
|
56
|
+
raise ValueError("runner_limit 必须大于 0")
|
|
57
|
+
|
|
47
58
|
self.namespace = self._validate_name(namespace, "namespace")
|
|
59
|
+
self.runner_limit = runner_limit
|
|
48
60
|
self._bindings: dict[str, _DeploymentBinding] = {}
|
|
49
61
|
self._serving = False
|
|
50
62
|
|
|
@@ -52,21 +64,23 @@ class PrefectDeploymentManager:
|
|
|
52
64
|
self,
|
|
53
65
|
key: str,
|
|
54
66
|
flow: Flow,
|
|
55
|
-
config: ScheduleConfig,
|
|
67
|
+
config: ScheduleConfig | None = None,
|
|
56
68
|
) -> None:
|
|
57
|
-
"""绑定一个 Flow
|
|
69
|
+
"""绑定一个 Flow 及其可选调度配置,不连接 Prefect 服务端。
|
|
58
70
|
|
|
59
71
|
Args:
|
|
60
72
|
key: 当前管理实例中唯一的任务标识,例如 cluster。
|
|
61
73
|
flow: 使用 Prefect @flow 装饰的工作流对象。
|
|
62
74
|
config: Cron 或固定间隔调度配置。
|
|
75
|
+
为 None 时,不配置定时计划,可通过 API、CLI 或页面触发。
|
|
63
76
|
|
|
64
77
|
Raises:
|
|
65
78
|
ValueError: 任务标识非法或重复。
|
|
66
|
-
TypeError: flow 或 config 类型不正确。
|
|
79
|
+
TypeError: key、flow 或 config 类型不正确。
|
|
67
80
|
RuntimeError: 服务运行期间尝试添加绑定。
|
|
68
81
|
|
|
69
82
|
Examples:
|
|
83
|
+
添加定时任务:
|
|
70
84
|
manager.add(
|
|
71
85
|
key="cluster",
|
|
72
86
|
flow=cluster_flow,
|
|
@@ -76,6 +90,16 @@ class PrefectDeploymentManager:
|
|
|
76
90
|
timezone="Asia/Shanghai",
|
|
77
91
|
),
|
|
78
92
|
)
|
|
93
|
+
|
|
94
|
+
添加无定时计划的任务:
|
|
95
|
+
manager.add(
|
|
96
|
+
key="upload-api",
|
|
97
|
+
flow=data_source_algorithm_pipeline_flow,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
Notes:
|
|
101
|
+
调度配置重新校验后保存独立副本。
|
|
102
|
+
此方法不注册部署,也不执行 Flow。
|
|
79
103
|
"""
|
|
80
104
|
if self._serving:
|
|
81
105
|
raise RuntimeError("服务运行期间不能添加任务")
|
|
@@ -88,17 +112,21 @@ class PrefectDeploymentManager:
|
|
|
88
112
|
if not isinstance(flow, Flow):
|
|
89
113
|
raise TypeError("flow 必须是使用 @flow 装饰的 Prefect Flow")
|
|
90
114
|
|
|
91
|
-
|
|
92
|
-
config,
|
|
93
|
-
(CronScheduleConfig, IntervalScheduleConfig),
|
|
94
|
-
):
|
|
95
|
-
raise TypeError(
|
|
96
|
-
"config 必须是 CronScheduleConfig 或 IntervalScheduleConfig"
|
|
97
|
-
)
|
|
115
|
+
config_copy: ScheduleConfig | None = None
|
|
98
116
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
117
|
+
if config is not None:
|
|
118
|
+
if not isinstance(
|
|
119
|
+
config,
|
|
120
|
+
(CronScheduleConfig, IntervalScheduleConfig),
|
|
121
|
+
):
|
|
122
|
+
raise TypeError(
|
|
123
|
+
"config 必须是 CronScheduleConfig、"
|
|
124
|
+
"IntervalScheduleConfig 或 None"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
config_copy = type(config).model_validate(
|
|
128
|
+
config.model_dump(mode="python")
|
|
129
|
+
)
|
|
102
130
|
|
|
103
131
|
self._bindings[key] = _DeploymentBinding(
|
|
104
132
|
key=key,
|
|
@@ -130,7 +158,7 @@ class PrefectDeploymentManager:
|
|
|
130
158
|
raise ValueError("没有可构建的任务,请先调用 add()")
|
|
131
159
|
|
|
132
160
|
schedules = [
|
|
133
|
-
self._build_schedule(binding.config)
|
|
161
|
+
self._build_schedule(binding.config) if binding.config is not None else None
|
|
134
162
|
for binding in bindings
|
|
135
163
|
]
|
|
136
164
|
|
|
@@ -139,11 +167,11 @@ class PrefectDeploymentManager:
|
|
|
139
167
|
for binding, schedule in zip(bindings, schedules, strict=True):
|
|
140
168
|
deployment = await binding.flow.ato_deployment(
|
|
141
169
|
name=f"{self.namespace}-{binding.key}",
|
|
142
|
-
schedules=[schedule],
|
|
170
|
+
schedules=[schedule] if schedule is not None else [],
|
|
143
171
|
concurrency_limit=ConcurrencyLimitConfig(
|
|
144
172
|
limit=binding.config.max_instances,
|
|
145
173
|
collision_strategy=ConcurrencyLimitStrategy.CANCEL_NEW,
|
|
146
|
-
),
|
|
174
|
+
) if binding.config is not None else None,
|
|
147
175
|
)
|
|
148
176
|
deployments.append(deployment)
|
|
149
177
|
|
|
@@ -178,6 +206,7 @@ class PrefectDeploymentManager:
|
|
|
178
206
|
|
|
179
207
|
await aserve(
|
|
180
208
|
*deployments,
|
|
209
|
+
limit=self.runner_limit,
|
|
181
210
|
pause_on_shutdown=True,
|
|
182
211
|
print_starting_message=True,
|
|
183
212
|
)
|
|
@@ -246,17 +275,20 @@ class PrefectDeploymentManager:
|
|
|
246
275
|
if isinstance(config, CronScheduleConfig):
|
|
247
276
|
schedule_text = f"Cron: {config.cron}"
|
|
248
277
|
timezone_text = config.timezone
|
|
249
|
-
|
|
278
|
+
elif isinstance(config, IntervalScheduleConfig):
|
|
250
279
|
schedule_text = f"Every {config.seconds} seconds"
|
|
251
280
|
timezone_text = "N/A"
|
|
281
|
+
else:
|
|
282
|
+
schedule_text = "API / Manual"
|
|
283
|
+
timezone_text = "N/A"
|
|
252
284
|
|
|
253
285
|
table.add_row(
|
|
254
286
|
Text(f"{self.namespace}-{binding.key}"),
|
|
255
287
|
Text(binding.flow.name),
|
|
256
288
|
Text(schedule_text),
|
|
257
289
|
Text(timezone_text),
|
|
258
|
-
str(config.max_instances),
|
|
259
|
-
"CANCEL_NEW",
|
|
290
|
+
str(config.max_instances) if config is not None else "Not set",
|
|
291
|
+
"CANCEL_NEW" if config is not None else "Not set",
|
|
260
292
|
)
|
|
261
293
|
|
|
262
294
|
panel = Panel(
|
|
@@ -269,4 +301,4 @@ class PrefectDeploymentManager:
|
|
|
269
301
|
expand=False,
|
|
270
302
|
)
|
|
271
303
|
|
|
272
|
-
Console().print(panel)
|
|
304
|
+
Console().print(panel)
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from typing import Any
|
|
4
|
-
|
|
5
|
-
from json_repair import repair_json
|
|
6
|
-
from langchain_core.language_models import BaseChatModel
|
|
7
|
-
from langchain_core.prompts import ChatPromptTemplate
|
|
8
|
-
from langchain_core.runnables import Runnable
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def jsonify(value: Any) -> Any:
|
|
12
|
-
content = value.content if hasattr(value, "content") else value
|
|
13
|
-
return repair_json(str(content).strip(), return_objects=True)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def create_json_chain(llm: BaseChatModel):
|
|
17
|
-
chat_prompt = ChatPromptTemplate.from_messages([
|
|
18
|
-
("human", "{content}")
|
|
19
|
-
])
|
|
20
|
-
return (
|
|
21
|
-
chat_prompt
|
|
22
|
-
| llm
|
|
23
|
-
| jsonify
|
|
24
|
-
)
|
|
@@ -1,202 +0,0 @@
|
|
|
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__}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/exception_handlers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/scalar/standalone.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|