☰
Sty 使用教程:零依赖 ANSI 终端颜色与样式拼接
2026/11/2 20:38:15 网站建设 项目流程

终端里那行红色的 ERROR,多数人都是靠一坨\033[31m手拼出来的。我第一次在线上脚本里看到日志文件里漏进去一堆[1;31m字面量的时候,就知道这套作坊式的写法迟早要出事——重定向到文件、接进日志系统、被别的库再处理一遍,随时翻车。后来我把这件事固定交给一个开源项目处理,也就是这篇使用教程的主角 Sty。它是 Python 生态里一个零依赖的终端文本样式库,干的事情很窄:把 ANSI 转义序列封装成能直接参与字符串拼接的常量对象,让你写fg.red + "内容" + rs.all就出效果,不用记数字码、不用背复位序列、不用为了一个下划线引入一整套富文本渲染框架。它最适合三类人:写命令行工具、需要在 CI 日志里做关键信息高亮的后端;维护运维脚本、想要"出错红、警告黄、正常绿"的一线工程师;以及刚开始学 Python、想知道终端颜色到底怎么来的新手。下面我按真实落地的顺序,把安装、配色体系、嵌套复位、自定义样式、工程化降级和排错这几块完整拆一遍。

1. 先把 Sty 是什么说透:一个零依赖的 ANSI 拼装器

1.1 它解决的问题比"让字变红"复杂得多

很多人对终端颜色的理解停留在"加个码就红了",但真正写进项目里,麻烦全在细节上。第一是复位。你写了\033[31m忘了写\033[0m,后面所有输出都会继承红色,包括你print的下一句调试信息,排查起来极其费劲。第二是嵌套。你想让"(失败)"这三个字在红色报错里再加粗,手工拼就是\033[31m\033[1m失败\033[0m,这一复位把红色也一起干掉了,后半段颜色直接丢失。第三是流污染。输出被重定向到文件、被tee接走、被日志采集器抓走的时候,那些转义字节会成为脏数据,grep 都搜不干净。

Sty 的思路是把前两个问题从"人肉记忆"变成"结构化常量",第三个问题留给使用者通过开关控制。它不接管你的 stdout,不 monkey patchprint,不改变任何全局状态,只提供一组str子类实例。这个定位决定了它的体积极小、副作用极低,任何接受字符串的地方都能直接塞进去——logging、argparse的帮助文本、f-string、str.format、甚至第三方模板引擎,全都能用。这也是我长期留它在依赖列表里的核心原因。

1.2 为什么它选择"常量拼接"而不是"函数调用"

市面上同类方案大致分两派。一派是函数式,形如colorize("内容", "red"),好处是能自动帮你补上复位,坏处是没法跨段拼接、嵌套要额外传参;另一派是富文本渲染框架,功能极强,但会接管整个输出管道,你要为了一行红色提示引入一个相当重的依赖树。

Sty 走的是第三派:样式本身就是字符串。fg.red的值就是'\x1b[31m'这四个字符,rs.all的值就是'\x1b[0m'。你把它和内容相加,得到的仍然是普通str,没有任何包装对象、没有__str__钩子、没有运行时解析开销。这种设计的收益非常直接:拼接是一等公民,你想在哪里插颜色就在哪里插,fg.red + name + fg.green + " 完成"完全合法,不会因为"这不是一个合法的样式调用"而被拦住。

代价也要说清楚:复位得你自己写,什么时候该用局部复位、什么时候该用全局复位,Sty 不会替你判断;输出目标是不是终端,也得你自己判断。这两点恰恰是新手翻车最集中的地方,后面的第 4 章和第 6 章我会单独拆开讲。

维度手写转义码函数式封装Sty 的常量拼接
记忆负担高,要背 SGR 数字低低
跨段拼接可以但难维护一般不支持原生支持
嵌套样式极易互相覆盖需要额外参数用局部复位可控
运行时开销最低一次函数调用约等于字符串常量
自动降级无通常内置需自行控制
依赖体积零小零(纯标准库实现)

2. 环境准备与第一行带颜色的输出

2.1 安装、版本确认与能力自检

安装就一行,没有任何编译依赖,也不拉别的包:

pip install sty

装完别急着写业务代码,先做一次能力自检,确认环境里能用、以及你这个版本到底暴露了哪些属性名。这一步很多人跳过,结果在别的机器上跑挂了才发现是版本差异——Sty 的颜色别名在历史版本里调整过几轮,照抄网上博客很容易踩空:

python -c "import sty; print(sty.__version__)" python -c "from sty import fg; print([n for n in dir(fg) if not n.startswith('_')])"

第二行命令会直接把你手头这个实例支持的全部前景色名字打出来,比你翻半天文档可靠得多。同理可以查bg、ef、rs三个命名空间。我在团队里推这套写法的原因很简单:文档会滞后,dir()不会。你也可以顺手验证一下它到底是不是字符串:

from sty import fg, rs print(type(fg.red), len(fg.red), repr(fg.red))

你会看到它是str的子类、长度 5、内容是'\x1b[31m'。亲眼确认过这一点,后面所有关于"为什么能直接拼接"的疑惑就都自然消解了,你对它的心智模型也就建立起来了:它不是样式引擎,它是一包精心命名的转义码常量。

2.2 三种最常用的输出姿势

第一种,最直白的加法拼接,适合短提示:

from sty import fg, rs print(fg.red + "构建失败" + rs.all)

第二种,f-string。这里有个高频陷阱:很多人写完f"{fg.red}构建失败"就收工,忘了结束符,结果后面所有输出都被染红。只要你用了f-string,就必须保证每个前缀都有对应的后缀:

status = "构建失败" print(f"{fg.red}{status}{rs.all} 耗时 12s")

第三种,自己封一层函数,把成对出现的逻辑收敛掉:

from sty import fg, rs def paint(text, color=fg.white, reset=rs.all): return f"{color}{text}{reset}" print(paint("构建失败", fg.red))

我个人在中小项目里首选第三种。原因不是代码好看,而是它把"前缀和复位必须成对"这条约束从人的注意力转移到了函数签名上——只要没人绕过paint直接用fg.xxx,就不会出现漏复位。团队协作场景下,这个约束的价值远大于多写几行代码。

2.3 别急着背颜色名,先摸清 API 边界

新手最常见的动作是去搜"sty 有哪些颜色",然后抄一张表回来。这件事本身没错,但更高效的做法是让解释器告诉你。fg和bg里的基础色命名基本对应终端那八种:黑、红、绿、黄、蓝、品红、青、白;明暗两档通常通过前缀区分,有的版本用li_表示亮色、da_表示暗色,具体拼写以你dir(fg)的输出为准。ef命名空间放的是效果类样式,粗体、暗淡、斜体、下划线、反显、隐藏这些,命名基本是人话,不用硬记。

真正需要你主动记住的只有rs。它代表重置,是整个库里唯一"必须配套使用"的部分。rs.all是全局复位,把前景、背景、所有效果一次性清掉;rs.fg、rs.bg、rs.ef是分域复位,只清各自那一块。这几个常量的差别,是后面嵌套写法的全部基础。

3. 颜色体系:8 色、256 色、真彩色怎么选

3.1 8 色和 16 色的本质:你只是借用了主题的调色板

先把底层讲明白,后面选型才有依据。终端颜色是靠 SGR 参数控制的,格式是ESC [ 参数 m。基础前景色占 30 到 37,基础背景色占 40 到 47;明亮档前景是 90 到 97,背景是 100 到 107;前景复位是 39,背景复位是 49,全局复位是 0。这些数字是全终端通用的约定,Sty 只是帮你把它们命名成了人话。

关键在于:这八种颜色没有固定色值。fg.red到底红成什么样,完全取决于用户终端主题里"红色"这个槽位被配成了什么。同一个脚本,在你的深色主题下是番茄红,在同事的浅色主题下可能是砖红,在某个高对比度主题下甚至接近橙色。这就是为什么"用 8 色做品牌色"注定失败——你控制不了最终呈现。

这也不全是坏事。做状态语义提示时,8 色反而是最稳的选择:用户主题是他自己调的,对比度一定是他能看清的。红色表示错误、黄色表示警告、绿色表示成功,这套语义在几乎所有主题下都成立。所以我的实践原则是:语义提示用 8 色,视觉设计用真彩色,两者不要混着来。

3.2 256 色:需要中间档时的折中方案

256 色用一个索引数字表达颜色,写法是给fg传单个整数:

from sty import fg, rs print(fg(160) + "这一行是索引 160 的颜色" + rs.all)

它的调色板结构是有规律的:0 到 15 是系统保留的基础色和明亮色,16 到 231 是一个 6×6×6 的 RGB 立方体,232 到 255 是 24 级灰阶。色立方的索引公式是16 + 36r + 6g + b,其中 r、g、b 取值 0 到 5,对应的通道值分别是 0、95、135、175、215、255。记住这六个值,你就能口算出任意一个色立方索引大概是什么颜色。

实际工作中更常见的是反过来的需求:手上有一个品牌 RGB 值,终端只支持 256 色,怎么转?通用的近似做法是先把每个通道量化到 0 到 5,再算立方体索引,同时算一次灰阶候选,最后比距离取近的那个:

def rgb_to_xterm256(r, g, b): base = [0, 95, 135, 175, 215, 255] def pick(v): if v < 48: return 0 if v < 115: return 1 return (v - 35) // 40 ri, gi, bi = pick(r), pick(g), pick(b) cube_idx = 16 + 36 * ri + 6 * gi + bi cube_rgb = (base[ri], base[gi], base[bi]) avg = (r + g + b) // 3 gray_idx = None if 8 <= avg <= 238: step = round((avg - 8) / 10) gray_idx = 232 + step gray_rgb = (8 + 10 * step,) * 3 def dist(c): return sum((a - b) ** 2 for a, b in zip(c, (r, g, b))) if gray_idx is not None and dist(gray_rgb) < dist(cube_rgb): return gray_idx return cube_idx

这个函数不追求绝对精确,追求的是"肉眼看起来差不多"。量化误差主要出现在低饱和度的中间色上,因为 6×6×6 立方体的步长是 40 左右,跨度不小。如果一段文本恰好落在两个格子中间,颜色会有可感知的偏移。真要严格还原品牌色,就别在 256 色上纠结,直接上真彩色。

3.3 真彩色:值得用,但必须准备降级方案

真彩色走的是 SGR 的扩展形式,38;2;r;g;b表示前景、48;2;r;g;b表示背景。Sty 里直接给三个整数就行:

from sty import fg, bg, rs print(fg(255, 128, 0) + "品牌橙" + rs.all) print(bg(20, 20, 20) + fg(230, 230, 230) + "深色卡片" + rs.all)

现代终端基本都支持真彩色,包括各类 Linux 终端模拟器、macOS 的默认终端、以及近年版本的 Windows 终端。但"基本都支持"不等于"你这条链路上都支持":CI 环境里的日志采集器可能只认 16 色,某些老旧的跳板环境可能不支持扩展序列,用户把输出重定向到文件时这些字节就是纯噪音。

我的处理方式是分三档,按探测结果自动选颜色深度:

模式代码量级终端兼容性单次拼接开销适用场景
8/16 色4-5 字节几乎全部最低状态语义、跨平台脚本、CI 日志
256 色5-9 字节绝大多数低需要多级区分、图表类输出
真彩色15-19 字节现代终端略高品牌视觉、TUI 界面、演示输出

真彩色那 15 到 19 个字节看着不多,但当你一秒钟输出几千行、每行好几段彩色文本的时候,字节量和转义解析开销是能感知的。这一点在第 6 章的性能部分我会给具体数字。

4. 嵌套与复位:Sty 里最容易翻车的地方

4.1 rs.fg、rs.bg、rs.ef、rs.all 的差别是嵌套的全部基础

前面提过这四个复位常量,这里展开讲为什么它们不是可以随便替换的。rs.all对应 SGR 0,效果是把所有样式属性重置到默认——前景、背景、粗体、下划线、反显,一个不留。rs.fg对应 SGR 39,只把前景色恢复默认,背景和其他效果原封不动。rs.bg对应 49,只管背景。rs.ef是一组,对应 22、23、24、27、28、29 这些,逐个关掉对应的效果属性,不影响颜色。

理解了这个差异,嵌套的写法就变成了一道逻辑题,而不是玄学。你要做的只是判断:这一层结束时,哪些属性该被撤销,哪些该保留给外层。撤销得多了,外层样式被破坏;撤销得少了,内层样式泄漏到外面。所有嵌套 bug 都出在这两个方向上。

有一个细节值得单独提:粗体(SGR 1)和暗淡(SGR 2)共用 SGR 22 来关闭,也就是说关粗体会把暗淡一起关掉。这个历史遗留设计在几乎所有终端里都一样,不是你写错了。遇到"只想去掉粗体但保留暗淡"的需求,正确做法是重新声明暗淡,而不是找一个不存在的独立关闭码。

4.2 三种典型写法对比:错误、能用、推荐

先看错误示范,这是我在代码评审里见得最多的一种:

# 错误:内层用 rs.all,外层红色被一起清掉 print(fg.red + "错误:" + ef.bold + "连接超时" + rs.all + " 请重试" + rs.all)

"请重试"这三个字会变回默认色,因为它前面的rs.all把外层的红色也清掉了。更隐蔽的错误是漏掉最后的rs.all,颜色泄漏到后续所有输出。

再看"能用但难维护"的写法,手工分层管理:

print(fg.red + "错误:" + ef.bold + "连接超时" + rs.ef + " 请重试" + rs.all)

这里的逻辑是:进入粗体之前先记住当前位置是红色,退出时只用rs.ef撤销效果层,红色背景板还在。这个写法没毛病,但它依赖写代码的人在心里维护一个"当前样式栈",一旦嵌套超过两层,正确率急剧下降。

推荐的写法是把嵌套关系显式化。要么用函数层层返回,让复位天然成对:

from sty import fg, ef, rs def bold(text): return f"{ef.bold}{text}{rs.ef}" def err(text): return f"{fg.red}{text}{rs.all}" print(err("错误:" + bold("连接超时") + " 请重试"))

要么给样式加上下文管理器,让with来管作用域,这在需要输出多段文本时特别省心:

from contextlib import contextmanager from sty import fg, rs @contextmanager def colored(color): print(color, end="") try: yield finally: print(rs.all, end="") with colored(fg.red): print("第一段报错") print("第二段报错")

我个人在构建输出型 CLI 时更偏向前者,因为拼接式返回值可以被logging、模板、断言复用;后者适合纯打印型脚本。两种都不算错,关键是团队里要统一,避免同一个人今天用一种、明天用另一种。

4.3 一个能扛住实际项目的嵌套辅助

如果你不想每处都手写复位逻辑,可以做一个极简的样式栈封装。核心思路是每次进入新样式时记录当时的完整状态,退出时整体还原:

from dataclasses import dataclass, field from sty import fg, rs @dataclass class Painter: stack: list = field(default_factory=list) def push(self, style): self.stack.append(style) return style def pop(self): self.stack.pop() return rs.all

说实话,对大多数项目而言这个封装是过度设计——两三层嵌套用函数式写法就足够了。我把它列出来是因为它揭示了一个本质:Sty 不给你自动管理作用域,所以一旦嵌套深度上去了,作用域管理就必须由你自己的抽象来承担。想清楚这一点,你就不会指望库本身去解决"复位漏了"的问题,而是会在架构层面把它挡掉。

5. 自定义样式:把品牌色和项目色写进代码

5.1 用 Style 配合 RGB 定义私有颜色

Sty 允许你把任意 SGR 序列包成一个样式对象,再挂到fg、bg命名空间上。真彩色场景下最常用的组合是这样:

from sty import fg, bg, Style, RgbFg, RgbBg fg.brand = Style(RgbFg(24, 144, 255)) bg.panel = Style(RgbBg(20, 22, 26)) print(fg.brand + "品牌色文本" + rs.all) print(bg.panel + fg.white + "面板背景" + rs.all)

RgbFg和RgbBg分别对应前景和背景的真彩色序列,Style负责把序列包装成可以直接拼接的字符串对象。挂上去之后,fg.brand的用法和内置的fg.red完全一致,不会因为它是自定义的而有任何行为差异。

这里有三个实操注意点。第一,自定义属性一旦挂到共享的命名空间上,全进程都可见,包括你依赖的第三方库看到的fg,虽然它们通常不会去读,但心里要清楚这是全局副作用。第二,命名一定要加前缀,fg.brand、fg.accent这类词太通用,将来和别的约定冲突时很难改。第三,凡是自定义样式,都应该在大型项目里集中定义一次,绝不要在多处import之后各自fg.xxx = Style(...),否则后导入的会覆盖先导入的,排查起来非常痛苦。

5.2 建一个 palette 模块集中管理

这是我建议所有超过五百行的项目都做的事:单独一个palette.py,把所有颜色定义、语义映射、降级开关全放进去,业务代码只从它这里拿语义常量,永远不直接碰fg.red。

# palette.py from sty import fg, bg, ef, rs, Style, RgbFg, RgbBg fg.brand = Style(RgbFg(24, 144, 255)) fg.ok = Style(RgbFg(82, 196, 26)) fg.warn = Style(RgbFg(250, 173, 20)) fg.error = Style(RgbFg(255, 77, 79)) OK = fg.ok WARN = fg.warn ERROR = fg.error MUTED = fg.da_gray if hasattr(fg, "da_gray") else fg.black RESET = rs.all

业务代码里只写print(f"{ERROR}失败{RESET}")。这样做的好处是换配色只改一个文件,而且语义和颜色解耦了——将来你想把"成功"从绿色改成蓝色,不用全项目搜索fg.green,改一行映射就够了。MUTED那行用了hasattr做兼容,是因为不同版本的暗色命名有差异,写死在代码里换环境就炸。

5.3 深色与浅色终端下的适配问题

这一块很少有人提,但实际影响巨大。你精心选的真彩色,在深色终端上很舒服,用户切到浅色主题就变成糊成一团。原因很简单:浅色底的亮度高,你那个偏亮的颜色对比度不够;深色底同理。所以一套配色要同时兼顾两种底,要么做两套,要么选中间明度。

我的经验值是这样的:文本主色控制在亮度 30% 到 70% 之间,纯黑纯白只用于极端强调;背景色要么明显深于正文、要么明显浅于正文,不要和正文挤在一起;如果你做的是状态色,红绿黄三色在浅色主题下整体调暗一档,深色主题下整体调亮一档,切换时机可以用环境变量,也可以让用户自己配。

还有个偷懒但很稳的做法:主文本压根不上色,只用效果类的粗体和暗淡做层次,把颜色留给真正需要强调的少量信息。这样在任何主题下都不会崩,虽然视觉上朴素,但可读性从来不会翻车。我在做那种要分发给很多人的脚本时,默认走的就是这条路。

6. 工程化落地:日志、CLI 与降级策略

6.1 先判断该不该上色,再决定上什么色

Sty 不判断输出目标,所以这一步必须自己写。判断依据主要有三个:第一,sys.stdout.isatty()为真说明接的是终端,为假通常意味着重定向到文件或管道;第二,环境变量里如果有NO_COLOR这类社区约定,就应该尊重用户意愿关掉颜色;第三,CI 环境通常设了CI=true,这时候是否上色要看日志采集链路,我个人默认关掉,除非明确知道会渲染。

import os, sys def color_enabled(stream=sys.stdout): if os.environ.get("NO_COLOR"): return False if os.environ.get("FORCE_COLOR"): return True return hasattr(stream, "isatty") and stream.isatty()

FORCE_COLOR那个分支是为了应对"用户在终端里用tee记录日志、但仍然想看到颜色"的场景,这也是很多命令行工具的通行做法。有了这个开关,你的所有输出函数只要在最前面判断一次,就可以决定返回带样式还是纯文本。

6.2 用一个统一出口收拢所有输出

开关有了,接下来要保证全项目只有一个地方读它。我一般的做法是写一个console.py,对外只暴露info、warn、error、success四个函数,内部根据开关决定要不要拼样式:

from palette import OK, WARN, ERROR, RESET from sty import fg _ON = color_enabled() def _emit(prefix, text, stream=None): if _ON: print(f"{prefix}{text}{RESET}", file=stream or sys.stdout) else: print(text, file=stream or sys.stdout) def info(text): _emit(fg.cyan, text) def warn(text): _emit(WARN, "[警告] " + text) def error(text): _emit(ERROR, "[错误] " + text, stream=sys.stderr)

这个封装的收益远超"少写几个字符"。它让颜色成为实现细节而不是接口契约,将来换成别的库、改成 JSON 结构化输出、加时间戳前缀,都只改这一个文件。我见过太多项目把fg.red撒在几十个文件里,最后想做一次"关掉颜色"的需求,改了两天才改干净。

6.3 接进 logging:Formatter 是正解

直接在日志消息里拼样式是错的做法,因为消息会被 handler 二次加工。正确姿势是自定义Formatter,在格式化完成之后对整行做样式处理:

import logging from sty import fg, ef, rs class ColorFormatter(logging.Formatter): COLORS = { logging.DEBUG: fg.da_gray if hasattr(fg, "da_gray") else fg.white, logging.INFO: fg.cyan, logging.WARNING: fg.yellow, logging.ERROR: fg.red, logging.CRITICAL: fg.red + ef.bold, } def format(self, record): text = super().format(record) color = self.COLORS.get(record.levelno, "") return f"{color}{text}{rs.all}" if _ON else text

关键在于"对整行处理"而不是"对消息处理"。这样时间戳、模块名、级别名全都有颜色,视觉上是一整块,而不会出现半行彩色半行灰的割裂感。另外注意 handler 要区分对待:终端 handler 用彩色 formatter,文件 handler 用普通 formatter,同一个 logger 挂两个 handler 各管各的,这样终端好看、文件干净,两全其美。

6.4 性能:什么时候该心疼那几字节

随手上机做了个粗略对比,同样的输出量(十万行、每行两段彩色文本),fg.red + s + rs.all这种拼接和纯文本输出的耗时差距在个位数百分比区间,主要是字符串对象分配的开销,而不是转义码本身。但如果每行要拼十几次,或者输出被逐字符写进 socket,差距就会放大到肉眼可感知。

radare 优化思路有两条。第一条,把频繁使用的"前缀+后缀"组合提前拼成常量,循环里只用一次join:

OPEN = OK CLOSE = RESET lines = [f"{OPEN}{name} 完成{CLOSE}" for name in names] sys.stdout.write("\n".join(lines))

第二条,能用一次拼接就别用多次加法。Python 的+每次都会新建字符串,用f-string或者join能少分配几次。这两条在普通 CLI 里其实无所谓,只有在做进度条那种每帧重绘、一秒刷新几十次的场景下才值得认真对待。真到了那个级别,你该考虑的是增量重绘而不是全量重打,那已经是另一个话题了。

7. 常见问题与排查速查表

7.1 颜色不生效的六种原因

排第一位的是终端本身不支持。这不是开玩笑,最典型的是把输出接到了 Windows 早期控制台,或者接进了一个只渲染纯文本的界面。验证方法很土但很有效:printf '\033[31mRED\033[0m\n'直接打到终端,看有没有红。没有就是环境问题,跟 Sty 无关。

第二位是环境变量拦截。有些框架和测试工具会自动设置关色变量,你在本地跑得好好的,进了测试环境就全是白字。排查方法是把你的开关函数单独打印一次结果,看看它到底走了哪个分支。

第三位是重定向。python app.py > out.txt之后打开out.txt一看全是转义字符——这不是 bug,行为完全正确,因为文件里本来就不该有 ANSI。你该做的是拿到终端里cat一下,或者干脆less -R。

第四位是样式泄漏导致"看起来没生效"。实际上颜色生效了,但被你前面某处漏掉的复位改成了别的颜色。这种情况的典型症状是整屏偏色,而不是完全无色。

第五位是颜色名写错了。fg.light_red和fg.li_red在不同版本里可能只有一个存在。属性不存在会直接抛AttributeError,但如果你的代码里用了getattr(fg, name, "")这种兜底,就会静默变成空字符串,表现为"没颜色"。

第六位是间接调用踩坑。你把带样式的字符串传给了某个会做 HTML 转义或长度截断的库,它把\x1b当特殊字符处理掉了。这种最难查,建议二分法定位:把带样式的字符串单独打印一次,确认它本身是对的,再去找是哪个中间层动了手脚。

7.2 颜色"漏"到后面:三种经典场景

最常见的是漏写复位。判断方法很简单:在出问题的输出后面紧跟一句纯print("---")看它是不是也被染色,如果是,百分之百是前面漏了rs.all。

第二种是局部复位用错了域。比如你想关掉粗体却用了rs.all,结果前景色也一起丢了。症状是"这一行后半截突然变白",而不是"整段变白"。定位方法是对着 SGR 码表逐段检查,看每个复位常量到底该对应哪个域。

第三种是嵌套顺序错了。内层样式必须在进入前就准备好外层的复位逻辑,退出时先撤内层再撤外层。顺序一乱,最终状态就不是你想要的默认态。

现象最可能原因定位方法修复方式
整屏无颜色终端不支持或开关关闭printf原始序列测试换终端或打开开关
颜色泄漏到后续行漏写复位后接一行纯文本观察是否染色补rs.all
后半行突然变默认用了rs.all代替局部复位检查复位常量域换rs.ef或rs.fg
背景色蔓延到行尾背景复位缺失观察换行后首字符补rs.bg或rs.all
属性抛 AttributeError颜色名与版本不匹配dir(fg)查实际名字用getattr兜底或改名字

7.3 跨平台与终端能力差异

Linux 和 macOS 上基本不用操心,主流终端模拟器对 ANSI 的支持都很完整。Windows 这边要分情况:近年的 Windows 终端默认开启虚拟终端处理,直接可用;老的conhost窗口需要显式启用 VT 模式,否则会看到一堆←[31m这样的乱码。最省事的办法是在程序入口处做一次能力探测,探测失败就退化成无色输出,而不是硬上:

def supports_ansi(stream): if not hasattr(stream, "isatty") or not stream.isatty(): return False if os.name != "nt": return True # Windows 上做一次真实写入探测更稳 try: import ctypes kernel32 = ctypes.windll.kernel32 return bool(kernel32.SetConsoleMode( kernel32.GetStdHandle(-11), 7)) except Exception: return False

这段代码的思路是尝试开启 VT 模式并看返回结果,成功就用颜色,失败就纯文本。它不能覆盖所有情况,但足够挡住最典型的坏环境。

还有一个容易被忽略的差异:不同终端对制表符宽度、宽字符(中文、emoji)占位的计算不一致。你如果用颜色做"进度条"这类需要对齐的东西,中文字符宽度算错会导致错位。Sty 本身不解决这个问题,但你在拼接带样式的对齐文本时要有意识——先把纯文本补齐、再套样式,而不是先套样式再算长度,因为转义码也是字符,会把len()撑大。

7.4 开发期的两个自检习惯

第一个习惯是写一个demo.py,把所有自定义样式、所有嵌套组合、所有复位写法各打一行,改代码前先跑一遍。这在你要调整配色方案时特别有用,一眼就能看出哪个颜色在深色背景下糊了、哪个嵌套没关干净。成本是十行代码,收益是省掉无数次线上排查。

第二个习惯是给所有输出函数写一条"无色快照"测试:设置NO_COLOR=1跑一遍,把 stdout 抓下来断言里面不包含\x1b。这条断言能挡住绝大多数"忘了走统一出口"的回归,是性价比极高的一条防线。

我用这套东西大概三四年了,中间换过两次配色方案、接过三个不同的日志后端,真正卡住我的从来不是 Sty 本身的能力边界,而是自己有没有想清楚"哪一层该负责复位、哪一层该负责降级"。把这些边界提前定死,它就是一个怎么写都不用操心的字符串工具;边界不定,再轻的库也会被用出一堆隐性耦合。真要说一条经验,那就是:样式定义集中一处、输出出口只留一个、复位永远成对出现,这三条做到,剩下的基本都是顺手的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询