mirror of
https://github.com/fastapi-practices/fastapi-best-architecture.git
synced 2026-09-21 13:12:24 +00:00
Refactor code generation and plugin hot reloading (#1032)
* Refactor code generation and plugin hot reloading * Improve implementation * Update plugin config loader
This commit is contained in:
@@ -26,7 +26,7 @@ async def get_all_tables(
|
||||
|
||||
@router.post(
|
||||
'/imports',
|
||||
summary='导入代码生成业务和模型列',
|
||||
summary='导入代码生成业务和模型列(仅开发环境)',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('codegen:table:import')),
|
||||
DependsRBAC,
|
||||
@@ -56,7 +56,7 @@ async def get_generate_paths(
|
||||
@router.post(
|
||||
'/{pk}',
|
||||
summary='代码生成',
|
||||
description='文件磁盘写入,请谨慎操作',
|
||||
description='文件磁盘写入,请谨慎操作(仅开发环境)',
|
||||
dependencies=[
|
||||
Depends(RequestPermission('codegen:local:write')),
|
||||
DependsRBAC,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from collections.abc import Sequence
|
||||
@@ -10,8 +12,10 @@ from anyio import open_file
|
||||
from pydantic.alias_generators import to_pascal
|
||||
from sqlalchemy import RowMapping
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.common.exception import errors
|
||||
from backend.core.conf import settings
|
||||
from backend.core.path_conf import BASE_PATH
|
||||
from backend.plugin.code_generator.crud.crud_business import gen_business_dao
|
||||
from backend.plugin.code_generator.crud.crud_column import gen_column_dao
|
||||
@@ -24,6 +28,7 @@ from backend.plugin.code_generator.service.column_service import gen_column_serv
|
||||
from backend.plugin.code_generator.utils.format_code import format_python_code
|
||||
from backend.plugin.code_generator.utils.gen_template import gen_template
|
||||
from backend.plugin.code_generator.utils.type_conversion import sql_type_to_pydantic
|
||||
from backend.utils.locks import acquire_distributed_reload_lock
|
||||
|
||||
|
||||
class GenService:
|
||||
@@ -50,6 +55,8 @@ class GenService:
|
||||
:param obj: 导入参数对象
|
||||
:return:
|
||||
"""
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise errors.ForbiddenError(msg='禁止在非开发环境下导入代码生成业务')
|
||||
|
||||
table_info = await gen_dao.get_table(db, obj.table_schema, obj.table_name)
|
||||
if not table_info:
|
||||
@@ -179,27 +186,38 @@ class GenService:
|
||||
:param pk: 业务 ID
|
||||
:return:
|
||||
"""
|
||||
if settings.ENVIRONMENT != 'dev':
|
||||
raise errors.ForbiddenError(msg='禁止在非开发环境下生成代码')
|
||||
|
||||
business = await gen_business_dao.get(db, pk)
|
||||
if not business:
|
||||
raise errors.NotFoundError(msg='业务不存在')
|
||||
|
||||
gen_path = business.gen_path or str(BASE_PATH / 'app')
|
||||
|
||||
init_files = gen_template.get_init_files(business)
|
||||
for init_filepath, init_content in init_files.items():
|
||||
full_path = os.path.join(gen_path, *init_filepath.split('/'))
|
||||
init_folder = anyio.Path(full_path).parent
|
||||
await init_folder.mkdir(parents=True, exist_ok=True)
|
||||
async with await open_file(full_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(init_content)
|
||||
async with acquire_distributed_reload_lock():
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
all_files = {}
|
||||
init_files = gen_template.get_init_files(business)
|
||||
all_files.update(init_files)
|
||||
rendered_codes = await self._render_tpl_code(db=db, business=business)
|
||||
all_files.update(rendered_codes)
|
||||
|
||||
rendered_codes = await self._render_tpl_code(db=db, business=business)
|
||||
for code_filepath, code in rendered_codes.items():
|
||||
full_path = os.path.join(gen_path, *code_filepath.split('/'))
|
||||
code_folder = anyio.Path(full_path).parent
|
||||
await code_folder.mkdir(parents=True, exist_ok=True)
|
||||
async with await open_file(full_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(code)
|
||||
for filepath, content in all_files.items():
|
||||
full_path = os.path.join(tmp_dir, *filepath.split('/'))
|
||||
code_folder = anyio.Path(full_path).parent
|
||||
await code_folder.mkdir(parents=True, exist_ok=True)
|
||||
async with await open_file(full_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(content)
|
||||
|
||||
for item in os.listdir(tmp_dir):
|
||||
src = os.path.join(tmp_dir, item)
|
||||
dst = os.path.join(gen_path, item)
|
||||
src_path = anyio.Path(src)
|
||||
if await src_path.is_dir():
|
||||
await run_in_threadpool(shutil.copytree, src, dst, dirs_exist_ok=True)
|
||||
else:
|
||||
await run_in_threadpool(shutil.copy2, src, dst)
|
||||
|
||||
return gen_path
|
||||
|
||||
@@ -215,15 +233,16 @@ class GenService:
|
||||
if not business:
|
||||
raise errors.NotFoundError(msg='业务不存在')
|
||||
|
||||
all_files = {}
|
||||
init_files = gen_template.get_init_files(business)
|
||||
all_files.update(init_files)
|
||||
rendered_codes = await self._render_tpl_code(db=db, business=business)
|
||||
all_files.update(rendered_codes)
|
||||
|
||||
bio = io.BytesIO()
|
||||
with zipfile.ZipFile(bio, 'w') as zf:
|
||||
init_files = gen_template.get_init_files(business)
|
||||
for init_filepath, init_content in init_files.items():
|
||||
zf.writestr(init_filepath, init_content)
|
||||
|
||||
rendered_codes = await self._render_tpl_code(db=db, business=business)
|
||||
for code_filepath, code in rendered_codes.items():
|
||||
zf.writestr(code_filepath, code)
|
||||
for filepath, content in all_files.items():
|
||||
zf.writestr(filepath, content)
|
||||
|
||||
bio.seek(0)
|
||||
return bio
|
||||
|
||||
+47
-41
@@ -8,6 +8,7 @@ import anyio
|
||||
from anyio import open_file
|
||||
from dulwich import porcelain
|
||||
from fastapi import UploadFile
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.common.exception import errors
|
||||
from backend.common.log import log
|
||||
@@ -15,6 +16,7 @@ from backend.core.conf import settings
|
||||
from backend.core.path_conf import PLUGIN_DIR
|
||||
from backend.database.redis import redis_client
|
||||
from backend.plugin.requirements import install_requirements_async
|
||||
from backend.utils.locks import acquire_distributed_reload_lock
|
||||
from backend.utils.pattern_validate import is_git_url
|
||||
|
||||
|
||||
@@ -33,43 +35,45 @@ async def install_zip_plugin(file: UploadFile | str) -> str:
|
||||
file_bytes = io.BytesIO(contents)
|
||||
if not zipfile.is_zipfile(file_bytes):
|
||||
raise errors.RequestError(msg='插件压缩包格式非法')
|
||||
with zipfile.ZipFile(file_bytes) as zf:
|
||||
# 校验压缩包
|
||||
plugin_namelist = zf.namelist()
|
||||
plugin_dir_name = plugin_namelist[0].split('/')[0]
|
||||
if not plugin_namelist:
|
||||
raise errors.RequestError(msg='插件压缩包内容非法')
|
||||
if (
|
||||
len(plugin_namelist) <= 3
|
||||
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
|
||||
or f'{plugin_dir_name}/README.md' not in plugin_namelist
|
||||
):
|
||||
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
|
||||
|
||||
# 插件是否可安装
|
||||
plugin_name = re.match(
|
||||
r'^([a-zA-Z0-9_]+)',
|
||||
file.split(os.sep)[-1].split('.')[0].strip()
|
||||
if isinstance(file, str)
|
||||
else file.filename.split('.')[0].strip(),
|
||||
).group()
|
||||
full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name)
|
||||
if await full_plugin_path.exists():
|
||||
raise errors.ConflictError(msg='此插件已安装')
|
||||
await full_plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
async with acquire_distributed_reload_lock():
|
||||
with zipfile.ZipFile(file_bytes) as zf:
|
||||
# 校验压缩包
|
||||
plugin_namelist = zf.namelist()
|
||||
plugin_dir_name = plugin_namelist[0].split('/')[0]
|
||||
if not plugin_namelist:
|
||||
raise errors.RequestError(msg='插件压缩包内容非法')
|
||||
if (
|
||||
len(plugin_namelist) <= 3
|
||||
or f'{plugin_dir_name}/plugin.toml' not in plugin_namelist
|
||||
or f'{plugin_dir_name}/README.md' not in plugin_namelist
|
||||
):
|
||||
raise errors.RequestError(msg='插件压缩包内缺少必要文件')
|
||||
|
||||
# 解压(安装)
|
||||
members = []
|
||||
for member in zf.infolist():
|
||||
if member.filename.startswith(plugin_dir_name):
|
||||
new_filename = member.filename.replace(plugin_dir_name, '')
|
||||
if new_filename:
|
||||
member.filename = new_filename
|
||||
members.append(member)
|
||||
zf.extractall(full_plugin_path, members)
|
||||
# 插件是否可安装
|
||||
plugin_name = re.match(
|
||||
r'^([a-zA-Z0-9_]+)',
|
||||
file.split(os.sep)[-1].split('.')[0].strip()
|
||||
if isinstance(file, str)
|
||||
else file.filename.split('.')[0].strip(),
|
||||
).group()
|
||||
full_plugin_path = anyio.Path(PLUGIN_DIR / plugin_name)
|
||||
if await full_plugin_path.exists():
|
||||
raise errors.ConflictError(msg='此插件已安装')
|
||||
await full_plugin_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
await install_requirements_async(plugin_dir_name)
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
# 解压(安装)
|
||||
members = []
|
||||
for member in zf.infolist():
|
||||
if member.filename.startswith(plugin_dir_name):
|
||||
new_filename = member.filename.replace(plugin_dir_name, '')
|
||||
if new_filename:
|
||||
member.filename = new_filename
|
||||
members.append(member)
|
||||
await run_in_threadpool(zf.extractall, full_plugin_path, members)
|
||||
|
||||
await install_requirements_async(plugin_dir_name)
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
|
||||
return plugin_name
|
||||
|
||||
@@ -88,13 +92,15 @@ async def install_git_plugin(repo_url: str) -> str:
|
||||
path = anyio.Path(PLUGIN_DIR / repo_name)
|
||||
if await path.exists():
|
||||
raise errors.ConflictError(msg=f'{repo_name} 插件已安装')
|
||||
try:
|
||||
porcelain.clone(repo_url, PLUGIN_DIR / repo_name, checkout=True)
|
||||
except Exception as e:
|
||||
log.error(f'插件安装失败: {e}')
|
||||
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
|
||||
|
||||
await install_requirements_async(repo_name)
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
async with acquire_distributed_reload_lock():
|
||||
try:
|
||||
await run_in_threadpool(porcelain.clone, repo_url, PLUGIN_DIR / repo_name, checkout=True)
|
||||
except Exception as e:
|
||||
log.error(f'插件安装失败: {e}')
|
||||
raise errors.ServerError(msg='插件安装失败,请稍后重试') from e
|
||||
|
||||
await install_requirements_async(repo_name)
|
||||
await redis_client.set(f'{settings.PLUGIN_REDIS_PREFIX}:changed', 'ture')
|
||||
|
||||
return repo_name
|
||||
|
||||
Reference in New Issue
Block a user