PEP 844: `public` and `private` builtins

FWIW, the “right” way to do this (as it’s a common need for all accelerated modules, including those in the stdlib, not that they all do it perfectly) is to have a very thin subclass instead of an exported alias:

try:
    import _uuid

    class UUID(_uuid.UUID):
        pass
except ImportError:
    class UUID:
        # pure-Python implementation
        ....

Doesn’t really help the tools that only statically analyse Python code, but it helps a lot at runtime (e.g. pickle can store from the accelerated type will restore the non-accelerated type later).

In terms of “is it public or private”, since that’ the topic of the thread, I’d be very comfortable saying that the UUID in my example is public and the _uuid.UUID is private based entirely on one being imported and the other being defined.

The only really challenging aspect of this whole thing is transitive imports, which would usually be private through intent and public through reality, and doing the import X as _X dance feels like more work than it’s worth. Builtins, and certainly decorators, don’t really help as much here as having accurate __module__ and tools being aware that they probably shouldn’t be telling users to use urllib.parse.re.match instead of re.match just because it’s available. (In the past I’ve made tools recommend the shortest full name first, which is usually a good enough heuristic in the absence of better hints in the sources).

2 Likes

Maybe I wasn’t clear in my post. What I wanted to say is that users could also have their __all__ = ("foo", "bar") (because the language allows it and says it’s a way to document the public interface; you’re not forced to use a list). So I don’t think raising a ValueError is legitimate here. More precisely, the following should be legitimate:

__all__ = ("foo", "bar")

def foo(): pass
def bar(): pass
<lots of private functions>
@public
def qux(): pass
<lots of private functions>

assert __all__ == ("foo", "bar", "qux")

Now, if __all__ already exists I think it’s messy if both are mixed. Visually, you will see __all__ but if a decorator alters it later in the file and you don’t see it, it’s hard to know about it. I would suggest that any module using public or private shouldn’t really be allowed to have its own __all__. But this also means that having private is no longer necessary except for visual semantics.

To summarize: having code mixing __all__ and @public/@private is hard to read and to reason about. Is __all__ up-to-date? or is it the decorators that are out-of-date? thus I wonder whether existing __all__ should be allowed in this case.

1 Like

A list can be composed with addition only, sure, when the construction of the entire list is under control by the current code. What if part of it is not? Combining locating, slices and addition one can edit __all__ to delete entries, but having @private seems cleaner to me.

# __init__.py

from thisParty import *
from thirdParty import * # imports unwantedFunc

@private
def unwantedFunc():
    ...

# vs, I don't know, perhaps
# __all__ = [_ for _ in __all__ if _ != "unwantedFunc"]
1 Like

I find this case very weird especially because of the dummy redefinition of unwantedFunc (although private('unwantedFunc') may be an alternative in this case). In this case, I would rather consider doing del unwantedFunc.

In general for re-exporting modules, I find it better to either construct __all__ manually or use del.

1 Like

For what it’s worth, one can so to speak add lists and tuples without worrying about their differing types. I’ve learnt this from David Beazley’s Curio whose __init__.py reads

# curio/__init__.py

__version__ = '1.6'

from .errors import *
from .queue import *
from .task import *
from .time import *
from .kernel import *
from .sync import *
from .workers import *
from .network import *
from .file import *
from .channel import *
from .thread import *

__all__ = [*errors.__all__,
           *queue.__all__,
           *task.__all__,
           *time.__all__,
           *kernel.__all__,
           *sync.__all__,
           *workers.__all__,
           *network.__all__,
           *file.__all__,
           *channel.__all__,
           *thread.__all__,
           ]
4 Likes

I agree with that. I like the del better.

On the other hand, it means something different, right? After del unwantedFunc the function cannot be used internally in the module.

I disagree, primarily because tuples are immutable. That’s both the whole point of using them for __all__ but also a reason not to use it if you’re going to use @public, which explicitly mutates __all__.

“Allowed” is too strong, but I do agree that mixing explicit __all__ and @public is not good form and should be avoided.

5 Likes

I’m sorry, but I don’t think a built-in should disagree with something that is guaranteed by the language. It’s perfectly fine to change __all__ via reassignment and the outside world will only see the final value of __all__ once the module is fully imported. I don’t understand why this should be forbidden. It’s fine to write something like:

__all__ = ("foo", "bar")

def foo(): ...
def bar(): ...
<lots of private functions>
def qux()
__all__ += ("qux",)

While still weird, it’s a legitimate pattern and a correct one. If mixing __all__ and @public should be avoided but not enforced at runtime, there is no reason why mixing a tuple __all__ and @public raises a ValueError.

1 Like

It’s complicated and underspecified. The language spec says only that it must be “a sequence of strings” where “strings” is essentially unicode str objects. The implementation requires that sequence of strings be indexable, via __getitem__()/PySequence_GetItem(). So even __all__ = 'abc' is technically legal, as is a custom object defining a __getitem__() that returns strs, or a dict subclass with a __getitem__() taking sequential int keys. IndexError terminates the from __all__ import * loop[1]. Linters are often more strict, but those are tools that infer and impose additional rules rather than conform to the language specification or CPython interpreter behavior.

So, consider the @public implementation in those exotic but legal situations. Limiting the type to a list makes it easy: __all__.append() just works without possibly more expensive runtime introspection or exception fallbacks. You could type check __all__ for tuple-ness and += to “extend” but that’s a more expensive create-and-discard operation. Is that where you draw the line? Or do you explicitly also support the str and custom object use cases (and for the latter, I don’t think there’s any reliable way to extend it).

The “must-be-a-list-if-you-want-to-use-public” rule is simple to explain and implement, and is performant for a builtin that gets executed at module import time. I also don’t think it’s that insurmountable of a constraint, especially since you can always turn it into a tuple at the bottom of the module and gain all the same benefits.

At least that’s the reasoning for the library, and what I intend to continue to propose in the PEP. If say a future SC or PEP Delegate insists on different semantics, then I think we should also tighten up the language specification and CPython implementation.


  1. and forgetting the IndexError is a good way to hang the interpreter! ↩︎

3 Likes

Based on feedback here and elsewhere, plus some additional use case testing, I’ve released a new alpha version of @public. If you’re interested in this library, either as a reference implementation for PEP 844 or in your own independent use, please take a look. The changelog is below. You can install with pip install --pre atpublic[install] to get a sense of how it will work for PEP 844.

1 Like

If we’re already considering a built-in, it wouldn’t really be important. You can simply use PySequence_InPlaceConcat for concatenating what needs to be concatenated in this case. No need to type check anything here. It won’t really be more expensive IMO compared to the rest of the module’s execution, though you’ll need to replace the reference in globals() by the new tuple.

I also don’t think it’s that insurmountable of a constraint, especially since you can always turn it into a tuple at the bottom of the module and gain all the same benefits.

Yes but it defeats the purpose for me of “simplifying your code”. Writing __all__ = tuple(__all__) makes it harder for static analyzers as well (and I don’t know how mypy would infer the type in this case for instance).


Now, I think this won’t really be a nuisance. But I’d like the limitation’s rationale to be clearly spelled in the “rejected ideas” section (I know it’s in the “Restrictions” but it’s only because we need to mutate it; however there is nothing wrong with updating the __all__'s value in globals() with a replaced one; references to it would be lost though).


Now, I’d like to nitpick on some parts:

There is no complicated introspection, no allocation or work proportional to module size

Strictly speaking, there is a very small allocation for the list and its elements. But I agree that it’s negligible.

Code that already uses public or private as a variable or parameter name will begin to trip linters that flag shadowed builtins, such as flake8-builtins and the equivalent ruff rule. This is a diagnostic change rather than a behavioral one, and the same has been true of every builtin added to Python. How much existing code this affects has not been measured.

Can we have a quick GH search result just to have an idea (saying “as of TIMESTAMP, there are N usages of public/private”?


In the “Acknowledgments” section, the link to PEP-843 is missing.


It follows that @private alone does not exclude a name from from spam import *. Excluding names is the job of @public: as soon as any name in the module is marked public, __all__ exists, and everything not marked public is excluded automatically. @private records the author’s intent; @public is what makes that intent observable.

As an author, I wished I could also have this but I know it will be hard to do because this can’t be achieved solely with a decorator (but could be achieved with additional syntax). That is, it may be simply easier to write

<no __all__>
<lots of public functions>
@private
def my_private_function(): ...

Instead of having a leading _ or adding @public to EVERY other public function, it’d be easier for an author to write __private__ and consider __all__ = [<all names except the private one>]. Instead of creating an empty __all__, one should create an __all__ that contains all current global names in the module that do not start with an underscore.

Since the specs say

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 ('_').

I consider that not specifying __all__ means that it’s equivalent to have this variable implicitly defined as above. In particular, using @private should alter that set of variable at import time.

# <no __all__>
def f1(): ...
... 
def f999(): ...

@private
def g(): ...

# __all__ is implicitly set to ["f1", ..., "f999"]

For backwards compatibility, and to avoid breaking possible tests, __all__ is created by @private here and not automatically for all modules. By default, __all__ is not created (when neither @public/@private are used), but __all__ is always created when using that in a module (whether by creating it and extending it through @public calls, or by creating the implicit __all__ when @private is called first).

I’m unsure however if this is possible because I don’t well in which order the names are created when importing the module. If we follow regular execution, a public name defined after the call to @private would not be added in the initial list of __all__ because the function would not exist already in the module’s globals:

# __all__ is not specified
def f(): ...
@private
# Here, we need for @private to know that "h" exists later
# which needs the symtable as "h" is not in globals() here.
def g(): ...
# Here, __all__ should be ["f", "h"] even if "h" will be seen later.
def h(): ...

It’s not impossible to do, but it will definitely add an overhead as I don’t think we are keeping in memory the top-level names for the module being executed. Now, this can be easily achieved if populate_all() were exposed. I think there should be no heuristic, and clear specifications, so I would definitely prefer having something where I can explicitly say “this is private, the rest is public” rather than having to deal with “I add @public everywhere AND in addition I add @private where I want to be more expressive”.

Should public() and private() diagnose being called outside module scope? Neither inspects its calling scope today

I’d say yes. We want to be nice to users, not possibly trip them. I can definitely see people thinking that @public or @private could be used for more things (despite the docs saying something different) and I can definitely see a usage of @public/@private being extended to methods if we once decide to add __all__ to classes as well. So I would rather having this failing loudly. We should first determine how costly the check is.


FTR, a new keyword could also solve those issues and I personally would prefer a keyword but as the transition from @coroutine to async def, I think it’s also good to check if this is adopted enough first.

So much bikeshedding about the type of __all__. :frowning:

Why don’t we silently deprecate __all__ that’s not a list, supporting it forever, but documenting it as frowned upon.

Then @public can issue a non-silent deprecation warning if it finds an existing __all__ that isn’t a list, and silently do nothing. This won’t break any existing code, because no existing code is currently using the proposed built-in version of @public.

Type checkers and/or linters may also warn when they see __all__ is not a list.


Separately, I still don’t see the point of @private, and I doubly don’t see why it should try to remove the name from __all__. Having the same name declared both public and private is obviously a mistake (dynamic shenanigans notwithstanding) and should be flagged rather than silently doing something random.

9 Likes

Same feeling about private. It actually makes things more implicit than explicit.

# foo is implicitly public?
def foo(): ...

@private
def bar(): ...
# is foo public or private??
def foo(): ...

@public
def bar(): ...
@private
def baz(): ...

Except for the verbosity case above, where you have more public names than private ones (a case that isn’t supported by the current PEP anyway), I didn’t see compelling cases for private.

Poll: Public/internal handling — what behavior do we want?

There seem to be different views on what the public/internal distinction should actually mean. I’ve put together a short poll to gather broader input on the desired behavior and help ground the discussion of possible solutions.

Please share your preference here: Poll: Public/internal handling — what behavior do we want?

2 Likes

My post addresses that exact point.

It could be that they are done in different places.

E.g. Module A uses submodule B, in particular function B.f, it also wants to export everything from submodule B, except for function B.f. In A’s __init__.py it is convenient to from B import * and then subtract B.f.

That looks very contrived. Have you ever come across such a situation?

It would always be possible to do that, if you really wanted it, by direct manipulation of __all__.

Exactly. I think it would help people to look at common, real-world use-cases for this PEP. For example, textwrap.py. What would this PEP do? It would replace __all__ = [...] with @public before the respective functions. Does that look like an improvement? If so, then PEP 844 is probably a good idea :smiley:

As long as if you use a custom object, it must support __iadd__, otherwise you’d get a TypeError.

For a tuple, there’s creating the new tuple, copying the references from the old tuple, adding the new one, and gc’ing the old tuple. I haven’t measured anything, but PEP 844 could adjust the “it must be a list” constraint, to “it must support __iadd__” and if you use a tuple, it won’t be as performant, but that’s up to you.

It’s more subtle than that, because tuple.__iadd__() only accepts another tuple, but fortunately that syntax works for both lists and tuples:

>>> a = ()
>>> b = []
>>> a += ('public',)
>>> b += ('public',)
>>> a += ('other',)
>>> b += ('other',)
>>> a
('public', other)
>>> b
['public', 'other']