
ty 类型检查器对 SQLAlchemy 的类型推断实测以 mdtest 外部依赖测试为视角【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff导读本文以 ruff 仓库中 ty 类型检查器ty_python_semanticcrate针对 SQLAlchemy 2.0 的 mdtest 测试文档为核心完整还原该测试用例对 SQLAlchemy ORM 声明式模型、同步/异步查询 API、旧版queryAPI 的类型推断结果并讲解这类外部依赖 Markdown 测试的运行机制、依赖安装流程与断言写法。读完本文你将掌握 ty 对dataclass_transform、Mapped、Select、Row、AsyncSession等核心类型的实际推断行为并能在本地复现cargo test与uv run mdtest.py两种执行方式。测试定位一个外部依赖 mdtest的典型样本SQLAlchemy 测试文件位于 crates/ty_python_semantic/resources/mdtest/external/sqlalchemy.md它隶属于 ty_python_semantic 的 mdtest 测试体系。external目录专门存放需要真实安装第三方包的测试同目录下还有 pydantic、numpy、pytest、attrs、strawberry、sqlmodel 等目录内的 README.md 说明了这一点。与之配套的是同名的锁文件 sqlalchemy.lock。从锁文件内容可以确认测试通过 uv 锁定sqlalchemy2.0.44与文档中 TOML 依赖声明一致依赖传递解析出greenlet3.3.0与typing-extensions环境要求requires-python 3.13.*与文档中[environment] python-version 3.13对应锁文件版本为version 1、revision 3说明该测试的依赖版本被更新维护过。整个测试的配置头如下它同时声明了 Python 版本、运行平台与外部依赖[environment] python-version 3.13 python-platform linux [project] dependencies [SQLAlchemy2.0.44]mdtest 与外部依赖测试的运行机制测试框架如何执行一个 Markdown 文件mdtest 的核心约定是任何 Markdown 文件都可以是一个测试套件。文件内的 fenced code block 按语言标签区分角色py代码块是待检查的 Python 源文件toml代码块是测试配置# revealed: ...形式的注释是类型推断断言# error: ...形式则是诊断断言。测试运行时框架把代码块写入内存文件系统默认工作区根目录/src/然后对文件执行类型检查并将产生的诊断与断言逐条比对。对类型推断与类型检查测试的完整格式说明见 crates/ty_test/README.md该文档还说明了一条重要规则同一个测试内的多个无显式路径的py代码块会按出现顺序合并进同一个文件这正是sqlalchemy.md中先写模型、再写查询、最后写异步代码这种连载式写法能够成立的原因——所有片段最终被拼成一个连续的 Python 源文件参与检查。外部依赖的安装与注入流程sqlalchemy.md属于需要真实安装第三方包的外部依赖测试其流程在 crates/ty_test/README.md 的 Testing with external dependencies 一节中有明确说明测试框架在临时目录生成一个pyproject.toml把同名锁文件sqlalchemy.lock复制到临时目录运行uv sync --locked安装依赖要求本机PATH中存在uv将虚拟环境site-packages中的已安装包复制进测试的内存文件系统配置类型检查器使用这些包。因此该测试的可复现性完全依赖锁文件。需要更新依赖版本时可用下述命令重新生成锁文件# 方式一Python runner uv run crates/ty_python_semantic/mdtest.py -e external/ # 方式二cargo MDTEST_EXTERNAL1 MDTEST_UPGRADE_LOCKFILES1 cargo test -p ty_python_semantic --test mdtest mdtest__external运行该测试的两种方式方式一直接用 cargo 过滤 mdtest外部依赖测试需要MDTEST_EXTERNAL1测试名中的mdtest__external对应external目录下的用例MDTEST_EXTERNAL1 cargo test -p ty_python_semantic --test mdtest -- mdtest__external::sqlalchemy方式二使用带 watch 模式的 Python runner crates/ty_python_semantic/mdtest.py其-e/--enable-external参数会向测试进程注入MDTEST_EXTERNAL1uv run crates/ty_python_semantic/mdtest.py -e external/sqlalchemy.md从 crates/ty_python_semantic/mdtest.py 源码看runner 先编译cargo test --no-run得到测试可执行文件再以过滤参数调用它watch 模式下它监听resources/mdtest、ty_vendored、mdtest/src等目录Markdown 文件或 Rust 代码变化时会自动重跑对应测试。测试入口本身在 crates/ty_python_semantic/tests/mdtest.rs通过datatest_stable把所有.md文件注册为测试并用单线程 Rayon 池隔离并发资源竞争。测试一ORM 模型与 dataclass_transform 支持文档中的第一个测试验证 ty 对 SQLAlchemy 声明式基类的理解。SQLAlchemy 2.0 的DeclarativeBase、Mapped、mapped_column组合本质上依赖dataclass_transform机制ty 需要正确解析这一机制才能给出精确类型from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class User(Base): __tablename__ user id: Mapped[int] mapped_column(primary_keyTrue, initFalse) internal_name: Mapped[str] mapped_column(aliasname) user User(nameJohn Doe) reveal_type(user.id) # revealed: int reveal_type(user.internal_name) # revealed: str这里有两个关键点Mapped[int]注解与mapped_column(primary_keyTrue, initFalse)初始化组合后user.id被推断为int而非Mapped[int]——这正是 SQLAlchemy 依赖dataclass_transform对Mapped类型做解包的体现mapped_column(aliasname)使internal_name的构造参数别名生效因此User(nameJohn Doe)是合法的且user.internal_name被推断为str。测试同时记录了 ty 的当前局限SQLAlchemy 重写了__init__并显式接受任意关键字参数组合因此 ty 目前无法标记非法的构造调用reveal_type(User.__init__) # revealed: def __init__(self, **kw: Any) - Unknown # TODO: this should ideally be an error invalid_user User(invalid_arg42)从源码结构看这意味着User.__init__落入**kw: Any - Unknown的宽泛签名任何关键字参数在类型层面都不会报错文档以TODO注释明确标注理想情况下这应当是一个错误属于已知的精度缺口。测试二基础查询——select 新式 API搭建 Session 与模型from sqlalchemy import select, Integer, Text, Boolean from sqlalchemy.orm import Session from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy import create_engine engine create_engine(sqlite://example.db) session Session(engine) class Base(DeclarativeBase): pass class User(Base): __tablename__ users id: Mapped[int] mapped_column(Integer, primary_keyTrue) name: Mapped[str] mapped_column(Text) is_admin: Mapped[bool] mapped_column(Boolean, defaultFalse)create_engine与Session(engine)的返回值均未标注具体类型但 mdtest 中并未对这两行断言没有# revealed:说明该测试只验证它们可被正常调用。Mapped[int]、Mapped[str]、Mapped[bool]与Integer/Text/Boolean列类型组合是 SQLAlchemy 2.0 声明式模型的典型写法。整表查询的类型形态stmt select(User) reveal_type(stmt) # revealed: Select[tuple[User]] users session.scalars(stmt).all() reveal_type(users) # revealed: Sequence[User] for row in session.execute(stmt): reveal_type(row) # revealed: Row[tuple[User]]select(User)的推断结果是Select[tuple[User]]Select的类型参数是结果行的元组类型整表查询对应单元素元组tuple[User]。scalars().all()剥离行结构返回Sequence[User]而execute()迭代出来的每个元素是Row[tuple[User]]。条件筛选与单行取值stmt select(User).where(User.name Alice) alice1 session.scalars(stmt).first() reveal_type(alice1) # revealed: User | None alice2 session.scalar(stmt) reveal_type(alice2) # revealed: User | None result session.execute(stmt) row result.one_or_none() assert row is not None (alice3,) row._tuple() reveal_type(alice3) # revealed: Userfirst()、scalar()和one_or_none()的返回值都是User | None数据库查询天然可能无结果assert row is not None之后通过row._tuple()解包单列行得到User。这个解包写法正是 SQLAlchemy 2.0 推荐的Row访问方式ty 对_tuple()的推断能够精确到元组元素类型。链式复杂查询stmt select(User).where(User.is_admin True).order_by(User.name).limit(10) admin_users session.scalars(stmt).all() reveal_type(admin_users) # revealed: Sequence[User]where(...).order_by(...).limit(...)的链式调用没有破坏类型信息结果仍是Sequence[User]。指定列投影stmt select(User.id, User.name) reveal_type(stmt) # revealed: Select[tuple[int, str]] ids_and_names session.execute(stmt).all() reveal_type(ids_and_names) # revealed: Sequence[Row[tuple[int, str]]] for row in session.execute(stmt): reveal_type(row) # revealed: Row[tuple[int, str]] for user_id, name in session.execute(stmt).tuples(): reveal_type(user_id) # revealed: int reveal_type(name) # revealed: str result session.execute(stmt) row result.one_or_none() assert row is not None user_id, name row._tuple() reveal_type(user_id) # revealed: int reveal_type(name) # revealed: str stmt select(User.id).where(User.name Alice) reveal_type(stmt) # revealed: Select[tuple[int]] alice_id session.scalars(stmt).first() reveal_type(alice_id) # revealed: int | None alice_id session.scalar(stmt) reveal_type(alice_id) # revealed: int | None当显式选择列时Select的类型参数会精确反映列类型元组select(User.id, User.name)得到Select[tuple[int, str]]execute().all()得到Sequence[Row[tuple[int, str]]]。两列投影下tuples()迭代解包user_id为int、name为strone_or_none()row._tuple()解包同样得到int与str。单列投影select(User.id)得到Select[tuple[int]]scalars().first()与scalar()分别得到int | None——即scalars()会剥掉外层Row直接给出int。测试三旧版 query APIty 同样覆盖了 SQLAlchemy 1.x 风格的Session.query()遗留 APIusers_legacy session.query(User).all() reveal_type(users_legacy) # revealed: list[User] query session.query(User) reveal_type(query) # revealed: Query[User] reveal_type(query.all()) # revealed: list[User] for row in query: reveal_type(row) # revealed: User整表查询下session.query(User)是Query[User]all()返回list[User]迭代元素就是User本身旧 API 的行就是模型对象。指定列投影时旧 API 的类型形态与新式Select不同query session.query(User.id, User.name) reveal_type(query) # revealed: RowReturningQuery[tuple[int, str]] reveal_type(query.all()) # revealed: list[Row[tuple[int, str]]] for row in query: reveal_type(row) # revealed: Row[tuple[int, str]]列投影查询返回RowReturningQuery[tuple[int, str]]是Query的一个专门子类型all()返回list[Row[tuple[int, str]]]迭代元素是Row[tuple[int, str]]需要像新式 API 一样通过Row解包获取列值。对比可见ty 对RowReturningQuery的推断精确地区分了返回模型对象与返回行对象两种语义列类型也随投影逐列保留。测试四AsyncSession 异步 API异步支持通过sqlalchemy.ext.asyncio.AsyncSession验证from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, Integer, Text from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class User(Base): __tablename__ users id: Mapped[int] mapped_column(Integer, primary_keyTrue) name: Mapped[str] mapped_column(Text) async def test_async(session: AsyncSession): stmt select(User).where(User.name Alice) alice await session.scalar(stmt) reveal_type(alice) # revealed: User | None stmt select(User.id, User.name) result await session.execute(stmt) for user_id, name in result.tuples(): reveal_type(user_id) # revealed: int reveal_type(name) # revealed: str异步 API 的结果与同步完全一致await session.scalar(stmt)返回User | Noneawait session.execute(stmt)后result.tuples()迭代解包得到int与str。说明 ty 对AsyncSession的scalar/execute方法签名与同步Session保持一致的推断精度。汇总ty 对 SQLAlchemy 2.0 的类型推断能力清单综合整个sqlalchemy.md测试ty 目前已验证的能力包括场景推断结果select(User)Select[tuple[User]]select(User.id, User.name)Select[tuple[int, str]]select(User.id)Select[tuple[int]]session.scalars(stmt).all()Sequence[User]整表/Sequence[Row[tuple[int, str]]]投影session.scalar(stmt)/scalars().first()User \| None整表/int \| None单列session.execute(stmt)迭代元素Row[tuple[User]]/Row[tuple[int, str]]row._tuple()解包精确到User/int, strresult.tuples()迭代解包int,strsession.query(User)Query[User]all()为list[User]session.query(User.id, User.name)RowReturningQuery[tuple[int, str]]all()为list[Row[...]]await session.scalar(stmt)AsyncSessionUser \| NoneMapped[int]mapped_column属性推断为intdataclass_transform解包非法构造参数如User(invalid_arg42)不报错__init__为**kw: Any - Unknown已知局限该测试的覆盖范围横跨新式selectAPI、旧式queryAPI、同步Session与异步AsyncSession同时验证了列投影、条件筛选、排序、limit、单行/多行取值与Row解包等核心读写路径可作为 ty 对 SQLAlchemy 支持程度的第一手参考。若需继续探索同体系测试可参考 external 目录 下的 pydantic、numpy、sqlmodel、strawberry 等同源用例。【免费下载链接】ruffAn extremely fast Python linter and code formatter, written in Rust.项目地址: https://gitcode.com/GitHub_Trending/ru/ruff创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考