ShiErFeng/shierfeng-fastapi-backend/cli/core/app_builder.py
2026-07-24 09:43:10 +08:00

364 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import importlib
import sys
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Annotated, Literal
import typer
from cli.output import OutputRenderer
from cli.runtime.base import RUNTIME_ENVIRONMENT
@dataclass(frozen=True)
class ProjectRuntimeLocator:
"""
CLI 项目运行时定位器。
该对象负责判断当前目录是否为后端项目根目录、注入 `sys.path`
并为配置导入阶段提取最小化的 `argv` 参数。
"""
def is_backend_project_dir(self, project_dir: Path) -> bool:
"""
判断给定目录是否为后端项目根目录。
:param project_dir: 待检查目录
:return: 是否为后端项目根目录
"""
return RUNTIME_ENVIRONMENT.is_backend_project_dir(project_dir)
def ensure_backend_dir_on_sys_path(self) -> Path:
"""
将当前后端项目根目录加入 `sys.path` 首位。
:return: 当前后端项目根目录
:raises typer.Exit: 当前目录不是后端项目根目录时退出命令
"""
project_dir = Path.cwd().resolve()
if not self.is_backend_project_dir(project_dir):
typer.echo('`shierfeng` 命令需在 `shierfeng-fastapi-backend` 目录下执行', err=True)
raise typer.Exit(code=2)
project_dir_str = str(project_dir)
if sys.path and sys.path[0] == project_dir_str:
return project_dir
sys.path.insert(0, project_dir_str)
return project_dir
def extract_import_argv(self, argv: list[str] | None = None) -> list[str]:
"""
提取供配置模块导入阶段使用的最小参数集。
:param argv: 原始命令行参数列表
:return: 仅保留程序名和 `--env` 的参数列表
"""
current_argv = argv or sys.argv
program_name = current_argv[0] if current_argv else 'shierfeng'
import_argv = [program_name]
env_value = ''
current_args = current_argv[1:]
for index, argument in enumerate(current_args):
if argument.startswith('--env='):
env_value = argument.split('=', 1)[1].strip()
break
if argument == '--env' and index + 1 < len(current_args):
env_value = current_args[index + 1].strip()
break
if env_value:
import_argv.extend(['--env', env_value])
return import_argv
@dataclass(frozen=True)
class CliCommandGroupRegistry:
"""
CLI 命令组注册表。
该注册表集中维护根 CLI 需要挂载的业务命令组模块路径,
避免命令组声明散落在应用构建器主体中。
:param command_modules: 命令组到模块路径的映射
"""
command_modules: dict[str, str]
@dataclass(frozen=True)
class CliExtensionRegistration:
"""
CLI 扩展入口注册描述。
:param module_path: 扩展模块导入路径
:param attach: 已加载模块挂载到根 CLI 的函数
"""
module_path: str
attach: Callable[[typer.Typer, object], None]
@dataclass(frozen=True)
class CliExtensionRegistry:
"""
CLI 扩展入口注册表。
该注册表集中维护 completion、wizard、TUI 等扩展入口的模块路径与
挂载方式,避免这些规则继续散落在注册器主体中。
:param registrations: 扩展注册描述列表
"""
registrations: tuple[CliExtensionRegistration, ...]
@dataclass(frozen=True)
class CliCommandGroupRegistrar:
"""
CLI 命令组注册器。
该对象负责根据命令组注册表向根 Typer 应用挂载业务命令组。
:param command_group_registry: CLI 命令组注册表
"""
command_group_registry: CliCommandGroupRegistry
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
def register(self, cli: typer.Typer) -> None:
"""
向根应用注册业务命令组。
:param cli: 根 Typer 应用
:return: None
"""
for command_name, module_path in self.command_group_registry.command_modules.items():
command_module = self.module_loader.load(module_path)
cli.add_typer(command_module.app, name=command_name)
class CliExtensionMountSupport:
"""
CLI 扩展入口挂载支持对象。
该对象负责封装不同扩展模块挂载到根 CLI 的差异,避免扩展注册器本体
继续堆积 `if/else` 或硬编码模块处理逻辑。
"""
@staticmethod
def attach_completion(cli: typer.Typer, module: object) -> None:
"""
挂载 completion 扩展入口。
:param cli: 根 Typer 应用
:param module: 已加载模块
:return: None
"""
cli.add_typer(module.COMPLETION_COMMAND_BUILDER.build(cli), name='completion')
@staticmethod
def attach_wizard(cli: typer.Typer, module: object) -> None:
"""
挂载 wizard 扩展入口。
:param cli: 根 Typer 应用
:param module: 已加载模块
:return: None
"""
cli.add_typer(module.WIZARD_COMMAND_BUILDER.build(), name='wizard')
@staticmethod
def attach_tui(cli: typer.Typer, module: object) -> None:
"""
挂载 TUI 扩展入口。
:param cli: 根 Typer 应用
:param module: 已加载模块
:return: None
"""
module.TUI_COMMAND_REGISTRATION.register(cli)
@dataclass(frozen=True)
class CliExtensionRegistrar:
"""
CLI 扩展入口注册器。
该对象负责向根 Typer 应用挂载 completion、wizard 与 TUI
等扩展入口。
"""
extension_registry: CliExtensionRegistry
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
def register(self, cli: typer.Typer) -> None:
"""
向根应用注册扩展入口。
:param cli: 根 Typer 应用
:return: None
"""
for registration in self.extension_registry.registrations:
extension_module = self.module_loader.load(registration.module_path)
registration.attach(cli, extension_module)
@dataclass(frozen=True)
class CliRootOptionInitializer:
"""
CLI 根级选项初始化器。
该对象负责将根级 `--color`、`--icon` 选项映射到共享输出渲染器,
避免根回调继续内联输出配置逻辑。
:param output_renderer: 根命令共享的输出渲染器
"""
output_renderer: OutputRenderer
def initialize(self, *, color: str, icon: str) -> None:
"""
初始化根级输出选项。
:param color: 文本输出颜色模式
:param icon: 文本输出图标模式
:return: None
"""
self.output_renderer.set_color_mode(color)
self.output_renderer.set_icon_mode(icon)
@dataclass(frozen=True)
class CliRootCallbackRegistrar:
"""
CLI 根回调注册器。
该对象负责把根级 `--color`、`--icon` 选项回调挂载到 Typer 根应用,
让 `CliApplicationBuilder` 更专注于整体装配流程。
:param root_option_initializer: 根级选项初始化器
"""
root_option_initializer: CliRootOptionInitializer
def register(self, cli: typer.Typer) -> None:
"""
向根应用注册回调。
:param cli: 根 Typer 应用
:return: None
"""
@cli.callback()
def root_callback(
color: Annotated[
Literal['auto', 'always', 'never'],
typer.Option('--color', help='文本输出颜色模式'),
] = 'always',
icon: Annotated[
Literal['emoji', 'ascii', 'none'],
typer.Option('--icon', help='文本输出图标模式'),
] = 'emoji',
) -> None:
"""
初始化 CLI 根级运行参数。
:param color: 文本输出颜色模式
:param icon: 文本输出图标模式
:return: None
"""
self.root_option_initializer.initialize(color=color, icon=icon)
@dataclass(frozen=True)
class CliModuleLoader:
"""
CLI 模块加载服务。
该对象负责集中执行命令组与扩展入口所需的延迟导入,
让注册器本身更专注于挂载动作。
"""
@staticmethod
def load(module_path: str) -> object:
"""
按模块路径加载模块对象。
:param module_path: 模块导入路径
:return: 已加载模块
"""
return importlib.import_module(module_path)
@dataclass
class CliApplicationBuilder:
"""
CLI 根应用构建器。
:param output_renderer: 根命令共享的输出渲染器
:param command_group_registrar: CLI 命令组注册器
:param extension_registrar: CLI 扩展入口注册器
"""
output_renderer: OutputRenderer
command_group_registrar: CliCommandGroupRegistrar = field(
default_factory=lambda: CliCommandGroupRegistrar(DEFAULT_COMMAND_GROUP_REGISTRY)
)
extension_registrar: CliExtensionRegistrar = field(
default_factory=lambda: CliExtensionRegistrar(DEFAULT_CLI_EXTENSION_REGISTRY)
)
root_option_initializer: CliRootOptionInitializer = field(init=False)
root_callback_registrar: CliRootCallbackRegistrar = field(init=False)
def __post_init__(self) -> None:
"""
初始化根应用构建器依赖。
:return: None
"""
self.root_option_initializer = CliRootOptionInitializer(self.output_renderer)
self.root_callback_registrar = CliRootCallbackRegistrar(self.root_option_initializer)
def build(self) -> typer.Typer:
"""
构建 Typer 根应用并注册全部命令组。
:return: Typer 根应用
"""
cli = typer.Typer(
name='shierfeng',
help='ShiErFeng FastAPI 后端统一命令入口',
no_args_is_help=True,
add_completion=False,
context_settings={'help_option_names': ['-h', '--help']},
)
self.root_callback_registrar.register(cli)
self.command_group_registrar.register(cli)
self.extension_registrar.register(cli)
return cli
DEFAULT_COMMAND_GROUP_REGISTRY = CliCommandGroupRegistry(
command_modules={
'app': 'cli.groups.app',
'db': 'cli.groups.db',
'ops': 'cli.groups.ops',
'cache': 'cli.groups.cache',
'job': 'cli.groups.job',
'config': 'cli.groups.config',
'crypto': 'cli.groups.crypto',
'gen': 'cli.groups.gen',
'dev': 'cli.groups.dev',
}
)
DEFAULT_CLI_EXTENSION_REGISTRY = CliExtensionRegistry(
registrations=(
CliExtensionRegistration('cli.completion.commands', CliExtensionMountSupport.attach_completion),
CliExtensionRegistration('cli.wizard.commands', CliExtensionMountSupport.attach_wizard),
CliExtensionRegistration('cli.tui', CliExtensionMountSupport.attach_tui),
)
)
DEFAULT_CLI_MODULE_LOADER = CliModuleLoader()