Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 842 – Module Exports

PEP 842 – Module Exports

Author:
Peter Bierma <peter at python.org>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
25-Jul-2026
Python-Version:
3.16
Post-History:
24-Jul-2026, 31-Jul-2026, 07-Aug-2026

Table of Contents

Abstract

This PEP proposes an export statement that modules can use to express intent about the visibility of variables from outside the module.

For example:

# spam.py
from mypackage export name

export foo = "42"

export class Public:
   pass

class Private:
   pass
>>> import spam
>>> 'Public' in dir(spam)
True
>>> 'Private' in dir(spam)
False
>>> spam.Public
<class 'spam.Public'>
>>> spam.Private
Traceback (most recent call last):
  File "<python-input-4>", line 1, in <module>
    spam.Private
ExportError: 'Private' is not exported by 'spam'

This is not intended to be an access modifier for Python; see the rationale.

Motivation

Module-level names need privacy

A developer is writing a Python module. The module is intended to have one “public” class – a class that is intended for users of the module – called PublicAPI. As part of implementing PublicAPI, the developer wants to create another class, called Helper. However, Helper is not meant to be public in the same way that PublicAPI is public. Helper is supposed to only be used by the developer of the module – a “private” API.

Nonetheless, the developer declares the two classes as such:

# spam.py
class Helper:
   ...

class PublicAPI:
   ...

The problem with this is that Helper comes with no indication that it’s not a public API. It shows up in autocomplete by language servers, the dir() function, Python’s interactive help() function, and every other API meant for introspection. How are users supposed to know that they aren’t supposed to use this?

Prefixed names aren’t necessarily a great solution

In Python, the convention for declaring private names is to prefix them with _. So, the developer changes Helper into _Helper:

# spam.py
class _Helper:
   ...

This is generally the standard for Python libraries today, but it’s not clear that this is the best long term solution. This works (with some caveats; see the sections below), but this is (subjectively) less readable, and does require more keystrokes by the maintainer. Ideally, users shouldn’t be tempted to reach for private names from modules in the first place.

However, it is acknowledged that this idea is going against 30 years of convention; even if this PEP is accepted, it’s expected that “underscored” names (names prefixed with a leading _) will remain a staple of Python for years to come. The purpose of this PEP is not to eliminate the need for _ in module-level names, but instead to clear up corner cases where a private name is ambiguous or tempting. In other words, this PEP is intended to improve expressiveness and clarity with private APIs, not to add brand new functionality.

It’s not always clear where names need prefixing

Python defines names through many different constructs, some of which are not always clear or intuitive to the developer. As a result, it can be difficult to remember where names need to be prefixed. To put this issue into perspective, imagine that a developer wants to import some other modules in their code:

# spam.py
import argparse
import asyncio
import tabnanny

In the above example, the spam module will have argparse, asyncio, and tabnanny as seemingly public attributes. In practice, this is not good for a maintainer, because maintainers may want to remove and change imports as they please, so these attributes should not be treated as public APIs.

Python’s standard library currently sidesteps this problem through a note in the backwards compatibility policy (PEP 387) that states that imported modules are not considered public APIs and may change at any time, but unfortunately, users are unable to determine this without directly reading the backwards compatibility policy, which is not a common thing to do. The solution to this is to also prefix every imported name with _:

# spam.py
import argparse as _argparse
import asyncio as _asyncio
import tabnanny as _tabnanny

But, again, this sprinkles the code with even more underscored names, and doesn’t necessarily send a crystal-clear message that the name is private; see the next section.

Prefixed names are not a universal rule

As modules evolve, some underscored names are made public, either because users did not clearly understand that an underscore indicated instability, or because users found useful functionality in a module’s private API, and nothing was discouraging them from using it.

In the standard library, a prime example of this is the ctypes module. ctypes is full of stable APIs that are subject to Python’s backwards compatibility policy, but contain a leading underscore. For example:

  1. ctypes._CFuncPtr
  2. ctypes._CData
  3. ctypes._Pointer

This sends the wrong message to consumers of the API. When seeing things like this in a codebase, it makes it seem like the code is opting out of backwards compatibility, or that an underscored name does not mean “private” in the module. In both cases, consumers are inclined to reach for more private names (because there’s no apparent consequence for doing so), making this problem worse.

Modules aren’t immune to this problem either. The standard _thread module, for example, is prefixed with _ while being public.

Some libraries have native counterparts

In some cases, prefixing an import with _ makes it ambiguous, because some complicated modules come with extension modules that provide access to native functionality or otherwise speed up the module in some way. These native modules are often prefixed with a leading underscore.

For example, in CPython, the asyncio module has a private _asyncio accelerator module, so a reader seeing _asyncio may take it to mean the C accelerator and not the normal module.

Imports are suggested by language servers and linters

Circling back to the issue described earlier, imports defined at the module-level are visible as “public” names to the API surface. In fact, when developing a module, the autocomplete provided by language servers will often suggest importing modules that were also imported by that module. So, not only are users not prevented from accessing seemingly-public imports, they may be encouraged to do so by their language server! (This problem applies to any name that is meant to be private; it’s just that imports are a particularly common case for this to occur.)

Real-world cases

This is not a hypothetical problem. There are many real examples of this causing issues in practice.

Note

Special thanks to Hugo van Kemenade for compiling this list.

os.errno

In Python 3.7, an import to the errno module was removed from os. This caused a lot of breakage:

botocore.vendored

The botocore package had vendored dependencies under the botocore.vendored namespace, which ended up being relied upon by users:

SciPy and pandas

Both the SciPy and pandas packages had other packages visible at the module-level, which had to be deprecated and removed due to third-party usage:

scikit-learn

The scikit-learn package vendored six and joblib. Downstream packages then used those vendored copies and broke when they were removed in v0.23:

Linters cannot fight against imports

As a solution to the above problem, one might suggest that linters should simply warn against importing modules from another module. The primary issue with this is that this pattern is particularly common in __init__.py files to move all packages into one namespace. For example:

# __init__.py

from my_package import subpackage_1
from my_package import subpackage_2
# etc

Linters have no language-level way to distinguish this pattern from “standard” imports. As a solution, many linters use import name as name to identify intentional re-exports, but this pattern is only a convention. For example, the above __init__.py would be rewritten as this:

# __init__.py

from my_package import subpackage_1 as subpackage_1
from my_package import subpackage_2 as subpackage_2
# etc

Not only is this redundant (and a violation of the DRY principle), it’s confusing! Python’s official documentation does not document this pattern for re-exports (because it’s not defined by the language and is only a convention enforced by linters), so the packages that do this are primarily just “in the know”.

But, because this is only a convention, linters can’t enforce the negative case; if an import is not given the name as name treatment, a linter can’t necessarily assume that an import is not a re-export.

We want to be nice to users, not shrug them off

When a user decides to use a private API, accidentally or not, they will inevitably be broken by the library author. In many cases, this results in a bug report asking for the API to be fixed or restored to prevent downstream breakage. In this case, the library maintainer has to make a decision:

  1. Tell the user that they’re in the wrong for using it, and allow the breakage to take place.
  2. Commit to maintaining the private API as public, increasing the burden on themselves and encountering some of the problems described in the motivation.

This PEP is not intended to solve this problem entirely, but instead is meant to mitigate it by making it much clearer that a user is accessing a private name; in other words, this PEP wants to decrease (or eliminate) the amount of accidental private API usage in practice. By accessing a private API, the user must make a conscious decision to do so.

Library consumers use runtime introspection for documentation

A counterargument to the above section is that a library should clearly document what is private and what is public. In theory, yes, but in practice, users don’t read the documentation in full.

A common practice when designing APIs is to design for intuition. If an API is named and placed well, then a user often won’t need to reach for the documentation. Python is no exception to this.

When prototyping, it’s typical for someone to use dir() or help() in Python’s interactive REPL to look for attributes that are useful to them. In this case, if something is intuitive enough for the user, they will simply reach for it without checking the documentation first. In a language as dynamic as Python, the way people consume APIs is also dynamic.

__all__ is only a convention

The fundamental issue here is that Python has no way to express which names in a module are “private” or “public”. Prefixing is an option, but given the reasons above, it’s not always a bulletproof solution for library authors.

Currently, the other convention for expressing which names are public is done through a module’s __all__ variable. This has two major downsides:

  1. __all__ often gets out of sync, because as developers add, change, or remove names from their module, there is often nothing pushing them towards changing __all__, because again, using it to list public names is only a convention and not enforced by anything.
  2. __all__ is not always exhaustive. See the rejected ideas for examples of where the items in __all__ might only be a subset of the “public” names in a module. In short, it can be difficult to control namespace pollution and declare all public names in __all__ simultaneously.

This PEP intends to solve both of these problems with a new __export__ variable and export statement.

Specification

The ExportError type

A new exception type, called ExportError, is added to the builtins module. ExportError inherits from AttributeError.

Though allowed, it is not intended to be raised by user code; instead, it is meant to be raised by a module object when accessing a name that is not in __export__; see Module attribute access.

C API

Note

This section is specific to CPython.

The ExportError class will be added to the public C API headers under the name PyExc_ExportError. As with all other global exception types, it will be in the Stable ABI and will be immortal at runtime.

__export__ variables

Requirements

When defined in a module’s global scope, __export__ must be assigned to an instance of list (or a subclass of it) containing str objects:

__export__ = ["name1", "name2", "name3"]
Item requirements

The strings inside __export__ are not required to correspond to names defined in the module, though there is no practical reason to include undefined names. For example, the following is valid (as in, it will not generate an exception at runtime), with one caveat:

__export__ = ["does not exist"]

The caveat is that this will raise an exception when used with a wildcard import (from module import *), because __all__ is implicitly set by __export__; see Implicit __all__ definitions.

Module attribute access

When __export__ is present in a module’s globals, all access to attributes present on the module object will also check if the attribute name is present in __export__ (via __contains__ or through iteration, as specified previously). If the attribute name is not present in __export__, then an ExportError is raised. For example:

# spam.py
a = 42
b = 24

__export__ = ["a"]
>>> import spam
>>> spam.a
42
>>> spam.b
Traceback (most recent call last):
  File "<python-input-2>", line 1, in <module>
    spam.b
ExportError: 'b' is not exported by 'spam'

Note

This also affects from imports, because those use the same attribute access mechanism.

Dunder names

This does not apply to dunder names; attributes such as __dict__ and __file__ will always be accessible on the module through attribute access, even if they are not included in the module’s __export__. For example:

# spam.py
__export__ = []
>>> import spam
>>> spam.__name__
'spam'

Module __getattr__ functions

The behavior of __export__ cannot be overridden by a module’s __getattr__() function, as __getattr__ functions are only invoked for undefined names on modules. However, in cases where a module __getattr__ is invoked, __export__ has no effect. For example:

# spam.py
__export__ = ["exported"]

exported = 42

def __getattr__(name):
   if name == "exported":
      # This is never triggered!
      raise ImportError()

   if name == "hello":
      # "hello" is never put through the __export__ filter
      return 42

   raise AttributeError(f"{__name__!r} has no attribute {name!r}")
>>> import spam
>>> spam.__export__
["exported"]
>>> spam.exported
42
>>> spam.hello
42

__dir__ behavior

On a module with __export__, the module’s __dir__() function will be modified to exclude names that are not in the module’s __export__. As with attributes, this behavior does not apply to dunder names; those will always be included in the output of dir(), regardless of whether the names are included in __export__. For example:

# spam.py
class Public:
   ...

class Private:
   ...

__export__ = ["Public"]
>>> import spam
>>> dir(spam)
['Public', '__builtins__', '__doc__', '__export__', '__file__', '__loader__', '__name__', '__package__', '__spec__']
User-defined module __dir__ functions

If a module defines its own __dir__ method, it takes precedence over this behavior. It is up to the implementer of __dir__ to exclude names that are not present in __export__. For example:

# spam.py
a = 42
b = 24

__export__ = ['a']

def __dir__():
   return list(globals().keys())
>>> import spam
>>> dir(spam)
[..., 'a', 'b']

Implicit __all__ definitions

If a module defines __export__ but does not define __all__, then __all__ will be assigned to __export__. To visualize:

# spam.py
a = 42
b = 24
c = 'c'

__export__ = ['a', 'b']
# __all__ is implicitly set to ['a', 'b'], so 'c' will not be included in
# wildcard imports.
>>> from spam import *
>>> a
42
>>> b
24
>>> c
Traceback (most recent call last):
  File "<python-input-3>", line 1, in <module>
    c
NameError: name 'c' is not defined

This means that the previously specified requirements for __export__ are not exhaustive, as __export__ in this case must also be a valid __all__. For example, including a name in __export__ that does not exist in the module will break wildcard imports:

# spam.py
a = 42
__export__ = ['a', 'noexist']
>>> from spam import *
Traceback (most recent call last):
  File "<python-input-0>", line 1, in <module>
    from spam import *
AttributeError: module 'spam' has no attribute 'noexist'

Semantic implementation

For a module, defining __export__ is roughly equivalent to adding the following code:

if "__all__" not in globals():
   __all__ = __export__

def _is_dunder_name(name):
    return (len(name) > 4) and name.startswith("__") and name.endswith("__")

# Attributes not in the __dict__ fall back to the normal lookup
def __getattribute__(name):
    try:
        value = globals()[name]
    except KeyError:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None

    if _is_dunder_name(name):
        return value

    if name not in __export__:
        raise ExportError(f"{name!r} is not exported by {__name__!r}")

    return value

_module = sys.modules[__name__]
# This is a spooky magic function -- pretend it exists for example's sake
patch(_module, '__getattribute__', __getattribute__)

def __dir__():
   names = []
   for name in globals().keys():
      if (name in __export__) or _is_dunder_name(name):
         names.append(name)
   return names

Exporting names

Grammar

The grammar is changed to allow for the standalone export statement and export assignments:

export_stmt[stmt_ty]:
   | "export" ','.NAME+
   | "export" assignment

simple_stmt[stmt_ty] (memo):
   | assignment
   | &"export" export_stmt

Note that augmented assignments (x += y) are disallowed through a PEG action at compile time.

Standalone exports

A standalone export statement is a shorthand for appending one or more names to a global __export__ list.

When the export statement is used, the interpreter first checks if each name exists in the global scope. If any do not exist, a NameError is raised. The interpreter then checks if an __export__ variable exists in the global namespace. If not, it is assigned to an empty list object. Then, for each name used in the export statement, a str containing the name of the variable is passed as the first positional argument to the __export__.append method.

To visualize, the following code:

export NAME1, NAME2

is semantically equivalent to:

if "NAME1" not in globals():
   raise NameError(...)

if "NAME2" not in globals():
   raise NameError(...)

try:
   __export__
except NameError:
   __export__ = []

__export__.append("NAME1")
__export__.append("NAME2")

The export statement is only allowed in the global namespace; using it elsewhere (such as inside of a function body) raises a SyntaxError during compilation.

Export assignments

When an assignment statement is prefixed with export, the name is defined and then exported.

As an example, the following code:

export NAME1, NAME2 = VALUE1, VALUE2

is semantically equivalent to:

NAME1 = VALUE1
NAME2 = VALUE2
export NAME1, NAME2

“Export assignment” statements are valid when used with standard assignment statements (a = b, a, b = c, d, etc), and individual assignments that contain a type annotation (a: type = b; in contrast, a standalone export a: type is not valid). For example, each of the following is valid:

export hello = "world"
export my, hovercraft = "full of", "eels"
export types_work_too: int = 42

The following are NOT valid:

export hello: str
export my: str, hovercraft: str = "full of", "eels"
export name := "walrus"
export hello += "world"

Exporting functions and classes

Grammar

export_compound_stmt[stmt_ty]:
   | "export" (function_def | class_def)

compound_stmt[stmt_ty]:
   | &"export" export_compound_stmt

The export keyword must be the first token in a class or def statement; it cannot be put after def or class. For example, the following is not valid:

def export name():  # NOT VALID
   ...

class export Name:  # NOT VALID
   ...

Behavior

A function definition or class definition statement can be prefixed with export to automatically export the name.

To visualize, the following code:

export def NAME1():
   ...

export class NAME2:
   ...

is semantically equivalent to:

def NAME1():
   ...
export NAME1

class NAME2:
   ...
export NAME2

As with assignments and standalone exports, using export def or export class outside of the global scope will raise a SyntaxError at compile time.

There are no other caveats; all other syntax features of classes and functions work when prefixed with export.

The module re-export statement

Grammar

A new rule is added and the existing import_from rule is modified:

import_or_export[expr_ty]:
   | 'import'
   | "export"

import_from[stmt_ty]:
   | "lazy"? 'from' ('.' | '...')* dotted_name import_or_export import_from_targets
   | "lazy"? 'from' ('.' | '...')+ import_or_export import_from_targets

Behavior

The “module re-export statement” is an extension to the behavior of the from imports; it does the exact same thing, but also exports each of the imported names.

For example, the following code:

from MODULE export NAME1, NAME2

is semantically equivalent to:

from MODULE import NAME1, NAME2
export NAME1, NAME2

Similar to the other export constructs, this must occur at the module-level; using it elsewhere is a SyntaxError.

Lazy imports, as described by PEP 810, are also allowed to be used with from exports. For example:

lazy from foo export bar

The existing rules for lazy imports apply here as well.

Rationale

This is not an access modifier

This PEP does not aim to be a mechanism for preventing access to private attributes in modules. The ExportError can be bypassed (such as by accessing attributes through the module’s __dict__).

This is by design. Python does not include access modifiers as a language feature for a reason. To quote Eric Smith: “Access to internals of other classes is a feature when you need it”. This PEP does not intend to change this convention, nor should it be interpreted as an indication that Python is moving toward true access modifiers.

Instead, the intention of this PEP is to improve clarity when inspecting modules at runtime, which should, in turn, improve the maintainer experience of Python modules in the long term.

Backwards Compatibility

This does not require changes to existing code

The functionality described in this PEP is only activated when a module defines __export__ in the global scope (or by using the export statement, which implicitly defines __export__). Modules that do not do this will experience the current behavior, where every name is exported by default.

__export__ overloads

This PEP has the potential to break users who were already defining global variables called __export__. That said, the Python language reference explicitly forbids users from doing this in the first place.

export (soft) keyword

export, as proposed by this PEP, is a soft keyword. It does not break backwards compatibility, meaning that existing code using “export” as a variable name will continue to work.

Security Implications

This PEP has no known security implications.

How to Teach This

Both the export statement and the __export__ variable will be documented as part of the language standard.

Maintaining backwards compatible codebases

To help adoption, it will be recommended that users define both __all__ and __export__ in their modules. This allows code on Python 3.16+ to get the proper export behavior, while older versions still keep their __all__ attribute. In practice, this should look something like this:

__all__ = ["hovercraft"]
__export__ = __all__ + ["eels"]

Or, if the package’s __all__ is equivalent to __export__:

__export__ = __all__

Comparison to JavaScript’s export keyword

In JavaScript, names are private by default, so the export keyword means “make this name public”. This idea does not apply well to Python, because in Python, names are public (as in, importable) by default, except when __export__ is present in a module’s namespace. So, a clearer definition for export in Python is “make everything else private except for this name”.

Reference Implementation

A reference implementation of this PEP can be found here.

Performance

The reference implementation does not currently implement any optimizations to reduce the overhead of the __export__ lookup or iteration, meaning that there is likely some overhead. However, if this PEP is accepted, optimizations will be implemented before the feature lands in CPython.

Rejected Ideas

Reuse __all__ for exports

Instead of adding a new __export__ variable, an alternative was to reuse __all__ for names.

This was ultimately decided against because it seemed clear that there were cases where a name could be in __export__, but not in __all__. The primary example for this case was with static typing. For example, a module may define several type aliases that would pollute a namespace if used with a wildcard import, so the developer chooses to not include them in __all__, but users of static typing will still want access to these type aliases for annotating their own code.

In addition, it’s not clear that there’s any good spelling for this behavior that covers all cases. The “obvious” solution is to add a new future statement that makes __all__ more strict, but that isn’t backwards compatible; codebases wanting to opt in to the behavior described by this PEP must use a spelling that works on all supported Python versions in order to keep their code working on older versions, so any solutions that add special functionality to __all__ generally will not work.

Emitting a warning upon accessing unexported attributes

This PEP initially proposed raising an ImportError upon accessing module attributes that were not listed in __export__. This was not well received, as the PEP did not clearly describe the intentions behind the proposal, and as such, many rejected the notion of “private attributes” as a knee-jerk reaction. Following that feedback, the ImportError turned into a warning, which was eventually determined to be a bad compromise.

Introduce __export__ on its own

The original revision of this proposal included __export__ as a standalone variable and did not provide any new syntax. The appeal of this was that it was backwards compatible; projects could simply write __export__ = __all__, and then when users upgraded to a version that supported __export__, they would get the documentation and enforcement benefits described by this PEP.

It was eventually decided that this was too conservative, because while __export__ was compatible with __all__, it shared many of the same problems with it, such as forgetting to add or remove items from the list.

To quote Guido van Rossum:

But the ergonomics are similar to those of __all__, and those are bad. It’s too easy to forget to add (or remove!) something to the list, and it’s distracting to have to update the export info in a totally different part of a file than the definition of the exported thing.

Add a private keyword for class bodies

During discussion of this proposal, it was suggested to add a private keyword for use in classes (or to allow export in class bodies). For example:

class Something:
   private def hello(self):
      print("Hello, world!")

   export def goodbye(self):
      print("Goodbye, world!")

This was rejected primarily because it does not have a clear benefit over the existing name mangling behavior (using the __ prefix), which also solves many of the problems described in the motivation of this PEP.

Additionally, this is much more difficult to implement. The author’s reference implementation involved new access protocols, disabling optimizations, and overall much more complexity when compared to the simple modification to the default module.__getattribute__ behavior required by __export__.

Add public and private decorators as builtins

Instead of adding a new export keyword, it was suggested to add private and public decorators, based on Barry Warsaw’s atpublic package, to the builtins module.

The decorators would have provided the same documentation aspect of this PEP, and potentially the same enforcement aspect, without the need for new syntax. For example:

@public
class MyPublicClass:
   ...

# Or
@private
class MyPrivateClass:
   ...

This is the author’s next preferred solution after export syntax, but it does come with some caveats. In particular, there’s no easy way to export simple variables without duplicating the name, which many dislike due to the violation of the DRY principle.

Nonetheless, this is specified in a competing proposal: PEP 844.

Make module names public by default

Many have argued that the current behavior of this proposal is counterintuitive, because in Python, names are public by default. This means the semantics of export break down when compared to other languages (see how JavaScript handles this). As a solution, it was proposed to keep names public by default and add a private keyword (along with a __private__ list) to describe which names are private. For example:

def public_name():
   ...

private def private_name():
   ...

This was rejected because it makes the intended goal of this proposal (making it clearer which names in a module are public) much harder to achieve in practice.

The purpose of this proposal is to avoid leaking private names into the public namespace, but with the proposed __private__ feature, the developer has to know and think about every possible name that can be defined. export does not have this problem; the developer thinks about the few names that need to be public, and then never has to think about privacy again. It is expected that if __private__ were used instead of __export__, then module authors would often forget to mark a name as private, and thus users would be led to believe that a private name is public.

It is also mechanically more difficult for developers, as large modules tend to have many more private names than public names, through imports, helper functions, and similar.

Finally, it is not believed that export is difficult to understand. As previously mentioned, export in Python doesn’t match the definition for export in JavaScript, but beyond that, understanding what export does, and why, seems fairly comprehensible for Python users.

Open Issues

How should packages have access to their own private members?

Imagine that a package has two modules:

  1. library/_utils.py, which is meant to contain utilities that are only for the developer of library.
  2. library/main.py, which holds public APIs that are usable to the users of library.

The names in _utils.py are not exported, because the module is not intended to be accessed by users of library. But, main.py should have access to these names; the current proposal would result in main.py getting an ExportError upon importing private names from _utils.py.

How should this be resolved? Is this necessary at all – as in, should _utils.py mark its utilities as exported, and ask that users don’t import anything from it?

Acknowledgements

Thanks to Hugo van Kemenade and Savannah Ostrowski for inspiring the idea behind this PEP.

In addition, the design behind this PEP was largely influenced by discussion and ideas from many people, including, but not limited to, Guido van Rossum, Paul Moore, Steve Dower, and Barry Warsaw.

Change History

  • 11-Aug-2026
    • Required __export__ to always be a list object.
    • Clarified some parts of the PEP based on questions from the discussion thread.
    • Added the ExportError builtin type (removing ExportWarning), and switched back to raising an exception upon accessing unexported names.
  • 05-Aug-2026
    • Added an export statement.
    • Added the ExportWarning builtin type, which is now emitted instead of a RuntimeWarning when accessing unexported attributes.
  • 01-Aug-2026
    • Accessing an unexported attribute now emits a RuntimeWarning instead of raising an ImportError.
    • Significantly overhauled the motivation section.