Add getLibraryApi and getStdlibApi: extract Python library & stdlib public-API types - #19
Open
knutwannheden wants to merge 23 commits into
Open
Add getLibraryApi and getStdlibApi: extract Python library & stdlib public-API types#19knutwannheden wants to merge 23 commits into
knutwannheden wants to merge 23 commits into
Conversation
- Add test_stdlib_multi_module_local_set: verifies that when builtins is in the requested module set, str is a full classLiteral (not classRef) - Add test_stdlib_all_modules_dump: verifies all-stdlib expansion (empty modules param) includes os/sys/collections/builtins with str as classLiteral - Fix needless_lifetimes clippy warning in handle_get_stdlib_api - Document getStdlibApi in CLAUDE.md wire protocol section
initialize gains firstPartyRoot / firstPartyModules; when set, the session registry emits classes outside the boundary as classRef. No boundary fields keep full-expansion behavior unchanged. Reuses the Boundary enum.
A pip distribution installs several roots, not one: mypy ships mypy/ and mypyc/, pytest ships _pytest/, pytest/ and a bare py.py. getLibraryApi now takes a roots array whose union forms the classRef boundary, so a class defined in one root and referenced from another stays a full classLiteral. Extracting one root at a time instead forces the consumer to merge results with disjoint type-id spaces and reunite each cross-root class with its body; 340 mypyc classes reference mypy.* across that boundary. A root may now be a bare module file as well as a package directory, and is extracted as named regardless of the underscore convention. The two visibility filters gain opt-outs, includePrivateModules and includeNonExportedSymbols, since a dependency table can be consumed by first-party code that imports library internals. Both default to off, preserving current behavior. LibraryModuleInfo.file is now relative to its root's parent, matching the shape the design doc specifies, so same-named modules from sibling roots (mypy/main.py vs mypyc/main.py) stay distinct. The legacy single root param is still accepted and unioned with roots.
Conflict resolutions: - ruff submodule: the two pins are unrelated tips, each a "widen visibility" stack rebuilt on a different upstream base, so neither is an ancestor of the other. Took main's newer pin (4630db80). - TypeRegistry: main gave it a ProgramEnvironment built from a Program, so the boundary constructors thread that Program through alongside the boundary. is_external resolves module names through resolve_module_name, which wraps the file in the ResolverFile the new API expects. - library.rs: global_scope and dunder_all_names now take a ProgramFile, and all_modules a ResolverEnvironment, so both extract entry points take the session's Program. - KnownInstance: both sides added fields, and the descriptor carries all of them. Upstream made KnownClass::canonical_module private and reshaped it to take a PythonVersion, so moduleName resolves via try_to_class_literal and the class literal's file, the same path every other descriptor uses. - tests: both sides append at the same point, so this keeps both blocks. A union merge is wrong here — the two appended blocks share trailing context and it splices two test bodies together.
# Conflicts: # src/registry.rs
#21 gave classLiteral a qualifiedName, so a nested class inside the extracted boundary is named mod.Outer.Inner. Outside it, classRef carried only className + moduleName, from which a consumer can rebuild no more than mod.Inner — the two names for the same class never join, and the V3 writer's first-wins FQN dedup can alias the real class body onto the body-less ref. Populate it at both emission sites (the ClassLiteral and GenericAlias arms).
all_end_of_scope_members chains every end-of-scope declaration with every end-of-scope binding, so a name that is both declared and bound — an import, an annotated assignment, a def or a class — came back twice. On six.py that was 111 entries for 72 names. Three of six's names bound to genuinely different types across the two entries (advance_iterator, callable, print_): the declared type from the def, and a union of both branches from the bindings. Keeping the first entry gives the declaration precedence, matching how ty itself resolves a symbol.
main gained #22, #23, #25, #26, #27 and #28 since 3afe40f. Only CLAUDE.md's descriptor table conflicted textually; the substance is #26 meeting this branch's cross-boundary join. #26 omits `qualifiedName` where it cannot identify a class — ty spells a class built from a runtime name `<unknown>`, so two of them in one scope render the same dotted path. It applied that to the `classLiteral` arms, which the `classRef` arms now match: a ref keeping `mypkg.anon.<unknown>` while the body-carrying literal omits it hands the consumer a key that joins two unrelated classes, the merge #26 set out to stop. Every case the join does rely on — nested `mod.Outer.Inner`, function-local scopes, module-level classes — keeps its name, so the join itself is unaffected. The submodule takes main's 844cfafc. That commit's widen list still carries `dunder_all`, and `ty_python_core` still exports `global_scope` and `ProgramFile`, so moving to the newer base loses nothing this branch needs. 78 tests = 61 (branch) + 56 (main) - 39 (merge base), no name dropped or repeated, plus one pinning the `classRef` omission. README gains the `classRef` section that #25's per-variant tables imply.
Under a boundary, `instance.classId`, `subclassOf.base` and `super.pivotClassId` each resolve through `is_external` and report `classRef`, while their field docs name only the `classLiteral`. Stated once, on `classRef` itself. The profile rationale gives a test count, which the suite has outgrown; the sentence is about the tests being cheap next to the build either way.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
ty-typescurrently exposes per-AST-node type attribution for a single file (getTypes). To build type tables for Python libraries — the Python analogue of how Moderne builds Java type tables by scanning a JAR's class files with ASM intoJavaTypeobjects — we need a different output shape: the public API surface (module-level declarations, including class members), not the inferred type of every expression in every method body.This PR adds two methods:
getLibraryApi— extracts one installed distribution fromsite-packages.getStdlibApi— extracts the standard library (from ty's vendored typeshed) for the project's configured Python version.Classes defined outside the extraction unit (stdlib/typeshed, other distributions, or other stdlib modules) are emitted as a lightweight
classRefrather than fully expanded — mirroring the self-contained type-table model where a referenced-but-not-defined class is aTAG_CLASS_REF. The emitted JSON is consumed Java-side, where the existingTypeTableWriterserializes it totypes.bin; reproducing that format (minimal-perfect-hash index, ZSTD framing, theJavaTypegraph) from Rust was deliberately avoided.A distribution installs several roots, not one: mypy ships
mypy/andmypyc/, pytest ships_pytest/,pytest/and a barepy.py.getLibraryApitherefore takes a set of roots and spans the boundary across their union, so a class defined in one root and referenced from another stays a fullclassLiteral. Extracting one root at a time instead would force the consumer to merge results with disjoint type-id spaces and reunite each cross-root class with its body — and a merge that misses one, or keys on a descriptor lacking a qualified name, lets the V3 writer's first-wins FQN dedup alias the real class body onto the body-less ref, producing a member-less class that still reports success. In mypy, 143 distinctmypy.*classes are referenced across that boundary frommypyc.Examples
One distribution, several roots (after
initializewith a project/venv root from which the packages resolve):{"jsonrpc":"2.0","method":"getLibraryApi","params":{"roots":["/path/to/site-packages/mypy","/path/to/site-packages/mypyc"]},"id":2}A root may be a package directory or a bare module file, so pytest's three roots extract in one call:
{"jsonrpc":"2.0","method":"getLibraryApi","params":{"roots":["/site-packages/_pytest","/site-packages/pytest","/site-packages/py.py"]},"id":2}{ "modules": [ { "name": "mypkg.core", "file": "mypkg/core.py", "symbols": [ {"name": "Widget", "typeId": 12}, {"name": "make", "typeId": 30} ] } ], "types": { "12": { "kind": "classLiteral", "className": "Widget", "moduleName": "mypkg.core", "members": [ {"name": "size", "typeId": 7} ] }, "7": { "kind": "instance", "className": "int", "classId": 9 }, "9": { "kind": "classRef", "className": "int", "moduleName": "builtins" } } }Two visibility filters are on by default, each with an opt-out, since a dependency table can be consumed by first-party code that imports library internals:
{"jsonrpc":"2.0","method":"getLibraryApi","params":{"roots":["/site-packages/_pytest"],"includePrivateModules":true,"includeNonExportedSymbols":true},"id":2}Standard library —
modulesselects the local unit; everything else (other stdlib modules,builtinswhen not requested) becomes aclassRef. Omitmodulesfor a whole-stdlib dump.{"jsonrpc":"2.0","method":"getStdlibApi","params":{"modules":["os","collections"]},"id":3}In both cases, in-unit classes are full
classLiterals with members; out-of-unit classes collapse to the newclassRefdescriptor (identity only).Summary
getLibraryApi(src/library.rs): walks each root for.py/.pyimodules (preferring.pyi), enumerates each module's public top-level symbols, and registers their types.rootsaccepts package directories and bare module files alike; a root named explicitly is extracted whatever its name.rootnames a single root and is unioned withroots. Modules reached through more than one root are emitted once.includePrivateModuleskeeps modules with an underscore-prefixed path component;includeNonExportedSymbolskeeps module-level symbols that__all__omits (or, with no__all__, underscore-prefixed ones). Both default to off, so the default output is unchanged.LibraryModuleInfo.fileis relative to its root's parent (mypkg/core.py), which is the shape the design doc specifies and keeps same-named modules from sibling roots (mypy/main.pyvsmypyc/main.py) distinct.getStdlibApi(src/library.rs): discovers stdlib modules viaty_module_resolver::all_modulesfiltered to the standard-library search path (no filesystem walk — typeshed is vendored), excludes_typeshed. Version comes from theinitializeproject config.__all__when defined; otherwise drops underscore-prefixed names (whattyapplies forfrom x import *). Shared between both methods.all_end_of_scope_memberschains every end-of-scope declaration with every end-of-scope binding, so a name that is both declared and bound (import, annotated assignment,def,class) arrives twice. Keeping the first gives the declared type precedence, matching ty's own resolution. Onsix.pythis is 111 entries for 72 names; three of them (advance_iterator,callable,print_) differ between the two entries — thedef's declared signature versus a union across theif PY3:branches — so deduping is a real precedence choice, not just deflation.src/registry.rs): aBoundaryis eitherUnderRoots(paths)(distribution extraction) orModules(set)(stdlib extraction). Classes outside the boundary emit the newclassRefdescriptor.getTypes/getTypeRegistryare unchanged (no boundary).classRefTypeDescriptorvariant (src/protocol.rs):className+moduleName+qualifiedName, maps 1:1 to the type-tableTAG_CLASS_REF. The qualified name is what joins a ref to theclassLiteralcarrying the same class's body: a nested class ismod.Outer.Inneron both sides, wheremoduleName+classNamealone would rebuild onlymod.Inner.ty-types-2fork now widensdunder_allmodule visibility (via the existingwiden_ty_visibility.shfix-up list) so__all__is reachable; addsty_python_coreas a dependency forglobal_scope.Known gap, pre-existing and left for its own change:
specialFormdescriptors carry a barenameand no qualified name, so consumers can still mint colliding names for them.Test plan
cargo test— 61 integration tests pass, including:classLiteralwith members when both roots are given, and aclassRefwhen only B is — the two tests are each other's control.import,x: int = 1,def,class) emits each name once, and the surviving entry carries the declared type (int, notLiteral[1]) — the second test fails if the dedupe keeps the binding instead.classRefwhosequalifiedNameis the full dotted path when its defining root is excluded, and as aclassLiteralwith that samequalifiedNamewhen both roots are given.__all__appear only with their respective flag set; each flag leaves the other filter alone..pyi;__all__/underscore symbol filtering; in-package class → fullclassLiteral, typeshedint→classRef; cross-module in-package class (sibling import) stays a fullclassLiteral.builtins.str→classRef; multi-module local set (["string","builtins"]) →strfull; whole-stdlib dump (nomodules) includesos/sys/collections/builtins.mypy+mypyctogether dropsmypy.*classRefs from 143 to 5, and all 5 remaining aremypy_extensions— a separate distribution installed as its own bare module, correctly outside both roots.mypy/main.pyandmypyc/main.pycome back as distinctfilevalues._pytest,pytest,py.py) extract in one call,py.pyincluded; on_pytestthe flags are independent and additive (59 → 78 modules withincludePrivateModules, 3794 → 4286 symbols withincludeNonExportedSymbols).rootkey still extracts (867 modules formypy).getTypes/getTypeRegistrytests remain green (boundary unused there; the boundary generalization is a pure refactor).six.py: 111 symbols → 72, no name repeated, andadvance_iterator/callable/print_resolve to their declareddefsignatures.cargo clippy --all-targetsclean for this cratemain(Emit qualifiedName for enum, TypedDict, NewType and nested classes #21,qualifiedNamefor enums/TypedDicts/NewTypes/nested classes); one conflict in theGenericAliasarm ofsrc/registry.rs, resolved by keeping both sides.