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

874 lines
28 KiB
Python

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'(?<!^)(?=[A-Z])')
def __init__(self, status_styler: OutputStatusStyler) -> 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)