Hi,

I was refactoring some code today and ran into an issue that always bugs me with
Python modules. It bugged me enough this time that I spent an hour banging out 
this
potential proposal to add a new contextual keyword. Let me know what you think!

Theia

--------------------------------------------------------------------------------

A typical pattern for a python module is to have an __init__.py that looks
something like:

from .foo import (
    A,
    B,
    C,
)

from .bar import (
    D,
    E,
)

def baz():
    pass

__all__ = [
    "A",
    "B",
    "C",
    "D",
    "E",
    "baz",
]

This is annoying for a few reasons:

1. It requires name duplication
    a. It's easy for the top-level imports to get out of sync with __all__,
       meaning that __all__, instead of being useful for documentation, is
       actively misleading
    b. This encourages people to do `from .bar import *`, which screws up many
       linting tools like flake8, since they can't introspect the names, and
       also potentially allows definitions that have been deleted to
       accidentally persist in __all__.
2. Many symbol-renaming tools won't pick up on the names in __all__, as they're
   strings.

Prior art:
================================================================================

# Rust

Rust distinguishes between "use", which is a private import, "pub use", which is
a globally public import, and "pub(crate) use", which is a library-internal
import ("crate" is Rust's word for library)


# Javascript

In Javascript modules, there's an "export" keyword:

export function foo() { ... }

And there's a pattern called the "barrel export" that looks similar to a Python
import, but additionally exports the imported names:

export * from "./foo"; // re-exports all of foo's definitions

Additionally, a module can be gathered and exported by name, but not in one 
line:

import * as foo from "./foo";
export { foo };


# Python decorators

People have written utility Python decorators that allow exporting a single
function, such as this SO answer: https://stackoverflow.com/a/35710527/1159735

import sys

def export(fn):
    mod = sys.modules[fn.__module__]
    if hasattr(mod, '__all__'):
        mod.__all__.append(fn.__name__)
    else:
        mod.__all__ = [fn.__name__]
    return fn

, which allows you to write:

@export
def foo():
    pass

# __all__ == ["foo"]

, but this doesn't allow re-exporting imported values.


# Python implicit behavior

Python already has a rule that, if __all__ isn't declared, all
non-underscore-prefixed names are automatically exported. This is /ok/, but it's
not very explicit (Zen) -- it's easy to accidentally "import sys" instead of
"import sys as _sys" -- it makes doing the wrong thing the default state.


Proposal:
================================================================================

Add a contextual keyword "export" that has meaning in three places:

1. Preceding an "import" statement, which directs all names imported by that
   statement to be added to __all__:

    import sys
    export import .foo
    export import (
        A,
        B,
        C,
        D
    ) from .bar

    # __all__ == ["foo", "A", "B", "C", "D"]

2. Preceding a "def", "async def", or "class" keyword, directing that function
   or class's name to be added to __all__:

    def private(): pass
    export def foo(): pass
    export async def async_foo(): pass
    export class Foo: pass

    # __all__ == ["foo", "async_foo", "Foo"]

3. Preceding a bare name at top-level, directing that name to be added to
   __all__:

    x = 1
    y = 2
    export y

    # __all__ == ["y"]


# Big Caveat

For this scheme to work, __all__ needs to not be auto-populated with names.
While the behavior is possibly suprising, I think the best way to handle this is
to have __all__ not auto-populate if an "export" keyword appears in the file.
While this is somewhat-implicit behavior, it seems reasonable to me to expect 
that
if a user uses "export", they are opting in to the new way of managing __all__.
Likewise, I think manually assigning __all__ when using "export" should raise
an error, as it would overwrite all previous exports and be very confusing.
_______________________________________________
Python-ideas mailing list -- python-ideas@python.org
To unsubscribe send an email to python-ideas-le...@python.org
https://mail.python.org/mailman3/lists/python-ideas.python.org/
Message archived at 
https://mail.python.org/archives/list/python-ideas@python.org/message/HL3P7CXZX3U5SMNIJODL45BE6E72MWTI/
Code of Conduct: http://python.org/psf/codeofconduct/

Reply via email to