☰
使用Python标准库tokenize实现精确代码统计与轻量语法高亮
2026/11/3 4:58:14 网站建设 项目流程

paperclip 这个词,我琢磨了很久。它让我想起办公桌上那种不起眼却永远缺不了的回形针:单个夹不住什么大东西,但一盒放在手边,随手就能把散乱的票据、线缆、讲义稳稳夹在一起。我决定拿它当这个小项目的代号,目标也很朴素——做一个“回形针级别”的工具:不引入任何重量级依赖,只用 Python 标准库,就能精确解析 Python 源码,完成代码统计和语法高亮这两件小事。如果你写脚本时经常被正则表达式折磨,或者一直想搞明白解释器是怎么把一个.py文件拆成一堆零件的,这篇文章应该正合你胃口。

1. 项目起源:回形针的隐喻与两个小目标

1.1 为什么叫 paperclip

我给项目取名 paperclip,不是突发奇想。回形针这东西的特点很明确:结构简单,随手即用,不占地方,却能在一堆乱糟糟的东西里精准地“夹住”该夹的部分。我当时手头遇到一个场景:需要批量分析一批 Python 脚本,统计每个文件的代码行数、注释数量、字符串占比,同时还要在前端页面里把源码渲染成带颜色的代码块。第一反应当然是正则表达式——谁没这么干过呢?但很快我就发现,正则处理源码这件事,就像用剪刀去绣花:能剪断,但永远剪不出干净的形状。

所以 paperclip 的定位就出来了:不做一个编译器,不做一个 IDE 插件,只做一个能夹住关键信息的微型工具。它解决的核心痛点是“在不安装任何第三方库的前提下,拿到 Python 代码的结构化信息”。听起来好像不难,真正动手之后你会发现,Python 标准库里那个叫 tokenize 的冷门模块,才是解决这种问题最顺手的那枚回形针。

1.2 两个具体目标

paperclip 的第一个目标,是做一个足够精确的源码统计器。普通的统计工具一般是靠“空行、注释行、代码行”这种粗粒度分类,但真正的代码分析往往需要更细的信息:总共有多少个标识符、多少个字符串字面量、多少行代码是真正的逻辑代码而不是单纯的多行字符串内容。用 tokenize 之后,这些数据是白送的,不需要猜。

第二个目标,是做一个不依赖 pygments 的轻量语法高亮器。pygments 确实强大,但它的安装体积和主题体系对很多场景来说太重了。如果只是要把源码转成一段带span标签的 HTML,Python 标准库完全够用。tokenize 能告诉你每个 token 的类型和位置,高亮不过就是把这些 token 按照类型套上不同的 CSS class,再拼回原文而已。

1.3 适合谁看

这篇文章适合三类人。第一类是经常写脚本处理代码文本的开发者,尤其是被正则表达式搞到头秃的那批。第二类是好奇 Python 解释器内部机制的人,tokenize 是进入编译前端的一扇便宜的门。第三类是像我一样喜欢做“小而美”工具的人,想看看一个纯标准库小工具能走多远。代码我会完整给出,你直接复制就能跑。

2. tokenize 模块:把 Python 源码拆成零件

2.1 它到底在做什么

很多人听说过 AST,也就是抽象语法树,那是一个编译前端的“高层视图”。但 AST 之前还有一层更接近源码的加工工序,tokenize 做的就是这一层。它把 Python 源码按规则切成一个个 token,每个 token 都带着自己的类型、起止坐标和所在行的原始文本。

你可以这样理解:AST 是把源代码读成“意思”,tokenize 是把源代码拆成“词”。一个def add(a, b):在 tokenize 眼里是这样一串零件:NAME(def)、NAME(add)、OP(()、NAME(a)、OP(,)、NAME(b)、OP())、OP(:),再加上一个NEWLINE。每个零件的边界清清楚楚,哪一段是标识符、哪一段是运算符、哪一段是注释,都不会模棱两可。

2.2 generate_tokens 与 tokenize 两种入口的区别

这个模块提供了两个主要的生成器函数,很多人第一次接触就在这里绕晕了。

tokenize.tokenize(readline)接收的是一个返回 bytes 的 readline 函数,它内部会先处理文件编码,所以第一个 token 通常是ENCODING。比如你打开文件时用的是open(file, 'rb'),再包装一下 readline,走这个入口就没问题。

tokenize.generate_tokens(readline)接收的是返回字符串的 readline 函数,常见写法是io.StringIO(source).readline。它不做编码探测,直接把你已经解码好的字符串当输入,所以不会出现ENCODINGtoken。

我一般建议,除非你要处理任意编码的源码文件,否则优先用generate_tokens。原因很简单:在脚本里我们通常已经用utf-8读好了源码,没必要再绕一次编码检测;而且generate_tokens的输出更简洁,处理字符串输入时心智负担小很多。

2.3 TokenInfo 的结构

tokenize 产生的每个 token 都是一个TokenInfo对象,它在文档里称为具名元组的扩展。你可以像访问元组一样用下标,也可以像访问对象一样用字段名。我实际用到最多的是这几个字段:

  • type:token 类型,是一个整数,比如 1 代表NAME,2 代表NUMBER,3 代表STRING。
  • string:token 对应的源码文本,比如"def"、"add"、"("。
  • start:token 起始位置,是一个(行号, 列号)元组,行号从 1 开始,列号从 0 开始。
  • end:token 结束位置,规则同上。
  • line:token 所在的整行原文。注意,这行原文里经常还包含别的 token,所以不要直接拿它当 token 自身内容用。

2.4 token 类型速查

常用 token 类型没那么可怕,我整理了一张我在写 paperclip 时反复对照的表。

token 类型常量对应数字含义常见例子
tokenize.ENCODING0文件编码声明utf-8
tokenize.NAME1标识符import、def、add
tokenize.NUMBER2数字字面量42、3.14
tokenize.STRING3字符串字面量"hello"、'''文本'''
tokenize.OP4运算符和分隔符+、(、:
tokenize.COMMENT5注释# 说明
tokenize.NL6非逻辑行结束空行、装饰器后的换行等
tokenize.NEWLINE7逻辑行结束普通语句末尾的换行
tokenize.INDENT8缩进开始
tokenize.DEDENT9缩进结束无显式文本
tokenize.ENDMARKER10源码结束标记无显式文本

2.5 5 行代码看 token 流

光看文档容易晕,直接上代码最直观。你可以把下面这段保存成show_tokens.py跑一下。

import io import tokenize source = 'def add(a, b): # 求和\n return a + b\n' for tok in tokenize.generate_tokens(io.StringIO(source).readline): print(tokenize.tok_name[tok.type], repr(tok.string), tok.start, tok.end)

输出会非常清楚地展示每个 token 的类型和坐标。我第一次跑的时候还挺震惊的:原来是这么回事,我之前用正则折腾半天的事情,标准库早就帮你干完了。尤其是那句# 求和,它被单独标成了COMMENT,根本不需要正则去识别字符串里的#和行首的#有什么区别。

3. 不用正则的代码统计器:精确到 token 的行数统计

3.1 统计器要解决的定义问题

写一个统计工具之前,先要回答一个哲学问题:什么算一行代码?对现代 Python 来说这没那么简单。

如果按物理行数,一个跨五行的多行字符串算五行代码,这显然不合理。如果按“去掉空行”算,那三引号字符串中间的内容行会混进来。如果按“有没有非空字符”算,那if x:下面的被注释掉的代码块又无法识别。只有 tokenize 能给你一个可解释的口径:一行里只要出现了非注释、非换行的有效 token,这一行就属于代码逻辑行。

这个口径的好处是它能区分“代码行”和“字符串内容行”。比如下面这段:

text = """ 第一行 第二行 """

物理上它是五行,但真正的逻辑代码只有一行,也就是text = """..."""的起始行。后面两行中文只是字符串的内容,它们有文本,但并不是代码。

3.2 核心实现

我的统计器核心代码很短,逻辑却非常稳。

import io import tokenize def analyze_source(source: str): code_lines = set() token_counts = {} tokens = tokenize.generate_tokens(io.StringIO(source).readline) for tok in tokens: ttype, tstring, start, end, _ = tok if ttype in ( tokenize.ENCODING, tokenize.ENDMARKER, tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT, ): continue name = tokenize.tok_name[ttype] token_counts[name] = token_counts.get(name, 0) + 1 if ttype != tokenize.COMMENT: code_lines.add(start[0]) comment_lines = { start[0] for tok in tokenize.generate_tokens(io.StringIO(source).readline) if tok.type == tokenize.COMMENT for start in [tok.start] } return token_counts, len(code_lines), len(comment_lines)

这里有两个关键点。第一,我过滤掉的 token 分别是编码声明、结束标记、换行和缩进,因为这些都不代表真实的代码内容。第二,我用set保存行号,所以同一个逻辑行里出现多个 token 时不会重复计数;比如return a + b这一行会有四个有效 token,但行号只会被记录一次。

3.3 实测对比

我用一个混合了注释、空行、行内注释和多行字符串的样例来对比:

import os import sys # 这是一个注释 def add(a, b): # 行内注释 return a + b value = add(1, 2) text = """ 第一行 第二行 """ print(value)

物理行数是 14 行。用 tokenize 统计出来的“逻辑代码行”是 7 行,分别是import os、import sys、def add...、return a + b、value = add(1, 2)、text = """..."""、print(value)。

纯注释行是 1 行。剩下的 3、7、8 行是空行,11、12 行是多行字符串内容行,13 行是字符串的收尾行。

如果你的统计脚本用“非空行减去注释行”的粗暴算法,得到的结果很可能把 11、12、13 行算成代码,那误差就太大了。tokenize 的方式虽然多写了几行代码,但是每个数字都可以解释清楚。这对需要写技术报告或者做代码质量评估的场景非常重要。

3.4 两个统计口径:物理行与逻辑行

我后来给 paperclip 加了一个参数,分别输出物理行和逻辑行。物理行就是源文件的总行数,用source.count('\n') + 1就能拿到。逻辑行就是上面analyze_source算出来的行数。

这个区分在实践里很有用。比如团队做代码量统计时,如果只按物理行算,一个大字符串就能把代码量冲高。如果只按逻辑行算,又没法评价一个文件的可读性排布。我的建议是,物理行和逻辑行一起展示,再额外展示注释行数和字符串内容行数,多维度的数据互相印证,才能描述清楚一个文件到底是什么状态。用 tokenize 之后,这些口径都是统一的,不会出现这次这样算、下次那样算的问题。

4. 直接上手的轻量语法高亮器

4.1 为什么不用 pygments

先别误会,pygments 是个非常好的项目,我自己也经常用。但在 paperclip 这个场景里,当我只需要把代码转成一段静态 HTML 时,pygments 的引入成本远大于收益。它的安装包会带来一整套 lexer 和 style 体系,API 也偏重,还需要额外处理主题 CSS。

而 tokenize 本身就是 Python 源码的官方 lexer,它给出的 token 类型已经足够支撑一套基础高亮。只要你不需要支持 Python 以外的语言,标准库就是那个“够用且最轻”的方案。说白了,pygments 是瑞士军刀,tokenize 是那把顺手的小剪刀。paperclip 想做的是剪刀,不是瑞士军刀。

4.2 高亮的本质是切片

语法高亮看起来复杂,本质却很简单:把原始源码切成若干块,给每块按类型套上一个 HTML 标签,再按原顺序拼回去。难点只在于“怎么切得准”,而这恰恰是 tokenize 擅长的事情。

我采用的方案是自己管理绝对偏移量。因为 TokenInfo 的 start 是(行号, 列号),而我要在字符串切片时用的是绝对字符位置,所以我先构造一个偏移表,记住每个物理行在源码字符串里的起始偏移量。这样,任意一个 token 的起始位置都能换算成唯一的绝对偏移。

4.3 实现代码

这是我的高亮器核心实现。

import html import io import tokenize TOKEN_CSS = { tokenize.NAME: "tok-name", tokenize.NUMBER: "tok-number", tokenize.STRING: "tok-string", tokenize.COMMENT: "tok-comment", tokenize.OP: "tok-op", tokenize.ERRORTOKEN: "tok-error", } def highlight(source: str) -> str: lines = source.splitlines(keepends=True) offsets = [] pos = 0 for line in lines: offsets.append(pos) pos += len(line) result = [] scan_pos = 0 for tok in tokenize.generate_tokens(io.StringIO(source).readline): ttype, tstring, start, _, _ = tok if ttype in (tokenize.ENCODING, tokenize.ENDMARKER): continue start_offset = offsets[start[0] - 1] + start[1] result.append(html.escape(source[scan_pos:start_offset])) css_class = TOKEN_CSS.get(ttype, "tok-other") result.append(f'<span class="{css_class}">{html.escape(tstring)}</span>') scan_pos = start_offset + len(tstring) result.append(html.escape(source[scan_pos:])) return "".join(result)

这段代码有几个细节值得展开。我保留keepends=True,是为了偏移表能精确对应到每个换行符;如果用splitlines()丢掉换行,行号偏移就会越算越乱。token 之间的原文部分要原样保留,包括空格、Tab、换行,所以我只对“token 本身”做高亮,不做任何格式化。所有输出都过了html.escape,这样可以避免源码中的<、>、&破坏页面结构。

4.4 输出 HTML 与配色

调用highlight之后得到的是一段连续的 HTML 字符串。你只需要在外面包一个<pre>,再配几行 CSS,效果就出来了。

pre.code-block { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; } .tok-name { color: #569cd6; } .tok-number { color: #b5cea8; } .tok-string { color: #ce9178; } .tok-comment { color: #6a9955; font-style: italic; } .tok-op { color: #d4d4d4; } .tok-error { color: #f48771; }

我实测过一段包含中文字符串注释、正则表达式、字典解包的 Python 代码,渲染出来的 HTML 结构是干净的,浏览器解析不会出问题。和 pygments 的默认主题相比,这个配色当然朴素,但作为内部工具、博客系统或文档站点的代码高亮,完全够用。

4.5 untokenize 的局限

这里必须提一个很多人踩过的坑:tokenize 模块里还有一个untokenize函数,可以把 token 列表还原成源码。你可能想问,能不能先 tokenize,再往里插<span>,最后用 untokenize 还原?

答案是不能。因为 tokenize 在分词时会丢弃大部分空白字符——空格和 Tab 不会生成 token,所以 untokenize 重建出来的代码会变得非常紧凑。比如def f( x ): return x经过一轮 tokenize 再 untokenize,很可能变成def f( x ):return x。对高亮来说这是不可接受的,因为我们的目标是保留源码原样,而不是重新格式化源码。正确的做法,就是我在上一节给你展示的“手动按偏移量切片拼接”方案。

5. 我在写 paperclip 时踩过的坑

5.1 编码:bytes 与 str 两套入口

我最早写统计器时,直接用了tokenize.tokenize,然后把文件按二进制读进来,结果输出的 token 流里多了一个ENCODINGtoken,统计数字总是比预期多一个。后来我把输入全部统一成generate_tokens,并且明确在入口处用utf-8解码,问题就消失了。

这里我总结了一个判断规则:不管你将来处理什么输入,先确定你的 readline 函数返回的是 bytes 还是 str。如果是 bytes,用tokenize.tokenize;如果是 str,用tokenize.generate_tokens,两者不要混。如果你拿一个返回 str 的 readline 传给tokenize.tokenize,它会直接抛TypeError;反过来,拿 bytes 传给generate_tokens,也会因为编码问题出各种乱象。

5.2 INDENT 和 DEDENT 干扰统计

缩进 token 是新手最容易忽略的东西。generate_tokens会在代码块开始和结束时产生INDENT和DEDENT,它们的 string 字段往往为空字符串或几个空格,肉眼看不见,但会参与 token 计数。

我在统计 token 总数时,一开始把INDENT也算进去了,于是同一个文件在不同机器上的数字居然不一样,因为缩进风格不同。后来我在过滤列表里加上了INDENT和DEDENT,数据才稳定下来。如果你要展示“标识符数量”“运算符数量”,也必须过滤这两个 token,否则统计结果没有可比性。

5.3 多行字符串的行号陷阱

还有一个我差点翻车的细节:多行字符串的start是起始行,end是结束行。如果你做行号统计时只记录start[0],那一个跨越十行的长文本字符串只会算一行代码。这对“逻辑代码行”来说其实是正确的,但如果你想要的指标是“这个文件里有多少行源码被字符串覆盖”,就得遍历range(start[0], end[0] + 1)把整段字符串的行号都加进去。

我最终的方案是给统计器加了一个“字符串覆盖行”的独立指标,这样既保留逻辑代码行的含义,又能反映大段长字符串对文件结构的影响。两种口径分开出数,谁也别想偷换概念。

5.4 untokenize 不是万能还原器

我在前面已经提到 untokenize 会丢空白。这里再补充一个更隐蔽的坑:如果你从 token 流里手动删除了某些 token,再调用 untokenize,它不会自动帮你补充语法上必需的换行和缩进。比如你删掉一个NEWLINE,还原出来的两行代码可能会拼到同一行。

所以如果你想做源码转换工具,老老实实基于 TokenInfo 的位置信息去切原文,而不是先 tokenize 再 untokenize。tokenize 最强的能力是给原始文本做标注,原始文本本身才是最可信的。

5.5 批量扫描时的性能控制

tokenize 本身是纯 Python 实现的,扫描一个几百行的文件体感就是毫秒级,但如果你要对整个项目几千个文件做全量统计,逐文件创建StringIO的开销会累积起来。我实测下来,性能瓶颈不在分词,而在反复创建对象和读取文件 IO 上。

我后来做的优化很简单:一次性读入整个文件的文本,然后在一个循环里连续调用generate_tokens,每个文件只创建一次StringIO。另外,如果只是统计本无需渲染,就不要生成高亮 HTML,两者拆成两个独立函数,避免无用字符串拼接。

6. 还能怎么玩:几个低成本扩展

6.1 代码风格硬指标

token 流做出来之后,很多代码风格指标都变成了简单的统计题。比如:单个函数平均使用了多少个NAMEtoken?一个文件里OP中(和)的比例是否失衡?字符串 token 占所有 token 的比例是否过高,暗示着代码里塞了大量文案?

这些指标用传统文本统计很难算准,但用 tokenize 只是对 token 类型做过滤计数。我当时把这些数据接进了一个简单的报告脚本,能快速扫出项目里“过于复杂”的文件,再人工抽查,比纯粹靠眼睛看高效非常多。

6.2 字符串与 f-string 分析

STRINGS 类型的 token 包含了单引号、双引号、三引号字符串,也包含 f-string。你可以通过 token 的 string 前缀来判断它是不是格式化字符串,比如以f、r、b、u开头。

如果你要检查团队代码里有没有人把敏感信息直接写死在字符串里,也可以用这个思路做一层很粗糙的静态检查。用正则去匹配字符串内容很容易漏掉转义和三引号边界,但 tokenize 能保证你拿到的就是一个个完整的字符串字面量。

6.3 AST 前置校验

tokenize 的分词速度快,但它不做语法检查。你可以先用 tokenize 快速提取字符串、注释、标识符做统计,再在需要语法级判断时用ast模块解析。两者是很好的互补关系。

我经常在 CI 脚本里先跑一遍 tokenize 的统计逻辑,如果某些硬指标明显异常,比如某个文件字符串占比超过阈值,就终止检查,输出警告。这样能把最明显的代码坏味道挡在代码评审之前。

6.4 做成命令行工具

把上面的功能包起来是很自然的事。我的 paperclip 命令长这样:

paperclip analyze path/to/file.py --json paperclip highlight path/to/file.py --output out.html

analyze 子命令输出 JSON 格式的统计结果,highlight 子命令把高亮 HTML 写到指定文件。整个工具只依赖标准库,放在任何一台跑 Python 的机器上都能用。

6.5 接入 CI

我目前最常用的场景是把 token 统计接入到代码库的 CI 流程里。每次拉取请求跑一次 paperclip,输出一份简单的报告:物理行、逻辑行、注释行、字符串覆盖行。数值只做展示,不做强制门禁。

如果你想让这些数字有约束力,可以自己决定阈值。比如逻辑行数超过 800 就标记需要人工评审,注释占比低于 5% 就提示缺少注释。用 tokenize 统计出来的数字口径统一,大家可以围绕同一个标准讨论问题,而不会因为“什么叫代码行”这种定义吵起来。

7. 实操心得:回形针项目的三点体会

整个 paperclip 写下来,我最大的感受是:正则表达式在源码分析这件事上,能做的事比想象中少得多。一开始我以为统计行数和做高亮无非是几个正则的事,结果被注释符、多行字符串、缩进 token 反复教做人。换成 tokenize 之后,所有边界情况都变成了已经定义好的 token 类型,代码量反而缩短了。

第二点体会是,标准库里的好东西远比我们以为的多。tokenize 这个模块在国内社区讨论度不算高,文档也写得比较干巴,但它的设计思路非常清晰。读它的源码可以学到很多东西,比如怎么处理 Python 的隐式续行、怎么识别括号配对、怎么处理物理行和逻辑行的关系。这些知识放到任何一门语言的词法分析里都是通用的。

最后一点是关于工具定位的。paperclip 没有做花哨的功能,也没有图形界面,它就像真的回形针一样,夹住两件小事就走。可正是因为足够小、足够专注,它才真正被我用了起来。我现在看到任何需要解析 Python 源码的任务,第一反应不再是正则,而是先想想 tokenize 能不能更干净地搞定。如果你也想做类似的小工具,或者只是想把代码分析玩明白,从 tokenize 开始,绝对是个正确的选择。

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

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

立即咨询