Skip to content

← Modules, Packaging and Tooling step 7 of 41

Hard Framework

Work Out What a Module Exports

Write a tool that reads a module's source and reports which names it exports, using the rules from the article. It builds on List a Module's Functions with ast. Parse with ast.parse, loop over module.body, and check each node's type with isinstance.

Verdict = tuple[Literal["all", "inferred", "dynamic"], frozenset[str]]


def public_surface(source: str) -> Verdict: ...
public_surface("from .core import Thing as Thing\nVERSION = 1\n")
# ("inferred", frozenset({"Thing", "VERSION"}))

public_surface('__all__ = ["missing", "_private"]\n')
# ("all", frozenset({"missing", "_private"}))

public_surface('__all__ = ["a"] + ["b"]\n')
# ("dynamic", frozenset())

Look only at the statements directly in module.body. Read the source without running it or following its imports. Skip the inside of functions, classes, if blocks, loops, try blocks and every other block.

First, look for __all__

Find the first statement in module.body that assigns a value to the plain name __all__. It can be an ast.Assign, including a chained one such as __all__ = names = [...], or an ast.AnnAssign with a value, such as __all__: list[str] = []. Skip an annotation without a value.

  • If the value is a list or tuple of string constants, return ("all", frozenset(those_strings)). Keep names that start with _ and names the module never defines. Duplicates collapse into the set.
  • Otherwise return ("dynamic", frozenset()).

Stop at that first __all__, and ignore any later change to it. Skip an unpacking target such as __all__, x = ... and an augmented assignment such as __all__ += [...].

With no __all__, collect names

Return ("inferred", frozenset(names)), with the names this table gives:

Statement in module.body Names to collect
import X as X or from M import X as X X, when the alias is exactly the imported name
Any other import, including a renamed or star import None
def, async def or class The name, unless it starts with _
Assignment to a plain name That name, unless it starts with _
Annotated assignment to a plain name, with a value That name, unless it starts with _
Annotation without a value None
Any other statement None
  • In a chained assignment, check each target. Skip tuple, list, attribute and subscript targets.
  • The _ rule applies to definitions and assignments only. import _x as _x still counts.
  • import os.path as path adds nothing, because the alias path differs from the imported name os.path.
  • Collect every name you find. Keep a name even if a later statement deletes or reassigns it.

Your solution must pass mypy --strict. Narrow each AST node with isinstance before you read its fields. Check that each constant is a str before you add it to a set[str].