import json import os import re import shlex import subprocess import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any from cli.exit_codes import RUNTIME_ERROR from cli.runtime.base import RUNTIME_ENVIRONMENT _SNAKE_CASE_BOUNDARY_PATTERN = re.compile(r'(? str: """ 将命令参数列表格式化为 shell 可读文本。 :param arguments: 命令参数列表 :return: shell 命令文本 """ return shlex.join(arguments) @staticmethod def truncate_text(value: object, max_length: int) -> str: """ 将文本截断到指定长度。 :param value: 原始文本值 :param max_length: 最大长度 :return: 截断后的文本 """ text = '' if value is None else str(value).strip() if len(text) <= max_length: return text return f'{text[: max_length - 3]}...' @staticmethod def to_snake_case(value: object) -> str: """ 将字段名标准化为 `snake_case` 形式。 :param value: 原始字段名 :return: `snake_case` 字段名 """ text = '' if value is None else str(value).strip() if not text: return '' normalized_text = text.replace('-', '_').replace(' ', '_') return _SNAKE_CASE_BOUNDARY_PATTERN.sub('_', normalized_text).lower() @dataclass(frozen=True) class NestedCliProjectLocator: """ 内部 CLI 项目目录定位器。 该对象负责识别后端根目录,并为内部 CLI 调用返回稳定的项目目录。 """ @staticmethod def is_backend_project_dir(project_dir: Path) -> bool: """ 判断给定目录是否为后端项目根目录。 :param project_dir: 待检查目录 :return: 是否为后端项目根目录 """ return RUNTIME_ENVIRONMENT.is_backend_project_dir(project_dir) def resolve_project_dir(self) -> Path: """ 解析内部 CLI 子进程应使用的后端项目目录。 优先使用当前工作目录;若当前目录不是后端目录,则回退到当前 `cli/utils.py` 所在代码树的上一层目录。 :return: 后端项目根目录 """ current_dir = Path.cwd().resolve() if self.is_backend_project_dir(current_dir): return current_dir return Path(__file__).resolve().parents[1] @dataclass(frozen=True) class NestedCliEnvironmentBuilder: """ 内部 CLI 环境构建器。 该对象负责构建子进程环境变量以及内部 CLI 调用命令。 """ @staticmethod def build_process_env(project_dir: Path) -> dict[str, str]: """ 构建内部 CLI 子进程环境变量。 :param project_dir: 后端项目根目录 :return: 子进程环境变量字典 """ process_env = dict(os.environ) project_dir_str = str(project_dir) python_path = process_env.get('PYTHONPATH', '').strip() if python_path: process_env['PYTHONPATH'] = os.pathsep.join([project_dir_str, python_path]) else: process_env['PYTHONPATH'] = project_dir_str return process_env @staticmethod def build_nested_cli_command(*arguments: str) -> list[str]: """ 构建内部 CLI 调用命令。 :param arguments: CLI 参数列表 :return: 完整子进程命令参数列表 """ return [sys.executable, '-m', 'cli.main', '--color=never', '--icon=none', *arguments] @dataclass(frozen=True) class NestedCliPayloadParser: """ 内部 CLI 负载解析器。 该对象负责从标准输出/错误中提取 JSON,并为非 JSON 结果构建兜底负载。 """ @staticmethod def extract_json_payload(output_text: str) -> dict[str, Any] | list[Any] | None: """ 从原始输出文本中提取 JSON 负载。 该逻辑优先尝试直接解析;若输出前后混入提示文本、ANSI 控制序列 或其他噪声,则尝试截取最外层 JSON 对象或数组再解析。 :param output_text: 原始输出文本 :return: 解析成功的 JSON 负载,失败时返回 None """ normalized_output = _ANSI_ESCAPE_PATTERN.sub('', output_text).strip() if not normalized_output: return None decoder = json.JSONDecoder() try: return decoder.decode(normalized_output) except Exception: pass for index, char in enumerate(normalized_output): if char not in '{[': continue try: payload, _ = decoder.raw_decode(normalized_output[index:]) return payload except Exception: continue return None @staticmethod def build_non_json_payload_fallback( *, stdout: str, stderr: str, returncode: int, ) -> dict[str, Any]: """ 为未返回 JSON 的内部 CLI 输出构建兜底负载。 :param stdout: 标准输出文本 :param stderr: 标准错误文本 :param returncode: 进程退出码 :return: 兜底结果负载 """ stdout_text = stdout.strip() stderr_text = stderr.strip() summary = stdout_text or stderr_text or '内部 CLI 未返回可解析的 JSON 输出' return { 'ok': returncode == 0, 'message': summary, 'stdout': stdout_text, 'stderr': stderr_text, 'exit_code': returncode or RUNTIME_ERROR, 'fallback': 'non_json_output', } @dataclass class NestedCliSupport: """ 内部 CLI 子进程支持服务。 该对象统一负责编排项目目录解析、环境注入、标准输出解析以及 实时执行/进程替换执行路径。 :param project_locator: 项目目录定位器 :param environment_builder: 子进程环境构建器 :param payload_parser: 负载解析器 """ project_locator: NestedCliProjectLocator = field(default_factory=NestedCliProjectLocator) environment_builder: NestedCliEnvironmentBuilder = field(default_factory=NestedCliEnvironmentBuilder) payload_parser: NestedCliPayloadParser = field(default_factory=NestedCliPayloadParser) def resolve_project_dir(self) -> Path: """ 解析内部 CLI 子进程应使用的后端项目目录。 :return: 后端项目根目录 """ return self.project_locator.resolve_project_dir() def build_process_env(self, project_dir: Path) -> dict[str, str]: """ 构建内部 CLI 子进程环境变量。 :param project_dir: 后端项目根目录 :return: 子进程环境变量字典 """ return self.environment_builder.build_process_env(project_dir) def build_nested_cli_command(self, *arguments: str) -> list[str]: """ 构建内部 CLI 调用命令。 :param arguments: CLI 参数列表 :return: 完整子进程命令参数列表 """ return self.environment_builder.build_nested_cli_command(*arguments) def extract_json_payload(self, output_text: str) -> dict[str, Any] | list[Any] | None: """ 从原始输出文本中提取 JSON 负载。 :param output_text: 原始输出文本 :return: 解析成功的 JSON 负载,失败时返回 None """ return self.payload_parser.extract_json_payload(output_text) def build_non_json_payload_fallback( self, *, stdout: str, stderr: str, returncode: int, ) -> dict[str, Any]: """ 为未返回 JSON 的内部 CLI 输出构建兜底负载。 :param stdout: 标准输出文本 :param stderr: 标准错误文本 :param returncode: 进程退出码 :return: 兜底结果负载 """ return self.payload_parser.build_non_json_payload_fallback( stdout=stdout, stderr=stderr, returncode=returncode, ) def run(self, *arguments: str, parse_json: bool = False) -> NestedCliResult: """ 在当前后端目录中执行内部 CLI 命令。 :param arguments: CLI 参数列表 :param parse_json: 是否尝试解析标准输出中的 JSON 负载 :return: 内部 CLI 调用结果 """ project_dir = self.resolve_project_dir() command = self.build_nested_cli_command(*arguments) completed = subprocess.run( command, capture_output=True, text=True, check=False, cwd=str(project_dir), env=self.build_process_env(project_dir), ) payload: dict[str, Any] | list[Any] | None = None if parse_json: payload = self.extract_json_payload(completed.stdout) if payload is None: payload = self.extract_json_payload(completed.stderr) if payload is None: payload = self.build_non_json_payload_fallback( stdout=completed.stdout, stderr=completed.stderr, returncode=completed.returncode, ) return NestedCliResult( command=command, returncode=completed.returncode, stdout=completed.stdout, stderr=completed.stderr, payload=payload, ) def run_live(self, *arguments: str) -> subprocess.CompletedProcess[str]: """ 在当前终端中直接执行内部 CLI 命令。 该模式不会捕获标准输出/错误,适用于需要占用当前终端交互的 wizard 或其他外部命令入口;调用方应自行决定是否先挂起 TUI。 :param arguments: CLI 参数列表 :return: 子进程执行结果 """ project_dir = self.resolve_project_dir() command = self.build_nested_cli_command(*arguments) return subprocess.run( command, text=True, check=False, cwd=str(project_dir), env=self.build_process_env(project_dir), ) def exec(self, *arguments: str) -> None: """ 以进程替换方式执行内部 CLI 命令。 :param arguments: CLI 参数列表 :return: None """ project_dir = self.resolve_project_dir() command = self.build_nested_cli_command(*arguments) executable = command[0] subprocess_arguments = [executable, *command[1:]] os.chdir(project_dir) os.execvp(executable, subprocess_arguments) SHELL_TEXT_FORMATTER = ShellTextFormatter() NESTED_CLI_SUPPORT = NestedCliSupport()