uv-globfilter:uv 的 PEP 639 受限 Glob 解析与目录遍历预过滤实现
【免费下载链接】uvAn extremely fast Python package and project manager, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/uv/uv
uv-globfilter是 uv 内部的一个组件 crate,目标是实现"跨语言、跨操作系统"的受限 Glob 语法,并在此基础上提供一个目录遍历预过滤器,用于在walkdir遍历中尽早跳过"内部不可能出现目标文件"的目录。读完本篇,你将掌握 PEP 639 受限 Glob 的完整语法规则、GlobDirFilter基于 DFA 的目录匹配算法、与WalkDir::filter_entry的集成方式,以及它在 uv 构建后端打包license-files、source-include等配置项时的真实用法。
一、定位与动机
crates/uv-globfilter/README.md 对该 crate 的定义是:"Portable directory walking with includes and excludes."(带 include/exclude 的跨平台目录遍历)。其核心动机是:
Motivating example: You want to allow the user to select paths within a project.
即允许用户以 glob 方式在pyproject.toml中声明"哪些路径要包含进构建产物、哪些要排除",例如:
include = ["src", "License.txt", "resources/icons/*.svg"] exclude = ["target", "/dist", ".cache", "*.tmp"]在遍历目录树时,可以调用
GlobDirFilter::from_globs(...)?.match_directory(&relative)并在walkdir的filter_entry回调中用它跳过那些"永远不会命中"的目录,从而避免对大目录树的无谓 I/O。这正是"预过滤"的含义:不是判断当前条目本身是否匹配,而是判断当前目录的子树是否还有任何可能匹配 glob 的路径。
该 crate 目前仅作为 uv 的内部组件使用(crates/uv-globfilter/Cargo.toml 中description = "This is an internal component crate of uv",版本0.0.76),其公开 API 只有两个类型,外加一个演示用的二进制入口:
GlobDirFilter—— 目录遍历预过滤器,见 crates/uv-globfilter/src/glob_dir_filter.rs;PortableGlobParser/PortableGlobError—— PEP 639 受限 glob 的解析器与错误类型,见 crates/uv-globfilter/src/portable_glob.rs;- 演示二进制
main.rs—— 展示 include/exclude 配合WalkDir的完整用法,见 crates/uv-globfilter/src/main.rs。
crate 的 lib.rs 模块文档明确了设计目标:
The goal is globs that are portable between languages and operating systems.
也就是说,同一个 glob 模式在 Python 工具、Node 工具、Rust 工具中应表现一致,且在 Windows 与类 Unix 系统上一致。为此它选择了 PEP 639 定义的那套受限 glob 语法,并在GlobBuilder层面强制literal_separator(true)(路径分隔符按字面量处理,*不跨目录)。
二、PEP 639 受限 Glob 语法(README 核心规则完整继承)
README 明确声明支持的是PEP 639 的跨语言受限 glob 语法,规则如下(与 portable_glob.rs 中PortableGlobParser::parse的文档注释一一对应):
| 规则 | 说明 |
|---|---|
| 逐字匹配字符 | 字母数字、下划线_、连字符-、点.按字面量匹配 |
* | 匹配任意数量的字符,但不跨路径分隔符 |
? | 匹配单个字符,不跨路径分隔符 |
** | 匹配任意数量的字符,包含路径分隔符(可跨目录) |
[...] | 字符集,仅包含逐字匹配字符;内部连字符-表示与 locale 无关的范围(按 Unicode 码点排序,如a-z);位于开头或结尾的连字符按字面量匹配 |
| 路径分隔符 | 固定为斜杠/;模式相对于给定目录,不支持以/开头的绝对路径 |
.. | 父目录指示符不允许出现 |
README 最后还点出了这条规则链的推论:
These rules mean that matching the backslash (
\) is forbidden, which avoids collisions with the windows path separator.
由于/是唯一合法分隔符、反斜杠禁止匹配,Windows 下的反斜杠分隔符问题被从语法层面彻底消除。
uv 扩展变体:PortableGlobParser::Uv
源码中PortableGlobParser是一个两变体的枚举(portable_glob.rs#L64-L73):
pub enum PortableGlobParser { /// Follow the PEP 639 rules strictly. Pep639, /// In addition to the PEP 639 syntax, allow escaping characters with backslashes. /// /// For cross-platform compatibility, escaping path separators is not allowed, i.e., forward /// slashes and backslashes can't be escaped. Uv, }Pep639严格遵循 PEP 639,反斜杠是非法字符;Uv额外允许用反斜杠转义字符(以便匹配@、空格等 PEP 639 不逐字匹配的字符),但出于跨平台兼容,不允许转义/和\本身。
测试用例 portable_glob.rs#L303-L332 给出了两套合法模式样例,可作为语法参考:
// Pep639 与 Uv 均合法 r"licenses/*.txt" r"licenses/**/*.txt" r"LICEN[CS]E.txt" r"LICEN?E.txt" r"[a-z].txt" r"[a-z._-].txt" r"*/**" r"LICENSE..txt" r"LICENSE_file-1.txt" r"licenses/라이센스*.txt" // 韩文 r"licenses/ライセンス*.txt" // 日文 r"licenses/执照*.txt" // 中文 r"src/**" // 仅 Uv 变体合法(反斜杠转义) r"public-domain/Gulliver\’s\ Travels.txt" r"**/\@test"三、解析实现:check()前置校验与GlobBuilder构建
PortableGlobParser::parse(portable_glob.rs#L99-L106)的实现分两步:
pub fn parse(&self, glob: &str) -> Result<Glob, PortableGlobError> { self.check(glob)?; Ok(GlobBuilder::new(glob) .literal_separator(true) // No need to support Windows-style paths, so the backslash can be used a escape. .backslash_escape(self.backslash_escape()) .build()?) }literal_separator(true)保证了*/?不会跨越/,**的跨目录语义由globset内部处理。check()(portable_glob.rs#L109-L219)是一台逐字符的状态机,在交给globset之前先做 PEP 639 合规性预校验,覆盖以下细节:
- 星号数量限制:
***以及**后紧跟非/(如**license)都会报TooManyStars。源码注释说明原因:这些形式可以用更少的星号等价表示,globcrate 禁止、globset允许、而 PEP 639 文本本身存在歧义,所以这里主动过滤。 - 父目录检测:
..出现在字符串开头或/之后才判定为ParentDirectory(如licenses/..位置 9 报错),而LICENSE..txt这类文件名中的连续点是合法的。 - 字符集校验:
[...]内部只允许字母数字、_、-、.;出现其他字符(如!、?)报InvalidCharacterRange。 - 反斜杠:
Pep639模式下任何\都是InvalidBackslash;Uv模式下\后必须是可转义字符,转义/或\报InvalidEscapee,结尾悬空报TrailingEscape。 - 其他字符:报
InvalidCharacter(Pep639)或InvalidCharacterUv(Uv,多带一条 hint)。
错误类型与快照测试
PortableGlobError共 8 个变体(portable_glob.rs#L7-L46),每种都有带"错误位置 + 原始 glob"的格式化消息。test_error测试(portable_glob.rs#L228-L301)用 insta 快照锁定了代表性错误输出,例如:
The parent directory operator (`..`) at position 0 is not allowed in glob: `..` The parent directory operator (`..`) at position 9 is not allowed in glob: `licenses/..` Invalid character `!` at position 14 in glob: `licenses/LICEN!E.txt` Invalid character `!` in range at position 15 in glob: `licenses/LICEN[!C]E.txt` Too many at stars at position 9 in glob: `licenses/**license` Only forward slashes are allowed as path separator, invalid character at position 8 in glob: `licenses\eula.txt`Uv 变体还演示了 hint 的呈现(**/@test报错后会附带hint: Characters can be escaped with a backslash),见下一节。
四、GlobDirFilter:从 Glob 到 DFA 的目录预过滤
构建:from_globs
GlobDirFilter持有两个字段(glob_dir_filter.rs#L15-L18):
pub struct GlobDirFilter { glob_set: GlobSet, dfa: Option<dfa::dense::DFA<Vec<u32>>>, }from_globs(glob_dir_filter.rs#L24-L73)做了三件事:
- 正则转换:把每个 glob 的
regex()输出去掉(?-u)前缀(glob 本身是逐字节匹配的非 unicode 正则),并把模式中的/替换为平台的MAIN_SEPARATOR,使在 Windows 上运行时能匹配反斜杠路径; - 构建
GlobSet:供match_path做精确的"该路径是否匹配"判断; - 构建 dense DFA:用
regex_automata的dfa::dense::Builder以Anchored起始方式编译所有 glob 正则,并设置dfa_size_limit与determinize_size_limit,两者共用常量DFA_SIZE_LIMIT = 1_000_000字节(源码注释直言Chosen at a whim)。若 DFA 构建失败(通常是组合爆炸超出限制),则记录warn!并置dfa = None,退化为"完整目录遍历"(即match_directory恒返回true),保证功能正确性优先于剪枝效率。
匹配算法:match_directory与match_path
match_path(glob_dir_filter.rs#L78-L80)是"文件或目录是否匹配任一 glob"的最终判定:
pub fn match_path(&self, path: &Path) -> bool { self.match_directory(path) || self.glob_set.is_match(path) }match_directory(glob_dir_filter.rs#L86-L120)才是预过滤的核心。它的语义是:"该目录或其任意子孙可能命中"——永不漏判(不会因返回 false 而丢掉实际匹配的子孙),但允许误报(返回 true 但最终没有子孙命中)。算法要点:
- 根路径(空
Path)直接放行; - 没有 DFA 时恒返回
true(即退化路径); - 逐字节把路径喂给 DFA(anchored 起始状态),得到"读到路径末尾"的状态
state; - 计算两个后继状态:
eoi_state = next_eoi_state(state):目录自身是否完整匹配某个 glob(例如 glob 是foo/*时,目录foo/bar本身可命中);slash_state = next_state(state, MAIN_SEPARATOR):目录之后还能不能再接路径分量(例如 glob 是foo/bar/*时,目录foo/bar需要继续下钻)。注意源码特意不对slash_state调next_eoi_state,因为要检查的是"还能不能再加字符",而不是"此处是否到达$锚点";
- 返回
is_match_state(eoi_state) || !is_dead_state(slash_state)。
这一"自身匹配或可继续下钻"的双条件,恰好与filter_entry的需求吻合:filter_entry只需要知道"该子树值不值得进入"。
测试证据:预过滤确实剪掉了分支
prefilter测试(glob_dir_filter.rs#L164-L218)在临时目录里构造了 5 条path*/dir*/subdir/a.txt文件链,配合 5 个代表性模式:
const PATTERNS: [&str; 5] = [ "path1/*", // 只需下钻一级 "path2/dir2", // 只需下钻一级 "path3/dir3/subdir/a.txt", // 精确到文件 "path4/**/*", // 需要完整下钻 "path5", // 只匹配目录本身,无需下钻 ];断言结果显示WalkDir实际访问的条目为:
""、path1、path1/dir1、 path2、path2/dir2、 path3、path3/dir3、path3/dir3/subdir、path3/dir3/subdir/a.txt、 path4、path4/dir4、path4/dir4/subdir、path4/dir4/subdir/a.txt、 path5 ← 注意:path5/dir5 及更深层完全未被访问path5只匹配目录自身,match_directory("path5/dir5")返回false,于是filter_entry剪掉了整个子树——这正是"目录永远不会再匹配"的跳过语义。同文件的walk_dir测试(glob_dir_filter.rs#L221-L282)进一步验证:在filter_entry用match_directory剪枝、对留下的条目用match_path做最终筛选后,得到的文件集合与预期完全一致,即预过滤不会造成漏选。
五、与WalkDir的集成范式(main.rs演示)
crates/uv-globfilter/src/main.rs 是一个可运行的参考实现,展示了 README 中 motivating example 的完整落地方式:
let includes = ["src/**", "pyproject.toml"]; let excludes = ["__pycache__", "*.pyc", "*.pyo"]; // include:用 PortableGlobParser::Pep639 解析后交给 GlobDirFilter let include_matcher = GlobDirFilter::from_globs(include_globs).unwrap(); // exclude:构造 unanchored GlobSet let mut exclude_builder = GlobSetBuilder::new(); for exclude in excludes { // Excludes are unanchored let exclude = if let Some(exclude) = exclude.strip_prefix("/") { exclude.to_string() } else { format!("**/{exclude}").to_string() }; let glob = PortableGlobParser::Pep639.parse(&exclude).unwrap(); exclude_builder.add(glob); } let exclude_matcher = exclude_builder.build().unwrap();两个值得注意的设计:
exclude 是"非锚定"的:不带前导
/的排除模式会被自动包上**/前缀,因此exclude = ["__pycache__"]会排除任意深度的__pycache__目录;而"/dist"这类带前导/的模式(去掉/后)只匹配相对根目录的顶层路径。这与 README 示例中exclude = ["target", "/dist", ".cache", "*.tmp"]的写法完全对应。两级过滤:在
WalkDir上先以include_matcher.match_directory(&relative) && !exclude_matcher.is_match(&relative)做
filter_entry(剪枝),再对幸存条目做if !include_matcher.match_path(&relative) || exclude_matcher.is_match(&relative) { continue; }做最终包含/排除判定。剪枝阶段宁松勿漏,最终阶段才精确裁决。
注意:演示二进制的
includes/excludes是硬编码的(当前仓库中如此),它的作用是在开发期验证过滤行为,生产代码(如 uv-build-backend)则从pyproject.toml读取同构的配置。
六、在 uv 构建后端中的真实应用
uv-globfilter目前唯一的下游使用方是 crates/uv-build-backend(uv 的 PEP 517 构建后端实现),有三条调用链,分别对应不同的配置项与 parser 变体。
6.1project.license-files(PEP 639):严格Pep639变体
PEP 639 定义了license-files字段用于声明许可证文件 glob。uv 在构建 source dist 与 wheel 时都消费它:
- 源分发包:source_dist.rs#L138-L147 中,
pyproject_toml.license_files_source_dist()返回的每个 glob 都用PortableGlobParser::Pep639.parse(...)解析,错误上下文标记为project.license-files; - wheel:wheel.rs#L208-L225 中,wheel 构建把命中的许可证文件写入
{dist-info-name}/{version}.dist-info/licenses/目录(通过wheel_subdir_from_globs,内部同样使用Pep639变体与GlobDirFilter::from_globs); - 元数据:metadata.rs#L712-L729 中,wheel 的
METADATA也按 PEP 639 变体解析license_files,且 metadata.rs#L589-L599 显示"一旦检测到project.license-files(或license = { text = ... }SPDX 表达式),METADATA 版本即提升到 2.4"。
因为这是标准字段,必须严格遵循 PEP 639,故使用Pep639变体而非Uv变体。
6.2tool.uv.build-backend.source-include:宽松Uv变体
自定义源包含是 uv 的扩展配置,允许反斜杠转义以便匹配带特殊字符的文件名,因此使用Uv变体。source_dist.rs#L114-L122:
for include in includes { let glob = PortableGlobParser::Uv .parse(&include) .map_err(|err| Error::PortableGlob { field: "tool.uv.build-backend.source-include".to_string(), source: err, })?; include_globs.push(glob); }配置项文档(settings.rs#L51-L60)给出的官方示例即:
[tool.uv.build-backend] source-include = ["tests/**"]同一段构建流程中,数据目录(tool.uv.build-backend.data.<name>)也以"{dir}/**"形式经Uv变体解析后加入 include globs(source_dist.rs#L149-L171);而模块目录与pyproject.toml、readme 始终被强制包含。最终所有 include globs 经GlobDirFilter::from_globs合并为唯一include_matcher(source_dist.rs#L180-L184),与本文第五节的main.rs范式一致。
6.3source-exclude与默认排除
排除侧的逻辑在 source_dist.rs#L186-L200:
default-excludes默认true,内置默认排除为__pycache__、*.pyc、*.pyo(settings.rs#L62-L70);- 用户
source-exclude与默认值合并去重后构造exclude_matcher; - 有一个硬性校验:若排除规则命中
pyproject.toml,直接报错(Error::PyprojectTomlExcluded,错误文案见 lib.rs#L59:"pyproject.tomlmust not be excluded from source distribution build"),因为源分发包没有pyproject.toml就无法再被构建。
排除模式在build_exclude_matcher中同样遵循main.rs展示的"非锚定"约定(自动补**/前缀),且这些排除对 source dist 与 wheel 生效,保证"从源树直接构 wheel"与"先构 sdist 再构 wheel"产物一致(settings.rs#L72-L79 的注释明确了这一点)。
七、错误提示设计:Hint与构建后端的呈现
PortableGlobError实现了uv_errors::Hinttrait(portable_glob.rs#L48-L57):只有InvalidCharacterUv变体会附带 hint——"Characters can be escaped with a backslash"。这是刻意针对Uv变体的:当用户写了 PEP 639 不支持逐字匹配的字符(如@),提示应引导其使用反斜杠转义,而不是直接失败。
构建后端如何呈现这条 hint,可在 lib.rs#L513-L528 的快照测试format_err_renders_portable_glob_hints中验证:
Unsupported glob expression in: tool.uv.build-backend.source-include Caused by: Invalid character `@` at position 3 in glob: `**/@test` hint: Characters can be escaped with a backslash外层Error::PortableGlob携带出错配置项的字段名,内层PortableGlobError携带位置与原始 glob,配合 hint 形成"字段 + 原因 + 修复建议"三层信息。
八、小结与边界说明
- 语法边界:
uv-globfilter实现的是 PEP 639 的受限glob 子集——不支持绝对路径(无前导/)、不支持..、不支持反斜杠路径分隔符;Uv变体是唯一合法的反斜杠用途(转义非分隔符字符)。 - 预过滤语义:
match_directory是"可能包含"判断,存在误报、不存在漏报;真正的包含判定必须由match_path完成。两者配合WalkDir::filter_entry与最终filter_map使用,glob_dir_filter.rs的测试保证了该组合下文件集合的正确性。 - 性能边界:DFA 大小上限为 1,000,000 字节,超限后退化为完整遍历(正确性不变,仅失去剪枝收益);
globset层面的GlobSet构建失败则由调用方(如source-include场景)包装为GlobSetTooLarge错误向用户报错。 - 当前适用范围:截至当前仓库版本(
uv-globfilter 0.0.76),该 crate 仅被uv-build-backend使用,服务于 sdist/wheel 构建中的文件选择;仓库中未见其他 crate 依赖它(除自身main.rs演示外)。
如需深入,建议按以下顺序阅读源码:crates/uv-globfilter/src/portable_glob.rs(语法与错误)、crates/uv-globfilter/src/glob_dir_filter.rs(DFA 预过滤)、crates/uv-globfilter/src/main.rs(集成范式),再到 crates/uv-build-backend/src/source_dist.rs 与 crates/uv-build-backend/src/wheel.rs(生产调用链)。
【免费下载链接】uvAn extremely fast Python package and project manager, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/uv/uv
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考