mirror of
https://github.com/fastapi-practices/fastapi_best_architecture.git
synced 2026-03-13 09:31:31 +08:00
248 lines
7.8 KiB
Python
248 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
import asyncio
|
|
import subprocess
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Annotated, Literal
|
|
|
|
import cappa
|
|
import granian
|
|
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
from sqlalchemy import text
|
|
from watchfiles import PythonFilter
|
|
|
|
from backend import console, get_version
|
|
from backend.common.enums import DataBaseType, PrimaryKeyType
|
|
from backend.common.exception.errors import BaseExceptionMixin
|
|
from backend.core.conf import settings
|
|
from backend.database.db import async_db_session
|
|
from backend.plugin.tools import get_plugin_sql
|
|
from backend.utils.file_ops import install_git_plugin, install_zip_plugin, parse_sql_script
|
|
|
|
|
|
class CustomReloadFilter(PythonFilter):
|
|
"""自定义重载过滤器"""
|
|
|
|
def __init__(self):
|
|
super().__init__(extra_extensions=['.json', '.yaml', '.yml'])
|
|
|
|
|
|
def run(host: str, port: int, reload: bool, workers: int | None) -> None:
|
|
url = f'http://{host}:{port}'
|
|
docs_url = url + settings.FASTAPI_DOCS_URL
|
|
redoc_url = url + settings.FASTAPI_REDOC_URL
|
|
openapi_url = url + (settings.FASTAPI_OPENAPI_URL or '')
|
|
|
|
panel_content = Text()
|
|
panel_content.append(f'📝 Swagger 文档: {docs_url}\n', style='blue')
|
|
panel_content.append(f'📚 Redoc 文档: {redoc_url}\n', style='yellow')
|
|
panel_content.append(f'📡 OpenAPI JSON: {openapi_url}\n', style='green')
|
|
panel_content.append(
|
|
'🌍 fba 官方文档: https://fastapi-practices.github.io/fastapi_best_architecture_docs/',
|
|
style='cyan',
|
|
)
|
|
|
|
console.print(Panel(panel_content, title='fba 服务信息', border_style='purple', padding=(1, 2)))
|
|
granian.Granian(
|
|
target='backend.main:app',
|
|
interface='asgi',
|
|
address=host,
|
|
port=port,
|
|
reload=not reload,
|
|
reload_filter=CustomReloadFilter,
|
|
workers=workers or 1,
|
|
).serve()
|
|
|
|
|
|
def run_celery_worker(log_level: Literal['info', 'debug']) -> None:
|
|
try:
|
|
subprocess.run(['celery', '-A', 'backend.app.task.celery', 'worker', '-l', f'{log_level}', '-P', 'gevent'])
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
def run_celery_beat(log_level: Literal['info', 'debug']) -> None:
|
|
try:
|
|
subprocess.run(['celery', '-A', 'backend.app.task.celery', 'beat', '-l', f'{log_level}'])
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
def run_celery_flower(port: int, basic_auth: str) -> None:
|
|
try:
|
|
subprocess.run([
|
|
'celery',
|
|
'-A',
|
|
'backend.app.task.celery',
|
|
'flower',
|
|
f'--port={port}',
|
|
f'--basic-auth={basic_auth}',
|
|
])
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
async def install_plugin(
|
|
path: str, repo_url: str, no_sql: bool, db_type: DataBaseType, pk_type: PrimaryKeyType
|
|
) -> None:
|
|
if not path and not repo_url:
|
|
raise cappa.Exit('path 或 repo_url 必须指定其中一项', code=1)
|
|
if path and repo_url:
|
|
raise cappa.Exit('path 和 repo_url 不能同时指定', code=1)
|
|
|
|
plugin_name = None
|
|
console.print(Text('开始安装插件...', style='bold cyan'))
|
|
|
|
try:
|
|
if path:
|
|
plugin_name = await install_zip_plugin(file=path)
|
|
if repo_url:
|
|
plugin_name = await install_git_plugin(repo_url=repo_url)
|
|
|
|
console.print(Text(f'插件 {plugin_name} 安装成功', style='bold green'))
|
|
|
|
sql_file = await get_plugin_sql(plugin_name, db_type, pk_type)
|
|
if sql_file and not no_sql:
|
|
console.print(Text('开始自动执行插件 SQL 脚本...', style='bold cyan'))
|
|
await execute_sql_scripts(sql_file)
|
|
|
|
except Exception as e:
|
|
raise cappa.Exit(e.msg if isinstance(e, BaseExceptionMixin) else str(e), code=1)
|
|
|
|
|
|
async def execute_sql_scripts(sql_scripts: str) -> None:
|
|
async with async_db_session.begin() as db:
|
|
try:
|
|
stmts = await parse_sql_script(sql_scripts)
|
|
for stmt in stmts:
|
|
await db.execute(text(stmt))
|
|
except Exception as e:
|
|
raise cappa.Exit(f'SQL 脚本执行失败:{e}', code=1)
|
|
|
|
console.print(Text('SQL 脚本已执行完成', style='bold green'))
|
|
|
|
|
|
@cappa.command(help='运行 API 服务')
|
|
@dataclass
|
|
class Run:
|
|
host: Annotated[
|
|
str,
|
|
cappa.Arg(
|
|
long=True,
|
|
default='127.0.0.1',
|
|
help='提供服务的主机 IP 地址,对于本地开发,请使用 `127.0.0.1`。'
|
|
'要启用公共访问,例如在局域网中,请使用 `0.0.0.0`',
|
|
),
|
|
]
|
|
port: Annotated[
|
|
int,
|
|
cappa.Arg(long=True, default=8000, help='提供服务的主机端口号'),
|
|
]
|
|
no_reload: Annotated[
|
|
bool,
|
|
cappa.Arg(long=True, default=False, help='禁用在(代码)文件更改时自动重新加载服务器'),
|
|
]
|
|
workers: Annotated[
|
|
int | None,
|
|
cappa.Arg(long=True, default=None, help='使用多个工作进程,必须与 `--no-reload` 同时使用'),
|
|
]
|
|
|
|
def __call__(self):
|
|
run(host=self.host, port=self.port, reload=self.no_reload, workers=self.workers)
|
|
|
|
|
|
@cappa.command(help='从当前主机启动 Celery worker 服务')
|
|
@dataclass
|
|
class Worker:
|
|
log_level: Annotated[
|
|
Literal['info', 'debug'],
|
|
cappa.Arg(long=True, short='-l', default='info', help='日志输出级别'),
|
|
]
|
|
|
|
def __call__(self):
|
|
run_celery_worker(log_level=self.log_level)
|
|
|
|
|
|
@cappa.command(help='从当前主机启动 Celery beat 服务')
|
|
@dataclass
|
|
class Beat:
|
|
log_level: Annotated[
|
|
Literal['info', 'debug'],
|
|
cappa.Arg(long=True, short='-l', default='info', help='日志输出级别'),
|
|
]
|
|
|
|
def __call__(self):
|
|
run_celery_beat(log_level=self.log_level)
|
|
|
|
|
|
@cappa.command(help='从当前主机启动 Celery flower 服务')
|
|
@dataclass
|
|
class Flower:
|
|
port: Annotated[int, cappa.Arg(long=True, default=8555, help='提供服务的主机端口号')]
|
|
basic_auth: Annotated[str, cappa.Arg(long=True, default='admin:123456', help='页面登录的用户名和密码')]
|
|
|
|
def __call__(self):
|
|
run_celery_flower(port=self.port, basic_auth=self.basic_auth)
|
|
|
|
|
|
@cappa.command(help='运行 Celery 服务')
|
|
@dataclass
|
|
class Celery:
|
|
subcmd: cappa.Subcommands[Worker | Beat | Flower]
|
|
|
|
|
|
@cappa.command(help='新增插件')
|
|
@dataclass
|
|
class Add:
|
|
path: Annotated[
|
|
str | None,
|
|
cappa.Arg(long=True, help='ZIP 插件的本地完整路径'),
|
|
]
|
|
repo_url: Annotated[
|
|
str | None,
|
|
cappa.Arg(long=True, help='Git 插件的仓库地址'),
|
|
]
|
|
no_sql: Annotated[
|
|
bool,
|
|
cappa.Arg(long=True, default=False, help='禁用插件 SQL 脚本自动执行'),
|
|
]
|
|
db_type: Annotated[
|
|
DataBaseType,
|
|
cappa.Arg(long=True, default='mysql', help='执行插件 SQL 脚本的数据库类型'),
|
|
]
|
|
pk_type: Annotated[
|
|
PrimaryKeyType,
|
|
cappa.Arg(long=True, default='autoincrement', help='执行插件 SQL 脚本数据库主键类型'),
|
|
]
|
|
|
|
async def __call__(self):
|
|
await install_plugin(self.path, self.repo_url, self.no_sql, self.db_type, self.pk_type)
|
|
|
|
|
|
@cappa.command(help='一个高效的 fba 命令行界面')
|
|
@dataclass
|
|
class FbaCli:
|
|
version: Annotated[
|
|
bool,
|
|
cappa.Arg(short='-V', long=True, default=False, show_default=False, help='打印当前版本号'),
|
|
]
|
|
sql: Annotated[
|
|
str,
|
|
cappa.Arg(value_name='PATH', long=True, default='', show_default=False, help='在事务中执行 SQL 脚本'),
|
|
]
|
|
subcmd: cappa.Subcommands[Run | Celery | Add | None] = None
|
|
|
|
async def __call__(self):
|
|
if self.version:
|
|
get_version()
|
|
if self.sql:
|
|
await execute_sql_scripts(self.sql)
|
|
|
|
|
|
def main() -> None:
|
|
output = cappa.Output(error_format='[red]Error[/]: {message}\n\n更多信息,尝试 "[cyan]--help[/]"')
|
|
asyncio.run(cappa.invoke_async(FbaCli, output=output))
|