Skip to content

← Seams, Modules, Packaging and Tooling step 6 of 36

Hard Framework

What does this module actually export?

Write the check a type checker runs: given a module’s source, decide what its public surface is.

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

def public_surface(source: str) -> Verdict:

Parse source with ast and look only at module-level statements — names bound inside a function or a class body are not part of the module namespace.

If a module-level assignment to __all__ exists, it decides everything:

  • the value is a list or tuple whose every element is a string literal → return ("all", frozenset(those strings)). Exactly those, even if a name is not defined anywhere in the file. __all__ is the author’s declaration and it wins.
  • the value is anything else — a call, a name, a concatenation, a comprehension, a list containing a non-string — → return ("dynamic", frozenset()). A non-literal __all__ is not a surface a static tool can verify, and reporting that honestly is the whole job.

Otherwise infer the surface under the typing spec’s re-export rules and return ("inferred", names):

Statement Contributes
import os nothing — an import is not a re-export
import os.path nothing
import os as os "os" — the redundant alias is the spec’s opt-in
from .core import Thing nothing
from .core import Thing as Thing "Thing"
def f / async def f / class C the name, unless it starts with _
NAME = ... or NAME: T = ... the name, unless it starts with _
NAME: T with no value nothing — it binds nothing at runtime

An import X as Y counts only when Y == X exactly. import os.path as path is a rename, not a re-export.

The production consequence. This is the rule that decides whether deleting a line from your __init__.py is a refactor or a breaking change. An unaliased from .core import Thing reads as “I needed this to write the module”; Thing as Thing reads as “this is API”. Ship the first and users will import it anyway; delete it later and you break them in a patch release.

Your submission must pass mypy --strict, which is most of the exercise here: every ast node arrives typed as ast.stmt or ast.expr and you must narrow with isinstance before touching .value, .targets, .elts, .name or .id. ast.Constant.value is Any, so narrow that too before putting it in a set[str].

Loading visualization…