Skip to content

Commit 1531f4f

Browse files
authored
Merge pull request #17598 from mhvk/astropy-dir-getattr
ENH: ensure that dir(astropy) lists subpackages
2 parents 80aa67d + 8fd65e0 commit 1531f4f

6 files changed

Lines changed: 104 additions & 22 deletions

File tree

‎astropy/__init__.py‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,55 @@
1111

1212
from astropy.utils.system_info import system_info
1313

14+
__all__ = [ # noqa: RUF100, RUF022
15+
"__version__",
16+
"__bibtex__",
17+
# Subpackages (mostly lazy-loaded)
18+
"config",
19+
"constants",
20+
"convolution",
21+
"coordinates",
22+
"cosmology",
23+
"io",
24+
"modeling",
25+
"nddata",
26+
"samp",
27+
"stats",
28+
"table",
29+
"tests",
30+
"time",
31+
"timeseries",
32+
"uncertainty",
33+
"units",
34+
"utils",
35+
"visualization",
36+
"wcs",
37+
# Functions
38+
"test",
39+
"log",
40+
"find_api_page",
41+
"online_help",
42+
"online_docs_root",
43+
"conf",
44+
"physical_constants",
45+
"astronomical_constants",
46+
"system_info",
47+
]
48+
49+
50+
def __getattr__(attr):
51+
if attr in __all__:
52+
from importlib import import_module
53+
54+
return import_module("astropy." + attr)
55+
56+
raise AttributeError(f"module 'astropy' has no attribute {attr!r}")
57+
58+
59+
def __dir__():
60+
return sorted(set(globals()).union(__all__))
61+
62+
1463
from .version import version as __version__
1564

1665
# The location of the online documentation for astropy
@@ -198,30 +247,14 @@ def online_help(query):
198247
webbrowser.open(url)
199248

200249

201-
__dir_inc__ = [
202-
"__version__",
203-
"__githash__",
204-
"__bibtex__",
205-
"test",
206-
"log",
207-
"find_api_page",
208-
"online_help",
209-
"online_docs_root",
210-
"conf",
211-
"physical_constants",
212-
"astronomical_constants",
213-
"system_info",
214-
]
215-
216-
217250
from types import ModuleType as __module_type__
218251

219-
# Clean up top-level namespace--delete everything that isn't in __dir_inc__
252+
# Clean up top-level namespace--delete everything that isn't in __all__
220253
# or is a magic attribute, and that isn't a submodule of this package
221254
for varname in dir():
222255
if not (
223256
(varname.startswith("__") and varname.endswith("__"))
224-
or varname in __dir_inc__
257+
or varname in __all__
225258
or (
226259
varname[0] != "_"
227260
and isinstance(locals()[varname], __module_type__)

‎astropy/tests/tests/test_imports.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# Licensed under a 3-clause BSD style license - see LICENSE.rst
22

33
import pkgutil
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
from textwrap import dedent
8+
from types import ModuleType
9+
10+
import pytest
11+
12+
import astropy
413

514

615
def test_imports():
@@ -32,3 +41,38 @@ def test_toplevel_namespace():
3241
assert "log" in d
3342
assert "test" in d
3443
assert "sys" not in d
44+
45+
46+
def test_toplevel_lazy_imports():
47+
# Check that subpackages are loaded on demand.
48+
cmd = dedent("""
49+
import astropy, sys
50+
assert 'astropy.units' not in sys.modules
51+
astropy.units
52+
assert 'astropy.units' in sys.modules
53+
""")
54+
cp = subprocess.check_call([sys.executable, "-c", cmd])
55+
assert cp == 0
56+
57+
58+
def test_toplevel_attribute_error():
59+
# Ensure that our __getattr__ does not leak an import error or so.
60+
with pytest.raises(AttributeError, match="module 'astropy' has no"):
61+
astropy.nonsense
62+
63+
64+
def test_completeness_toplevel__all__():
65+
# Implicitly check that all items in __all__ exist.
66+
all_items = {getattr(astropy, attr) for attr in astropy.__all__}
67+
# Verify that the list of modules in __all__ is complete.
68+
module_names = {
69+
item.__name__.partition(".")[2]
70+
for item in all_items
71+
if isinstance(item, ModuleType)
72+
}
73+
module_dirs = {
74+
f.name
75+
for f in Path(astropy.__file__).parent.glob("[a-z]*")
76+
if f.is_dir() and f.name != "extern"
77+
}
78+
assert module_names == module_dirs

‎astropy/utils/introspection.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,9 @@ def find_mod_objs(modname, onlylocals=False):
338338
mod = import_module(modname)
339339

340340
if hasattr(mod, "__all__"):
341-
pkgitems = [(k, mod.__dict__[k]) for k in mod.__all__]
341+
pkgitems = [(k, getattr(mod, k)) for k in mod.__all__]
342342
else:
343-
pkgitems = [(k, mod.__dict__[k]) for k in dir(mod) if k[0] != "_"]
343+
pkgitems = [(k, getattr(mod, k)) for k in dir(mod) if k[0] != "_"]
344344

345345
# filter out modules and pull the names and objs out
346346
ismodule = inspect.ismodule

‎docs/changes/17598.other.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
After ``import astropy``, ``dir(astropy)`` will now list all subpackages,
2+
including those that have not yet been loaded. This also means tab
3+
completion will work as expected (e.g., ``from astropy.coo<TAB>`` will
4+
expand to ``from astropy.coordinates``).
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
On representations the method `get_name` has been deprecated in favor of the class-level
2-
attribute `name`. The method will be removed in a future release.
1+
On representations the method ``get_name`` has been deprecated in favor of the class-level
2+
attribute ``name``. The method will be removed in a future release.

‎docs/nitpick-exceptions‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ py:class None. Remove all items from D.
7474
py:class a set-like object providing a view on D's items
7575
py:class a set-like object providing a view on D's keys
7676
py:class None. Update D from dict/iterable E and F.
77+
py:class None. Update D from mapping/iterable E and F.
7778
py:class an object providing a view on D's values
7879
py:class a shallow copy of D
7980

0 commit comments

Comments
 (0)