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 whyimport X as Xshould make X public. Also, the exposure state is not inspectable at runtime sinceimport Xandimport X as Xhave 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.