在 python 中,fnmatch 模块常被用来做文件名匹配。它实现了 unix shell 风格的通配符规则,允许你用 *.txt、data_?.csv 这样的模式来筛选文件名。与 glob 不同,fnmatch 不负责遍历目录,它只做纯粹的字符串匹配,因此更加灵活,也更容易被误用。很多开发者在使用 fnmatch 时都会遇到诡异的“漏匹配”或“过度匹配”:明明写了 *.py,却把 file.pyc 也算了进去;模式中的方括号被当成了普通字符,而不是字符集;在 windows 上能匹配的模式,在 linux 上却匹配不到。更令人困惑的是,fnmatch 到底区分大小写吗?它的行为为什么在不同平台上不一样?
这些问题的根源,在于 fnmatch 所遵循的匹配规则继承自操作系统的文件名匹配惯例,以及它内置的“大小写规范化”行为。今天,我们就来彻底解剖 fnmatch 的匹配机制,弄清它与 glob、re 的关系,并教你如何安全、精确地进行文件名匹配。

一、问题复现:那些“漏网”与“误捕”的匹配
场景 1:*.py竟然匹配到了file.pyc
import fnmatch
print(fnmatch.fnmatch('file.pyc', '*.py'))
# 输出:false(正常)
等等,这看起来没问题。但如果你在 windows 上使用 fnmatch,结果可能不同?实际上,fnmatch 的 * 匹配任意字符,包括扩展名中的多余字符。*.py 只会匹配以 .py 结尾的字符串。所以 file.pyc 不会匹配。真正的问题往往出在模式本身写错,比如 *py 会匹配 file.pyc。
场景 2:?只匹配一个字符,但中文字符可能占多个字节
import fnmatch
print(fnmatch.fnmatch('文件.txt', '??.txt'))
# 输出:false,因为 '文件' 是两个字符,但 '?' 在 python 3 中匹配一个 unicode 字符
# 实际上在 python 3 中,'?' 匹配一个 unicode 码点,所以 '??.txt' 可以匹配 '文件.txt'
print(fnmatch.fnmatch('文件.txt', '??.txt')) # true
这里的问题在于,很多人以为 ? 匹配一个字节,但在 python 3 中它匹配一个字符(unicode 码点)。对于中文字符,通常一个汉字就是一个码点,所以 ?? 可以匹配两个汉字。
场景 3:[!abc]不匹配a、b、c,但可能匹配到你不想要的字符
import fnmatch
print(fnmatch.fnmatch('d.txt', '[!abc].txt')) # true
print(fnmatch.fnmatch('a.txt', '[!abc].txt')) # false
这看起来正常。但如果模式中包含了 ] 或 - 等特殊字符,需要小心转义。
场景 4:大小写敏感性因平台而异
import fnmatch
print(fnmatch.fnmatch('file.txt', '*.txt'))
# 在 linux 上:false
# 在 windows 上:true
这是 fnmatch 最令人困惑的行为之一。在 windows 上,fnmatch 默认使用 os.path.normcase 对文件名和模式进行规范化,将大写转为小写,因为 windows 文件系统不区分大小写。在 linux 上则不进行转换,因此匹配是大小写敏感的。这导致同一段代码在不同平台上结果不同。
场景 5:fnmatch与glob混淆
import fnmatch
import glob
# fnmatch 只匹配字符串,不访问文件系统
print(fnmatch.fnmatch('data/file.txt', '*.txt')) # true,因为 * 匹配包括 / 在内的任意字符
# 注意:fnmatch 的 * 会匹配路径分隔符,而 glob 的 * 不会
fnmatch 中的 * 会匹配任意字符,包括路径分隔符。因此 fnmatch('data/file.txt', '*.txt') 返回 true。这常常导致误匹配。而 glob 的 * 不匹配路径分隔符,因此 glob.glob('*.txt') 不会匹配 data/file.txt。
场景 6:fnmatchcase与fnmatch的区别被忽视
import fnmatch
print(fnmatch.fnmatch('file.txt', '*.txt')) # 平台相关
print(fnmatch.fnmatchcase('file.txt', '*.txt')) # 总是 false(区分大小写)
fnmatchcase 强制区分大小写,不受平台影响。如果你需要跨平台一致的行为,应该使用 fnmatchcase 或手动规范化。
场景 7:filter和translate的误用
fnmatch.filter(names, pattern) 是 [n for n in names if fnmatch(n, pattern)] 的优化版本,但很多人不知道它也会受平台大小写规则影响。fnmatch.translate(pattern) 返回一个正则表达式字符串,用于自定义匹配,但转义规则可能出乎意料。
二、底层原理:fnmatch的匹配规则与平台差异
1. 通配符语义
fnmatch 支持以下通配符:
| 通配符 | 含义 |
|---|---|
* | 匹配任意数量的字符,包括零个,包括路径分隔符 |
? | 匹配任意单个字符(unicode 码点) |
[seq] | 匹配 seq 中的任意一个字符,支持范围 [a-z] |
[!seq] | 匹配不在 seq 中的任意字符 |
2. 大小写规范化
fnmatch.fnmatch(name, pattern) 在匹配前会对 name 和 pattern 都调用 os.path.normcase()。在 unix 上,normcase 返回原字符串;在 windows 上,它将所有字符转为小写,并将 / 转为 \。因此,在 windows 上,fnmatch 是大小写不敏感的,且路径分隔符被统一。
fnmatch.fnmatchcase(name, pattern) 不做任何规范化,直接进行大小写敏感的匹配。
3. 与正则表达式的转换
fnmatch.translate(pattern) 将通配符模式转换为正则表达式。例如:
fnmatch.translate('*.txt')
# '(?s:.*\\.txt)\\z'
注意 * 被转换为 .*,并使用了 (?s:) 使 . 匹配换行符。\z 表示字符串末尾。这使得 * 可以匹配包括 / 在内的任何字符。
4. 与glob的关系
glob 模块内部使用 fnmatch 的 translate 和 filter 来匹配文件名。但 glob 在遍历目录时,会将模式按路径分隔符拆分成多个部分,分别匹配每一级,从而使得 * 不跨越分隔符。而直接使用 fnmatch 时,* 会跨越分隔符,这是两者的关键区别。
5. 字符集的细节
[!abc]是排除字符集,等价于正则中的[^abc]。[]]匹配]本身。[a-z]匹配小写字母。- 如果
-出现在字符集开头或结尾,它被当作字面量。 - 字符集中的特殊字符(如
[、]、!)需要小心处理。
6. 转义
fnmatch 没有提供内置的转义机制。如果你想匹配字面量 *、?、[,需要将它们放在字符集中,例如 [*] 匹配 *,[?] 匹配 ?,[[] 匹配 [。
三、常见陷阱与错误模式
陷阱 1:用fnmatch匹配完整路径
fnmatch.fnmatch('/home/user/file.txt', '*.txt') # true!因为 * 匹配了 /
如果你只想匹配文件名部分,应该先用 os.path.basename() 提取文件名:
fnmatch.fnmatch(os.path.basename('/home/user/file.txt'), '*.txt') # true
# 但对于 '/home/user/file.txt',basename 是 'file.txt',所以匹配
# 如果要避免跨越路径,应确保模式中不包含路径分隔符,或对路径进行拆分
陷阱 2:依赖平台相关的大小写行为
# 在 linux 上
fnmatch.fnmatch('readme.md', 'readme.*') # false
# 在 windows 上
fnmatch.fnmatch('readme.md', 'readme.*') # true
跨平台代码应使用 fnmatchcase 并显式处理大小写,或者统一规范化。
陷阱 3:忘记fnmatch.filter也会规范化
names = ['file.txt', 'file.txt'] fnmatch.filter(names, '*.txt') # 在 windows 上返回两个,在 linux 上只返回第二个
陷阱 4:模式中的方括号未转义
fnmatch.fnmatch('file[1].txt', 'file[1].txt') # false,因为 [1] 被解释为字符集
# 正确写法:
fnmatch.fnmatch('file[1].txt', 'file[[]1].txt') # 匹配字面量
陷阱 5:?匹配多字节字符
在 python 3 中,? 匹配一个 unicode 码点,因此对于组合字符(如 é 可能由 e + 重音符号组成),? 可能只匹配其中的一个码点,导致匹配失败。如果需要按字素簇匹配,需要更复杂的处理。
陷阱 6:translate生成的正则表达式在不同版本中可能变化
fnmatch.translate('[!a]')
不同 python 版本的输出可能略有不同,不要依赖其精确格式。
陷阱 7:fnmatch不匹配隐藏文件?
fnmatch 本身不关心隐藏文件规则,它只是字符串匹配。* 可以匹配以 . 开头的文件名。但在 glob 中,* 不匹配隐藏文件,这是因为 glob 在目录遍历时过滤了隐藏文件。因此,直接使用 fnmatch 匹配文件名时,* 会匹配 .hidden。这是另一个 fnmatch 与 glob 的重要区别。
四、正确解决方案:精准匹配的黄金法则
1. 明确匹配范围:文件名还是完整路径
- 如果只匹配文件名,先提取
basename。 - 如果需要匹配路径,使用
glob或pathlib,而不是fnmatch。
import os
import fnmatch
filename = os.path.basename(path)
if fnmatch.fnmatch(filename, '*.py'):
...
2. 跨平台一致的大小写处理
import fnmatch
import os
def match_case_insensitive(name, pattern):
return fnmatch.fnmatchcase(name.lower(), pattern.lower())
# 或者统一使用 fnmatchcase,并在需要时手动规范化
fnmatch.fnmatchcase(os.path.normcase(name), os.path.normcase(pattern))
3. 转义特殊字符
def escape_fn_pattern(s):
return s.replace('*', '[*]').replace('?', '[?]').replace('[', '[[]')
pattern = escape_fn_pattern('file[1].txt')
fnmatch.fnmatch('file[1].txt', pattern) # true
4. 使用fnmatch.filter批量筛选
import fnmatch files = ['a.py', 'b.txt', 'c.pyc'] py_files = fnmatch.filter(files, '*.py') # ['a.py']
注意平台大小写差异。
5. 使用translate构建自定义匹配
import fnmatch
import re
regex = re.compile(fnmatch.translate('*.txt'))
if regex.match('file.txt'):
...
这允许你结合正则的其他功能。
6. 避免*跨越路径分隔符
如果模式不应匹配路径分隔符,可以使用 pathlib 的 purepath.match:
from pathlib import purepath
p = purepath('/home/user/file.txt')
print(p.match('*.txt')) # true,匹配文件名部分
print(p.match('*/*.txt')) # true
purepath.match 从右向左匹配,且 * 不跨越路径分隔符。
7. 使用fnmatchcase明确大小写敏感
如果你需要确定的行为,始终使用 fnmatchcase,并在需要大小写不敏感时自己处理。
8. 针对目录遍历使用glob或os.walk
fnmatch 不是为遍历设计的,遍历应使用 glob、pathlib 或 os.walk。
五、调试与验证技巧
打印 fnmatch.translate(pattern),查看实际使用的正则表达式,理解匹配逻辑。
在不同平台上测试:在 linux 和 windows 上运行相同的匹配,观察差异。
使用 fnmatchcase 验证大小写影响。
对特殊字符进行单元测试:包含 [、]、*、? 的文件名。
比较 fnmatch 与 glob 的结果:理解路径分隔符的处理差异。
使用 pathlib.purepath.match 作为现代替代。
六、最佳实践总结
fnmatch用于纯字符串匹配,不应用于路径遍历。- 匹配文件名时,先
os.path.basename()。 - 跨平台代码使用
fnmatchcase并显式处理大小写,避免依赖fnmatch的平台规范化。 - 注意
*会匹配路径分隔符,除非你使用的是pathlib.purepath.match。 - 使用
[*]、[?]、[[]转义特殊字符。 fnmatch.filter是批量筛选的便捷方式,但受平台大小写影响。- 需要更现代、更直观的匹配时,使用
pathlib.purepath.match。 - 在文档中说明你的匹配是大小写敏感还是平台相关。
- 为文件名匹配编写单元测试,覆盖不同平台和特殊字符。
- 避免在安全关键场景中使用
fnmatch做路径校验,因为它不感知目录边界。
七、结语
fnmatch 是一个小巧而实用的模块,它把 unix shell 的通配符规则带到了 python 的字符串世界。但它也继承了 shell 的“脾性”:大小写规范化因平台而异,* 不区分路径分隔符,隐藏文件没有特殊待遇。理解这些特性,你就能在文件名匹配时做出正确的选择:是使用 fnmatch 的便捷,还是转向 pathlib 的现代与精确。从今天起,当你需要匹配文件名时,请先问自己:我要匹配的是文件名还是路径?需要跨平台一致吗?* 应该跨越目录吗?回答这些问题,你就能选出最合适的工具,让每一次匹配都精准无误,不再被“漏网之鱼”或“误捕之虾”困扰。
以上就是python fnmatch模块文件名匹配的隐形规则与常见陷阱详解的详细内容,更多关于python fnmatch文件名匹配的资料请关注代码网其它相关文章!
发表评论