Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Lib/pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ def isdata(object):
inspect.isroutine(object) or inspect.isframe(object) or
inspect.istraceback(object) or inspect.iscode(object))

def _is_namespace_package(module):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ModuleSpec.origin docs give namespace packages only as an example of when it is None. It is also None for packages created by meta path importers that supplies its own loader and no origin.

Those are virtual packages whose submodules come from the loader, not from directories, so they are not namespace packages and repr() does not describe them as one. With this patch help() would report FILE (namespace) for them: no more accurate than today's (built-in), but now a specific claim that is false.

Every namespace package the import system creates, including ones from third-party path hooks that return loader-less specs, ends up with a NamespaceLoader (see _init_module_attrs). Checking the loader would be narrower and consistent with repr():

spec = getattr(module, '__spec__', None)
return isinstance(getattr(spec, 'loader', None), importlib.machinery.NamespaceLoader)

Is there a case you had in mind where the spec-based rule is needed?

spec = getattr(module, '__spec__', None)
return isinstance(getattr(spec, 'loader', None),
importlib.machinery.NamespaceLoader)

def replace(text, *pairs):
"""Do a series of global replacements on a string."""
while pairs:
Expand Down Expand Up @@ -798,7 +803,8 @@ def docmodule(self, object, name=None, mod=None, *ignored):
url = urllib.request.pathname2url(path)
filelink = self.filelink(url, path)
except TypeError:
filelink = '(built-in)'
filelink = ('(namespace)' if _is_namespace_package(object)
else '(built-in)')
info = []

if version := self._get_version(object):
Expand Down Expand Up @@ -1381,7 +1387,8 @@ def docmodule(self, object, name=None, mod=None, *ignored):
try:
file = inspect.getabsfile(object)
except TypeError:
file = '(built-in)'
file = ('(namespace)' if _is_namespace_package(object)
else '(built-in)')
result = result + self.section('FILE', file)
return result

Expand Down
122 changes: 122 additions & 0 deletions Lib/test/test_pydoc/test_pydoc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import sys
import contextlib
import importlib.machinery
import importlib.util
import inspect
import io
Expand Down Expand Up @@ -446,6 +447,127 @@ def test_text_doc(self):
(inspect.getabsfile(pydoc_mod),))
self.assertEqual(expected_text, result)

def test_namespace_package_file(self):
name = 'pydoc_namespace_package'
with tempfile.TemporaryDirectory() as tmpdir:
paths = [os.path.join(tmpdir, str(i)) for i in range(2)]
for path in paths:
os.makedirs(os.path.join(path, name, 'child'))
for count in (1, 2):
with self.subTest(portions=count):
with (import_helper.DirsOnSysPath(*paths[:count]),
import_helper.isolated_modules()):
sys.modules.pop(name, None)
sys.modules.pop(name + '.child', None)
for modname in (name, name + '.child'):
with self.subTest(module=modname):
module = importlib.import_module(modname)
self.assertIsNone(module.__file__)
self.assertEqual(len(module.__path__), count)
text, _ = get_pydoc_text(module)
html, _ = get_pydoc_html(module)
for kind, output, marker in (
('text', text, '\nFILE\n (namespace)'),
('html', html, 'index</a><br>(namespace)'),
):
with self.subTest(format=kind):
self.assertIn(marker, output)
self.assertNotIn('(built-in)', output)

def test_virtual_package_file(self):
name = 'pydoc_virtual_package'

class Loader:
def create_module(self, spec):
return None

def exec_module(self, module):
pass

class Finder:
def find_spec(self, fullname, path=None, target=None):
if fullname == name:
return importlib.util.spec_from_loader(
fullname, Loader(), is_package=True)

with (support.swap_attr(sys, 'meta_path', [Finder(), *sys.meta_path]),
import_helper.isolated_modules()):
module = importlib.import_module(name)
self.assertIsNone(module.__spec__.origin)
self.assertIsInstance(module.__spec__.loader, Loader)
self.assertNotIn('(namespace)', repr(module))
text, _ = get_pydoc_text(module)
html, _ = get_pydoc_html(module)
for kind, output, marker in (
('text', text, '\nFILE\n (built-in)'),
('html', html, 'index</a><br>(built-in)'),
):
with self.subTest(format=kind):
self.assertIn(marker, output)
self.assertNotIn('(namespace)', output)

def test_namespace_package_custom_path_hook(self):
name = 'pydoc_hook_namespace'
path = os.path.abspath('pydoc_namespace_hook_path')

class Finder:
def find_spec(self, fullname, target=None):
if fullname == name:
spec = importlib.machinery.ModuleSpec(
fullname, None, is_package=True)
spec.submodule_search_locations = [path]
return spec

def path_hook(entry):
if entry != path:
raise ImportError
return Finder()

with (support.swap_attr(sys, 'path_hooks', [path_hook, *sys.path_hooks]),
support.swap_attr(sys, 'path_importer_cache', {}),
import_helper.DirsOnSysPath(path),
import_helper.isolated_modules()):
module = importlib.import_module(name)
self.assertIsInstance(module.__spec__.loader,
importlib.machinery.NamespaceLoader)
text, _ = get_pydoc_text(module)
html, _ = get_pydoc_html(module)
for kind, output, marker in (
('text', text, '\nFILE\n (namespace)'),
('html', html, 'index</a><br>(namespace)'),
):
with self.subTest(format=kind):
self.assertIn(marker, output)
self.assertNotIn('(built-in)', output)

def test_module_file_classification_controls(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case from my comment on _is_namespace_package, where the two rules disagree, is not covered here: a package with origin=None and a loader of its own.

class Loader:
    def create_module(self, spec): return None
    def exec_module(self, module): pass

spec = importlib.util.spec_from_loader('pydoc_virtual_package', Loader(), is_package=True)
module = importlib.util.module_from_spec(spec)

The spec-based check renders it (namespace), the loader check keeps (built-in). Either way an assertion here would make the choice explicit.

modules = [sys, pydoc_mod, test.test_pydoc]
module = types.ModuleType('pydoc_originless_module')
module.__spec__ = importlib.util.spec_from_loader(
module.__name__, loader=None)
modules.append(module)
for origin in (None, 'built-in', 'frozen'):
module = types.ModuleType('pydoc_fileless_module')
if origin is not None:
module.__spec__ = importlib.util.spec_from_loader(
module.__name__, loader=None, origin=origin,
is_package=True)
module.__path__ = []
modules.append(module)
for module in modules:
with self.subTest(module=module.__name__, spec=module.__spec__):
text, _ = get_pydoc_text(module)
html, _ = get_pydoc_html(module)
self.assertNotIn('(namespace)', text)
self.assertNotIn('(namespace)', html)
if getattr(module, '__file__', None) is None:
self.assertIn('\nFILE\n (built-in)', text)
self.assertIn('index</a><br>(built-in)', html)
else:
filename = inspect.getabsfile(module)
self.assertIn('\nFILE\n ' + filename, text)
self.assertIn(urllib.request.pathname2url(filename), html)

def test_text_enum_member_with_value_zero(self):
# Test issue #20654 to ensure enum member with value 0 can be
# displayed. It used to throw KeyError: 'zero'.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Identify namespace packages correctly instead of labeling them as built-in
modules in the text and HTML documentation generated by :mod:`pydoc`.
Loading