Skip to content

Commit d6c05ab

Browse files
PEP 842: Address feedback from third discussion round (#5092)
1 parent bcf35f1 commit d6c05ab

1 file changed

Lines changed: 182 additions & 25 deletions

File tree

peps/pep-0842.rst

Lines changed: 182 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ For example:
4949
5050
5151
This is **not** intended to be an access modifier for Python; see
52-
:ref:`the rationale <pep-842-not-an-access-modifier>`.
52+
:ref:`the rationale <pep-842-not-an-access-modifier>`. The mechanisms specified
53+
by this PEP are easy to work around if necessary.
5354

5455

5556
Motivation
@@ -207,7 +208,8 @@ importing modules that were also imported by that module. So, not only
207208
are users not prevented from accessing seemingly-public imports, they may be
208209
*encouraged* to do so by their language server! (This problem applies to any
209210
name that is meant to be private; it's just that imports are a particularly
210-
common case for this to occur.)
211+
common case for this to occur. For other examples, see :ref:`below
212+
<pep-842-accidental-private-access>`)
211213

212214

213215
Real-world cases
@@ -235,10 +237,26 @@ This caused a lot of breakage:
235237
* `Red Hat Bug 1583196 <https://bugzilla.redhat.com/show_bug.cgi?id=1583196>`__
236238

237239

240+
``requests.packages``
241+
^^^^^^^^^^^^^^^^^^^^^
242+
243+
The `requests <https://requests.readthedocs.io/en/latest/>`__ package
244+
had an internal vendoring namespace that users treated as an API, so
245+
after unvendoring packages, ``requests`` kept ``requests.packages``
246+
as an alias, which led to its own subtle breakage:
247+
248+
* `psf/requests#3985 <https://github.com/psf/requests/issues/3985>`__
249+
* `psf/requests#4102 <https://github.com/psf/requests/issues/4102>`__
250+
* `psf/requests#4104 <https://github.com/psf/requests/issues/4104>`__
251+
* `psf/requests#5327 <https://github.com/psf/requests/issues/5327>`__
252+
* `psf/requests#5561 <https://github.com/psf/requests/issues/5561>`__
253+
* `urllib3/urllib3#1518 <https://github.com/urllib3/urllib3/issues/1518>`__
254+
255+
238256
``botocore.vendored``
239257
^^^^^^^^^^^^^^^^^^^^^
240258

241-
The `botocore <https://github.com/boto/botocore>`__ package had vendored
259+
The `botocore <https://github.com/boto/botocore>`__ package also had vendored
242260
dependencies under the ``botocore.vendored`` namespace, which ended up
243261
being `relied upon by users <https://github.com/search?q=%22botocore.vendored.requests.packages%22&type=code>`__:
244262

@@ -273,6 +291,84 @@ and broke when they were removed in v0.23:
273291
* `Kaggle Product Feedback <https://www.kaggle.com/discussions/product-feedback/158412>`__
274292

275293

294+
.. _pep-842-accidental-private-access:
295+
296+
Other examples
297+
**************
298+
299+
Beyond imports, there are several examples where users accidentally accessed
300+
internal APIs, which resulted in breakage.
301+
302+
303+
``logging._acquireLock`` / ``logging._releaseLock``
304+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
305+
306+
The documentation for the :mod:`logging` module included a private API
307+
in an example. This example was then copy-pasted to several downstream projects,
308+
and was broken when the private APIs were removed in Python 3.13:
309+
310+
- `sqlmapproject/sqlmap#5731 <https://github.com/sqlmapproject/sqlmap/issues/5731>`__
311+
- `sqlmapproject/sqlmap#5796 <https://github.com/sqlmapproject/sqlmap/issues/5796>`__
312+
- `conda/conda#14439 <https://github.com/conda/conda/issues/14439>`__
313+
- `madphysicist/haggis#2 <https://gitlab.com/madphysicist/haggis/-/work_items/2>`__
314+
- `Debian Bug#1088763 <https://www.mail-archive.com/debian-bugs-dist@lists.debian.org/msg2003691.html>`__
315+
316+
317+
``matplotlib.cbook._check_in_list`` / ``matplotlib.cbook._rename_parameter``
318+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
319+
320+
`matplotlib <https://matplotlib.org/>`__ left some utility functions in a
321+
module-level namespace. These functions were prefixed with a leading underscore,
322+
but users disregarded this, leading to breakage when they were removed:
323+
324+
- `matplotlib/matplotlib#18494 <https://github.com/matplotlib/matplotlib/pull/18494>`__
325+
- `dougcahl/eddy_identification_winding#1 <https://github.com/dougcahl/eddy_identification_winding/issues/1>`__
326+
- `guchengxi1994/mask2json#58 <https://github.com/guchengxi1994/mask2json/issues/58>`__
327+
328+
329+
``concurrent.futures.thread._threads_queues``
330+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
331+
332+
In Python 3.8, a recipe to make :class:`~concurrent.futures.ThreadPoolExecutor`
333+
be killed by CTRL+C was spread around. This recipe used the internal API, and
334+
was missed by many users (or potentially seen, but ignored, due to the issues
335+
described :ref:`above <pep-842-prefixed-public>`), leading to breakage in Python
336+
3.9 when worker threads stopped being daemon:
337+
338+
- `clchiou/non_graceful_shutdown.py <https://gist.github.com/clchiou/f2608cbe54403edb0b13>`__
339+
- `python/cpython#83993 <https://github.com/python/cpython/issues/83993>`__
340+
- `cognitedata/cognite-sdk-python#1122 <https://github.com/cognitedata/cognite-sdk-python/pull/1122>`__
341+
- `Opentrons/opentrons#12970 <https://github.com/Opentrons/opentrons/pull/12970>`__
342+
343+
344+
``re._pattern_type``
345+
^^^^^^^^^^^^^^^^^^^^
346+
347+
Before the existence of :class:`re.Pattern`, the type of objects returned by
348+
:func:`re.compile` was private. Many users found it easier to access the internal
349+
type rather than do ``type(re.compile(''))``, which led to breakage in 3.7 when
350+
it was removed:
351+
352+
- `beetbox/beets#2986 <https://github.com/beetbox/beets/issues/2986>`__
353+
- `django-precise-bbcode#25 <https://github.com/ellmetha/django-precise-bbcode/issues/25>`__
354+
- `python/cpython#1646 <https://github.com/python/cpython/pull/1646>`__
355+
356+
357+
``asyncio.staggered_race``
358+
^^^^^^^^^^^^^^^^^^^^^^^^^^
359+
360+
The `aiohappyeyeballs <https://aiohappyeyeballs.aio-libs.org/>`__ package
361+
(which is internally used by `aiohttp <https://docs.aiohttp.org/>`__) used
362+
the internal ``staggered_race`` API from the :mod:`asyncio` module.
363+
This broke when the implementation was updated to no longer have a ``loop``
364+
parameter:
365+
366+
- `aio-libs/aiohttp#8599 <https://github.com/aio-libs/aiohttp/issues/8599>`__
367+
- `python/cpython#124639 <https://github.com/python/cpython/issues/124639>`__
368+
- `python/cpython#124390 <https://github.com/python/cpython/pull/124390>`__
369+
- `python/cpython#124700 <https://github.com/python/cpython/pull/124700>`__
370+
371+
276372
Linters cannot fight against imports
277373
************************************
278374

@@ -701,8 +797,8 @@ The grammar is changed to allow for the standalone ``export`` statement and
701797
| &"export" export_stmt
702798
703799
704-
Note that augmented assignments (``x += y``) are disallowed through a PEG action
705-
at compile time.
800+
Note that augmented assignments (``x += y``), subscripts (``x[y] = z``), and
801+
attributes (``x.y = z``) are disallowed through a PEG action at compile time.
706802

707803

708804
Standalone exports
@@ -788,6 +884,8 @@ The following are NOT valid:
788884
export my: str, hovercraft: str = "full of", "eels"
789885
export name := "walrus"
790886
export hello += "world"
887+
export trees[0] = "the larch"
888+
export something.name = "python"
791889
792890
793891
Exporting functions and classes
@@ -811,7 +909,7 @@ following is not valid:
811909

812910
.. code-block:: python
813911
814-
def export name(): # NOT VALID
912+
async def export name(): # NOT VALID
815913
...
816914
817915
class export Name: # NOT VALID
@@ -917,8 +1015,8 @@ This is not an access modifier
9171015
------------------------------
9181016

9191017
This PEP does not aim to be a mechanism for preventing access to private
920-
attributes in modules. The ``ExportError`` can be bypassed (such as by
921-
accessing attributes through the module's ``__dict__``).
1018+
attributes in modules. The ``ExportError`` can be bypassed and avoided;
1019+
see :ref:`below <pep-842-bypassing-export>`.
9221020

9231021
This is by design. Python does not include access modifiers as a language
9241022
feature for a reason. To `quote <https://discuss.python.org/t/104994/2>`__ Eric Smith:
@@ -1006,6 +1104,43 @@ in Python, names are public (as in, importable) by default, *except* when
10061104
for ``export`` in Python is "make everything else private except for this name".
10071105

10081106

1107+
.. _pep-842-bypassing-export:
1108+
1109+
Bypassing ``__export__``
1110+
------------------------
1111+
1112+
As mentioned previously, this proposal is not meant to be an ironclad
1113+
shield around private variables.
1114+
1115+
For prototyping, the simplest way to get around ``__export__`` is to simply
1116+
delete it:
1117+
1118+
.. code-block:: python
1119+
1120+
import module
1121+
1122+
del module.__export__
1123+
# All private variables in 'module' are now available
1124+
1125+
Or, for a more granular workaround, append specific private names to ``__export__``:
1126+
1127+
.. code-block:: python
1128+
1129+
import module
1130+
1131+
module.__export__.append("name_you_want")
1132+
1133+
However, this approach modifies the ``__export__`` list globally, meaning that
1134+
enforcement inside other packages will also be disabled. To avoid this, access
1135+
private variables through the module's ``__dict__``:
1136+
1137+
.. code-block:: python
1138+
1139+
import module
1140+
1141+
name_you_want = module.__dict__["name_you_want"]
1142+
1143+
10091144
Reference Implementation
10101145
========================
10111146

@@ -1058,9 +1193,11 @@ Emitting a warning upon accessing unexported attributes
10581193
This PEP initially proposed raising an :exc:`ImportError` upon accessing
10591194
module attributes that were not listed in ``__export__``. This was not
10601195
well received, as the PEP did not clearly describe the intentions behind
1061-
the proposal, and as such, many rejected the notion of "private attributes"
1062-
as a knee-jerk reaction. Following that feedback, the ``ImportError`` turned
1063-
into a warning, which was eventually determined to be a bad compromise.
1196+
the proposal. Following that feedback, the ``ImportError`` turned
1197+
into a warning, which was eventually determined to be a bad compromise,
1198+
as the ergonomics of warnings are much worse than exceptions, and because
1199+
many testing frameworks (such as ``pytest``) turn warnings into exceptions
1200+
during testing.
10641201

10651202

10661203
Introduce ``__export__`` on its own
@@ -1100,16 +1237,8 @@ For example:
11001237
export def goodbye(self):
11011238
print("Goodbye, world!")
11021239
1103-
1104-
This was rejected primarily because it does not have a clear benefit over
1105-
the existing :ref:`name mangling behavior <private-name-mangling>` (using
1106-
the ``__`` prefix), which also solves many of the problems described in the
1107-
motivation of this PEP.
1108-
1109-
Additionally, this is much more difficult to implement. The author's reference
1110-
implementation involved new access protocols, disabling optimizations, and overall
1111-
much more complexity when compared to the simple modification to the default
1112-
``module.__getattribute__`` behavior required by ``__export__``.
1240+
This is considered out of scope for this PEP, and may be revisited by a
1241+
future proposal.
11131242

11141243

11151244
Add ``public`` and ``private`` decorators as builtins
@@ -1192,20 +1321,39 @@ How should packages have access to their own private members?
11921321

11931322
Imagine that a package has two modules:
11941323

1195-
1. ``library/_utils.py``, which is meant to contain utilities that are only
1324+
1. ``library/utils.py``, which is meant to contain utilities that are only
11961325
for the developer of ``library``.
11971326
2. ``library/main.py``, which holds public APIs that are usable to the
11981327
users of ``library``.
11991328

1200-
The names in ``_utils.py`` are not exported, because the module is not intended
1329+
The names in ``utils.py`` are not exported, because the module is not intended
12011330
to be accessed by users of ``library``. But, ``main.py`` should have access to
12021331
these names; the current proposal would result in ``main.py`` getting an
1203-
``ExportError`` upon importing private names from ``_utils.py``.
1332+
``ExportError`` upon importing private names from ``utils.py``.
12041333

1205-
How should this be resolved? Is this necessary at all -- as in, should ``_utils.py``
1334+
How should this be resolved? Is this necessary at all -- as in, should ``utils.py``
12061335
mark its utilities as exported, and ask that users don't import anything from it?
12071336

12081337

1338+
Does ``export`` need a top-level marker?
1339+
----------------------------------------
1340+
1341+
Usage of ``export`` affects the runtime behavior of all other names defined
1342+
in a module, so it has been argued that this can make maintenance more difficult
1343+
in some cases. For example, if a developer is unsure whether a module already
1344+
uses ``export``, they would have to search the module for it in order to know
1345+
whether it is safe to declare a public API as ``export`` without affecting
1346+
the rest of the code.
1347+
1348+
As a solution, it was proposed to require ``export`` syntax to have some sort
1349+
of marker at the top of the module (such as an ``__export__ = []`` declaration
1350+
or a ``__future__`` import). This has not been decided upon yet, because it is
1351+
unclear whether the problem described above will actually turn out to be an
1352+
issue in practice; it is expected that many libraries will be consistent about
1353+
their usage of ``export``/``__export__`` internally, and thus it should not be
1354+
very difficult for a developer to know what kind of module they are working in.
1355+
1356+
12091357
Acknowledgements
12101358
================
12111359

@@ -1221,6 +1369,15 @@ Paul Moore, Steve Dower, and Barry Warsaw.
12211369
Change History
12221370
==============
12231371

1372+
* 12-Aug-2026
1373+
1374+
- Clarified whether the ``export`` statement works with subscripts and
1375+
attribute assignments.
1376+
- Added an open issue on whether ``export`` syntax should be necessary
1377+
at the top of the file.
1378+
- Added more examples for real-world cases.
1379+
- Added a section in "How To Teach This" about how to bypass ``__export__``.
1380+
12241381
* 11-Aug-2026
12251382

12261383
- Required ``__export__`` to always be a :class:`list` object.

0 commit comments

Comments
 (0)