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.
Files changed (62) hide show
  1. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/PKG-INFO +1 -1
  2. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/pyproject.toml +1 -1
  3. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/__about__.py +1 -1
  4. fastapi_authly-0.1.34/src/fastapi_authly/ai/output_parsers.py +261 -0
  5. fastapi_authly-0.1.34/src/fastapi_authly/schedule/logging.py +193 -0
  6. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schedule/manager.py +54 -22
  7. fastapi_authly-0.1.32/src/fastapi_authly/ai/output_parsers.py +0 -24
  8. fastapi_authly-0.1.32/src/fastapi_authly/schedule.py +0 -202
  9. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/.gitignore +0 -0
  10. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/LICENSE +0 -0
  11. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/README.md +0 -0
  12. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/README.zh.md +0 -0
  13. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/ScienceClaw-master.zip +0 -0
  14. {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
  15. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/examples/chart_tools.py +0 -0
  16. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/examples/correct_usage.py +0 -0
  17. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/__init__.py +0 -0
  18. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/__init__.py +0 -0
  19. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/embeddings.py +0 -0
  20. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/rerank.py +0 -0
  21. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/ai/vectorstores.py +0 -0
  22. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/auth.py +0 -0
  23. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/charts.py +0 -0
  24. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/contrib/__init__.py +0 -0
  25. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/contrib/tortoise_pg.py +0 -0
  26. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/__init__.py +0 -0
  27. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/__init__.py +0 -0
  28. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/auth.py +0 -0
  29. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/base.py +0 -0
  30. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/config/database.py +0 -0
  31. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/error_codes.py +0 -0
  32. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/exception_handlers.py +0 -0
  33. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/exceptions.py +0 -0
  34. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/logging.py +0 -0
  35. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/pagination.py +0 -0
  36. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/response.py +0 -0
  37. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/core/security.py +0 -0
  38. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/deps.py +0 -0
  39. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/docs.py +0 -0
  40. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/interfaces.py +0 -0
  41. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/__init__.py +0 -0
  42. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/config.py +0 -0
  43. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/exceptions.py +0 -0
  44. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/factory.py +0 -0
  45. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/llm/providers.py +0 -0
  46. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/models/__init__.py +0 -0
  47. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/models/user.py +0 -0
  48. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/repo.py +0 -0
  49. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schedule/__init__.py +0 -0
  50. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schedule/config.py +0 -0
  51. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schemas/__init__.py +0 -0
  52. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/schemas/user.py +0 -0
  53. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/__init__.py +0 -0
  54. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/langchain_adapter.py +0 -0
  55. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/models.py +0 -0
  56. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/registry.py +0 -0
  57. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/skill_runtime/runtime.py +0 -0
  58. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/echarts.min.js +0 -0
  59. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/favicon.svg +0 -0
  60. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/scalar/standalone.js +0 -0
  61. {fastapi_authly-0.1.32 → fastapi_authly-0.1.34}/src/fastapi_authly/static/scalar/style.css +0 -0
  62. {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.32
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.32"
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"}
@@ -1,3 +1,3 @@
1
1
  """Version information."""
2
2
 
3
- __version__ = "0.1.32"
3
+ __version__ = "0.1.34"
@@ -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__(self, namespace: str) -> None:
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 与调度配置,不连接 Prefect 服务端。
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
- if not isinstance(
92
- config,
93
- (CronScheduleConfig, IntervalScheduleConfig),
94
- ):
95
- raise TypeError(
96
- "config 必须是 CronScheduleConfig 或 IntervalScheduleConfig"
97
- )
115
+ config_copy: ScheduleConfig | None = None
98
116
 
99
- config_copy = type(config).model_validate(
100
- config.model_dump(mode="python")
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
- else:
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