import json import os import re import sys from dataclasses import dataclass from typing import Any import typer from cli.context import CliContext @dataclass class OutputSettings: """ 输出渲染配置。 :param color_mode: 颜色模式 :param icon_mode: 图标模式 """ color_mode: str = 'always' icon_mode: str = 'emoji' @dataclass(frozen=True) class StatusTokenDefinition: """ 状态标签定义。 :param emoji: emoji 模式文案 :param ascii: ASCII 模式文案 :param none: 无图标模式文案 :param color: 默认前景色 """ emoji: str ascii: str none: str color: str @dataclass class CommandResult: """ 统一命令执行结果。 :param data: 命令结果负载 :param exit_code: 命令退出码 :param already_printed: 是否已由上层提前输出 """ data: Any = None exit_code: int = 0 already_printed: bool = False class OutputStatusStyler: """ 统一负责状态标签与单行文本样式。 :param settings: 输出配置 """ _STATUS_DEFINITIONS = { 'ok': StatusTokenDefinition(emoji='✅ OK', ascii='[OK]', none='OK', color='green'), 'fail': StatusTokenDefinition(emoji='❌ FAIL', ascii='[FAIL]', none='FAIL', color='red'), 'warn': StatusTokenDefinition(emoji='💡 HINT', ascii='[HINT]', none='HINT', color='yellow'), 'error': StatusTokenDefinition(emoji='🚨 ERROR', ascii='[ERROR]', none='ERROR', color='red'), 'info': StatusTokenDefinition(emoji='💬 INFO', ascii='[INFO]', none='INFO', color='blue'), } _STATUS_FIELD_KIND_MAPPING = { 'message': 'info', 'info': 'info', 'hint': 'warn', 'warn': 'warn', 'warning': 'warn', 'error': 'error', } def __init__(self, settings: OutputSettings) -> None: """ 初始化状态样式器。 :param settings: 输出配置 :return: None """ self.settings = settings def supports_color(self, stream: Any) -> bool: """ 判断当前输出流是否应启用颜色。 :param stream: 输出流对象 :return: 是否启用颜色 """ if self.settings.color_mode == 'always': return True if self.settings.color_mode == 'never': return False if os.environ.get('NO_COLOR'): return False if os.environ.get('TERM') == 'dumb': return False return bool(getattr(stream, 'isatty', lambda: False)()) @staticmethod def style_text(text: str, *, fg: str | None = None, bold: bool = False, dim: bool = False) -> str: """ 为文本附加终端样式。 :param text: 原始文本 :param fg: 前景色 :param bold: 是否加粗 :param dim: 是否弱化显示 :return: 样式化后的文本 """ return typer.style(text, fg=fg, bold=bold, dim=dim) def get_status_definition(self, kind: str) -> StatusTokenDefinition: """ 获取状态标签定义。 :param kind: 状态类型 :return: 状态标签定义 """ return self._STATUS_DEFINITIONS.get(kind, self._STATUS_DEFINITIONS['info']) def build_status_token(self, kind: str) -> str: """ 根据当前图标模式构建状态标签文本。 :param kind: 标签类型 :return: 标签文本 """ definition = self.get_status_definition(kind) return getattr(definition, self.settings.icon_mode, definition.none) def style_status_token(self, kind: str, *, fg: str | None = None) -> str: """ 将状态标签渲染为高亮样式。 :param kind: 标签类型 :param fg: 前景色 :return: 样式化后的标签文本 """ definition = self.get_status_definition(kind) return self.style_text(self.build_status_token(kind), fg=fg or definition.color, bold=True) def style_status_message(self, kind: str, text: str) -> str: """ 使用统一状态图标和颜色渲染消息文本。 :param kind: 状态类型 :param text: 原始消息文本 :return: 样式化后的消息文本 """ color = self.get_status_definition(kind).color return f'{self.style_status_token(kind, fg=color)} {self.style_text(text, fg=color, bold=kind == "error")}' def build_result_header(self, is_ok: bool) -> str: """ 构建统一的结果头部文本。 :param is_ok: 是否成功 :return: 结果头部文本 """ if is_ok: return f'{self.build_status_token("ok")} SUCCESS' return f'{self.build_status_token("fail")} FAILED' def style_scalar_text(self, value: str) -> str: """ 为标量文本值应用颜色样式。 :param value: 原始标量文本 :return: 样式化后的文本 """ normalized_value = value.strip().lower() if normalized_value == 'true': return self.style_text(value, fg='green', bold=True) if normalized_value == 'false': return self.style_text(value, fg='red', bold=True) if normalized_value in {'null', 'none', '-'}: return self.style_text(value, fg='bright_black') return value def style_inline_status_segment(self, segment: str) -> str: """ 为行内状态片段应用样式。 :param segment: 原始片段文本 :return: 样式化后的片段文本 """ normalized_segment = segment.strip().lower() if normalized_segment == 'true': return self.style_status_token('ok', fg='green') if normalized_segment == 'false': return self.style_status_token('fail', fg='red') for prefix, kind in self._STATUS_FIELD_KIND_MAPPING.items(): if normalized_segment.startswith(f'{prefix}:'): status_message = segment.split(':', 1)[1].strip() return self.style_status_message(kind, status_message) if ': ' in segment: segment_key, segment_value = segment.split(': ', 1) styled_segment_value = self.style_inline_status_value(segment_value) if styled_segment_value != segment_value: return f'{segment_key}: {styled_segment_value}' return segment def style_inline_status_value(self, value: str) -> str: """ 为带分隔符的行内状态值应用样式。 :param value: 原始字段值 :return: 样式化后的字段值 """ if ' | ' not in value: styled_value = self.style_inline_status_segment(value) if styled_value != value: return styled_value return self.style_scalar_text(value) return ' | '.join(self.style_inline_status_segment(segment) for segment in value.split(' | ')) def style_named_value(self, key: str, value: str) -> str: """ 按字段语义为值应用样式。 :param key: 字段名 :param value: 原始字段值 :return: 样式化后的字段值 """ normalized_key = key.strip().lower() normalized_value = value.strip().lower() if normalized_key == 'ok': if normalized_value == 'true': return self.style_status_token('ok', fg='green') if normalized_value == 'false': return self.style_status_token('fail', fg='red') status_kind = self._STATUS_FIELD_KIND_MAPPING.get(normalized_key) if status_kind: return self.style_status_message(status_kind, value) return self.style_inline_status_value(value) def style_line(self, line: str) -> str: """ 对单行文本输出应用可读性增强样式。 :param line: 原始输出行 :return: 样式化后的输出行 """ if not line.strip(): return line leading_spaces = len(line) - len(line.lstrip(' ')) indent = line[:leading_spaces] content = line[leading_spaces:] if content == self.build_result_header(True): return ( f'{indent}{self.style_status_token("ok", fg="green")} ' f'{self.style_text("SUCCESS", fg="green", bold=True)}' ) if content == self.build_result_header(False): return ( f'{indent}{self.style_status_token("fail", fg="red")} {self.style_text("FAILED", fg="red", bold=True)}' ) if content.startswith('- '): return ( f'{indent}{self.style_text("-", fg="bright_black", bold=True)} ' f'{self.style_inline_status_value(content[2:])}' ) if ': ' in content: key, value = content.split(': ', 1) styled_key = self.style_text(f'{key}:', fg='cyan', bold=leading_spaces == 0) return f'{indent}{styled_key} {self.style_named_value(key, value)}' if content.endswith(':'): return f'{indent}{self.style_text(content, fg="blue", bold=True)}' if content == '|': return f'{indent}{self.style_text(content, fg="bright_black")}' return line def render_error_text(self, message: str, exit_code: int, stream: Any) -> str: """ 渲染标准错误输出文本。 :param message: 错误消息 :param exit_code: 退出码 :param stream: 输出流对象 :return: 渲染后的错误文本 """ error_prefix = f'{self.build_status_token("error")}[{exit_code}]:' if not self.supports_color(stream): return f'{error_prefix} {message}' return ( f'{self.style_status_token("error", fg="red")}[{exit_code}]: ' f'{self.style_text(message, fg="red", bold=True)}' ) class StructuredTextRenderer: """ 负责结构化数据到文本的转换与装饰。 :param status_styler: 状态样式器 """ _SNAKE_CASE_BOUNDARY_PATTERN = re.compile(r'(? None: """ 初始化结构化文本渲染器。 :param status_styler: 状态样式器 :return: None """ self.status_styler = status_styler def render_text_output(self, data: Any) -> str: """ 将任意数据渲染为纯文本输出。 :param data: 待渲染数据 :return: 文本输出结果 """ if isinstance(data, str): return data return '\n'.join(self.render_text_lines(data)) def colorize_text_output(self, text: str, stream: Any) -> str: """ 按终端能力为文本输出增加颜色。 :param text: 原始文本输出 :param stream: 输出流对象 :return: 样式化后的文本输出 """ if not self.status_styler.supports_color(stream): return text return '\n'.join(self.status_styler.style_line(line) for line in text.splitlines()) def decorate_text_output(self, text: str) -> str: """ 对文本输出进行结构化装饰。 :param text: 原始文本输出 :return: 装饰后的文本输出 """ lines = text.splitlines() if not lines: return text first_non_empty_index = next((index for index, line in enumerate(lines) if line.strip()), None) if first_non_empty_index is None: return text first_line = lines[first_non_empty_index].strip().lower() if first_line == 'ok: true': decorated_lines = list(lines) decorated_lines[first_non_empty_index] = self.status_styler.build_result_header(True) return '\n'.join(decorated_lines) if first_line == 'ok: false': decorated_lines = list(lines) decorated_lines[first_non_empty_index] = self.status_styler.build_result_header(False) return '\n'.join(decorated_lines) return text @staticmethod def format_scalar(value: Any) -> str: """ 将标量值格式化为文本输出。 :param value: 待格式化的值 :return: 文本格式化结果 """ if value is None: return 'null' if isinstance(value, bool): return 'true' if value else 'false' return str(value) @classmethod def format_field_name(cls, key: object) -> str: """ 将字段名标准化为文本输出使用的 `snake_case` 形式。 :param key: 原始字段名 :return: 标准化后的字段名 """ text = str(key).strip() if not text: return '' normalized_text = text.replace('-', '_').replace(' ', '_') return cls._SNAKE_CASE_BOUNDARY_PATTERN.sub('_', normalized_text).lower() def append_multiline_text(self, lines: list[str], prefix: str, value: str, indent: str) -> None: """ 将多行文本追加到输出行列表中。 :param lines: 输出行列表 :param prefix: 当前字段前缀 :param value: 多行文本内容 :param indent: 子级缩进 :return: None """ lines.append(f'{prefix} |') lines.extend(f'{indent}{line}' for line in value.splitlines()) def render_nested_lines(self, prefix: str, nested_data: dict[str, Any] | list[Any], indent_level: int) -> list[str]: """ 渲染嵌套字典或列表字段。 :param prefix: 当前字段前缀 :param nested_data: 嵌套数据 :param indent_level: 当前缩进层级 :return: 渲染后的文本行列表 """ nested_lines = self.render_text_lines(nested_data, indent_level=indent_level + 1) if len(nested_lines) == 1 and nested_lines[0].strip() in {'{}', '[]'}: return [f'{prefix} {nested_lines[0].strip()}'] return [prefix, *nested_lines] def render_mapping_lines(self, data: dict[str, Any], *, indent_level: int) -> list[str]: """ 将字典渲染为层级化文本输出。 :param data: 待渲染字典 :param indent_level: 当前缩进层级 :return: 文本输出行列表 """ indent = ' ' * indent_level child_indent = ' ' * (indent_level + 1) if not data: return [f'{indent}{{}}'] lines: list[str] = [] for key, value in data.items(): field_name = self.format_field_name(key) prefix = f'{indent}{field_name}:' if isinstance(value, str) and '\n' in value: self.append_multiline_text(lines, prefix, value, child_indent) continue if isinstance(value, dict | list): lines.extend(self.render_nested_lines(prefix, value, indent_level)) continue lines.append(f'{prefix} {self.format_scalar(value)}') return lines def render_list_lines(self, data: list[Any], *, indent_level: int) -> list[str]: """ 将列表渲染为层级化文本输出。 :param data: 待渲染列表 :param indent_level: 当前缩进层级 :return: 文本输出行列表 """ indent = ' ' * indent_level child_indent = ' ' * (indent_level + 1) if not data: return [f'{indent}[]'] lines: list[str] = [] for item in data: item_prefix = f'{indent}-' if isinstance(item, str) and '\n' in item: self.append_multiline_text(lines, item_prefix, item, child_indent) continue if isinstance(item, dict | list): lines.extend(self.render_nested_lines(item_prefix, item, indent_level)) continue lines.append(f'{item_prefix} {self.format_scalar(item)}') return lines def render_text_lines(self, data: Any, *, indent_level: int = 0) -> list[str]: """ 将任意数据结构渲染为层级化文本输出。 :param data: 待渲染数据 :param indent_level: 当前缩进层级 :return: 文本输出行列表 """ if isinstance(data, dict): return self.render_mapping_lines(data, indent_level=indent_level) if isinstance(data, list): return self.render_list_lines(data, indent_level=indent_level) indent = ' ' * indent_level if isinstance(data, str) and '\n' in data: return [f'{indent}{line}' for line in data.splitlines()] return [f'{indent}{self.format_scalar(data)}'] class OutputEmitter: """ 负责标准输出、标准错误与命令退出收口。 :param text_renderer: 结构化文本渲染器 :param status_styler: 状态样式器 """ def __init__(self, text_renderer: StructuredTextRenderer, status_styler: OutputStatusStyler) -> None: """ 初始化输出发射器。 :param text_renderer: 结构化文本渲染器 :param status_styler: 状态样式器 :return: None """ self.text_renderer = text_renderer self.status_styler = status_styler def emit_output(self, data: Any, output_format: str) -> None: """ 输出命令结果。 :param data: 输出数据 :param output_format: 输出格式 :return: None """ if data is None: return if output_format == 'json': print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) return rendered_text = self.text_renderer.decorate_text_output(self.text_renderer.render_text_output(data)) print(self.text_renderer.colorize_text_output(rendered_text, sys.stdout)) def emit_error(self, message: str, output_format: str, *, exit_code: int) -> None: """ 输出错误信息。 :param message: 错误消息 :param output_format: 输出格式 :param exit_code: 退出码 :return: None """ if output_format == 'json': print( json.dumps({'ok': False, 'error': message, 'exitCode': exit_code}, ensure_ascii=False), file=sys.stderr, ) return print(self.status_styler.render_error_text(message, exit_code, sys.stderr), file=sys.stderr) def complete_command(self, result: CommandResult, ctx: CliContext) -> None: """ 输出命令结果并结束当前命令。 :param result: 命令执行结果 :param ctx: CLI 上下文 :return: None :raises typer.Exit: 始终通过 Typer 退出并返回对应退出码 """ if not result.already_printed: self.emit_output(result.data, ctx.output) raise typer.Exit(code=result.exit_code) class OutputRenderer: """ CLI 输出渲染门面。 :param color_mode: 颜色模式 :param icon_mode: 图标模式 """ def __init__(self, *, color_mode: str = 'always', icon_mode: str = 'emoji') -> None: """ 初始化输出渲染器。 :param color_mode: 颜色模式,支持 `auto`、`always`、`never` :param icon_mode: 图标模式,支持 `emoji`、`ascii`、`none` :return: None """ self.settings = OutputSettings(color_mode=color_mode, icon_mode=icon_mode) self.status_styler = OutputStatusStyler(self.settings) self.text_renderer = StructuredTextRenderer(self.status_styler) self.emitter = OutputEmitter(self.text_renderer, self.status_styler) @property def color_mode(self) -> str: """ 返回当前颜色模式。 :return: 颜色模式 """ return self.settings.color_mode @property def icon_mode(self) -> str: """ 返回当前图标模式。 :return: 图标模式 """ return self.settings.icon_mode def set_color_mode(self, color_mode: str) -> None: """ 设置颜色模式。 :param color_mode: 颜色模式 :return: None """ self.settings.color_mode = color_mode def set_icon_mode(self, icon_mode: str) -> None: """ 设置图标模式。 :param icon_mode: 图标模式 :return: None """ self.settings.icon_mode = icon_mode def supports_color(self, stream: Any) -> bool: """ 判断当前输出流是否应启用颜色。 :param stream: 输出流对象 :return: 是否启用颜色 """ return self.status_styler.supports_color(stream) @staticmethod def style_text(text: str, *, fg: str | None = None, bold: bool = False, dim: bool = False) -> str: """ 为文本附加终端样式。 :param text: 原始文本 :param fg: 前景色 :param bold: 是否加粗 :param dim: 是否弱化显示 :return: 样式化后的文本 """ return OutputStatusStyler.style_text(text, fg=fg, bold=bold, dim=dim) def build_status_token(self, kind: str) -> str: """ 根据当前图标模式构建状态标签文本。 :param kind: 标签类型 :return: 标签文本 """ return self.status_styler.build_status_token(kind) def style_status_token(self, kind: str, *, fg: str) -> str: """ 将状态标签渲染为高亮样式。 :param kind: 标签类型 :param fg: 前景色 :return: 样式化后的标签文本 """ return self.status_styler.style_status_token(kind, fg=fg) def style_status_message(self, kind: str, text: str) -> str: """ 使用统一状态图标和颜色渲染消息文本。 :param kind: 状态类型 :param text: 原始消息文本 :return: 样式化后的消息文本 """ return self.status_styler.style_status_message(kind, text) def build_result_header(self, is_ok: bool) -> str: """ 构建统一的结果头部文本。 :param is_ok: 是否成功 :return: 结果头部文本 """ return self.status_styler.build_result_header(is_ok) def style_scalar_text(self, value: str) -> str: """ 为标量文本值应用颜色样式。 :param value: 原始标量文本 :return: 样式化后的文本 """ return self.status_styler.style_scalar_text(value) def style_inline_status_segment(self, segment: str) -> str: """ 为行内状态片段应用样式。 :param segment: 原始片段文本 :return: 样式化后的片段文本 """ return self.status_styler.style_inline_status_segment(segment) def style_inline_status_value(self, value: str) -> str: """ 为带分隔符的行内状态值应用样式。 :param value: 原始字段值 :return: 样式化后的字段值 """ return self.status_styler.style_inline_status_value(value) def style_named_value(self, key: str, value: str) -> str: """ 按字段语义为值应用样式。 :param key: 字段名 :param value: 原始字段值 :return: 样式化后的字段值 """ return self.status_styler.style_named_value(key, value) def style_line(self, line: str) -> str: """ 对单行文本输出应用可读性增强样式。 :param line: 原始输出行 :return: 样式化后的输出行 """ return self.status_styler.style_line(line) def render_text_output(self, data: Any) -> str: """ 将任意数据渲染为纯文本输出。 :param data: 待渲染数据 :return: 文本输出结果 """ return self.text_renderer.render_text_output(data) def colorize_text_output(self, text: str, stream: Any) -> str: """ 按终端能力为文本输出增加颜色。 :param text: 原始文本输出 :param stream: 输出流对象 :return: 样式化后的文本输出 """ return self.text_renderer.colorize_text_output(text, stream) def decorate_text_output(self, text: str) -> str: """ 对文本输出进行结构化装饰。 :param text: 原始文本输出 :return: 装饰后的文本输出 """ return self.text_renderer.decorate_text_output(text) @staticmethod def format_scalar(value: Any) -> str: """ 将标量值格式化为文本输出。 :param value: 待格式化的值 :return: 文本格式化结果 """ return StructuredTextRenderer.format_scalar(value) @staticmethod def format_field_name(key: object) -> str: """ 将字段名标准化为文本输出使用的 `snake_case` 形式。 :param key: 原始字段名 :return: 标准化后的字段名 """ return StructuredTextRenderer.format_field_name(key) def append_multiline_text(self, lines: list[str], prefix: str, value: str, indent: str) -> None: """ 将多行文本追加到输出行列表中。 :param lines: 输出行列表 :param prefix: 当前字段前缀 :param value: 多行文本内容 :param indent: 子级缩进 :return: None """ self.text_renderer.append_multiline_text(lines, prefix, value, indent) def render_nested_lines(self, prefix: str, nested_data: dict[str, Any] | list[Any], indent_level: int) -> list[str]: """ 渲染嵌套字典或列表字段。 :param prefix: 当前字段前缀 :param nested_data: 嵌套数据 :param indent_level: 当前缩进层级 :return: 渲染后的文本行列表 """ return self.text_renderer.render_nested_lines(prefix, nested_data, indent_level) def render_mapping_lines(self, data: dict[str, Any], *, indent_level: int) -> list[str]: """ 将字典渲染为层级化文本输出。 :param data: 待渲染字典 :param indent_level: 当前缩进层级 :return: 文本输出行列表 """ return self.text_renderer.render_mapping_lines(data, indent_level=indent_level) def render_list_lines(self, data: list[Any], *, indent_level: int) -> list[str]: """ 将列表渲染为层级化文本输出。 :param data: 待渲染列表 :param indent_level: 当前缩进层级 :return: 文本输出行列表 """ return self.text_renderer.render_list_lines(data, indent_level=indent_level) def render_text_lines(self, data: Any, *, indent_level: int = 0) -> list[str]: """ 将任意数据结构渲染为层级化文本输出。 :param data: 待渲染数据 :param indent_level: 当前缩进层级 :return: 文本输出行列表 """ return self.text_renderer.render_text_lines(data, indent_level=indent_level) def emit_output(self, data: Any, output_format: str) -> None: """ 输出命令结果。 :param data: 输出数据 :param output_format: 输出格式 :return: None """ self.emitter.emit_output(data, output_format) def render_error_text(self, message: str, exit_code: int, stream: Any) -> str: """ 渲染标准错误输出文本。 :param message: 错误消息 :param exit_code: 退出码 :param stream: 输出流对象 :return: 渲染后的错误文本 """ return self.status_styler.render_error_text(message, exit_code, stream) def emit_error(self, message: str, output_format: str, *, exit_code: int) -> None: """ 输出错误信息。 :param message: 错误消息 :param output_format: 输出格式 :param exit_code: 退出码 :return: None """ self.emitter.emit_error(message, output_format, exit_code=exit_code) def complete_command(self, result: CommandResult, ctx: CliContext) -> None: """ 输出命令结果并结束当前命令。 :param result: 命令执行结果 :param ctx: CLI 上下文 :return: None """ self.emitter.complete_command(result, ctx)