Ruff 类型检查器 ty 的命名空间包(Namespace Package)导入解析机制全解
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
本文基于 Ruff 仓库中 ty 语义分析模块的导入测试文档 crates/ty_python_semantic/resources/mdtest/import/namespace.md 编写,围绕 PEP 420 命名空间包在 ty 类型检查器中的解析规则展开。读者将掌握:ty 如何区分常规包(regular package)与命名空间包、文件与同名命名空间包的优先级、
from导入跨搜索路径的解析行为,以及这些行为在源码中的实现依据。
命名空间包(Namespace Package)是 PEP 420 定义的一种不包含__init__.py的包形态,多个目录可以合并为同一个逻辑包。对于类型检查器而言,命名空间包没有对应的代码文件,因此其解析优先级、子模块发现方式都与常规包截然不同。ty(Ruff 内置的 Python 类型检查器)通过 crates/ty_module_resolver 实现了对命名空间包的完整支持,并用 markdown 测试文档固化了一系列行为契约。本文以该测试文档为主体,逐节拆解其规则并给出源码级依据。
一、测试文档的形态:markdown 即测试用例
在深入规则之前,需要先理解这份文档的运行机制。namespace.md并非普通说明文档,而是 ty 语义分析模块(crates/ty_python_semantic)的 mdtest 测试套件。其执行入口位于 crates/ruff_mdtest/src/lib.rs:
- 文档中的
[environment]TOML 代码块会被解析为测试运行环境配置(如python解释器路径、extra-paths额外搜索路径); py代码块会被写入虚拟文件系统(db.use_in_memory_system(),项目根固定为/src),作为被测源码;- 代码块中以
# error: [unresolved-import]注释标记预期诊断,以reveal_type(...) # revealed: ...标记预期的类型推断结果; - 最终由 crates/ruff_mdtest/src/db.rs 构建的 Salsa 数据库驱动测试执行,并用
matcher::match_file校验诊断与内联快照是否完全一致。
因此,本文中出现的每一个目录布局与代码示例,都是 ty 的真实回归测试用例,可直接用于验证或复现 ty 的导入解析行为。
二、基础用例:跨搜索路径合并的命名空间包
文档的第一个用例演示了最典型的命名空间包场景——同一个逻辑包parent.child的两个子模块分别位于不同的搜索路径中:
[environment] python = "/.venv"parent/child/one.py:
one = 1/.venv/<path-to-site-packages>/parent/child/two.py:
two = 2main.py:
import parent.child.one import parent.child.twofrom.py:
from parent.child import one, two reveal_type(one) # revealed: <module 'parent.child.one'> reveal_type(two) # revealed: <module 'parent.child.two'>该用例说明两个关键事实:
- 用户项目目录(first-party 搜索路径)与
site-packages目录同时贡献了parent.child的目录片段,两者合并为一个命名空间包; from parent.child import one, two能够跨搜索路径解析出两个子模块,且reveal_type能给出精确的模块类型<module 'parent.child.one'>。
源码依据:Module枚举的两种形态
在 crates/ty_module_resolver/src/module.rs 中,ty 将模块解析结果建模为两种形态:
pub enum Module<'db> { File(FileModule<'db>), Namespace(NamespacePackage<'db>), }其中NamespacePackage的注释明确说明了其特殊性(见 module.rs):
Namespace packages are special because there are multiple possible paths and they have no corresponding code file.
由于命名空间包没有对应文件,Module::file()对命名空间包返回None,Module::search_path()同样返回None(因为命名空间包跨越多条搜索路径,无法归属到单一搜索路径)。这正是"命名空间包天然低于常规包/模块优先级"这一设计在数据类型层面的体现。
三、命名空间包中的常规包:__init__.py截断解析
第二个用例是 PEP 420 嵌套命名空间包示例的改编(PEP 420 Nested namespace packages):
[environment] python = "/.venv"src parent child __init__.py one.py .venv/site-packages parent child two.pyparent/child/__init__.py:
parent/child/one.py:
one = 1/.venv/<path-to-site-packages>/parent/child/two.py:
two = 2main.py:
import parent.child.one import parent.child.two # error: [unresolved-import]这里的目录结构src/parent/child是常规包(含__init__.py)。文档明确断言:site_packages/parent/child/two.py不应被解析,即import parent.child.two必须报告[unresolved-import]。
源码依据:resolve_component的三级探测顺序
该行为在 crates/ty_module_resolver/src/resolve.rs 的resolve_component中实现。解析模块名的每一个组成部分时,按如下顺序探测:
- 常规包优先:
package_path.push("__init__"),若存在__init__.py/__init__.pyi(取决于ComponentFileFilter,typing 模式优先.pyi),则判定为RegularPackage(常规包),见 resolve.rs; - 文件模块次之:若存在
xxx.py/xxx.pyi,判定为Module,见 resolve.rs; - 目录兜底:以上均失败时,若存在同名目录,则判定为
NamespacePackage,见 resolve.rs。
ModuleResolutionCandidate::missing_submodule_is_terminal()(见 resolve.rs)进一步明确:常规包和文件模块都是"终结候选"——一个在更高优先级搜索路径上的foo.py或foo/__init__.py不会被低优先级搜索路径上的foo/__init__.py遮蔽,且两者都会遮蔽命名空间包。因此当src中的parent/child被判定为常规包后,搜索即终止,site-packages下的同名目录不再参与合并,parent.child.two自然无法解析。
四、文件与同名命名空间包的优先级:模块优先
第三个用例揭示了一个易被忽略的细节——文件模块的优先级高于同名目录:
foo.py:
x = "module"foo/bar.py:
x = "namespace"from foo import x reveal_type(x) # revealed: Literal["module"] import foo.bar # error: [unresolved-import]当foo.py与foo/目录同时存在时,foo解析为foo.py文件模块(而非foo/命名空间包目录),因此from foo import x得到Literal["module"],而import foo.bar因为文件模块无法包含子模块而报[unresolved-import]。
源码依据:normalize_candidates的剔除逻辑
在 resolve.rs 的normalize_candidates中,ty 会对同一模块名的多个候选进行归一化:一旦存在非命名空间包(RegularPackage或Module)的同名候选,命名空间包候选就会被直接丢弃(tracing::trace!("Discarding namespace package ..."))。这一剔除逻辑发生在最终组件解析时,确保"文件模块/常规包 > 命名空间包"的优先级在任意搜索路径顺序下都成立。
另外注意 resolve.rs 中的守卫:ResolvedModule::Module(_)形态的候选(单文件模块)无法拥有子模块,一旦命中即返回Err,这正是foo.bar无法解析的另一个层面保证。
五、from导入命名空间包:回归用例 #363
第四个用例是 astral-sh/ty#363 的回归测试,模拟了真实第三方包的典型布局:
google/cloud/pubsub_v1/__init__.py:
class PublisherClient: ...from google.cloud import pubsub_v1 reveal_type(pubsub_v1.PublisherClient) # revealed: <class 'PublisherClient'>该用例验证:google、google.cloud均为命名空间包(测试文档未给出它们的__init__.py),pubsub_v1是其中的常规包。from google.cloud import pubsub_v1必须穿过两层命名空间包,正确解析到pubsub_v1模块,并让pubsub_v1.PublisherClient的类型被精确推断为<class 'PublisherClient'>。
这里涉及命名空间包解析的一个关键点:当解析google.cloud.pubsub_v1时,中间组件google、cloud是命名空间包(无文件),最终组件pubsub_v1才落到常规包__init__.py。ty 的resolve_remaining会逐组件推进,期间命名空间包候选保留、但最终被具体包"替换"(见 resolve.rs 与normalize_candidates中"at the final component, a concrete package or module shadows it"的注释)。
六、from根导入子包:回归用例 #375
第五个用例是 astral-sh/ty#375 的回归测试:
opentelemetry/trace/__init__.py:
class Trace: ...opentelemetry/metrics/__init__.py:
class Metric: ...from opentelemetry import trace, metrics reveal_type(trace) # revealed: <module 'opentelemetry.trace'> reveal_type(metrics) # revealed: <module 'opentelemetry.metrics'>opentelemetry是一个命名空间包(无__init__.py),其子包trace、metrics是常规包。from opentelemetry import trace, metrics一次性导入两个子包,ty 必须分别解析opentelemetry.trace与opentelemetry.metrics,并给出<module 'opentelemetry.trace'>、<module 'opentelemetry.metrics'>的模块类型。
该用例与上一节共同验证了 ty 在from X import Y场景下的完整能力:既支持"Y 是命名空间包内的常规包",也支持"Y 是命名空间包的多个子包"。其实现路径统一收敛到 resolve.rs 的resolve_module_for_import_from,该函数从StmtImportFrom语句提取模块名后调用通用的resolve_module。
七、跨搜索路径的优先级:PEP 420 的全局规则
最后一个大节是两个互为镜像的用例,均为 astral-sh/ty#1749 的回归测试。文档开宗明义地给出了 PEP 420 的核心规则:
According PEP 420, namespace packages always have lower precedence than normal packages/modules, regardless of search path ordering.
即:命名空间包的优先级恒低于常规包/模块,与搜索路径的先后顺序无关。
场景一:命名空间包在搜索路径前面
[environment] extra-paths = ["/path-one", "/path-two"]/path-one/mod/sub1.py:
/path-two/mod/__init__.py:
/path-two/mod/sub2.py:
main.py:
import mod import mod.sub1 # error: [unresolved-import] import mod.sub2/path-one排在前面,其中的mod/是无__init__.py的命名空间包;/path-two排在后面,但其中的mod/__init__.py使mod成为常规包。结果:mod解析为常规包,mod.sub1报[unresolved-import](命名空间包贡献的sub1.py被常规包的"终结性"屏蔽),mod.sub2正常解析。
场景二:命名空间包在搜索路径后面
[environment] extra-paths = ["/path-two", "/path-one"]/path-one/mod/sub1.py:
/path-two/mod/__init__.py:
/path-two/mod/sub2.py:
main.py:
import mod import mod.sub1 # error: [unresolved-import] import mod.sub2调换extra-paths顺序后结果完全一致:mod.sub1依然报[unresolved-import],证明命名空间包优先级不依赖搜索路径顺序。
源码依据:终结候选的提前终止
这两个用例的对称性,正是discover_roots与resolve_remaining协作的结果:
- 在 resolve.rs 的
discover_roots中,ty 逐个遍历搜索路径收集候选;一旦遇到"终结候选"(terminal && can_stop)就break提前终止搜索,之后的搜索路径不再贡献候选; - 在 resolve.rs 的
resolve_remaining中,remaining_are_shadowed = candidate.missing_submodule_is_terminal()会在命中终结候选后丢弃所有低优先级候选——即使该组件解析失败,终结候选依然遮蔽其后的命名空间包。
因此无论命名空间包出现在搜索路径的前面还是后面,只要任一搜索路径中出现了同名常规包,命名空间包的其余贡献就会被整体屏蔽。这也解释了为何两个场景得到相同的[unresolved-import]诊断。
需要补充的是,extra-paths配置(即测试中的/path-one、/path-two)在 crates/ty_module_resolver/src/path.rs 中对应SearchPath::extra,属于SearchPathInner::Extra类别,排在模块解析顺序的最前面(见 path.rs 对搜索路径类型的文档注释)。
八、延伸:legacy namespace package 与解析优先级全景
与本文主题强相关、且同样位于同一测试目录的是 crates/ty_python_semantic/resources/mdtest/import/legacy_namespace.md,它测试的是 PEP 420 之前生态的"旧式命名空间包"(legacy namespace package):包内含__path__ = pkgutil.extend_path(__path__, __name__)或__import__("pkg_resources").declare_namespace(__name__)惯用写法。ty 对此类包的识别实现在 resolve.rs 的is_legacy_namespace_package与LegacyNamespacePackageVisitor——通过纯语法分析(AST)探测上述两种惯用表达式。旧式命名空间包在解析目标上按常规包处理(见 resolve.rs 中LegacyNamespacePackage的注释),这解释了其__version__等属性为何能被正常推断。
结合本文各用例,可以归纳 ty 的模块解析优先级全景(typing 模式下):
- PEP 561 stub 包(
<package>-stubs,见 resolve.rs 的CandidatePrecedence::StubPackage)优先级最高; - 常规包/文件模块按搜索路径顺序比较,且一旦命中即为终结候选;
- 命名空间包优先级恒最低,不依赖搜索路径顺序(PEP 420 规则)。
总结
namespace.md用七组可执行的测试用例,完整定义了 ty 对 PEP 420 命名空间包的解析语义:跨搜索路径合并、常规包对命名空间包的截断、文件模块对同名命名空间包的遮蔽、from导入穿过命名空间包、以及"命名空间包优先级恒低、与搜索路径顺序无关"的全局规则。这些行为在 crates/ty_module_resolver/src/resolve.rs 的resolve_component、normalize_candidates、missing_submodule_is_terminal等核心函数中有清晰的实现对应。对于在真实项目中依赖命名空间包(如google.cloud.*、opentelemetry.*等跨包布局)的开发者而言,理解上述优先级规则,有助于准确预判 ty 报告的[unresolved-import]是否源于目录结构冲突,并能据此调整包布局与extra-paths/src搜索路径配置。
【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考