Black 与 Doctest 格式化:为什么 Black 不碰 Docstring 里的代码,以及如何用 blackdoc / blacken-docs 补齐
本文基于 Black 官方集成文档 docs/integrations/doctest_formatting.md 展开,讲清三个问题:Black 对 docstring 中可执行代码(doctest)的处理边界及其底层原因、两个社区工具 blacken-docs 与 blackdoc 各自支持的场景与示例、以及两者行为差异下如何在项目中做出稳妥的选型。读完后,你可以为自己的仓库规划一套"Black 管源码、专用工具管文档内 doctest"的完整格式化方案。
Black 的默认行为:不格式化 docstring 内的 doctest 代码
Black 官方文档开篇即明确了这一职责边界:
Black 会对 docstring 的"风格"做一些决定,但不会假设文档内容本身的结构。因此,docstring 或文档文件中的可执行 Python 代码(例如 doctest),不会被 Black 格式化。
这个"克制"是有意为之的。doctest 的语义依赖字符串的精确内容:>>> 提示符、缩进、输出文本的换行,任何一处在被"格式化"后都可能改变 doctest 的比对结果甚至执行语义。把文档内容当作不透明文本处理,是保证 Black 幂等性与安全性的前提。
从源码看 Black 对 docstring 到底做了什么
结合仓库源码可以精确理解 Black 在 docstring 上的行为范围:
-
入口是字符串叶节点访问:visit_STRING 是所有字符串(含 docstring)的格式化入口。其中
is_docstring(leaf)判断命中后,Black 会:- 若开启了
string_normalization,先做前缀归一化(normalize_string_prefix 将F/B小写、去掉u/U前缀)和引号归一化(normalize_string_quotes 优先双引号、必要时增删反斜杠); - 剥离字符串前缀与外层三引号,得到 docstring 纯内容;
- 对多行 docstring 调用 fix_multiline_docstring 重新对齐缩进(按 lines_with_leading_tabs_expanded 展开前导 tab 后,以最小缩进为基准整体平移,遵循 PEP 257 的 docstring 缩进处理规则);
- 对首尾含引号或奇数个尾随反斜杠的内容补空格,防止转义问题。
注意整个流程中,docstring 的内部内容只按"文本行"做缩进平移,绝不会被再次解析为 Python 代码——这正是 doctest 代码不会被触碰的源码级原因。
- 若开启了
-
有一个明确的豁免点:visit_STRING 中对包含"反斜杠+换行"的 docstring 直接跳过重排,注释说明这样做会改变 AST 表示。这再次体现了 Black 对字符串内部语义的保守态度。
-
行为可由开关控制:docstring 的引号/前缀归一化属于
string_normalization能力的一部分,该字段在 Mode 中默认为True,可通过命令行--skip-string-normalization(见 src/black/init.py)关闭。但即使关闭该开关,Black 也不会开始格式化 docstring 里的 doctest 代码——这条边界是写死在行为设计中的,与开关无关。
换言之:Black 负责 docstring 作为"字符串"的风格(引号、前缀、缩进对齐),而 docstring 作为"文档"的内容(含其中嵌入的 doctest)完全交还给专门工具处理。
补齐方案的总览:blacken-docs 与 blackdoc
官方文档在给出上述边界后,列出了两个将 Black 格式化规则应用到 docstring 和文档文件中代码的工具,并附有一段重要提示:
注意:这些包之间已观察到一些不一致行为,因此官方不愿给出推荐。安装即自担风险。同时欢迎社区贡献更多 doctest 格式化工具的文档。
以下两节分别继承原文档中两个工具的能力说明与示例。
blacken-docs:面向文档文件的 doctest 格式化
blacken-docs 主要用于将 Black 格式化应用到文档文件(如 .rst、.md、.tex)中的代码。它支持以下场景:
1. Markdown / reStructuredText / LaTeX 文件中的 Python 代码块
与 blackdoc 相同,此处应用的是普通 Black 格式化,因此 Python 代码块内部的 doctest 不会被处理。
Markdown 形式:
```python
print("Hello world!")
```
reStructuredText 形式:
.. code-block:: python
print("Hello world!")
LaTeX 形式(minted 环境):
\begin{minted}{python}
print("Hello world!")
\end{minted}
2. Markdown 与 reStructuredText 中 Pycon 代码块里的 doctest
代码块可以位于 .md 或 .rst 文件中,也可以位于 Python 文件的 docstring 内部:
```python
>>> print("Hello world!")
```
.. code-block:: pycon
>>> print("Hello world!")
def add_one(n: int) -> int:
"""
Examples
--------
```pycon
>>> add_one(1) == 2
```
"""
return n + 1
可以看出 blacken-docs 的定位是"文档侧":它识别文档语法(fenced code block、.. code-block::、minted)中的 Pycon 块,对块内 doctest 应用 Black 风格。
blackdoc:面向 Python 文件的 doctest 格式化
blackdoc 主要用于将 Black 格式化应用到 Python 文件中的 doctest,且不会格式化任何已被 Black 本身覆盖的文件内容——两者职责互补、不重叠。它支持以下场景:
1. Python 文件中的 doctest
def add_one(n: int) -> int:
"""
Examples
--------
>>> add_one(1) == 2
"""
return n + 1
这是最典型的使用场景:>>> 语句在 docstring 中,Black 主流程(如 visit_STRING 所实现的逻辑)不会触碰它们,而 blackdoc 会解析这些 doctest 并按 Black 风格重排。
2. Markdown / reStructuredText 文件中的 Python 代码块
在这些情况下应用的是普通 Black 格式化,即 Python 代码块内部的 doctest 不会被处理:
```python
print("Hello world!")
```
.. code-block:: python
print("Hello world!")
两个工具的能力边界对比
将原文档的说明整理为对照表,便于检索与选型(表中"覆盖"指该工具会对相应位置应用 Black 风格):
| 位置 | blacken-docs | blackdoc |
|---|---|---|
.md / .rst / .tex 中的普通 Python 代码块 |
普通 Black 格式化(块内 doctest 不处理) | 普通 Black 格式化(仅 .md / .rst,块内 doctest 不处理) |
.md / .rst 中 Pycon 代码块的 doctest |
覆盖 | 不覆盖 |
| Python 文件 docstring 中的 doctest | 覆盖(含 docstring 内的 ```pycon 块形式) |
覆盖(标准 >>> doctest 形式) |
| 已被 Black 主流程覆盖的内容 | 不重复处理 | 不重复处理 |
需要强调的是原文档的告诫:两个包之间存在已观察到的不一致行为,官方因此不做推荐,任何安装均自担风险。从源码结构看,这一谨慎态度与 Black 自身的设计哲学一致——Black 对字符串内容(含 doctest)保持"不透明处理"(见 src/black/strings.py),而 doctest 解析、Pycon 块识别等能力属于文档侧工具的独立实现,彼此之间并无共享的解析器,行为差异在所难免。
实践建议:分层规划格式化职责
基于本文的边界说明,一个稳妥的项目实践是:
- 源码层:所有
.py文件交给 Black 本身(--check --diff可用于 CI 校验),docstring 的缩进对齐、引号风格由 Black 自动处理; - Python 文件中的 doctest:引入
blackdoc,只处理>>>形式的 doctest,不与 Black 主流程重叠; - 文档文件(
.md/.rst/.tex)中的代码块与 Pycon 块:引入blacken-docs; - 选型验证:由于官方提示工具间存在不一致,落地前应在项目文档语料上分别运行两个工具,对比其输出差异(尤其是 Pycon 块、LaTeX minted 环境、docstring 内嵌代码块等边界形态),以当前仓库版本的实际输出为准做决策;
- 跟踪上游:该集成文档本身是 Black 版本 26.3.x 期间新增的(见 CHANGES.md "Integrations" 小节中 "Added documentation for doctest formatting tools" 条目,PR #4916),后续版本可能更新工具清单,建议定期回看 docs/integrations/doctest_formatting.md。
小结
Black 的原则是"格式化代码,不揣测文档":visit_STRING 与 fix_multiline_docstring 只对 docstring 的字符串风格与缩进做确定性处理,从不解析其中的 doctest。而 doctest 与文档代码块的格式化由 blackdoc(Python 文件内的 doctest)和 blacken-docs(文档文件中的 Python / Pycon 代码块)按各自的识别规则补齐。理解这一分层边界,就能在项目中把"Black 管代码、专用工具管文档内代码"的完整格式化流水线搭起来,同时保留对工具间行为差异的验证环节。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00