PEP 843: Export Statement for DRY Re-exports

For hubs, PEP 843 does make imports private in the sense that the imported names are not added to __all__. If you additionally want the symbols to be deleted, I think that should be a separate proposal to treat __all__ as a symbol filter.

I think a set of hub modules (or a unified hub module) is probably the best approach for a library whose implementation layout differs from its public layout. That’s most large projects.

So, if that supposition is wrong, how long should the Python world wait for a more perfect solution?

1 Like

Yes, for a hub layout, PEP 843 has this effect. But it doesn’t help with normal modules that define classes and functions and where I want to avoid writing __all__. (I mean, export still has the effect there that other imports are now private, but it also makes all my other symbols private unless I tediously add them to __all__.)

EDIT: sorry, I guess this post doesn’t really contribute much to the discussion beyond saying “not everyone wants to use hubs”…

No need to wait. Use it for now, and if something better comes along, adopt it if you want to. Just because I’m not a fan of the hub model doesn’t stop people from using it.

You seem to be putting a lot more importance on PEP 843 for making the hub layout work than I do. That’s fine, of course, but the argument for the PEP seems to boil down to “if you want (or need) to use the hub layout, this PEP makes it a little bit easier”. Which feels like a weak argument to me…

I don’t think that’s a thing you need to apologise for :slight_smile:

Making the distinction between “import” and “export” has the potential to benefit everyone who writes any modules that are part of the public API and that contain imports that aren’t intended as re-exports.

But the PEP as written doesn’t help us in that situation. That’s a big problem for the PEP. It’s got the potential to help almost 100% of python users, but instead it’s written in a way that might only benefit 10%? Also a pretty bad strategy if you want the PEP to succeed :wink:

We see here above that @pf_moore is opposed to the PEP because it doesn’t help him, or at least he doesn’t see how it would help him.

I think even as written there is some potential here for it to help people like me/us who don’t use hubs, because it will finally give linters a definitive way to tell that we’re not using hubs, which means they can interpret our imports as “not for export, do not use except in this module”.

But I do not (in general) want to maintain __all__s anywhere frankly, and I think it would be a crying shame if this PEP passed in a format that doesn’t make it easy (most of the time) to write modules where exactly the symbols that should be public are public. I think it’s worth having a typing backwards-incompatibility, where from Python 3.16 onwards all imports that aren’t exports are now private in a manner that will make all the linters sad. Partially because it would only be a run-time backwards-incompatibility in the places where * imports have been used. (Actually scratch that. Having a run-time incompatibility would be bad. Maybe temporarily having a difference between the typing rules and the * imports would be acceptable though.)

The hub layout does work already, which is why many (most?) large projects use it today.

I think “little bit” doesn’t reflect reality. Have you actually looked at the various real-world examples I’ve provided or is that more from your own intuition? Have you considered real-world hubs and how they would actually change in practice? In my opinion, it’s more than “a little bit easier”.

Please see: numpy, scipy, typer, pandas, polars, plotly, fastapi. The way that __all__ is assembled is far from uniform and often quite convoluted, and does not reflect how the imports are even done. This makes it very difficult to keep in sync with the imports and very difficult to review.

Where are you getting this “10%” figure from? Is that based on real codebases, or your own experience with them?

Why would it make sense to address non-hub modules and hub modules in the same PEP? The non-hub interfaces have many open questions, and a variety of possible solutions (e.g., PEP 842, 844). It will take an enormous amount of discussion to figure out whether, and how, we should simplify non-hub interfaces.

And these questions really aren’t related to hub modules. Hub modules have a fairly obvious space for solutions and, in my opinion, a fairly obvious solution.

So I disagree with the idea that it would be a “better strategy” as you say for this PEP to succeed if it addressed both hubs and non-hubs. On the contrary, the more you ask for, the lower the chance of PEP success since there are many more questions to answer.

This PEP solves a problem in what I think is the simplest possible way.

Yes, but he doesn’t use hubs so a PEP about simplifying hubs is not for him. Why should that be surprising? I don’t use async, but I don’t comment on PEPs about threading asking for better multiprocess support. Is that not the same thing?

Good point, but I don’t want to add this as a selling point since it would open a lot of questions about how your non-hub approach should be implemented.

Exactly, neither do I. This PEP eliminates nearly all uses of the dunder for hub users — a group that includes many (most?) large Python libraries.

Why would it be “a shame”? We have 842 and 844 if you want to discuss 2 approaches to doing that, or you could make your own proposal.

Good idea!

Maybe, or maybe you just require __all__ to be defined for star-import to work at the cost of breaking some code. (Yes, I know Paul hates this, but it would be simpler!)

But anyway, this is out of scope for the PEP. It’ll be easier if we just focus on what this actually solves by looking at real-world code like what I linked above.

1 Like

This is beginning to sound more and more like both sides are digging in their positions.

Neil, you’re getting close to attacking Paul personally rather than attacking his ideas:

  • “Have you actually looked at the various real-world examples I’ve provided or is that more from your own intuition?”
  • “Where are you getting this “10%” figure from?”
  • And a few others I can think of.

That’s no way to get a PEP accepted. The many rhetorical questions (e.g. “Why should that be surprising?”) are also getting on my nerves.

I’d like to propose something concrete and positive. I just clicked on your link to pandas and I agree that it looks hard to maintain, with the many lists (imports and __all__) to keep in sync. Can you describe how, concretely, (once pandas doesn’t need to support Python 3.15 or lower), pandas could be restructured to make it much more maintainable?

I’m trying to think how that would work, but I have some kind of mental block – I see that it’s nice to be able to add certain import but not others to __all__, but this still requires manually adding public classes, functions and constants to __all__. What am I missing? Is the idea that only the toplevel __init__.py uses export, or also the modules that define the public APIs that the toplevel __init__.py imports and exports?

I’d also like to hear a clear definition of the hub model.

2 Likes

I take the point about the cumulative tone, especially the number of rhetorical questions. I wasn’t trying to make the discussion personal, and I definitely wasn’t attacking Paul himself. I was challenging the basis for some fairly strong claims about how much the PEP helps and how widely the hub pattern is used.

That said, I can see that repeatedly putting those challenges in question form can make the exchange feel more adversarial than I intended. I can make the same points more directly by stating where I disagree, what evidence I’m relying on, and why I think the real-world examples matter, without framing them as challenges to Paul personally.

So I’m happy to adjust the tone and keep the discussion focused on the technical substance. I think your suggestion to work through pandas concretely is a much better way to establish what the PEP does and doesn’t buy us than continuing to argue in the abstract.

1 Like

By the hub model, I mean a package structure in which hub modules deliberately collect and re-export names defined elsewhere in the package. These hub modules form a tree rooted at the package’s top-level __init__.py. They are intended as public import locations: users import names from them without needing to know which implementation module actually defines those names.

Implementation modules, by contrast, import names as dependencies needed for their own implementation. They are typically placed under a private package such as _src to discourage users from importing from them directly.

Hub modules always define __all__. However, implementers are free to either:

  1. Define __all__ lists in their implementation modules as well, and then star-import in their hub modules, or else
  2. Use explicit imports in their hub modules.

With PEP 843, either way, the public interface is provided once. Approach #1 has it in the __all__ lists; Approach #2 has it in the hub module explicit exports.

My guess is that you’re imagining approach #1 above.

Approach #2 is attractive because it eliminates most uses of __all__. However, with PEP 844, approach #1 also does the same. Approach #2 is also more flexible because multiple names in a single implementation module could be exposed in different public modules.

I think that since approach #2 is more flexible, it’s probably the approach to encourage. You wouldn’t want to start with approach #1 and then have to switch.


Pandas is a classic example:

Imports like this:

from pandas.core.api import (
    # dtype
    ArrowDtype,
    Int8Dtype,
    Int16Dtype,
    Int32Dtype,

become exports, and the __all__ declaration is deleted:

__all__ = [
    "NA",
    "ArrowDtype",
    "BooleanDtype",
    "Categorical",
    "CategoricalDtype",
    "CategoricalIndex",
    "DataFrame",

True imports (there are two of them) remain:

import pandas.core.config_init  # pyright: ignore[reportUnusedImport] # noqa: F401

Numpy does the above, but also benefits from replacing:

    __numpy_submodules__ = {
        "linalg", "fft", "dtypes", "random", "polynomial", "ma",
        "exceptions", "lib", "ctypeslib", "testing", "typing",
        "f2py", "test", "rec", "char", "core", "strings",
    }


    __all__ = list(
        __numpy_submodules__ | ...


    def __dir__():
        public_symbols = (
            globals().keys() | __numpy_submodules__
        )
        ...


    def __getattr__(attr):
        if attr == "linalg":
            import numpy.linalg as linalg
            return linalg
        elif attr == "fft":
            import numpy.fft as fft
            return fft
        elif attr == "dtypes":
            import numpy.dtypes as dtypes
            return dtypes
        ...

with simply

lazy from . export linalg
lazy from . export fft
...
2 Likes

Thanks Guido. I’d just been reading Neil’s response and was going to say something very similar. I have read some of the links Neil posted, and they do indeed look hard to maintain - but I wasn’t able to work out how PEP 843 would make them significantly similar (admittedly, I didn’t try very hard). In particular, the bulk of the linked scipy module seems unrelated to this PEP. So a worked example would help a lot here.

I would accept, though, that I’m letting myself get a little entrenched here. I’ll try to step back a bit. I do think that the assertions around how common hubs are depends a lot on what types of project you’re familiar with - for example, very few of the projects I work on use a hub model (although I’m surprised to see how many of the ones I use do :slightly_smiling_face:) - and it’s worth bearing that in mind.

This is where I struggle. The model you describe seems to me to involve massive duplication of effort - the whole public API structure is written out twice, once in the implementation modules, and a second time in the “hub”. That may be the best way in current Python of exposing this information, but is it really what we want to recommend? Surely[1] in most projects the public API structure is pretty close to the implementation layout, with maybe some minor tweaks (and likely a lot of internal helpers that should be kept private, of course)? Because I’d expect it to be a bit of a maintenance nightmare if a problem a user reports as being in pkg.calculation.foo is actually referring to code in pkg.implementation.helpers.low_level.foo

My apologies - I don’t think my position came across clearly enough. I’ve mentioned multiple times that I don’t use hubs, and this PEP wouldn’t help me. But that’s not the reason for my opposition - I say it to provide context, and to make sure people know not to give my views too much weight (it’s much more important to take into account the views of people the PEP is actually aimed at helping!)

I’m opposed to the PEP because it seems to be a fairly significant change (adding a keyword, even a soft keyword, has never been something we’ve done lightly) which offers very limited benefits. There’s been a lot of debate over the importance of hub models (and I’m to blame for a bunch of that, I’m afraid) but relatively little positive feedback from people who would actually use the PEP and get significant benefit:

Have the maintainers of any of these projects expressed support for this PEP? I’m not familiar with who works on them, so I may not have recognised names if they have shown up.


  1. And yes, this is simply my intuition, based on projects I’ve worked on ↩︎

I’m a maintainer of NumPy and scipy-stubs, and I personally think his PEP is a good idea :). In fact, I wrote a library that’s very similar to this 5 years ago: GitHub - jorenham/exports: @export decorator that adds a function or class to __all__ · GitHub .

5 Likes

That library is more similar to PEP 844 and hence more suitable for Hub Approach #1 as described above, which this PEP doesn’t help with, right?

I suppose exports is indeed closer to PEP 844. But currently I have a slight preference for PEP 843, mostly because the projects I maintain have a lot of re-exports, and having to use public(spam=spam) for that doesn’t seem like a better option than using __all__.

1 Like

If there aren’t any internal names in the hub module, wouldn’t importing be sufficient? In that case, what’s the need for __all__? I definitely could have missed something.

1 Like

As it says in the pandas file, __all__ is there only for type checkers:

# Use __all__ to let type checkers know what is part of the public API.
# Pandas is not (yet) a py.typed library: the public API is determined
# based on the documentation.
__all__ = [
    "NA",
    "ArrowDtype",
    "BooleanDtype",
   # ...

EDIT: and without the __all__, you’d need to use import pandas.core.config_init as _config_init for the two internal imports in the file

It’s very unlikely that you won’t ever have to import things as private in a hub-module. A few examples:

  • from __future__ import annotations, in the sake of consistency
  • import sys to use sys.version_info and decide what should be exported (or any other reason), etc.
  • import warnings to warn about deprecated names in a __getattr__ method

My humble projects would immediately benefit from PEP 843 :smiley: For example: griffe/packages/griffelib/src/griffe/__init__.py at 09a2a1323d1b4d022a4ce76a9f2839287b9c100e · mkdocstrings/griffe · GitHub

I’m using hub-modules in all my projects, importing stuff from _internal and re-exporting in the top-level __init__ module as public.

5 Likes

The public API isnt specified in the implementation modules. The implementation modules contain the implementation, but they don’t tell you which symbols are public to users nor where in the public layout they are located.

The divergence between public and implementation layouts is pretty common. Take a look at, for example, jax where symbols from jax._src.config/compiler/api/ad_checkpoint/etc., are exposed at the top level. Similarly, jax.numpy collects symbols from plenty of modules.

The symbols within each hub module are too complex to be implemented in single modules.

I don’t think this is a problem in practice.

Fair enough, did my reply clarify things?

That’s a good question. Suppose that we did

  • deprecate __all__ so that tools don’t use it and therefore no one needs to bother setting it up,
  • make it so that type checkers consider ordinary imports as exports, and
  • we insist that everyone uses hubs (so that all non-underscore symbols including imports are intended as exports)

Then, instead of:

from .x import y  # ordinary import in hub doesn't get added to __all__
from .x export z  # ordinary export in hub is added to __all__

you would use the patterns:

from .x import y as _y  # ordinary import needs to be imported with underscore
from .x import z  # ordinary export

That would be fine, but I doubt you can convince everyone to use hubs exclusively.

And if you try to make this work for non-hubs, the imports look like exports unless you import them with an underscore, which probably no one wants. And there’s a wrinkle because from .xyz import z as _z also binds the symbol xyz.

Why would we have to do any of that? The current situation is that if you don’t define __all__, everything imported is going to be “exported”. The pandas file already explicitly dels things they don’t want reexported, and the only thing they import that doesn’t appear to be in __all__ is pandas itself (because they import pandas... submodules, which defines their own module). They could simply not have __all__ and be fine.

If the problem is type checkers choosing to ignore hub modules, despite the fact that the names really exist at execution time, then fix the type checkers. Don’t define new syntax. At most, define a new annotation/comment/__dunder__ = True that type checkers can recognise (assuming the heuristic of “this module has nothing but imports from submodules so probably they are meant to be the preferred names” doesn’t work well enough).

1 Like

Is that true as far as type checkers are concerned? I think they ordinarily complain unless you either use the special import from .x import y as y or use __all__. Yes, you can delete things to keep them private, but I think that makes the module very hard to read, and is error-prone.

The problem is that type checkers treat hub modules and publicly visible non-hub modules the same way since they have no way of distinguishing these two approaches. And non-hub modules count on one of the two methods of marking exports. Therefore, hub modules need to “speak the same language”.

That means that hub modules would have to mark everything they don’t want to export with underscores. Even then, since from .xyz import abc will not only expose abc, but also xyz. So you’ll still have to delete xyz, which is unfortunate. Besides these issues, I think this would be a fine solution.

But hub modules have mostly public names, with very few private names ? Isn’t it easier to change a few private names than all public imports ? And it doesn’t require a new keyword.

1 Like

Pandas already deletes them. As I said, there’s one innocuous name left behind in there already. It’s clearly not that big a deal.

Luckily, it doesn’t expose xyz, so there’s nothing unfortunate to delete. (It does create sys.modules["<__name__>.xyz"], which occasionally is undesirable, but that’s going to be a very advanced situation.)

(Sorry to do a big edit, but I misread a “don’t” in your post and responded to totally the wrong thing.)