Python API exposure mechanisms (public/internal) - determining status quo

Motivation and scope

This is a summary of the API exposure mechanisms currently used in Python, i.e. whether interaction with names from outside of the namespace they live in is intended and supported.

The primarily relevant namespaces are modules and classes, but the exposure mechanisms are described independently of namespace type where possible.

This is modivated from apparently different understandings of the status-quo in the discussion threads on PEP 842-844, and specifically triggered through PEP 844: `public` and `private` builtins - #149 by guido.

AI Disclaimer: I have used AI to dig though the docs and structure findings. Structure, logic and interpretation are all mine.
Bear with me: Even though looking lengthy, this is not wordy AI slop. The topic is involved and I have tried to give a concise summary. I hope to have covered the the topic accurately. If I have overlookes some aspect, let’s refine together.

Scope: Please keep the discussion to the status quo: What do we currently have? What are fundamental issues with that.

Resources

[1] Python Language Reference - 7. Simple statements — Python 3.14.7 documentation

[2] Python Tutorial - 9. Classes — Python 3.14.7 documentation

[3] Typing specification - Distributing type information — typing documentation

[4] PEP 8 - PEP 8 – Style Guide for Python Code | peps.python.org

[5] Typing docs: stub files - Writing and Maintaining Stub Files — typing documentation

[6] Typing specification: stub files - Distributing type information — typing documentation

[7] Typing specification: import resolution ordering Distributing type information — typing documentation

API exposure levels

We define:

  • public: Meant to be used from outside of the namespace that defines it.
  • internal: Not meant to be used from outside of the namespace that defines it. This is an implementation detail.
  • private: In other languages, this is often used in the sense of not accessible from the outside. Python does not have this.

Usage in existing documentation is not quite consistent. Sometimes “private” or “non-public” is used to mean “internal” in the above sense. To avoid confusion, we only use “public” and “internal” in the following.

Remark: API exposure is tied to names and namespaces, not objects. The same object may be exposed publicly under one name and internally under another.

Remark: In some discussions there’s a notion of “module-internal” and “package-internal”, i.e. that there can be different “outsides”, so that another module in the same package may access a name, but a 3rd party user may not. There’s no evidence on this in the docs, which is why that distinction is not part of the defined API exposure levels.

API stability

Public API generally comes with an expectation of reasonable stability. It is left to individual project policies what exactly that means in terms of the expected impact of changes, deprecation policies, migration paths, etc.

PEP 8 [4] explicitly connects public API with backwards-compatibility expectations.

Conversely, there are typically no stability guarantees for internal API.

API exposure and API stability are nevertheless separate concepts. For example, a provisional API may be public while intentionally providing weaker stability guarantees.

Accessibility

Python follows the philosophy of “consenting adults”. There are no hard access limitations for internal names. Internal names can still be accessed, but doing so comes without the API stability guaranteeds associated with public names.

Remark: Since package developers are “consenting adults” in their own package. The missing “module-internal” vs. “package-internal” differentiation is minor. They can mark everything that is non-public as internal, and still access it throughout their package.

Exposure rules

The existing Python documentation contains several overlapping rules for determining API exposure. They do not form a single cohesive exposure model.

Naming convention

The general Python convention is that a leading underscore marks an internal name.

The Python tutorial [2] states:

“a name prefixed with an underscore […] should be treated as a non-public part of the API”

The typing specification [3] expresses essentially the same rule:

“Symbols whose names begin with an underscore […] are considered private.”

PEP 8 [4] applies the convention to packages, modules, classes, functions, attributes, and other names. It also makes exposure hierarchical: an interface is internal if one of its containing namespaces is internal.

Thus, for example:

_internal_module.PublicLookingName

is considered internal because the containing module is internal.

Dunder names are a special case and are not considered internal merely because they begin with underscores. The typing specification explicitly excludes dunder names from its underscore rule. [3]

Class names of the form __name are a bit special case. They trigger name mangling. The tutorial [2] defines their use-case as

“to avoid name clashes of names with names defined by subclasses”

So this is rather class-internal name management. In terms of our exposure definition to “users of the class” they are still internal.

Explicit exposure declaration via __all__

Modules can explicitly declare exposed names using __all__. This takes precedence over the leading underscore convention.

The language reference [1] states:

The public names defined by a module are determined by checking the module’s namespace for a variable named __all__ […] If __all__ is not defined, the set of public names includes all names found in the module’s namespace which do not begin with an underscore character (‘_’). __all__ should contain the entire public API.

The typing specification […] similarly state the precedence of __all__:

“This overrides all other rules above”

PEP 8 [4] recommends __all__ as an explicit declaration of a module’s public API. It still recommends to additionally maintain the leading underscore convention.

To better support introspection, modules should explicitly declare the names in their public API using the __all__ attribute. Setting __all__ to an empty list indicates that the module has no public API.

Even with __all__ set appropriately, internal interfaces (packages, modules, classes, functions, attributes or other names) should still be prefixed with a single leading underscore.

Wildcard imports

Technically, __all__ determines the names that are imported through wildcard imports. But following the language reference [1]

If the list of identifiers is replaced by a star (‘*’), all public names defined in the module are bound in the local namespace for the scope where the import statement occurs.

__all__ influencing wildcard imports is only a corollary of __all__ defining the public interface and wild card imports importing the public interface.

Note: Semantics have changed over time. __all__ was originally introduces solely as a mechanism to influence wildcard imports (see Built-in Package Support in Python 1.5 | Python.org). The “public name” semantic was added later, but can now be considered as leading.

Documentation

PEP 8 [4] assigns exposure semantics directly to documentation.

“Documented interfaces are considered public”
[…]
“All undocumented interfaces should be assumed to be internal.”

PEP 8 qualifies that documentation may still explicitly declare interfaces as provisional or internal.

Import and re-export convention

Importing a name creates a name in the importing module’s namespace. The existing sources disagree on whether that normally makes the name part of the importing module’s public interface.

PEP 8 [4] states:

“Imported names should always be considered an implementation detail. Other modules must not rely on indirect access to such imported names unless they are an explicitly documented part of the containing module’s API”

The typing specification starts from the same default:

“Imported symbols are considered private by default.” [3]

Unlike PEP 8, however, it defines import X as X as public re-export.

It also defines from Y import * as a re-export according to the public interface of Y.

It’s still alternatively possible to expose an imported name via __all__.

Stub files (*.pyi)

Excusively for type checkers, stub files are an additional exposure mechanism.

Typing docs on stubs [5] say that stubs should replicate the public interface.

Stubs should include the complete public interface (classes, functions, constants, etc.) of the module they cover

they concede that that is not always well defined

but it is not always clear exactly what is part of the interface.

and follow up with some heuristics.

Stubs are therefore no way to define exposure independently of the other exposure rules. However, as far as type checkers go, they are the primary source for detecting exposure (assuming that they replicate the public interface). [6] explicitly states

[stub files] can act as documentation, succinctly explaining the external API of a package, without including implementation details or private members.

Stub files are read with higher priority than the .py file [7], so that they shadow any other exposure rules (e.g. a re-export in the .py file).

Fundamental issues

This section lists fundamental issues like undefined behavior, contradictions and tooling impediments.

It does not consider quality-of-life improvements like "a nicer way to sync or populate __all__or possible additional features.

PEP 842-844 all fall into the latter category. Whether they are worthwhile is beyond the scope here, but the fundamental issues partly side-track these discussions and we should clarify them first to have a solid foundation to buid on or decide the present state is good enough.

Documentation as indicator for exposure

We should not deduce exposure from documentation for the following reasons:

  • first, it’s not quite well defined. What exactly is “documentation”? Docstrings, or listing in API sections of rendered docs, or being mentioned somewhere? Any of these?
  • one may well document internal stuff, e.g. it may be reasonable to document _internal_func() for you and your co-developers (maybe even publish that). This should not make _internal_func() public.
  • missing documentation: This could be an oversight or lack of resource to write documentation. It should not be takes as an indicator for exposure.
  • this would conflict with other exposure rules, requiring some precedence/solution mechanism.
  • this is hard for tooling to detect

While the intention is pragmatic - 3rd parties can use it, if we describe it - I would like to discard this as an official rule and rather see this in a historic context where automated tooling was little and people learned interfaces by reading the docs.

Exposure of imported names

The typing specification and PEP 8 consider imported names internal by default. However, they specify different mechanisms to make an imported name public, and both mechanisms have issues

  • PEP 8: “documented as part of the public API” - As discussed above documentation is fundamentally not a good indicator
  • typing: import X as X - this works, but looks more like a workaround, forcing semantics into existing API. There is no logical connection why import X as X should make X public. Also, the exposure state is not inspectable at runtime since import X and import X as X have exactly the same effect.

In contrast to this, the language reference does not explicitly mention imported names. When interpeting it literally, imports are considered public. This interpretation is supported by the folloing statement:

__all__ should contain the entire public API. It is intended to avoid accidentally exporting items that are not part of the API (such as library modules which were imported and used within the module).

When assuming public-by-default, the current workarounds are either

  • define __all__
  • or rename all imports for internal usage with leading underscore (import X a _X) - but that would in particular also apply to any third party libs (including stdlib), which is quite awkward as we can no longer access objects and libraries by their well-known public names
  • or one has to build separate modules for the pubic interface and the implementation (also called “hub-and-spoke” model). The public interface then only imports and the public parts.
21 Likes

This is great. It was good that you cited every claim. Maybe it would be good now to distill everything into the shortest, most readable text possible?

1 Like

Excellent piece of work, thanks for doing this.

I do have one disagreement, though:

I assume here that you’re explicitly separating exposure and stability? Because there’s no way to determine stability except from documentation - without a written statement of what behaviour is guaranteed[1], stability is meaningless.

In which case, I think there’s an important qualification. Deducing exposure from anywhere other than documentation requires duplication of information - the documentation needs to make it clear what the public API is, and any other expression of that information is of necessity duplicate data that must be kept in line (if you mark a name as “public” you have to document it, or if you want to document a name marked “private”, you need to explain that fact in the documentation to avoid misleading the reader).

Exposure and stability, while distinct in principle, are tightly coupled in practice. And the only realistic way to publish stability information is via documentation.

That duplication might be necessary, but it’s not ideal. After all, one of the reasons people state for their dislike of maintaining __all__ is precisely because it’s duplicated data.

I think it’s pretty disappointing if we characterise “learning by reading the documentation” as being a “historic context”. It’s also wrong - LLMs are apparently pretty good at using documentation to understand APIs".

I’d prefer it if you instead separated the analysis into two parts - human-readable mechanisms and tool-readable mechanisms. We could then discuss the trade-offs involved in the two types of approach, and make it clear that it’s up to the package author to evaluate those trade-offs and choose the mechanism they prefer accordingly.

(Personally, I favour human-readable over tool-readable, but that’s entirely my choice - I don’t want to block the development of a tool-readable approach, but I equally don’t want to be forced to use it).


  1. I’m assuming it’s obvious that the test suite isn’t a suitable way of defining what the stability guarantees of a project are… ↩︎

3 Likes

Another use for _-prefixed variables names is to ignore unused function output for some tools (at least ruff). It’s not directly linked to public/internal but maybe it could be mentionned ?

Here is an example of what I mean :

_unused, used = func()
foo(used)

I was thinking the same while reading the first post, this distinction is fundamental.

Thank you for this summary.

It is surprising to me that __all__ is recommended for declaring public names. I think it should’ve stayed a wildcard import mechanism. This facilitates declaring public names (in a hub, e.g. __init__.py), but is not identical to declaring them.

The summary already long, but I wish there was a section about the role of stub-files (.pyi). These have great IDE integration (in VSCode at least).

I’d prefer it if we kept typing-specific mechanisms out of the discussion, or we’ll end up getting sucked into “typing should be optional” debates… :slightly_frowning_face:

2 Likes

What is preventing stub-files from being used to declare the interface without any types? I already dump my doc-strings in them, so they get out of the way.

1 Like

I agree in general. Anyway overall I don’t think the discussion between exposure and naming conventions is too useful. In Python everything including _underscored names and even __mangled names can be used, everything is exposed to some extent if a user tries hard enough to look for it, and I don’t think anyone’s arguing to change that.

But whether one should depend on something, exposed or not, for their use is another issue entirely that requires judgment. Just because a module exports some symbol when you from module import * from it doesn’t mean you should use all the names that it splatters your namespace with.

I emphasize judgment because should one depend on something again just because it is documented? There are projects with internal APIs that are better documented than their external interfaces because while the external API is very simple and intuitive, the internal implementation is so non-obvious that it needs to be painstakingly documented for the sake of the future maintainer (occasionally, myself six months later :slightly_smiling_face:) rather than the user. And that the internals being documented doesn’t prevent them from being ripped up once someone (me 12 months later :smiling_face_with_tear: ) decides to reimplement it entirely.

I’ll still consider such _unused names private. They clearly have no intended use publicly.

1 Like

Stub files are indeed relevant for the status quo. They influence API exposure for the scope of type checkers. I’ve added a respective section on stub file in the original post.

While typing is optional, it’s relevant to users as soon as they opt into it. It’s good to have that in the complete picture. Note that we already have included the typing-specific import X as X interpretation.

1 Like

Yes.

Also, the executables that report errors about “the public API” are:

  • the linter,
  • the type checker, and
  • maybe the runtime itself.

So, mentioning how type checkers are supposed to interpret “public API” makes sense to me.

2 Likes

That distinction is a good point to be aware of. I believe human- (or AI-) and machine-readable are each so important that we should not define language-level solutions that substantially penalize one. The advantage of machine-readable is that it can have a very clear spec so that it’s easy and cheap to extracgt information. While AI can interpret docs, you want fast and reliable linters and LSPs. They cannot afford to scrape the docs (maybe they even could, but it’s unnecessary overhead).

Note also, that good concepts can be both (e.g. PEP 843’s from module export func is both very human and machine-readable. - Not juding PEP 843 in any other relevant language design aspects here.)

That’s a slight misunderstanding, maybe I was too terse here. The relevant part is “historic context where automated tooling was little”. The point I wanted to make is: if you don’t have automated tooling, machine-readable is not important, and all you needed was human-readable. So back then it may have been perfectly fine to base exposure only on documentation. Today, we also want machine-readable (see above).

But let’s not deviate into philosophical discussions on how learning has or has not changed, IMHO that’s going off-topic.

Please keep the discussion to the status quo for now. Hold this thought until the thread has converged on what we currently have and what fundamental issues we have. We can then better evaluate possible solution proposals.

1 Like

I think there’s a terminology misunderstanding. I have defined exposure as

i.e. trying to give the concepts of public/internal/private/… a name (is there a better one?).

So exposure is whether you should depend on someting.
I have the impression that you are using exposure for what I named accessible, i.e. can I technically get it. Other languages have it a bit easier as they can prevent access for thinks that should not be used, so exposed and accessible are synonym there. In Python we have to be a bit more careful with wording.
In the end I believe we mean the same thing.

3 Likes

I believe we can condense the status quo into the following rules:

  1. all names with a leading underscore (with exception to dunders) are internal.

    • This includes double leading underscores (name mangling).
    • Packages or modules with a leading underscore are internal as a whole; i.e. third parties should not access them.
  2. Imported names are considered public by the language reference. Inconsistently, typing and PEP 8 claim they are internal. Typing and PEP 8 differ in how they expect to make an imported name public: import X as X (typing) or documentation as public (PEP 8)

  3. Modules can define the public interface via __all__. This takes precedence over rules 1 and 2.

  4. PEP 8 claims that documentatoin makes interfaces public. It’s unclear how this interacts with the other rules.

  5. Stub files should repliciate the public interface given through the above rules. They are not an alternative mechanism to declare public, but if they exist, they are the primary information source for type checkers.

13 Likes

Putting a heart for the excellent summary. I think we can agree that there’s some room for improvement with the status quo?

1 Like

Great work!

Except for the stdlib, I don’t think we should get into the mapping between “publicness” and “API stability”. That way leads to length detours into semver and other unrelated discussions. As you say above, individual projects will determine what stability means for their own public APIs.

2 Likes

I’d flip that around, and say that documentation should ideally be generated from the code. Otherwise, you’re increasing the potential for documentation drift and entropy.

This is thoroughly off-topic, but there are serious issues with that approach as well (unless you mean something that just extracts docstrings from the code). The docs specify what users can rely upon (maybe inferring stability); a specific implementation may guarantee more, but a new implementation may change its guarantees as long as it still satisfies the pre-existing docs.

But let’s keep this out of the thread, it doesn’t matter for how we decide about PEP 843.

This shorter summary is closer to what I had in mind when I first asked for one. I am interested in listing precisely what the runtime and type-checkers currently do (because the runtime is the language spec; and type-checking is a separate spec that’s precise and closely related to the language spec, and relevant to everyone using type annotations regardless of which type-checker they use).

I’d say anything you find in PEP 8 should be irrelevant – PEP 8 is never normative, it’s a collection of coding and style advice and conventions that, when followed (universally, but without fanaticism), should make it easier for any Python user to understand any piece of Python code.

So then I’d say the language spec and the typing spec disagree on one main point: whether imported names are by default public or not. Where “by default” means “in the absence of __all__”. The runtime considers imported names public by default if they don’t start with an underscore; the typing spec considers them private by default unless you use the “…import X as X” convention.

Arguably type checkers have no business deviating from the language spec like this; one could argue that this is an issue for linters. But it was included in PEP 484 (the initial typing PEP), specifically for stub (.pyi) files. Presumably this was on the ground that conceptually most imports (in stub files, anyways) are not intended as exports, and thus it would behoove type checkers to catch related mistakes. Which in turn required an escape hatch for imports that were meant as exports; and PEP 484 did not want to propose any syntactic changes, so we introduced the hacky “X as X” convention (originally just “using ‘as’ exports”, but clarified by a later edit to the PEP).

I also believe that at some point the convention spread to non-stub files.

(I had previously assumed that type checkers also took __all__ to define the public API of a module in other contexts, but I can’t find any evidence of that; I tested pyright, mypy and ty.)

Did you check .py and .pyi files? The typing spec says that __all__ must be replicated in sub files. Writing and Maintaining Stub Files — typing documentation So it considers it somehow relevant. Of course, this does not necessarily mean tools take it into account.