A tool to analyze the type annotation coverage of Python projects on PyPI.
For a given project:
- Query PyPI for the latest version
- Install the package (and any companion stub package) into a temporary venv via
uv pip install --no-deps - When the input is a stubs package (
{project}-stubsortypes-{project}), also install the base{project}package so that the stubs overlay can be merged with the original package (see below) - Compute the import graph using
ruff analyze graph - Filter to files transitively reachable from public modules (skip tests, tools, etc.)
- For each reachable file, parse it using
libcst, and extract:- all typable global symbols and their type annotations
- the
__all__exports (if defined) - imports and implicit re-exports (i.e.
from a import b as b) - type aliases (
_: TypeAlias = ...andtype _ = ...) - type-ignore comments (
# (type|pyright|pyrefly|ty): ignore) - overloaded functions/methods
- Unfold type aliases to detect
Anyannotations (directtyping.Anyusage, local aliases liketype Unknown = Any, and cross-module alias chains) - Resolve public symbols via origin-tracing (follow re-export chains to their defining module). When merging stubs, both packages use public-name mode instead (no origin tracing) so that FQNs match directly between the two packages
- Merge stubs overlay: when a companion
{project}-stubspackage was installed (step 2), stubs types take priority per-module and original symbols missing from stubs are markedUNTYPED - Collect the type-checker configs to see which strictness flags are used and which type-checkers it supports (mypy, (based)pyright, pyrefly, ty, zuban)
- Compute various statistics:
- coverage (% of public symbols typed)
- strict coverage (% of public symbols typed without
Any) - average overload ratio (function without overloads counts as 1 overload)
- supported type-checkers + strictness flags
- stubs-only classification (
no,yes (third party), oryes (typeshed))
- Export the statistics for use in a website/dashboard
Per-module (via libcst):
- Imports:
import m,import m as a,from m import x,from m import x as a - Wildcard imports:
from m import * - Explicit exports:
__all__ = [...](list, tuple, or set literals) - Dynamic exports:
__all__ += other.__all__(spec) - Implicit re-exports:
from m import x as x,import m as m(spec) - Type aliases:
X: TypeAlias = ...,type X = ...,X = TypeAliasType("X", ...) - Name aliases:
X = YwhereYis a local symbol (viz. type alias) or an imported name (viz. import alias) - Special typeforms (excluded from symbols):
TypeVar,ParamSpec,TypeVarTuple,NewType,TypedDict,namedtuple - Typed variables:
x: Tandx: T = ... - Functions/methods: full parameter signatures with
self/clsinference - Overloaded functions:
@overloadsignatures collected and merged - Method aliases:
__radd__ = __add__inherits the full function signature - Properties:
@property/@cached_propertywith@name.setterand@name.deleteraccessors; each accessor's full signature (parameters + return type) contributes to coverage - Classes: typed only when all members (attributes, methods, properties) are typed; protocols are excluded from coverage
- Class-body attributes: annotated and unannotated assignments collected as class members
- Instance attributes:
self.xassignments in__init__/__new__/__post_init__collected as class members; private (_-prefixed) attributes excluded; inherited typed attributes not re-collected in subclasses __slots__exclusion:__slots__assignments are ignored- Enum members: auto-detected as
IMPLICIT(viaEnum/IntEnum/StrEnum/Flag/... bases) - Dataclass / NamedTuple / TypedDict fields: auto-detected as
IMPLICIT(typed by definition) - Type-ignore comments:
# type: ignore[...],# pyrefly:ignore[...], etc. Annotatedunwrapping:Annotated[T, ...]→T(spec)- Aliased typing imports:
import typing as tresolved via a lightweight import map (built incrementally during the single-passlibcstvisitor), avoiding the expensiveQualifiedNameProvider/ScopeProviderpipeline Anydetection: annotations that resolve totyping.Any(ortyping_extensions.Any,_typeshed.Incomplete,_typeshed.MaybeNone,_typeshed.sentinel,_typeshed.AnnotationForm)—whether used directly, through local type aliases (type Unknown = Any), or cross-module alias chains—are markedANYand tracked separately, but still count as typed for coverage purposes
Cross-module (via import graph):
- Import graph:
ruff analyze graphwith/withoutTYPE_CHECKINGbranches - Reachability filtering: only files transitively reachable from public modules are parsed, skipping tests, benchmarks, and internal tooling
- Excluded directories and files: the following directories are automatically excluded from
analysis:
.spin,_examples,benchmarks,doc,docs,examples,tests. The filesconftest.pyandsetup.pyare also excluded wherever they appear. - Namespace package exclusion: directories without
__init__.pynested inside a proper package are excluded (e.g. vendored third-party code likenumpy/linalg/lapack_lite/) - Origin-based symbol attribution: public symbols are traced back through re-export chains to their defining module; each symbol is attributed to its origin source file and fully qualified name rather than the re-exporting module
- Private module re-exports: symbols re-exported from
_privatemodules via__all__ - Wildcard re-export expansion:
from _internal import *resolved to concrete symbols - Module dunder exclusion: module-level dunders (
__all__,__doc__,__dir__,__getattr__) are excluded from the public symbol set—they are module infrastructure, not importable symbols - External vs unknown: imported symbols from external packages marked
EXTERNAL, notUNTYPED, and excluded from coverage denominator - Unresolved
__all__names: names listed in__all__that cannot be resolved to any local definition or import are treated asUNTYPED--matching the behavior of type-checkers, which would infer these asAnyorUnknown(e.g. modules using__getattr__for lazy loading) - Stub file priority: When both
.pyand.pyifiles exist for the same module, only the.pyistub is used—matching the behavior of type-checkers (spec) - Stubs overlay merge: When analyzing a
{project}-stubspackage, its.pyifiles take priority over both.pyand.pyiin the original{project}package, per-module. Both packages are analyzed withtrace_origins=False(public import names) so FQNs match directly. The full public API is determined from both packages (union of symbols). Symbols in the original that are absent from stubs for a module the stubs cover are markedUNTYPED(type-checkers can't resolve them). Symbols from modules not covered by stubs retain their original types (the type-checker falls back to the.py). Analyzing a base package standalone does not trigger a stubs probe—only analyzing a-stubspackage triggers the merge. py.typeddetection:YES,NO,PARTIAL, orSTUBS(for-stubspackages) (spec)
All IO (HTTP requests, subprocesses, file IO, etc) is performed asynchronously using anyio and
httpx (over HTTP/2). This way we effectively get pipeline parallelism for free (i.e. by doing
other things while waiting on IO, instead of blocking).
Use free-threading for best performance (e.g. use --python 3.14t with uv).
To set up a development environment (using uv), run:
uv syncIn CI we currently run ruff, dprint, pyrefly, and pytest. It's easy to run them locally as well, just
uv run ruff check
uv run ruff format
uv run dprint check
uv run dprint fmt
uv run pyrefly check
uv run pytest(uv run can be omitted if you manually activated the virtual environment created by uv)
You can optionally install and enable lefthook by running:
uv tool install lefthook --upgrade
uvx lefthook install
uvx lefthook validateFor alternative ways of installing lefthook, see https://github.com/evilmartians/lefthook#install
scripts/preview.py provides a live-reloading preview of the generated dashboard site:
uv run scripts/preview.pyOn first run (and whenever the data branch changes) it extracts report data from origin/data,
builds the _site/ pages via build_site, and then starts zensical serve.
Subsequent runs reuse the cached data if the origin/data SHA is unchanged.
While the server is running, changes to Jinja2 templates (src/typestats/templates/) or
projects.toml are detected automatically and trigger an incremental rebuild.
Template-only changes skip reloading the JSON reports entirely, so they complete in milliseconds.
Changes to .py source files require a manual restart.
Pass --clean to force a fresh extraction regardless of the cached SHA:
uv run scripts/preview.py --cleanAny extra flags are forwarded to zensical serve, for example:
uv run scripts/preview.py --dev-addr 0.0.0.0:9000