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

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__.

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.
9 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?

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… ↩︎