From 1e54b0ba42dfcea6a6506d1c4bd1eaed9e7f191e Mon Sep 17 00:00:00 2001 From: Wu Clan Date: Thu, 15 May 2025 20:50:24 +0800 Subject: [PATCH] Add plugin info config and interfaces (#601) * Add plugin info config and interfaces * Remove test data --- .pre-commit-config.yaml | 4 +- backend/app/admin/api/v1/sys/__init__.py | 2 +- backend/app/admin/api/v1/sys/plugin.py | 115 +++++----- backend/app/admin/service/plugin_service.py | 172 +++++++++++++++ backend/common/response/response_code.py | 4 + backend/core/conf.py | 9 +- backend/plugin/code_generator/plugin.toml | 6 + backend/plugin/config/plugin.toml | 6 + backend/plugin/dict/plugin.toml | 6 + backend/plugin/errors.py | 10 + backend/plugin/notice/api/v1/sys/notice.py | 6 +- backend/plugin/notice/plugin.toml | 6 + backend/plugin/tools.py | 224 ++++++++++++++------ backend/utils/re_verify.py | 36 +++- pyproject.toml | 2 + requirements.txt | 6 + uv.lock | 68 +++++- 17 files changed, 537 insertions(+), 145 deletions(-) create mode 100644 backend/app/admin/service/plugin_service.py create mode 100644 backend/plugin/errors.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d25b2da3..dac8c694 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,8 +2,8 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - - id: check-added-large-files - - id: end-of-file-fixer +# - id: check-added-large-files +# - id: end-of-file-fixer - id: check-yaml - id: check-toml diff --git a/backend/app/admin/api/v1/sys/__init__.py b/backend/app/admin/api/v1/sys/__init__.py index a7628b45..b51691e4 100644 --- a/backend/app/admin/api/v1/sys/__init__.py +++ b/backend/app/admin/api/v1/sys/__init__.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +# !/usr/bin/env python3 # -*- coding: utf-8 -*- from fastapi import APIRouter diff --git a/backend/app/admin/api/v1/sys/plugin.py b/backend/app/admin/api/v1/sys/plugin.py index 58c92389..f4fe9976 100644 --- a/backend/app/admin/api/v1/sys/plugin.py +++ b/backend/app/admin/api/v1/sys/plugin.py @@ -1,72 +1,79 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- -import io -import os.path -import zipfile - -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends, File, UploadFile from fastapi.params import Query from starlette.responses import StreamingResponse -from backend.common.exception import errors -from backend.common.response.response_schema import ResponseModel, response_base +from backend.app.admin.service.plugin_service import plugin_service +from backend.common.response.response_code import CustomResponseCode +from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base +from backend.common.security.jwt import DependsJwtAuth from backend.common.security.permission import RequestPermission from backend.common.security.rbac import DependsRBAC -from backend.core.path_conf import PLUGIN_DIR -from backend.plugin.tools import install_requirements_async router = APIRouter() +@router.get('', summary='获取所有插件', dependencies=[DependsJwtAuth]) +async def get_all_plugins() -> ResponseSchemaModel[list[dict[str, Any]]]: + plugins = await plugin_service.get_all() + return response_base.success(data=plugins) + + @router.post( - '/install', - summary='安装插件', - description='需使用插件 zip 压缩包进行安装', + '/install/zip', + summary='安装 zip 插件', + description='使用插件 zip 压缩包进行安装', dependencies=[ Depends(RequestPermission('sys:plugin:install')), DependsRBAC, ], ) -async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: - contents = await file.read() - file_bytes = io.BytesIO(contents) - if not zipfile.is_zipfile(file_bytes): - raise errors.ForbiddenError(msg='插件压缩包格式非法') - with zipfile.ZipFile(file_bytes) as zf: - # 校验压缩包 - plugin_dir_in_zip = f'{file.filename[:-4]}/backend/plugin/' - members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)] - if not members_in_plugin_dir: - raise errors.ForbiddenError(msg='插件压缩包内容非法') - plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '') - if ( - len(members_in_plugin_dir) <= 3 - or f'{plugin_dir_in_zip}{plugin_name}/plugin.toml' not in members_in_plugin_dir - or f'{plugin_dir_in_zip}{plugin_name}/README.md' not in members_in_plugin_dir - ): - raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件') +async def install_zip_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: + await plugin_service.install_zip(file=file) + return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS) - # 插件是否可安装 - full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) - if os.path.exists(full_plugin_path): - raise errors.ForbiddenError(msg='此插件已安装') - else: - os.makedirs(full_plugin_path, exist_ok=True) - # 解压(安装) - members = [] - for member in zf.infolist(): - if member.filename.startswith(plugin_dir_in_zip): - new_filename = member.filename.replace(plugin_dir_in_zip, '') - if new_filename: - member.filename = new_filename - members.append(member) - zf.extractall(PLUGIN_DIR, members) - if os.path.exists(os.path.join(full_plugin_path, 'requirements.txt')): - await install_requirements_async() +@router.post( + '/install/git', + summary='安装 git 插件', + description='使用插件 git 仓库地址进行安装,不限制平台;如果需要凭证,需在 git 仓库地址中添加凭证信息', + dependencies=[ + Depends(RequestPermission('sys:plugin:install')), + DependsRBAC, + ], +) +async def install_git_plugin(repo_url: Annotated[str, Query(description='插件 git 仓库地址')]) -> ResponseModel: + await plugin_service.install_git(repo_url=repo_url) + return response_base.success(res=CustomResponseCode.PLUGIN_INSTALL_SUCCESS) + +@router.post( + '/uninstall', + summary='卸载插件', + description='此操作会直接删除插件依赖,但不会直接删除插件,而是将插件移动到备份目录', + dependencies=[ + Depends(RequestPermission('sys:plugin:uninstall')), + DependsRBAC, + ], +) +async def uninstall_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel: + await plugin_service.uninstall(plugin=plugin) + return response_base.success(res=CustomResponseCode.PLUGIN_UNINSTALL_SUCCESS) + + +@router.post( + '/status', + summary='更新插件状态', + dependencies=[ + Depends(RequestPermission('sys:plugin:status')), + DependsRBAC, + ], +) +async def update_plugin_status(plugin: Annotated[str, Query(description='插件名称')]) -> ResponseModel: + await plugin_service.update_status(plugin=plugin) return response_base.success() @@ -79,19 +86,7 @@ async def install_plugin(file: Annotated[UploadFile, File()]) -> ResponseModel: ], ) async def build_plugin(plugin: Annotated[str, Query(description='插件名称')]) -> StreamingResponse: - plugin_dir = os.path.join(PLUGIN_DIR, plugin) - if not os.path.exists(plugin_dir): - raise errors.ForbiddenError(msg='插件不存在') - - bio = io.BytesIO() - with zipfile.ZipFile(bio, 'w') as zf: - for root, dirs, files in os.walk(plugin_dir): - dirs[:] = [d for d in dirs if d != '__pycache__'] - for file in files: - file_path = os.path.join(root, file) - arcname = os.path.relpath(file_path, start=plugin_dir) - zf.write(file_path, arcname) - + bio = await plugin_service.build(plugin=plugin) bio.seek(0) return StreamingResponse( bio, diff --git a/backend/app/admin/service/plugin_service.py b/backend/app/admin/service/plugin_service.py new file mode 100644 index 00000000..9f00d06f --- /dev/null +++ b/backend/app/admin/service/plugin_service.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import io +import json +import os +import shutil +import zipfile + +from typing import Any + +from dulwich import porcelain +from fastapi import UploadFile + +from backend.common.enums import StatusType +from backend.common.exception import errors +from backend.common.log import log +from backend.core.conf import settings +from backend.core.path_conf import PLUGIN_DIR +from backend.database.redis import redis_client +from backend.plugin.tools import install_requirements_async, uninstall_requirements_async +from backend.utils.re_verify import is_git_url +from backend.utils.timezone import timezone + + +class PluginService: + """插件服务类""" + + @staticmethod + async def get_all() -> list[dict[str, Any]]: + """获取所有插件""" + keys = [] + result = [] + + async for key in redis_client.scan_iter(f'{settings.PLUGIN_REDIS_PREFIX}:info:*'): + keys.append(key) + + for info in await redis_client.mget(*keys): + result.append(json.loads(info)) + + return result + + @staticmethod + async def install_zip(*, file: UploadFile) -> None: + """ + 通过 zip 压缩包安装插件 + + :param file: 插件 zip 压缩包 + :return: + """ + contents = await file.read() + file_bytes = io.BytesIO(contents) + if not zipfile.is_zipfile(file_bytes): + raise errors.ForbiddenError(msg='插件压缩包格式非法') + with zipfile.ZipFile(file_bytes) as zf: + # 校验压缩包 + plugin_dir_in_zip = file.filename[:-4] + members_in_plugin_dir = [name for name in zf.namelist() if name.startswith(plugin_dir_in_zip)] + if not members_in_plugin_dir: + raise errors.ForbiddenError(msg='插件压缩包内容非法') + plugin_name = members_in_plugin_dir[1].replace(plugin_dir_in_zip, '').replace('/', '') + if ( + len(members_in_plugin_dir) <= 3 + or f'{plugin_dir_in_zip}/plugin.toml' not in members_in_plugin_dir + or f'{plugin_dir_in_zip}/README.md' not in members_in_plugin_dir + ): + raise errors.ForbiddenError(msg='插件压缩包内缺少必要文件') + + # 插件是否可安装 + full_plugin_path = os.path.join(PLUGIN_DIR, plugin_name) + if os.path.exists(full_plugin_path): + raise errors.ForbiddenError(msg='此插件已安装') + else: + os.makedirs(full_plugin_path, exist_ok=True) + + # 解压(安装) + members = [] + for member in zf.infolist(): + if member.filename.startswith(plugin_dir_in_zip): + new_filename = member.filename.replace(plugin_dir_in_zip, '') + if new_filename: + member.filename = new_filename + members.append(member) + zf.extractall(PLUGIN_DIR, members) + + await install_requirements_async(plugin_name) + + @staticmethod + async def install_git(*, repo_url: str): + """ + 通过 git 安装插件 + + :param repo_url: git 存储库的 URL + :return: + """ + match = is_git_url(repo_url) + if not match: + raise errors.ForbiddenError(msg='Git 仓库地址格式非法') + repo_name = match.group('repo') + plugins = await redis_client.lrange(settings.PLUGIN_REDIS_PREFIX, 0, -1) + if repo_name in plugins: + raise errors.ForbiddenError(msg=f'{repo_name} 插件已安装') + try: + porcelain.clone(repo_url, os.path.join(PLUGIN_DIR, repo_name), checkout=True) + except Exception as e: + log.error(f'插件安装失败: {e}') + raise errors.ServerError(msg='插件安装失败,请稍后重试') from e + else: + await install_requirements_async(repo_name) + + @staticmethod + async def uninstall(*, plugin: str): + """ + 卸载插件 + + :param plugin: 插件名称 + :return: + """ + plugin_dir = os.path.join(PLUGIN_DIR, plugin) + if not os.path.exists(plugin_dir): + raise errors.ForbiddenError(msg='插件不存在') + await uninstall_requirements_async(plugin) + bacup_dir = os.path.join(PLUGIN_DIR, f'{plugin}.{timezone.now().strftime("%Y%m%d%H%M%S")}.backup') + shutil.move(plugin_dir, bacup_dir) + + @staticmethod + async def update_status(*, plugin: str): + """ + 更新插件状态 + + :param plugin: 插件名称 + :return: + """ + plugin_info = await redis_client.get(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}') + if not plugin_info: + raise errors.ForbiddenError(msg='插件不存在') + plugin_info = json.loads(plugin_info) + new_status = ( + StatusType.enable.value + if plugin_info.get('plugin', {}).get('enable') == StatusType.disable.value + else StatusType.disable.value + ) + plugin_info['plugin']['enable'] = new_status + await redis_client.set( + f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(plugin_info, ensure_ascii=False) + ) + await redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', plugin, str(new_status)) + + @staticmethod + async def build(*, plugin: str) -> io.BytesIO: + """ + 打包插件为 zip 压缩包 + + :param plugin: 插件名称 + :return: + """ + plugin_dir = os.path.join(PLUGIN_DIR, plugin) + if not os.path.exists(plugin_dir): + raise errors.ForbiddenError(msg='插件不存在') + + bio = io.BytesIO() + with zipfile.ZipFile(bio, 'w') as zf: + for root, dirs, files in os.walk(plugin_dir): + dirs[:] = [d for d in dirs if d != '__pycache__'] + for file in files: + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, start=plugin_dir) + zf.write(file_path, arcname) + + return bio + + +plugin_service: PluginService = PluginService() diff --git a/backend/common/response/response_code.py b/backend/common/response/response_code.py index 5ec754be..b84aa904 100644 --- a/backend/common/response/response_code.py +++ b/backend/common/response/response_code.py @@ -39,6 +39,10 @@ class CustomResponseCode(CustomCodeBase): HTTP_503 = (503, '服务器暂时无法处理请求') HTTP_504 = (504, '网关超时') + # Plugin + PLUGIN_INSTALL_SUCCESS = (200, '插件安装成功,请根据插件说明(README.md)进行相关配置并重启服务') + PLUGIN_UNINSTALL_SUCCESS = (200, '插件卸载成功,请根据插件说明(README.md)移除相关配置并重启服务') + class CustomErrorCode(CustomCodeBase): """自定义错误状态码""" diff --git a/backend/core/conf.py b/backend/core/conf.py index f8ace006..8a2e0087 100644 --- a/backend/core/conf.py +++ b/backend/core/conf.py @@ -181,9 +181,10 @@ class Settings(BaseSettings): 'confirm_password', ] - # 插件配置 + # Plugin 配置 PLUGIN_PIP_CHINA: bool = True PLUGIN_PIP_INDEX_URL: str = 'https://mirrors.aliyun.com/pypi/simple/' + PLUGIN_REDIS_PREFIX: str = 'fba:plugin' # App Admin # .env OAuth2 @@ -200,11 +201,11 @@ class Settings(BaseSettings): CAPTCHA_LOGIN_EXPIRE_SECONDS: int = 60 * 5 # 3 分钟 # App Task - # .env Redis 配置 + # .env Redis CELERY_BROKER_REDIS_DATABASE: int CELERY_BACKEND_REDIS_DATABASE: int - # .env RabbitMQ 配置 + # .env RabbitMQ # docker run -d --hostname fba-mq --name fba-mq -p 5672:5672 -p 15672:15672 rabbitmq:latest CELERY_RABBITMQ_HOST: str CELERY_RABBITMQ_PORT: int @@ -238,11 +239,9 @@ class Settings(BaseSettings): } # Plugin Code Generator - # 代码下载 CODE_GENERATOR_DOWNLOAD_ZIP_FILENAME: str = 'fba_generator' # Plugin Config - # 参数配置 CONFIG_BUILT_IN_TYPES: list[str] = ['website', 'protocol', 'policy'] @model_validator(mode='before') diff --git a/backend/plugin/code_generator/plugin.toml b/backend/plugin/code_generator/plugin.toml index 3bc9b08d..164a394a 100644 --- a/backend/plugin/code_generator/plugin.toml +++ b/backend/plugin/code_generator/plugin.toml @@ -1,2 +1,8 @@ +[plugin] +summary = '代码生成' +version = '0.0.1' +description = '生成通用业务代码' +author = 'wu-clan' + [app] router = ['v1'] diff --git a/backend/plugin/config/plugin.toml b/backend/plugin/config/plugin.toml index 5c380851..de8e1815 100644 --- a/backend/plugin/config/plugin.toml +++ b/backend/plugin/config/plugin.toml @@ -1,3 +1,9 @@ +[plugin] +summary = '参数配置' +version = '0.0.1' +description = '通常用于前端工程数据展示' +author = 'wu-clan' + [app] include = 'admin' diff --git a/backend/plugin/dict/plugin.toml b/backend/plugin/dict/plugin.toml index 98379c21..0011b765 100644 --- a/backend/plugin/dict/plugin.toml +++ b/backend/plugin/dict/plugin.toml @@ -1,3 +1,9 @@ +[plugin] +summary = '数据字典' +version = '0.0.1' +description = '通常用于约束前端工程数据展示' +author = 'wu-clan' + [app] include = 'admin' diff --git a/backend/plugin/errors.py b/backend/plugin/errors.py new file mode 100644 index 00000000..213b243a --- /dev/null +++ b/backend/plugin/errors.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + + +class PluginConfigError(Exception): + """插件信息错误""" + + +class PluginInjectError(Exception): + """插件注入错误""" diff --git a/backend/plugin/notice/api/v1/sys/notice.py b/backend/plugin/notice/api/v1/sys/notice.py index 2832130d..8ec4d344 100644 --- a/backend/plugin/notice/api/v1/sys/notice.py +++ b/backend/plugin/notice/api/v1/sys/notice.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- from typing import Annotated -from fastapi import APIRouter, Depends, Path, Query +from fastapi import APIRouter, Depends, Path, Query, Request from backend.common.pagination import DependsPagination, PageData, paging_data from backend.common.response.response_schema import ResponseModel, ResponseSchemaModel, response_base @@ -17,7 +17,9 @@ router = APIRouter() @router.get('/{pk}', summary='获取通知公告详情', dependencies=[DependsJwtAuth]) -async def get_notice(pk: Annotated[int, Path(description='通知公告 ID')]) -> ResponseSchemaModel[GetNoticeDetail]: +async def get_notice( + request: Request, pk: Annotated[int, Path(description='通知公告 ID')] +) -> ResponseSchemaModel[GetNoticeDetail]: notice = await notice_service.get(pk=pk) return response_base.success(data=notice) diff --git a/backend/plugin/notice/plugin.toml b/backend/plugin/notice/plugin.toml index 79fc97c7..a1c24d2e 100644 --- a/backend/plugin/notice/plugin.toml +++ b/backend/plugin/notice/plugin.toml @@ -1,3 +1,9 @@ +[plugin] +summary = '通知公告' +version = '0.0.1' +description = '发布系统内部通知、公告' +author = 'wu-clan' + [app] include = 'admin' diff --git a/backend/plugin/tools.py b/backend/plugin/tools.py index 9ac0747a..1b109e58 100644 --- a/backend/plugin/tools.py +++ b/backend/plugin/tools.py @@ -1,33 +1,42 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import asyncio import inspect +import json import os import subprocess import sys import warnings +from functools import lru_cache from typing import Any +import nest_asyncio import rtoml -from fastapi import APIRouter +from fastapi import APIRouter, Depends, Request from starlette.concurrency import run_in_threadpool +from backend.common.enums import StatusType +from backend.common.exception.errors import ForbiddenError +from backend.common.log import log from backend.core.conf import settings from backend.core.path_conf import PLUGIN_DIR +from backend.database.redis import redis_client +from backend.plugin.errors import PluginConfigError, PluginInjectError from backend.utils.import_parse import import_module_cached -class PluginInjectError(Exception): - """插件注入错误""" - - +@lru_cache def get_plugins() -> list[str]: """获取插件列表""" plugin_packages = [] # 遍历插件目录 for item in os.listdir(PLUGIN_DIR): + if item.endswith('.py') or item.endswith('backup') or item == '__pycache__': + continue + item_path = os.path.join(PLUGIN_DIR, item) # 检查是否为目录且包含 __init__.py 文件 @@ -41,10 +50,7 @@ def get_plugin_models() -> list[type]: """获取插件所有模型类""" classes = [] - # 获取所有插件 - plugins = get_plugins() - - for plugin in plugins: + for plugin in get_plugins(): # 导入插件的模型模块 module_path = f'backend.plugin.{plugin}.model' module = import_module_cached(module_path) @@ -72,21 +78,67 @@ def load_plugin_config(plugin: str) -> dict[str, Any]: return rtoml.load(f) -def inject_extra_router(plugin: str, data: dict[str, Any]) -> None: +def parse_plugin_config() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """解析插件配置""" + + extra_plugins = [] + app_plugins = [] + + # 事件循环嵌套: https://pypi.org/project/nest-asyncio/ + loop = asyncio.get_running_loop() + nest_asyncio.apply(loop) + + plugin_status = asyncio.run(redis_client.hgetall(f'{settings.PLUGIN_REDIS_PREFIX}:status')) # type: ignore + if not plugin_status: + plugin_status = {} + + for plugin in get_plugins(): + data = load_plugin_config(plugin) + + plugin_info = data.get('plugin') + if not plugin_info: + raise PluginConfigError(f'插件 {plugin} 配置文件缺少 plugin 配置') + + required_fields = ['summary', 'version', 'description', 'author'] + missing_fields = [field for field in required_fields if field not in plugin_info] + if missing_fields: + raise PluginConfigError(f'插件 {plugin} 配置文件缺少必要字段: {", ".join(missing_fields)}') + + if data.get('api'): + if not data.get('app', {}).get('include'): + raise PluginConfigError(f'扩展级插件 {plugin} 配置文件缺少 app.include 配置') + extra_plugins.append(data) + else: + if not data.get('app', {}).get('router'): + raise PluginConfigError(f'应用级插件 {plugin} 配置文件缺少 app.router 配置') + app_plugins.append(data) + + # 补充插件信息 + data['plugin']['enable'] = plugin_status.setdefault(plugin, StatusType.enable.value) + data['plugin']['name'] = plugin + + # 缓存插件信息 + asyncio.create_task( + redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:info:{plugin}', json.dumps(data, ensure_ascii=False)) + ) + + # 缓存插件状态 + asyncio.create_task(redis_client.hset(f'{settings.PLUGIN_REDIS_PREFIX}:status', mapping=plugin_status)) + + return extra_plugins, app_plugins + + +def inject_extra_router(plugin: dict[str, Any]) -> None: """ 扩展级插件路由注入 :param plugin: 插件名称 - :param data: 插件配置数据 :return: """ - app_include = data.get('app', {}).get('include', '') - if not app_include: - raise PluginInjectError(f'扩展级插件 {plugin} 配置文件存在错误,请检查') - - plugin_api_path = os.path.join(PLUGIN_DIR, plugin, 'api') + plugin_name: str = plugin['plugin']['name'] + plugin_api_path = os.path.join(PLUGIN_DIR, plugin_name, 'api') if not os.path.exists(plugin_api_path): - raise PluginInjectError(f'插件 {plugin} 缺少 api 目录,请检查插件文件是否完整') + raise PluginConfigError(f'插件 {plugin} 缺少 api 目录,请检查插件文件是否完整') for root, _, api_files in os.walk(plugin_api_path): for file in api_files: @@ -94,7 +146,7 @@ def inject_extra_router(plugin: str, data: dict[str, Any]) -> None: continue # 解析插件路由配置 - file_config = data.get('api', {}).get(f'{file[:-3]}', {}) + file_config = plugin.get('api', {}).get(f'{file[:-3]}', {}) prefix = file_config.get('prefix', '') tags = file_config.get('tags', []) @@ -108,20 +160,22 @@ def inject_extra_router(plugin: str, data: dict[str, Any]) -> None: plugin_router = getattr(module, 'router', None) if not plugin_router: warnings.warn( - f'扩展级插件 {plugin} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整', + f'扩展级插件 {plugin_name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整', FutureWarning, ) continue # 获取目标 app 路由 relative_path = os.path.relpath(root, plugin_api_path) - target_module_path = f'backend.app.{app_include}.api.{relative_path.replace(os.sep, ".")}' + target_module_path = ( + f'backend.app.{plugin.get("app", {}).get("include")}.api.{relative_path.replace(os.sep, ".")}' + ) target_module = import_module_cached(target_module_path) target_router = getattr(target_module, 'router', None) if not target_router or not isinstance(target_router, APIRouter): raise PluginInjectError( - f'扩展级插件 {plugin} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整' + f'扩展级插件 {plugin_name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整' ) # 将插件路由注入到目标路由中 @@ -129,94 +183,142 @@ def inject_extra_router(plugin: str, data: dict[str, Any]) -> None: router=plugin_router, prefix=prefix, tags=[tags] if tags else [], + dependencies=[Depends(PluginStatusChecker(plugin_name))], ) except Exception as e: - raise PluginInjectError(f'扩展级插件 {plugin} 路由注入失败:{str(e)}') from e + raise PluginInjectError(f'扩展级插件 {plugin_name} 路由注入失败:{str(e)}') from e -def inject_app_router(plugin: str, data: dict[str, Any], target_router: APIRouter) -> None: +def inject_app_router(plugin: dict[str, Any], target_router: APIRouter) -> None: """ 应用级插件路由注入 :param plugin: 插件名称 - :param data: 插件配置数据 :param target_router: FastAPI 路由器 :return: """ - module_path = f'backend.plugin.{plugin}.api.router' + plugin_name: str = plugin['plugin']['name'] + module_path = f'backend.plugin.{plugin_name}.api.router' try: module = import_module_cached(module_path) - routers = data.get('app', {}).get('router', []) + routers = plugin.get('app', {}).get('router') if not routers or not isinstance(routers, list): - raise PluginInjectError(f'应用级插件 {plugin} 配置文件存在错误,请检查') + raise PluginConfigError(f'应用级插件 {plugin_name} 配置文件存在错误,请检查') for router in routers: plugin_router = getattr(module, router, None) if not plugin_router or not isinstance(plugin_router, APIRouter): raise PluginInjectError( - f'应用级插件 {plugin} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整' + f'应用级插件 {plugin_name} 模块 {module_path} 中没有有效的 router,请检查插件文件是否完整' ) # 将插件路由注入到目标路由中 - target_router.include_router(plugin_router) + target_router.include_router(plugin_router, dependencies=[Depends(PluginStatusChecker(plugin_name))]) except Exception as e: - raise PluginInjectError(f'应用级插件 {plugin} 路由注入失败:{str(e)}') from e + raise PluginInjectError(f'应用级插件 {plugin_name} 路由注入失败:{str(e)}') from e def build_final_router() -> APIRouter: """构建最终路由""" + extra_plugins, app_plugins = parse_plugin_config() - extra_plugins = [] - app_plugins = [] - - for plugin in get_plugins(): - data = load_plugin_config(plugin) - (extra_plugins if data.get('api') else app_plugins).append((plugin, data)) - - for plugin, data in extra_plugins: - inject_extra_router(plugin, data) + for plugin in extra_plugins: + inject_extra_router(plugin) # 主路由,必须在插件路由注入后导入 from backend.app.router import router as main_router - for plugin, data in app_plugins: - inject_app_router(plugin, data, main_router) + for plugin in app_plugins: + inject_app_router(plugin, main_router) return main_router -def _install_plugin_requirements(plugin: str, requirements_file: str) -> None: +def install_requirements(plugin: str) -> None: """ - 安装单个插件的依赖 + 安装插件依赖 - :param plugin: 插件名称 - :param requirements_file: 依赖文件路径 + :param plugin: 指定插件名,否则检查所有插件 :return: """ - try: - ensurepip_install = [sys.executable, '-m', 'ensurepip', '--upgrade'] - pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file] - if settings.PLUGIN_PIP_CHINA: - pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL]) - subprocess.check_call(ensurepip_install) - subprocess.check_call(pip_install) - except subprocess.CalledProcessError as e: - raise PluginInjectError(f'插件 {plugin} 依赖安装失败:{e.stderr}') from e + plugins = [plugin] if plugin else get_plugins() - -def install_requirements() -> None: - """安装插件依赖""" - for plugin in get_plugins(): + for plugin in plugins: requirements_file = os.path.join(PLUGIN_DIR, plugin, 'requirements.txt') if os.path.exists(requirements_file): - _install_plugin_requirements(plugin, requirements_file) + try: + ensurepip_install = [sys.executable, '-m', 'ensurepip', '--upgrade'] + pip_install = [sys.executable, '-m', 'pip', 'install', '-r', requirements_file] + if settings.PLUGIN_PIP_CHINA: + pip_install.extend(['-i', settings.PLUGIN_PIP_INDEX_URL]) + subprocess.check_call(ensurepip_install) + subprocess.check_call(pip_install) + except subprocess.CalledProcessError as e: + raise PluginInjectError(f'插件 {plugin} 依赖安装失败:{e.stderr}') from e -async def install_requirements_async() -> None: +def uninstall_requirements(plugin: str) -> None: + """ + 卸载插件依赖 + + :param plugin: 插件名称 + :return: + """ + requirements_file = os.path.join(PLUGIN_DIR, plugin, 'requirements.txt') + if os.path.exists(requirements_file): + try: + pip_uninstall = [sys.executable, '-m', 'pip', 'uninstall', '-r', requirements_file, '-y'] + subprocess.check_call(pip_uninstall) + except subprocess.CalledProcessError as e: + raise PluginInjectError(f'插件 {plugin} 依赖卸载失败:{e.stderr}') from e + + +async def install_requirements_async(plugin: str | None = None) -> None: """ 异步安装插件依赖 由于 Windows 平台限制,无法实现完美的全异步方案,详情: https://stackoverflow.com/questions/44633458/why-am-i-getting-notimplementederror-with-async-and-await-on-windows """ - await run_in_threadpool(install_requirements) + await run_in_threadpool(install_requirements, plugin) + + +async def uninstall_requirements_async(plugin: str) -> None: + """ + 异步卸载插件依赖 + + :param plugin: 插件名称 + :return: + """ + await run_in_threadpool(uninstall_requirements, plugin) + + +class PluginStatusChecker: + """插件状态检查器""" + + def __init__(self, plugin: str) -> None: + """ + 初始化插件状态检查器 + + :param plugin: 插件名称 + :return: + """ + self.plugin = plugin + + async def __call__(self, request: Request) -> None: + """ + 验证插件状态 + + :param request: FastAPI 请求对象 + :return: + """ + plugin_status = await redis_client.hgetall(f'{settings.PLUGIN_REDIS_PREFIX}:status') + if not plugin_status: + log.error('插件状态未初始化或丢失,需重启服务自动修复') + raise PluginInjectError('插件状态未初始化或丢失,请联系系统管理员') + + if self.plugin not in plugin_status: + log.error(f'插件 {self.plugin} 状态未初始化或丢失,需重启服务自动修复') + raise PluginInjectError(f'插件 {self.plugin} 状态未初始化或丢失,请联系系统管理员') + if not int(plugin_status.get(self.plugin)): + raise ForbiddenError(msg=f'插件 {self.plugin} 未启用,请联系系统管理员') diff --git a/backend/utils/re_verify.py b/backend/utils/re_verify.py index b2d8eb22..cdc6525a 100644 --- a/backend/utils/re_verify.py +++ b/backend/utils/re_verify.py @@ -3,7 +3,7 @@ import re -def search_string(pattern: str, text: str) -> bool: +def search_string(pattern: str, text: str) -> re.Match[str] | None: """ 全字段正则匹配 @@ -12,13 +12,13 @@ def search_string(pattern: str, text: str) -> bool: :return: """ if not pattern or not text: - return False + return None result = re.search(pattern, text) - return result is not None + return result -def match_string(pattern: str, text: str) -> bool: +def match_string(pattern: str, text: str) -> re.Match[str] | None: """ 从字段开头正则匹配 @@ -27,21 +27,35 @@ def match_string(pattern: str, text: str) -> bool: :return: """ if not pattern or not text: - return False + return None result = re.match(pattern, text) - return result is not None + return result -def is_phone(text: str) -> bool: +def is_phone(number: str) -> re.Match[str] | None: """ 检查手机号码格式 - :param text: 待检查的手机号码 + :param number: 待检查的手机号码 :return: """ - if not text: - return False + if not number: + return None phone_pattern = r'^1[3-9]\d{9}$' - return match_string(phone_pattern, text) + return match_string(phone_pattern, number) + + +def is_git_url(url: str) -> re.Match[str] | None: + """ + 检查 git URL 格式 + + :param url: 待检查的 URL + :return: + """ + if not url: + return None + + git_pattern = r'^(?!(git\+ssh|ssh)://|git@)(?Pgit|https?|file)://(?P[^/]*)(?P(?:/[^/]*)*/)(?P[^/]+?)(?:\.git)?$' + return match_string(git_pattern, url) diff --git a/pyproject.toml b/pyproject.toml index d783a177..eb7962b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ # https://github.com/celery/celery/issues/7874 "celery-aio-pool==0.1.0rc8", "cryptography>=44.0.0", + "dulwich>=0.22.8", "fast-captcha>=0.3.2", "fastapi-cli==0.0.5", "fastapi-limiter>=0.1.6", @@ -37,6 +38,7 @@ dependencies = [ "jinja2>=3.1.4", "loguru>=0.7.3", "msgspec>=0.19.0", + "nest-asyncio>=1.6.0", "path==17.0.0", "psutil>=6.0.0", "pwdlib>=0.2.1", diff --git a/requirements.txt b/requirements.txt index 317568eb..e455e3d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -74,6 +74,8 @@ distlib==0.3.9 # via virtualenv dnspython==2.7.0 # via email-validator +dulwich==0.22.8 + # via fastapi-best-architecture ecdsa==0.19.1 # via python-jose email-validator==2.2.0 @@ -159,6 +161,8 @@ mdurl==0.1.2 # via markdown-it-py msgspec==0.19.0 # via fastapi-best-architecture +nest-asyncio==1.6.0 + # via fastapi-best-architecture nodeenv==1.9.1 # via pre-commit packaging==24.2 @@ -292,6 +296,8 @@ ua-parser==1.0.1 # via user-agents ua-parser-builtins==0.18.0.post1 # via ua-parser +urllib3==2.4.0 + # via dulwich user-agents==2.2.0 # via fastapi-best-architecture uvicorn==0.34.0 diff --git a/uv.lock b/uv.lock index a3f63b8b..20ed6824 100644 --- a/uv.lock +++ b/uv.lock @@ -473,6 +473,46 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86" }, ] +[[package]] +name = "dulwich" +version = "0.22.8" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/8b/0f2de00c0c0d5881dc39be147ec2918725fb3628deeeb1f27d1c6cf6d9f4/dulwich-0.22.8.tar.gz", hash = "sha256:701547310415de300269331abe29cb5717aa1ea377af826bf513d0adfb1c209b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/4d/0bfc8a96456d033428875003b5104da2c32407363b5b829da5e27553b403/dulwich-0.22.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546176d18b8cc0a492b0f23f07411e38686024cffa7e9d097ae20512a2e57127" }, + { url = "https://mirrors.aliyun.com/pypi/packages/99/71/0dd97cf5a7a09aee93f8266421898d705eba737ca904720450584f471bd3/dulwich-0.22.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d2434dd72b2ae09b653c9cfe6764a03c25cfbd99fbbb7c426f0478f6fb1100f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/40/831bed622eeacfa21f47d1fd75fc0c33a70a2cf1c091ae955be63e94144c/dulwich-0.22.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8318bc0921d42e3e69f03716f983a301b5ee4c8dc23c7f2c5bbb28581257a9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/9e/5255b3927f355c95f6779debf11d551b7bb427a80a11564a1e1b78f0acf6/dulwich-0.22.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7a0f96a2a87f3b4f7feae79d2ac6b94107d6b7d827ac08f2f331b88c8f597a1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0c/f9/d3041cea8cbaaffbd4bf95343c5c16d64608200fc5fa26418bee00ebff23/dulwich-0.22.8-cp310-cp310-win32.whl", hash = "sha256:432a37b25733202897b8d67cdd641688444d980167c356ef4e4dd15a17a39a24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/95/e90a292fb00ffae4f3fbb53b199574eedfaf57b72b67a8ddb835536fc66b/dulwich-0.22.8-cp310-cp310-win_amd64.whl", hash = "sha256:f3a15e58dac8b8a76073ddca34e014f66f3672a5540a99d49ef6a9c09ab21285" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/6e/de1a1c35960d0e399f71725cfcd4dfdb3c391b22c0e5059d991f7ade3488/dulwich-0.22.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0852edc51cff4f4f62976bdaa1d82f6ef248356c681c764c0feb699bc17d5782" }, + { url = "https://mirrors.aliyun.com/pypi/packages/eb/61/b65953b4e9c39268c67038bb8d88516885b720beb25b0f6a0ae95ea3f6b2/dulwich-0.22.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:826aae8b64ac1a12321d6b272fc13934d8f62804fda2bc6ae46f93f4380798eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/eb/07e3974964bfe05888457f7764cfe53b6b95082313c2be06fbbb72116372/dulwich-0.22.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ae726f923057d36cdbb9f4fb7da0d0903751435934648b13f1b851f0e38ea1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/b3/69aebfda4dd4b05ae11af803e4df2d8d350356a30b3b6b6fc662fa1ff729/dulwich-0.22.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6987d753227f55cf75ba29a8dab69d1d83308ce483d7a8c6d223086f7a42e125" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/88/ea0f473d726e117f9fcd7c7a95d97f9ba0e0ee9d9005d745a38809d33352/dulwich-0.22.8-cp311-cp311-win32.whl", hash = "sha256:7757b4a2aad64c6f1920082fc1fccf4da25c3923a0ae7b242c08d06861dae6e1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f9/a8/ed23a435d6922ba7d9601404f473e49acdcb5768a35d89a5bc5fa51d882b/dulwich-0.22.8-cp311-cp311-win_amd64.whl", hash = "sha256:12b243b7e912011c7225dc67480c313ac8d2990744789b876016fb593f6f3e19" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/f2/53c5a22a4a9c0033e10f35c293bc533d64fe3e0c4ff4421128a97d6feda9/dulwich-0.22.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d81697f74f50f008bb221ab5045595f8a3b87c0de2c86aa55be42ba97421f3cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/57/7163ed06a2d9bf1f34d89dcc7c5881119beeed287022c997b0a706edcfbe/dulwich-0.22.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bff1da8e2e6a607c3cb45f5c2e652739589fe891245e1d5b770330cdecbde41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fa/73/50ddf1f3ad592c2526cb34287f45b07ee6320b850efddda2917cc81ac651/dulwich-0.22.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9969099e15b939d3936f8bee8459eaef7ef5a86cd6173393a17fe28ca3d38aff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/70/6b/1153b2793bfc34253589badb5fc22ed476cf741dab7854919e6e51cb0441/dulwich-0.22.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:017152c51b9a613f0698db28c67cf3e0a89392d28050dbf4f4ac3f657ea4c0dc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8a/e3/6b013b98254d7f508f21456832e757b17a9116752979e8b923f89f8c8989/dulwich-0.22.8-cp312-cp312-win32.whl", hash = "sha256:ee70e8bb8798b503f81b53f7a103cb869c8e89141db9005909f79ab1506e26e9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/20/b149f68557d42607b5dcc6f57c1650f2136049be617f3e68092c25861275/dulwich-0.22.8-cp312-cp312-win_amd64.whl", hash = "sha256:dc89c6f14dcdcbfee200b0557c59ae243835e42720be143526d834d0e53ed3af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/a3/7f88ba8ed56eaed6206a7d9b35244964a32eb08635be33f2af60819e6431/dulwich-0.22.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7bb18fa09daa1586c1040b3e2777d38d4212a5cdbe47d384ba66a1ac336fcc4c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/d0/664a38f03cf4264a4ab9112067eb4998d14ffbf3af4cff9fb2d1447f11bc/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2fda8e87907ed304d4a5962aea0338366144df0df60f950b8f7f125871707f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/e4/3595a23375b797a8602a2ca8f6b8207b4ebdf2e3a1ccba306f7b90d74c3f/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1748cd573a0aee4d530bc223a23ccb8bb5b319645931a37bd1cfb68933b720c1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/20/d1/32d89d37da8e2ae947558db0401940594efdda9fa5bb1c55c2b46c43f244/dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a631b2309feb9a9631eabd896612ba36532e3ffedccace57f183bb868d7afc06" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/dc/b9448b82de3e244400dc35813f31db9f4952605c7d4e3041fd94878613c9/dulwich-0.22.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:00e7d9a3d324f9e0a1b27880eec0e8e276ff76519621b66c1a429ca9eb3f5a8d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/20/d855d603ea49ce437d2a015fad9dbb22409e23520340aef3d3dca8b299bb/dulwich-0.22.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f8aa3de93201f9e3e40198725389aa9554a4ee3318a865f96a8e9bc9080f0b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/30/06/390a3a9ce2f4d5b20af0e64f0e9bcefb4a87ad30ef53ee122887f5444076/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e8da9dd8135884975f5be0563ede02179240250e11f11942801ae31ac293f37" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/cd/3c5731784bac200e41b5e66b1440f9f30f92781d3eeefb9f90147c3d392e/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc5ce2435fb3abdf76f1acabe48f2e4b3f7428232cadaef9daaf50ea7fa30ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/cf/01180599b0028e2175da4c0878fbe050d1f197825529be19718f65c5a475/dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:982b21cc3100d959232cadb3da0a478bd549814dd937104ea50f43694ec27153" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/7b/df95faaf8746cce65704f1631a6626e5bb4604a499a0f63fc9103669deba/dulwich-0.22.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6bde2b13a05cc0ec2ecd4597a99896663544c40af1466121f4d046119b874ce3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/56/395c6d82d4d9eb7a7ab62939c99db5b746995b0f3ad3b31f43c15e3e07a0/dulwich-0.22.8-py3-none-any.whl", hash = "sha256:ffc7a02e62b72884de58baaa3b898b7f6427893e79b1289ffa075092efe59181" }, +] + [[package]] name = "ecdsa" version = "0.19.1" @@ -558,6 +598,7 @@ dependencies = [ { name = "celery" }, { name = "celery-aio-pool" }, { name = "cryptography" }, + { name = "dulwich" }, { name = "fast-captcha" }, { name = "fastapi", extra = ["standard"] }, { name = "fastapi-cli" }, @@ -571,6 +612,7 @@ dependencies = [ { name = "jinja2" }, { name = "loguru" }, { name = "msgspec" }, + { name = "nest-asyncio" }, { name = "path" }, { name = "psutil" }, { name = "pwdlib" }, @@ -611,6 +653,7 @@ requires-dist = [ { name = "celery", specifier = "==5.3.6" }, { name = "celery-aio-pool", specifier = "==0.1.0rc8" }, { name = "cryptography", specifier = ">=44.0.0" }, + { name = "dulwich", specifier = ">=0.22.8" }, { name = "fast-captcha", specifier = ">=0.3.2" }, { name = "fastapi", extras = ["standard"], specifier = "==0.115.11" }, { name = "fastapi-cli", specifier = "==0.0.5" }, @@ -624,6 +667,7 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.4" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "msgspec", specifier = ">=0.19.0" }, + { name = "nest-asyncio", specifier = ">=1.6.0" }, { name = "path", specifier = "==17.0.0" }, { name = "psutil", specifier = ">=6.0.0" }, { name = "pwdlib", specifier = ">=0.2.1" }, @@ -1193,6 +1237,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/9c/fd/b247aec6add5601956d440488b7f23151d8343747e82c038af37b28d6098/multidict-6.2.0-py3-none-any.whl", hash = "sha256:5d26547423e5e71dcc562c4acdc134b900640a39abd9066d7326a7cc2324c530" }, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -1864,14 +1917,14 @@ asyncio = [ [[package]] name = "sqlalchemy-crud-plus" version = "1.8.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } dependencies = [ { name = "pydantic" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/56/57a19b9a55910f73c80e802c42e9374f83752e6bfb5ad46829cf99e07758/sqlalchemy_crud_plus-1.8.0.tar.gz", hash = "sha256:cda7fc71a07887ac6fbbc423c0e061dfb912063b4ae207eccba31e08dd87aabb", size = 41807 } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/dc/56/57a19b9a55910f73c80e802c42e9374f83752e6bfb5ad46829cf99e07758/sqlalchemy_crud_plus-1.8.0.tar.gz", hash = "sha256:cda7fc71a07887ac6fbbc423c0e061dfb912063b4ae207eccba31e08dd87aabb" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/25/7bb0ecc055dee08e18682f67af26671c33974722f477dd74c1cc1400c6f8/sqlalchemy_crud_plus-1.8.0-py3-none-any.whl", hash = "sha256:15ac0c6ce83df3b89585f05df25a8b5e35baf846d3a20fd8305fb1ff58aef65b", size = 8458 }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/25/7bb0ecc055dee08e18682f67af26671c33974722f477dd74c1cc1400c6f8/sqlalchemy_crud_plus-1.8.0-py3-none-any.whl", hash = "sha256:15ac0c6ce83df3b89585f05df25a8b5e35baf846d3a20fd8305fb1ff58aef65b" }, ] [[package]] @@ -2007,6 +2060,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/6f/d3/13adff37f15489c784cc7669c35a6c3bf94b87540229eedf52ef2a1d0175/ua_parser_builtins-0.18.0.post1-py3-none-any.whl", hash = "sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d" }, ] +[[package]] +name = "urllib3" +version = "2.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813" }, +] + [[package]] name = "user-agents" version = "2.2.0"