1665 lines
61 KiB
Python
1665 lines
61 KiB
Python
"""
|
|
Python package `pdoc` provides types, functions, and a command-line
|
|
interface for accessing public documentation of Python modules, and
|
|
for presenting it in a user-friendly, industry-standard open format.
|
|
It is best suited for small- to medium-sized projects with tidy,
|
|
hierarchical APIs.
|
|
|
|
.. include:: ./documentation.md
|
|
"""
|
|
import types
|
|
import ast
|
|
import enum
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import inspect
|
|
import os
|
|
import os.path as path
|
|
import re
|
|
import sys
|
|
import typing
|
|
from contextlib import contextmanager
|
|
from copy import copy
|
|
from functools import lru_cache, reduce, partial, wraps
|
|
from itertools import tee, groupby
|
|
from types import ModuleType
|
|
from typing import ( # noqa: F401
|
|
cast, Any, Callable, Dict, Generator, Iterable, List, Mapping, NewType,
|
|
Optional, Set, Tuple, Type, TypeVar, Union,
|
|
)
|
|
from warnings import warn
|
|
|
|
from mako.lookup import TemplateLookup
|
|
from mako.exceptions import TopLevelLookupException
|
|
from mako.template import Template
|
|
|
|
try:
|
|
from pdoc._version import version as __version__ # noqa: F401
|
|
except ImportError:
|
|
__version__ = '???' # Package not installed
|
|
|
|
|
|
def _isclass(obj):
|
|
return inspect.isclass(obj) and not isinstance(obj, types.GenericAlias)
|
|
|
|
|
|
_get_type_hints = lru_cache()(typing.get_type_hints)
|
|
|
|
_URL_MODULE_SUFFIX = '.html'
|
|
_URL_INDEX_MODULE_SUFFIX = '.m.html' # For modules named literal 'index'
|
|
_URL_PACKAGE_SUFFIX = '/index.html'
|
|
|
|
# type.__module__ can be None by the Python spec. In those cases, use this value
|
|
_UNKNOWN_MODULE = '?'
|
|
|
|
T = TypeVar('T', 'Module', 'Class', 'Function', 'Variable')
|
|
|
|
__pdoc__: Dict[str, Union[bool, str]] = {}
|
|
|
|
tpl_lookup = TemplateLookup(
|
|
cache_args=dict(cached=True,
|
|
cache_type='memory'),
|
|
input_encoding='utf-8',
|
|
directories=[path.join(path.dirname(__file__), "templates")],
|
|
)
|
|
"""
|
|
A `mako.lookup.TemplateLookup` object that knows how to load templates
|
|
from the file system. You may add additional paths by modifying the
|
|
object's `directories` attribute.
|
|
"""
|
|
if os.getenv("XDG_CONFIG_HOME"):
|
|
tpl_lookup.directories.insert(0, path.join(os.getenv("XDG_CONFIG_HOME", ''), "pdoc"))
|
|
|
|
|
|
class Context(dict):
|
|
"""
|
|
The context object that maps all documented identifiers
|
|
(`pdoc.Doc.refname`) to their respective `pdoc.Doc` objects.
|
|
|
|
You can pass an instance of `pdoc.Context` to `pdoc.Module` constructor.
|
|
All `pdoc.Module` objects that share the same `pdoc.Context` will see
|
|
(and be able to link in HTML to) each other's identifiers.
|
|
|
|
If you don't pass your own `Context` instance to `Module` constructor,
|
|
a global context object will be used.
|
|
"""
|
|
__pdoc__['Context.__init__'] = False
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# A surrogate so that the check in Module._link_inheritance()
|
|
# "__pdoc__-overriden key {!r} does not exist" can see the object
|
|
# (and not warn).
|
|
self.blacklisted = getattr(args[0], 'blacklisted', set()) if args else set()
|
|
|
|
|
|
_global_context = Context()
|
|
|
|
|
|
def reset():
|
|
"""Resets the global `pdoc.Context` to the initial (empty) state."""
|
|
global _global_context
|
|
_global_context.clear()
|
|
|
|
# Clear LRU caches
|
|
for func in (_get_type_hints,
|
|
_is_blacklisted,
|
|
_is_whitelisted):
|
|
func.cache_clear()
|
|
for cls in (Doc, Module, Class, Function, Variable, External):
|
|
for _, method in inspect.getmembers(cls):
|
|
if isinstance(method, property):
|
|
method = method.fget
|
|
if hasattr(method, 'cache_clear'):
|
|
method.cache_clear()
|
|
|
|
|
|
def _get_config(**kwargs):
|
|
# Apply config.mako configuration
|
|
MAKO_INTERNALS = Template('').module.__dict__.keys()
|
|
DEFAULT_CONFIG = path.join(path.dirname(__file__), 'templates', 'config.mako')
|
|
config = {}
|
|
for config_module in (Template(filename=DEFAULT_CONFIG).module,
|
|
tpl_lookup.get_template('/config.mako').module):
|
|
config.update((var, getattr(config_module, var, None))
|
|
for var in config_module.__dict__
|
|
if var not in MAKO_INTERNALS)
|
|
|
|
known_keys = (set(config)
|
|
| {'docformat'} # Feature. https://github.com/pdoc3/pdoc/issues/169
|
|
# deprecated
|
|
| {'module', 'modules', 'http_server', 'external_links', 'search_query'})
|
|
invalid_keys = {k: v for k, v in kwargs.items() if k not in known_keys}
|
|
if invalid_keys:
|
|
warn(f'Unknown configuration variables (not in config.mako): {invalid_keys}')
|
|
config.update(kwargs)
|
|
|
|
if 'search_query' in config:
|
|
warn('Option `search_query` has been deprecated. Use `google_search_query` instead',
|
|
DeprecationWarning, stacklevel=2)
|
|
config['google_search_query'] = config['search_query']
|
|
del config['search_query']
|
|
|
|
return config
|
|
|
|
|
|
def _render_template(template_name, **kwargs):
|
|
"""
|
|
Returns the Mako template with the given name. If the template
|
|
cannot be found, a nicer error message is displayed.
|
|
"""
|
|
config = _get_config(**kwargs)
|
|
|
|
try:
|
|
t = tpl_lookup.get_template(template_name)
|
|
except TopLevelLookupException:
|
|
paths = [path.join(p, template_name.lstrip('/')) for p in tpl_lookup.directories]
|
|
raise OSError(f"No template found at any of: {', '.join(paths)}")
|
|
|
|
try:
|
|
return t.render(**config).strip()
|
|
except Exception:
|
|
from mako import exceptions
|
|
print(exceptions.text_error_template().render(),
|
|
file=sys.stderr)
|
|
raise
|
|
|
|
|
|
def html(module_name, docfilter=None, reload=False, skip_errors=False, **kwargs) -> str:
|
|
"""
|
|
Returns the documentation for the module `module_name` in HTML
|
|
format. The module must be a module or an importable string.
|
|
|
|
`docfilter` is an optional predicate that controls which
|
|
documentation objects are shown in the output. It is a function
|
|
that takes a single argument (a documentation object) and returns
|
|
`True` or `False`. If `False`, that object will not be documented.
|
|
"""
|
|
mod = Module(import_module(module_name, reload=reload),
|
|
docfilter=docfilter, skip_errors=skip_errors)
|
|
link_inheritance()
|
|
return mod.html(**kwargs)
|
|
|
|
|
|
def text(module_name, docfilter=None, reload=False, skip_errors=False, **kwargs) -> str:
|
|
"""
|
|
Returns the documentation for the module `module_name` in plain
|
|
text format suitable for viewing on a terminal.
|
|
The module must be a module or an importable string.
|
|
|
|
`docfilter` is an optional predicate that controls which
|
|
documentation objects are shown in the output. It is a function
|
|
that takes a single argument (a documentation object) and returns
|
|
`True` or `False`. If `False`, that object will not be documented.
|
|
"""
|
|
mod = Module(import_module(module_name, reload=reload),
|
|
docfilter=docfilter, skip_errors=skip_errors)
|
|
link_inheritance()
|
|
return mod.text(**kwargs)
|
|
|
|
|
|
def import_module(module: Union[str, ModuleType],
|
|
*, reload: bool = False) -> ModuleType:
|
|
"""
|
|
Return module object matching `module` specification (either a python
|
|
module path or a filesystem path to file/directory).
|
|
"""
|
|
@contextmanager
|
|
def _module_path(module):
|
|
from os.path import abspath, dirname, isfile, isdir, split
|
|
path = '_pdoc_dummy_nonexistent'
|
|
module_name = inspect.getmodulename(module)
|
|
if isdir(module):
|
|
path, module = split(abspath(module))
|
|
elif isfile(module) and module_name:
|
|
path, module = dirname(abspath(module)), module_name
|
|
try:
|
|
sys.path.insert(0, path)
|
|
yield module
|
|
finally:
|
|
sys.path.remove(path)
|
|
|
|
if isinstance(module, Module):
|
|
module = module.obj
|
|
if isinstance(module, str):
|
|
with _module_path(module) as module_path:
|
|
try:
|
|
module = importlib.import_module(module_path)
|
|
except Exception as e:
|
|
raise ImportError(f'Error importing {module!r}: {e.__class__.__name__}: {e}')
|
|
|
|
assert inspect.ismodule(module)
|
|
# If this is pdoc itself, return without reloading. Otherwise later
|
|
# `isinstance(..., pdoc.Doc)` calls won't work correctly.
|
|
if reload and not module.__name__.startswith(__name__):
|
|
module = importlib.reload(module)
|
|
# We recursively reload all submodules, in case __all_ is used - cf. issue #264
|
|
for mod_key, mod in list(sys.modules.items()):
|
|
if mod_key.startswith(module.__name__):
|
|
importlib.reload(mod)
|
|
return module
|
|
|
|
|
|
def _pairwise(iterable):
|
|
"""s -> (s0,s1), (s1,s2), (s2, s3), ..."""
|
|
a, b = tee(iterable)
|
|
next(b, None)
|
|
return zip(a, b)
|
|
|
|
|
|
def _pep224_docstrings(doc_obj: Union['Module', 'Class'], *,
|
|
_init_tree=None) -> Tuple[Dict[str, str],
|
|
Dict[str, str]]:
|
|
"""
|
|
Extracts PEP-224 docstrings and doc-comments (`#: ...`) for variables of `doc_obj`
|
|
(either a `pdoc.Module` or `pdoc.Class`).
|
|
|
|
Returns a tuple of two dicts mapping variable names to their docstrings.
|
|
The second dict contains instance variables and is non-empty only in case
|
|
`doc_obj` is a `pdoc.Class` which has `__init__` method.
|
|
"""
|
|
# No variables in namespace packages
|
|
if isinstance(doc_obj, Module) and doc_obj.is_namespace:
|
|
return {}, {}
|
|
|
|
vars: Dict[str, str] = {}
|
|
instance_vars: Dict[str, str] = {}
|
|
|
|
if _init_tree:
|
|
tree = _init_tree
|
|
else:
|
|
try:
|
|
# Maybe raise exceptions with appropriate message
|
|
# before using cleaned doc_obj.source
|
|
_ = inspect.findsource(doc_obj.obj)
|
|
tree = ast.parse(doc_obj.source) # type: ignore
|
|
except (OSError, TypeError, SyntaxError) as exc:
|
|
# Don't emit a warning for builtins that don't have source available
|
|
is_builtin = getattr(doc_obj.obj, '__module__', None) == 'builtins'
|
|
if not is_builtin:
|
|
warn(f"Couldn't read PEP-224 variable docstrings from {doc_obj!r}: {exc}",
|
|
stacklevel=3 + int(isinstance(doc_obj, Class)))
|
|
return {}, {}
|
|
|
|
if isinstance(doc_obj, Class):
|
|
tree = tree.body[0] # ast.parse creates a dummy ast.Module wrapper
|
|
|
|
# For classes, maybe add instance variables defined in __init__
|
|
# Get the *last* __init__ node in case it is preceded by @overloads.
|
|
for node in reversed(tree.body):
|
|
if isinstance(node, ast.FunctionDef) and node.name == '__init__':
|
|
instance_vars, _ = _pep224_docstrings(doc_obj, _init_tree=node)
|
|
break
|
|
|
|
def get_name(assign_node):
|
|
if isinstance(assign_node, ast.Assign) and len(assign_node.targets) == 1:
|
|
target = assign_node.targets[0]
|
|
elif isinstance(assign_node, ast.AnnAssign):
|
|
target = assign_node.target
|
|
# Skip the annotation. PEP 526 says:
|
|
# > Putting the instance variable annotations together in the class
|
|
# > makes it easier to find them, and helps a first-time reader of the code.
|
|
else:
|
|
return None
|
|
|
|
if not _init_tree and isinstance(target, ast.Name):
|
|
name = target.id
|
|
elif (_init_tree and
|
|
isinstance(target, ast.Attribute) and
|
|
isinstance(target.value, ast.Name) and
|
|
target.value.id == 'self'):
|
|
name = target.attr
|
|
else:
|
|
return None
|
|
|
|
if not _is_public(name) and not _is_whitelisted(name, doc_obj):
|
|
return None
|
|
|
|
return name
|
|
|
|
# For handling PEP-224 docstrings for variables
|
|
for assign_node, str_node in _pairwise(ast.iter_child_nodes(tree)):
|
|
if not (isinstance(assign_node, (ast.Assign, ast.AnnAssign)) and
|
|
isinstance(str_node, ast.Expr) and
|
|
isinstance(str_node.value, ast.Str)):
|
|
continue
|
|
|
|
name = get_name(assign_node)
|
|
if not name:
|
|
continue
|
|
|
|
docstring = inspect.cleandoc(str_node.value.s).strip()
|
|
if not docstring:
|
|
continue
|
|
|
|
vars[name] = docstring
|
|
|
|
# For handling '#:' docstrings for variables
|
|
for assign_node in ast.iter_child_nodes(tree):
|
|
if not isinstance(assign_node, (ast.Assign, ast.AnnAssign)):
|
|
continue
|
|
|
|
name = get_name(assign_node)
|
|
if not name:
|
|
continue
|
|
|
|
# Already documented. PEP-224 method above takes precedence.
|
|
if name in vars:
|
|
continue
|
|
|
|
def get_indent(line):
|
|
return len(line) - len(line.lstrip())
|
|
|
|
source_lines = doc_obj.source.splitlines() # type: ignore
|
|
assign_line = source_lines[assign_node.lineno - 1]
|
|
assign_indent = get_indent(assign_line)
|
|
comment_lines = []
|
|
MARKER = '#: '
|
|
for line in reversed(source_lines[:assign_node.lineno - 1]):
|
|
if get_indent(line) == assign_indent and line.lstrip().startswith(MARKER):
|
|
comment_lines.append(line.split(MARKER, maxsplit=1)[1])
|
|
else:
|
|
break
|
|
|
|
# Since we went 'up' need to reverse lines to be in correct order
|
|
comment_lines = comment_lines[::-1]
|
|
|
|
# Finally: check for a '#: ' comment at the end of the assignment line itself.
|
|
if MARKER in assign_line:
|
|
comment_lines.append(assign_line.rsplit(MARKER, maxsplit=1)[1])
|
|
|
|
if comment_lines:
|
|
vars[name] = '\n'.join(comment_lines)
|
|
|
|
return vars, instance_vars
|
|
|
|
|
|
@lru_cache()
|
|
def _is_whitelisted(name: str, doc_obj: Union['Module', 'Class']):
|
|
"""
|
|
Returns `True` if `name` (relative or absolute refname) is
|
|
contained in some module's __pdoc__ with a truish value.
|
|
"""
|
|
refname = f'{doc_obj.refname}.{name}'
|
|
module: Optional[Module] = doc_obj.module
|
|
while module:
|
|
qualname = refname[len(module.refname) + 1:]
|
|
if module.__pdoc__.get(qualname) or module.__pdoc__.get(refname):
|
|
return True
|
|
module = module.supermodule
|
|
return False
|
|
|
|
|
|
@lru_cache()
|
|
def _is_blacklisted(name: str, doc_obj: Union['Module', 'Class']):
|
|
"""
|
|
Returns `True` if `name` (relative or absolute refname) is
|
|
contained in some module's __pdoc__ with value False.
|
|
"""
|
|
refname = f'{doc_obj.refname}.{name}'
|
|
module: Optional[Module] = doc_obj.module
|
|
while module:
|
|
qualname = refname[len(module.refname) + 1:]
|
|
if module.__pdoc__.get(qualname) is False or module.__pdoc__.get(refname) is False:
|
|
return True
|
|
module = module.supermodule
|
|
return False
|
|
|
|
|
|
def _is_public(ident_name):
|
|
"""
|
|
Returns `True` if `ident_name` matches the export criteria for an
|
|
identifier name.
|
|
"""
|
|
return not ident_name.startswith("_")
|
|
|
|
|
|
def _is_function(obj):
|
|
return inspect.isroutine(obj) and callable(obj)
|
|
|
|
|
|
def _is_descriptor(obj):
|
|
return (inspect.isdatadescriptor(obj) or
|
|
inspect.ismethoddescriptor(obj) or
|
|
inspect.isgetsetdescriptor(obj) or
|
|
inspect.ismemberdescriptor(obj))
|
|
|
|
|
|
def _filter_type(type: Type[T],
|
|
values: Union[Iterable['Doc'], Mapping[str, 'Doc']]) -> List[T]:
|
|
"""
|
|
Return a list of values from `values` of type `type`.
|
|
"""
|
|
if isinstance(values, dict):
|
|
values = values.values()
|
|
return [i for i in values if isinstance(i, type)]
|
|
|
|
|
|
def _toposort(graph: Mapping[T, Set[T]]) -> Generator[T, None, None]:
|
|
"""
|
|
Return items of `graph` sorted in topological order.
|
|
Source: https://rosettacode.org/wiki/Topological_sort#Python
|
|
"""
|
|
items_without_deps = reduce(set.union, graph.values(), set()) - set(graph.keys()) # type: ignore # noqa: E501
|
|
yield from items_without_deps
|
|
ordered = items_without_deps
|
|
while True:
|
|
graph = {item: (deps - ordered)
|
|
for item, deps in graph.items()
|
|
if item not in ordered}
|
|
ordered = {item
|
|
for item, deps in graph.items()
|
|
if not deps}
|
|
yield from ordered
|
|
if not ordered:
|
|
break
|
|
assert not graph, f"A cyclic dependency exists amongst {graph!r}"
|
|
|
|
|
|
def link_inheritance(context: Context = None):
|
|
"""
|
|
Link inheritance relationsships between `pdoc.Class` objects
|
|
(and between their members) of all `pdoc.Module` objects that
|
|
share the provided `context` (`pdoc.Context`).
|
|
|
|
You need to call this if you expect `pdoc.Doc.inherits` and
|
|
inherited `pdoc.Doc.docstring` to be set correctly.
|
|
"""
|
|
if context is None:
|
|
context = _global_context
|
|
|
|
graph = {cls: set(cls.mro(only_documented=True))
|
|
for cls in _filter_type(Class, context)}
|
|
|
|
for cls in _toposort(graph):
|
|
cls._fill_inheritance()
|
|
|
|
for module in _filter_type(Module, context):
|
|
module._link_inheritance()
|
|
|
|
|
|
class Doc:
|
|
"""
|
|
A base class for all documentation objects.
|
|
|
|
A documentation object corresponds to *something* in a Python module
|
|
that has a docstring associated with it. Typically, this includes
|
|
modules, classes, functions, and methods. However, `pdoc` adds support
|
|
for extracting some docstrings from abstract syntax trees, making
|
|
(module, class or instance) variables supported too.
|
|
|
|
A special type of documentation object `pdoc.External` is used to
|
|
represent identifiers that are not part of the public interface of
|
|
a module. (The name "External" is a bit of a misnomer, since it can
|
|
also correspond to unexported members of the module, particularly in
|
|
a class's ancestor list.)
|
|
"""
|
|
__slots__ = ('module', 'name', 'obj', 'docstring', 'inherits')
|
|
|
|
def __init__(self, name: str, module, obj, docstring: str = None):
|
|
"""
|
|
Initializes a documentation object, where `name` is the public
|
|
identifier name, `module` is a `pdoc.Module` object where raw
|
|
Python object `obj` is defined, and `docstring` is its
|
|
documentation string. If `docstring` is left empty, it will be
|
|
read with `inspect.getdoc()`.
|
|
"""
|
|
self.module = module
|
|
"""
|
|
The module documentation object that this object is defined in.
|
|
"""
|
|
|
|
self.name = name
|
|
"""
|
|
The identifier name for this object.
|
|
"""
|
|
|
|
self.obj = obj
|
|
"""
|
|
The raw python object.
|
|
"""
|
|
|
|
docstring = (docstring or inspect.getdoc(obj) or '').strip()
|
|
if '.. include::' in docstring:
|
|
from pdoc.html_helpers import _ToMarkdown
|
|
docstring = _ToMarkdown.admonitions(docstring, self.module, ('include',))
|
|
self.docstring = docstring
|
|
"""
|
|
The cleaned docstring for this object with any `.. include::`
|
|
directives resolved (i.e. content included).
|
|
"""
|
|
|
|
self.inherits: Optional[Union[Class, Function, Variable]] = None
|
|
"""
|
|
The Doc object (Class, Function, or Variable) this object inherits from,
|
|
if any.
|
|
"""
|
|
|
|
def __repr__(self):
|
|
return f'<{self.__class__.__name__} {self.refname!r}>'
|
|
|
|
@property # type: ignore
|
|
@lru_cache()
|
|
def source(self) -> str:
|
|
"""
|
|
Cleaned (dedented) source code of the Python object. If not
|
|
available, an empty string.
|
|
"""
|
|
try:
|
|
lines, _ = inspect.getsourcelines(self.obj)
|
|
except (ValueError, TypeError, OSError):
|
|
return ''
|
|
return inspect.cleandoc(''.join(['\n'] + lines))
|
|
|
|
@property
|
|
def refname(self) -> str:
|
|
"""
|
|
Reference name of this documentation
|
|
object, usually its fully qualified path
|
|
(e.g. <code>pdoc.Doc.refname</code>). Every
|
|
documentation object provides this property.
|
|
"""
|
|
# Ok for Module and External, the rest need it overriden
|
|
return self.name
|
|
|
|
@property
|
|
def qualname(self) -> str:
|
|
"""
|
|
Module-relative "qualified" name of this documentation
|
|
object, used for show (e.g. <code>Doc.qualname</code>).
|
|
"""
|
|
return getattr(self.obj, '__qualname__', self.name)
|
|
|
|
@lru_cache()
|
|
def url(self, relative_to: 'Module' = None, *, link_prefix: str = '',
|
|
top_ancestor: bool = False) -> str:
|
|
"""
|
|
Canonical relative URL (including page fragment) for this
|
|
documentation object.
|
|
|
|
Specify `relative_to` (a `pdoc.Module` object) to obtain a
|
|
relative URL.
|
|
|
|
For usage of `link_prefix` see `pdoc.html()`.
|
|
|
|
If `top_ancestor` is `True`, the returned URL instead points to
|
|
the top ancestor in the object's `pdoc.Doc.inherits` chain.
|
|
"""
|
|
if top_ancestor:
|
|
self = self._inherits_top()
|
|
|
|
if relative_to is None or link_prefix:
|
|
return link_prefix + self._url()
|
|
|
|
if self.module.name == relative_to.name:
|
|
return f'#{self.refname}'
|
|
|
|
# Otherwise, compute relative path from current module to link target
|
|
url = os.path.relpath(self._url(), relative_to.url()).replace(path.sep, '/')
|
|
# We have one set of '..' too many
|
|
if url.startswith('../'):
|
|
url = url[3:]
|
|
return url
|
|
|
|
def _url(self):
|
|
return f'{self.module._url()}#{self.refname}'
|
|
|
|
def _inherits_top(self):
|
|
"""
|
|
Follow the `pdoc.Doc.inherits` chain and return the top object.
|
|
"""
|
|
top = self
|
|
while top.inherits:
|
|
top = top.inherits
|
|
return top
|
|
|
|
def __lt__(self, other):
|
|
return self.refname < other.refname
|
|
|
|
|
|
class Module(Doc):
|
|
"""
|
|
Representation of a module's documentation.
|
|
"""
|
|
__pdoc__["Module.name"] = """
|
|
The name of this module with respect to the context/path in which
|
|
it was imported from. It is always an absolute import path.
|
|
"""
|
|
|
|
__slots__ = ('supermodule', 'doc', '_context', '_is_inheritance_linked',
|
|
'_skipped_submodules')
|
|
|
|
def __init__(self, module: Union[ModuleType, str], *, docfilter: Callable[[Doc], bool] = None,
|
|
supermodule: 'Module' = None, context: Context = None,
|
|
skip_errors: bool = False):
|
|
"""
|
|
Creates a `Module` documentation object given the actual
|
|
module Python object.
|
|
|
|
`docfilter` is an optional predicate that controls which
|
|
sub-objects are documentated (see also: `pdoc.html()`).
|
|
|
|
`supermodule` is the parent `pdoc.Module` this module is
|
|
a submodule of.
|
|
|
|
`context` is an instance of `pdoc.Context`. If `None` a
|
|
global context object will be used.
|
|
|
|
If `skip_errors` is `True` and an unimportable, erroneous
|
|
submodule is encountered, a warning will be issued instead
|
|
of raising an exception.
|
|
"""
|
|
if isinstance(module, str):
|
|
module = import_module(module)
|
|
|
|
super().__init__(module.__name__, self, module)
|
|
if self.name.endswith('.__init__') and not self.is_package:
|
|
self.name = self.name[:-len('.__init__')]
|
|
|
|
self._context = _global_context if context is None else context
|
|
"""
|
|
A lookup table for ALL doc objects of all modules that share this context,
|
|
mainly used in `Module.find_ident()`.
|
|
"""
|
|
assert isinstance(self._context, Context), \
|
|
'pdoc.Module(context=) should be a pdoc.Context instance'
|
|
|
|
self.supermodule = supermodule
|
|
"""
|
|
The parent `pdoc.Module` this module is a submodule of, or `None`.
|
|
"""
|
|
|
|
self.doc: Dict[str, Union[Module, Class, Function, Variable]] = {}
|
|
"""A mapping from identifier name to a documentation object."""
|
|
|
|
self._is_inheritance_linked = False
|
|
"""Re-entry guard for `pdoc.Module._link_inheritance()`."""
|
|
|
|
self._skipped_submodules = set()
|
|
|
|
var_docstrings, _ = _pep224_docstrings(self)
|
|
|
|
# Populate self.doc with this module's public members
|
|
public_objs = []
|
|
if hasattr(self.obj, '__all__'):
|
|
for name in self.obj.__all__:
|
|
try:
|
|
obj = getattr(self.obj, name)
|
|
except AttributeError:
|
|
warn(f"Module {self.module!r} doesn't contain identifier `{name}` "
|
|
"exported in `__all__`")
|
|
if not _is_blacklisted(name, self):
|
|
obj = inspect.unwrap(obj)
|
|
public_objs.append((name, obj))
|
|
else:
|
|
def is_from_this_module(obj):
|
|
mod = inspect.getmodule(inspect.unwrap(obj))
|
|
return mod is None or mod.__name__ == self.obj.__name__
|
|
|
|
for name, obj in inspect.getmembers(self.obj):
|
|
if ((_is_public(name) or
|
|
_is_whitelisted(name, self)) and
|
|
(_is_blacklisted(name, self) or # skips unwrapping that follows
|
|
is_from_this_module(obj) or
|
|
name in var_docstrings)):
|
|
|
|
if _is_blacklisted(name, self):
|
|
self._context.blacklisted.add(f'{self.refname}.{name}')
|
|
continue
|
|
|
|
obj = inspect.unwrap(obj)
|
|
public_objs.append((name, obj))
|
|
|
|
index = list(self.obj.__dict__).index
|
|
public_objs.sort(key=lambda i: index(i[0]))
|
|
|
|
for name, obj in public_objs:
|
|
if _is_function(obj):
|
|
self.doc[name] = Function(name, self, obj)
|
|
elif _isclass(obj):
|
|
self.doc[name] = Class(name, self, obj)
|
|
elif name in var_docstrings:
|
|
self.doc[name] = Variable(name, self, var_docstrings[name], obj=obj)
|
|
|
|
# If the module is a package, scan the directory for submodules
|
|
if self.is_package:
|
|
|
|
def iter_modules(paths):
|
|
"""
|
|
Custom implementation of `pkgutil.iter_modules()`
|
|
because that one doesn't play well with namespace packages.
|
|
See: https://github.com/pypa/setuptools/issues/83
|
|
"""
|
|
from os.path import isdir, join
|
|
for pth in paths:
|
|
for file in os.listdir(pth):
|
|
if file.startswith(('.', '__pycache__', '__init__.py')):
|
|
continue
|
|
module_name = inspect.getmodulename(file)
|
|
if module_name:
|
|
yield module_name
|
|
if isdir(join(pth, file)) and '.' not in file:
|
|
yield file
|
|
|
|
for root in iter_modules(self.obj.__path__):
|
|
# Ignore if this module was already doc'd.
|
|
if root in self.doc:
|
|
continue
|
|
|
|
# Ignore if it isn't exported
|
|
if not _is_public(root) and not _is_whitelisted(root, self):
|
|
continue
|
|
if _is_blacklisted(root, self):
|
|
self._skipped_submodules.add(root)
|
|
continue
|
|
|
|
assert self.refname == self.name
|
|
fullname = f"{self.name}.{root}"
|
|
try:
|
|
m = Module(import_module(fullname),
|
|
docfilter=docfilter, supermodule=self,
|
|
context=self._context, skip_errors=skip_errors)
|
|
except Exception as ex:
|
|
if skip_errors:
|
|
warn(str(ex), Module.ImportWarning)
|
|
continue
|
|
raise
|
|
|
|
self.doc[root] = m
|
|
# Skip empty namespace packages because they may
|
|
# as well be other auxiliary directories
|
|
if m.is_namespace and not m.doc:
|
|
del self.doc[root]
|
|
self._context.pop(m.refname)
|
|
|
|
# Apply docfilter
|
|
if docfilter:
|
|
for name, dobj in self.doc.copy().items():
|
|
if not docfilter(dobj):
|
|
self.doc.pop(name)
|
|
self._context.pop(dobj.refname, None)
|
|
|
|
# Build the reference name dictionary of the module
|
|
self._context[self.refname] = self
|
|
for docobj in self.doc.values():
|
|
self._context[docobj.refname] = docobj
|
|
if isinstance(docobj, Class):
|
|
self._context.update((obj.refname, obj)
|
|
for obj in docobj.doc.values())
|
|
|
|
class ImportWarning(UserWarning):
|
|
"""
|
|
Our custom import warning because the builtin is ignored by default.
|
|
https://docs.python.org/3/library/warnings.html#default-warning-filter
|
|
"""
|
|
|
|
__pdoc__['Module.ImportWarning'] = False
|
|
|
|
@property
|
|
def __pdoc__(self) -> dict:
|
|
"""This module's __pdoc__ dict, or an empty dict if none."""
|
|
return getattr(self.obj, '__pdoc__', {})
|
|
|
|
def _link_inheritance(self):
|
|
# Inherited members are already in place since
|
|
# `Class._fill_inheritance()` has been called from
|
|
# `pdoc.fill_inheritance()`.
|
|
# Now look for docstrings in the module's __pdoc__ override.
|
|
|
|
if self._is_inheritance_linked:
|
|
# Prevent re-linking inheritance for modules which have already
|
|
# had done so. Otherwise, this would raise "does not exist"
|
|
# errors if `pdoc.link_inheritance()` is called multiple times.
|
|
return
|
|
|
|
# Apply __pdoc__ overrides
|
|
for name, docstring in self.__pdoc__.items():
|
|
# In case of whitelisting with "True", there's nothing to do
|
|
if docstring is True:
|
|
continue
|
|
|
|
refname = f"{self.refname}.{name}"
|
|
if docstring in (False, None):
|
|
if docstring is None:
|
|
warn('Setting `__pdoc__[key] = None` is deprecated; '
|
|
'use `__pdoc__[key] = False` '
|
|
f'(key: {name!r}, module: {self.name!r}).')
|
|
|
|
if name in self._skipped_submodules:
|
|
continue
|
|
|
|
if (not name.endswith('.__init__') and
|
|
name not in self.doc and
|
|
refname not in self._context and
|
|
refname not in self._context.blacklisted):
|
|
warn(f'__pdoc__-overriden key {name!r} does not exist '
|
|
f'in module {self.name!r}')
|
|
|
|
obj = self.find_ident(name)
|
|
cls = getattr(obj, 'cls', None)
|
|
if cls:
|
|
del cls.doc[obj.name]
|
|
self.doc.pop(name, None)
|
|
self._context.pop(refname, None)
|
|
|
|
# Pop also all that startwith refname
|
|
for key in list(self._context.keys()):
|
|
if key.startswith(refname + '.'):
|
|
del self._context[key]
|
|
|
|
continue
|
|
|
|
dobj = self.find_ident(refname)
|
|
if isinstance(dobj, External):
|
|
continue
|
|
if not isinstance(docstring, str):
|
|
raise ValueError('__pdoc__ dict values must be strings; '
|
|
f'__pdoc__[{name!r}] is of type {type(docstring)}')
|
|
dobj.docstring = inspect.cleandoc(docstring)
|
|
|
|
# Now after docstrings are set correctly, continue the
|
|
# inheritance routine, marking members inherited or not
|
|
for c in _filter_type(Class, self.doc):
|
|
c._link_inheritance()
|
|
|
|
self._is_inheritance_linked = True
|
|
|
|
def text(self, **kwargs) -> str:
|
|
"""
|
|
Returns the documentation for this module as plain text.
|
|
"""
|
|
txt = _render_template('/text.mako', module=self, **kwargs)
|
|
return re.sub("\n\n\n+", "\n\n", txt)
|
|
|
|
def html(self, minify=True, **kwargs) -> str:
|
|
"""
|
|
Returns the documentation for this module as
|
|
self-contained HTML.
|
|
|
|
If `minify` is `True`, the resulting HTML is minified.
|
|
|
|
For explanation of other arguments, see `pdoc.html()`.
|
|
|
|
`kwargs` is passed to the `mako` render function.
|
|
"""
|
|
html = _render_template('/html.mako', module=self, **kwargs)
|
|
if minify:
|
|
from pdoc.html_helpers import minify_html
|
|
html = minify_html(html)
|
|
return html
|
|
|
|
@property
|
|
def is_package(self) -> bool:
|
|
"""
|
|
`True` if this module is a package.
|
|
|
|
Works by checking whether the module has a `__path__` attribute.
|
|
"""
|
|
return hasattr(self.obj, "__path__")
|
|
|
|
@property
|
|
def is_namespace(self) -> bool:
|
|
"""
|
|
`True` if this module is a namespace package.
|
|
"""
|
|
try:
|
|
return self.obj.__spec__.origin in (None, 'namespace') # None in Py3.7+
|
|
except AttributeError:
|
|
return False
|
|
|
|
def find_class(self, cls: type) -> Doc:
|
|
"""
|
|
Given a Python `cls` object, try to find it in this module
|
|
or in any of the exported identifiers of the submodules.
|
|
"""
|
|
# XXX: Is this corrent? Does it always match
|
|
# `Class.module.name + Class.qualname`?. Especially now?
|
|
# If not, see what was here before.
|
|
return self.find_ident(f'{cls.__module__ or _UNKNOWN_MODULE}.{cls.__qualname__}')
|
|
|
|
def find_ident(self, name: str) -> Doc:
|
|
"""
|
|
Searches this module and **all** other public modules
|
|
for an identifier with name `name` in its list of
|
|
exported identifiers.
|
|
|
|
The documentation object corresponding to the identifier is
|
|
returned. If one cannot be found, then an instance of
|
|
`External` is returned populated with the given identifier.
|
|
"""
|
|
_name = name.rstrip('()') # Function specified with parentheses
|
|
|
|
if _name.endswith('.__init__'): # Ref to class' init is ref to class itself
|
|
_name = _name[:-len('.__init__')]
|
|
|
|
return (self.doc.get(_name) or
|
|
self._context.get(_name) or
|
|
self._context.get(f'{self.name}.{_name}') or
|
|
External(name))
|
|
|
|
def _filter_doc_objs(self, type: Type[T], sort=True) -> List[T]:
|
|
result = _filter_type(type, self.doc)
|
|
return sorted(result) if sort else result
|
|
|
|
def variables(self, sort=True) -> List['Variable']:
|
|
"""
|
|
Returns all documented module-level variables in the module,
|
|
optionally sorted alphabetically, as a list of `pdoc.Variable`.
|
|
"""
|
|
return self._filter_doc_objs(Variable, sort)
|
|
|
|
def classes(self, sort=True) -> List['Class']:
|
|
"""
|
|
Returns all documented module-level classes in the module,
|
|
optionally sorted alphabetically, as a list of `pdoc.Class`.
|
|
"""
|
|
return self._filter_doc_objs(Class, sort)
|
|
|
|
def functions(self, sort=True) -> List['Function']:
|
|
"""
|
|
Returns all documented module-level functions in the module,
|
|
optionally sorted alphabetically, as a list of `pdoc.Function`.
|
|
"""
|
|
return self._filter_doc_objs(Function, sort)
|
|
|
|
def submodules(self) -> List['Module']:
|
|
"""
|
|
Returns all documented sub-modules of the module sorted
|
|
alphabetically as a list of `pdoc.Module`.
|
|
"""
|
|
return self._filter_doc_objs(Module)
|
|
|
|
def _url(self):
|
|
url = self.module.name.replace('.', '/')
|
|
if self.is_package:
|
|
return url + _URL_PACKAGE_SUFFIX
|
|
elif url.endswith('/index'):
|
|
return url + _URL_INDEX_MODULE_SUFFIX
|
|
return url + _URL_MODULE_SUFFIX
|
|
|
|
|
|
def _getmembers_all(obj: type) -> List[Tuple[str, Any]]:
|
|
# The following code based on inspect.getmembers() @ 5b23f7618d43
|
|
mro = obj.__mro__[:-1] # Skip object
|
|
names = set(dir(obj))
|
|
# Add keys from bases
|
|
for base in mro:
|
|
names.update(base.__dict__.keys())
|
|
# Add members for which type annotations exist
|
|
names.update(getattr(obj, '__annotations__', {}).keys())
|
|
|
|
results = []
|
|
for name in names:
|
|
try:
|
|
value = getattr(obj, name)
|
|
except AttributeError:
|
|
for base in mro:
|
|
if name in base.__dict__:
|
|
value = base.__dict__[name]
|
|
break
|
|
else:
|
|
# Missing slot member or a buggy __dir__;
|
|
# In out case likely a type-annotated member
|
|
# which we'll interpret as a variable
|
|
value = None
|
|
results.append((name, value))
|
|
return results
|
|
|
|
|
|
class Class(Doc):
|
|
"""
|
|
Representation of a class' documentation.
|
|
"""
|
|
__slots__ = ('doc', '_super_members')
|
|
|
|
def __init__(self, name: str, module: Module, obj, *, docstring: str = None):
|
|
assert inspect.isclass(obj)
|
|
|
|
if docstring is None:
|
|
init_doc = inspect.getdoc(obj.__init__) or ''
|
|
if init_doc == object.__init__.__doc__:
|
|
init_doc = ''
|
|
docstring = f'{inspect.getdoc(obj) or ""}\n\n{init_doc}'.strip()
|
|
|
|
super().__init__(name, module, obj, docstring=docstring)
|
|
|
|
self.doc: Dict[str, Union[Function, Variable]] = {}
|
|
"""A mapping from identifier name to a `pdoc.Doc` objects."""
|
|
|
|
# Annotations for filtering.
|
|
# Use only own, non-inherited annotations (the rest will be inherited)
|
|
annotations = getattr(self.obj, '__annotations__', {})
|
|
|
|
public_objs = []
|
|
for _name, obj in _getmembers_all(self.obj):
|
|
# Filter only *own* members. The rest are inherited
|
|
# in Class._fill_inheritance()
|
|
if ((_name in self.obj.__dict__ or
|
|
_name in annotations) and
|
|
(_is_public(_name) or
|
|
_is_whitelisted(_name, self))):
|
|
|
|
if _is_blacklisted(_name, self):
|
|
self.module._context.blacklisted.add(f'{self.refname}.{_name}')
|
|
continue
|
|
|
|
obj = inspect.unwrap(obj)
|
|
public_objs.append((_name, obj))
|
|
|
|
def definition_order_index(
|
|
name,
|
|
_annot_index=list(annotations).index,
|
|
_dict_index=list(self.obj.__dict__).index):
|
|
try:
|
|
return _dict_index(name)
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
return _annot_index(name) - len(annotations) # sort annotated first
|
|
except ValueError:
|
|
return 9e9
|
|
|
|
public_objs.sort(key=lambda i: definition_order_index(i[0]))
|
|
|
|
var_docstrings, instance_var_docstrings = _pep224_docstrings(self)
|
|
|
|
# Convert the public Python objects to documentation objects.
|
|
for name, obj in public_objs:
|
|
if _is_function(obj):
|
|
self.doc[name] = Function(
|
|
name, self.module, obj, cls=self)
|
|
else:
|
|
self.doc[name] = Variable(
|
|
name, self.module,
|
|
docstring=(
|
|
var_docstrings.get(name) or
|
|
(_isclass(obj) or _is_descriptor(obj)) and inspect.getdoc(obj)),
|
|
cls=self,
|
|
obj=getattr(obj, 'fget', getattr(obj, '__get__', None)),
|
|
instance_var=(_is_descriptor(obj) or
|
|
name in getattr(self.obj, '__slots__', ())))
|
|
|
|
for name, docstring in instance_var_docstrings.items():
|
|
self.doc[name] = Variable(
|
|
name, self.module, docstring, cls=self,
|
|
obj=getattr(self.obj, name, None),
|
|
instance_var=True)
|
|
|
|
@staticmethod
|
|
def _method_type(cls: type, name: str):
|
|
"""
|
|
Returns `None` if the method `name` of class `cls`
|
|
is a regular method. Otherwise, it returns
|
|
`classmethod` or `staticmethod`, as appropriate.
|
|
"""
|
|
func = getattr(cls, name, None)
|
|
if inspect.ismethod(func):
|
|
# If the function is already bound, it's a classmethod.
|
|
# Regular methods are not bound before initialization.
|
|
return classmethod
|
|
for c in inspect.getmro(cls):
|
|
if name in c.__dict__:
|
|
if isinstance(c.__dict__[name], staticmethod):
|
|
return staticmethod
|
|
return None
|
|
raise RuntimeError(f"{cls}.{name} not found")
|
|
|
|
@property
|
|
def refname(self) -> str:
|
|
return f'{self.module.name}.{self.qualname}'
|
|
|
|
def mro(self, only_documented=False) -> List['Class']:
|
|
"""
|
|
Returns a list of ancestor (superclass) documentation objects
|
|
in method resolution order.
|
|
|
|
The list will contain objects of type `pdoc.Class`
|
|
if the types are documented, and `pdoc.External` otherwise.
|
|
"""
|
|
classes = [cast(Class, self.module.find_class(c))
|
|
for c in inspect.getmro(self.obj)
|
|
if c not in (self.obj, object)]
|
|
if self in classes:
|
|
# This can contain self in case of a class inheriting from
|
|
# a class with (previously) the same name. E.g.
|
|
#
|
|
# class Loc(namedtuple('Loc', 'lat lon')): ...
|
|
#
|
|
# We remove it from ancestors so that toposort doesn't break.
|
|
classes.remove(self)
|
|
if only_documented:
|
|
classes = _filter_type(Class, classes)
|
|
return classes
|
|
|
|
def subclasses(self) -> List['Class']:
|
|
"""
|
|
Returns a list of subclasses of this class that are visible to the
|
|
Python interpreter (obtained from `type.__subclasses__()`).
|
|
|
|
The objects in the list are of type `pdoc.Class` if available,
|
|
and `pdoc.External` otherwise.
|
|
"""
|
|
return sorted(cast(Class, self.module.find_class(c))
|
|
for c in type.__subclasses__(self.obj))
|
|
|
|
def params(self, *, annotate=False, link=None) -> List[str]:
|
|
"""
|
|
Return a list of formatted parameters accepted by the
|
|
class constructor (method `__init__`). See `pdoc.Function.params`.
|
|
"""
|
|
name = self.name + '.__init__'
|
|
qualname = self.qualname + '.__init__'
|
|
refname = self.refname + '.__init__'
|
|
exclusions = self.module.__pdoc__
|
|
if name in exclusions or qualname in exclusions or refname in exclusions:
|
|
return []
|
|
|
|
return Function._params(self, annotate=annotate, link=link, module=self.module)
|
|
|
|
def _filter_doc_objs(self, type: Type[T], include_inherited=True,
|
|
filter_func: Callable[[T], bool] = lambda x: True,
|
|
sort=True) -> List[T]:
|
|
result = [obj for obj in _filter_type(type, self.doc)
|
|
if (include_inherited or not obj.inherits) and filter_func(obj)]
|
|
return sorted(result) if sort else result
|
|
|
|
def class_variables(self, include_inherited=True, sort=True) -> List['Variable']:
|
|
"""
|
|
Returns an optionally-sorted list of `pdoc.Variable` objects that
|
|
represent this class' class variables.
|
|
"""
|
|
return self._filter_doc_objs(
|
|
Variable, include_inherited, lambda dobj: not dobj.instance_var,
|
|
sort)
|
|
|
|
def instance_variables(self, include_inherited=True, sort=True) -> List['Variable']:
|
|
"""
|
|
Returns an optionally-sorted list of `pdoc.Variable` objects that
|
|
represent this class' instance variables. Instance variables
|
|
are those defined in a class's `__init__` as `self.variable = ...`.
|
|
"""
|
|
return self._filter_doc_objs(
|
|
Variable, include_inherited, lambda dobj: dobj.instance_var,
|
|
sort)
|
|
|
|
def methods(self, include_inherited=True, sort=True) -> List['Function']:
|
|
"""
|
|
Returns an optionally-sorted list of `pdoc.Function` objects that
|
|
represent this class' methods.
|
|
"""
|
|
return self._filter_doc_objs(
|
|
Function, include_inherited, lambda dobj: dobj.is_method,
|
|
sort)
|
|
|
|
def functions(self, include_inherited=True, sort=True) -> List['Function']:
|
|
"""
|
|
Returns an optionally-sorted list of `pdoc.Function` objects that
|
|
represent this class' static functions.
|
|
"""
|
|
return self._filter_doc_objs(
|
|
Function, include_inherited, lambda dobj: not dobj.is_method,
|
|
sort)
|
|
|
|
def inherited_members(self) -> List[Tuple['Class', List[Doc]]]:
|
|
"""
|
|
Returns all inherited members as a list of tuples
|
|
(ancestor class, list of ancestor class' members sorted by name),
|
|
sorted by MRO.
|
|
"""
|
|
return sorted(((cast(Class, k), sorted(g))
|
|
for k, g in groupby((i.inherits
|
|
for i in self.doc.values() if i.inherits),
|
|
key=lambda i: i.cls)), # type: ignore
|
|
key=lambda x, _mro_index=self.mro().index: _mro_index(x[0])) # type: ignore
|
|
|
|
def _fill_inheritance(self):
|
|
"""
|
|
Traverses this class's ancestor list and attempts to fill in
|
|
missing documentation objects from its ancestors.
|
|
|
|
Afterwards, call to `pdoc.Class._link_inheritance()` to also
|
|
set `pdoc.Doc.inherits` pointers.
|
|
"""
|
|
super_members = self._super_members = {}
|
|
for cls in self.mro(only_documented=True):
|
|
for name, dobj in cls.doc.items():
|
|
if name not in super_members and dobj.docstring:
|
|
super_members[name] = dobj
|
|
if name not in self.doc:
|
|
dobj = copy(dobj)
|
|
dobj.cls = self
|
|
|
|
self.doc[name] = dobj
|
|
self.module._context[dobj.refname] = dobj
|
|
|
|
def _link_inheritance(self):
|
|
"""
|
|
Set `pdoc.Doc.inherits` pointers to inherited ancestors' members,
|
|
as appropriate. This must be called after
|
|
`pdoc.Class._fill_inheritance()`.
|
|
|
|
The reason this is split in two parts is that in-between
|
|
the `__pdoc__` overrides are applied.
|
|
"""
|
|
if not hasattr(self, '_super_members'):
|
|
return
|
|
|
|
for name, parent_dobj in self._super_members.items():
|
|
try:
|
|
dobj = self.doc[name]
|
|
except KeyError:
|
|
# There is a key in some __pdoc__ dict blocking this member
|
|
continue
|
|
if (dobj.obj is parent_dobj.obj or
|
|
(dobj.docstring or parent_dobj.docstring) == parent_dobj.docstring):
|
|
dobj.inherits = parent_dobj
|
|
dobj.docstring = parent_dobj.docstring
|
|
del self._super_members
|
|
|
|
|
|
def maybe_lru_cache(func):
|
|
cached_func = lru_cache()(func)
|
|
|
|
@wraps(func)
|
|
def wrapper(*args):
|
|
try:
|
|
return cached_func(*args)
|
|
except TypeError:
|
|
return func(*args)
|
|
|
|
return wrapper
|
|
|
|
|
|
@maybe_lru_cache
|
|
def _formatannotation(annot):
|
|
"""
|
|
Format typing annotation with better handling of `typing.NewType`,
|
|
`typing.Optional`, `nptyping.NDArray` and other types.
|
|
|
|
>>> _formatannotation(NewType('MyType', str))
|
|
'MyType'
|
|
>>> _formatannotation(Optional[Tuple[Optional[int], None]])
|
|
'Optional[Tuple[Optional[int], None]]'
|
|
"""
|
|
class force_repr(str):
|
|
__repr__ = str.__str__
|
|
|
|
def maybe_replace_reprs(a):
|
|
# NoneType -> None
|
|
if a is type(None): # noqa: E721
|
|
return force_repr('None')
|
|
# Union[T, None] -> Optional[T]
|
|
if (getattr(a, '__origin__', None) is typing.Union and
|
|
len(a.__args__) == 2 and
|
|
type(None) in a.__args__):
|
|
t = inspect.formatannotation(
|
|
maybe_replace_reprs(next(filter(None, a.__args__))))
|
|
return force_repr(f'Optional[{t}]')
|
|
# typing.NewType('T', foo) -> T
|
|
module = getattr(a, '__module__', '')
|
|
if module == 'typing' and getattr(a, '__qualname__', '').startswith('NewType.'):
|
|
return force_repr(a.__name__)
|
|
# nptyping.types._ndarray.NDArray -> NDArray[(Any,), Int[64]] # GH-231
|
|
if module.startswith('nptyping.'):
|
|
return force_repr(repr(a))
|
|
# Recurse into typing.Callable/etc. args
|
|
if hasattr(a, 'copy_with') and hasattr(a, '__args__'):
|
|
if a is typing.Callable:
|
|
# Bug on Python < 3.9, https://bugs.python.org/issue42195
|
|
return a
|
|
a = a.copy_with(tuple([maybe_replace_reprs(arg) for arg in a.__args__]))
|
|
return a
|
|
|
|
return str(inspect.formatannotation(maybe_replace_reprs(annot)))
|
|
|
|
|
|
class Function(Doc):
|
|
"""
|
|
Representation of documentation for a function or method.
|
|
"""
|
|
__slots__ = ('cls',)
|
|
|
|
def __init__(self, name: str, module: Module, obj, *, cls: Class = None):
|
|
"""
|
|
Same as `pdoc.Doc`, except `obj` must be a
|
|
Python function object. The docstring is gathered automatically.
|
|
|
|
`cls` should be set when this is a method or a static function
|
|
beloing to a class. `cls` should be a `pdoc.Class` object.
|
|
|
|
`method` should be `True` when the function is a method. In
|
|
all other cases, it should be `False`.
|
|
"""
|
|
assert callable(obj), (name, module, obj)
|
|
super().__init__(name, module, obj)
|
|
|
|
self.cls = cls
|
|
"""
|
|
The `pdoc.Class` documentation object if the function is a method.
|
|
If not, this is None.
|
|
"""
|
|
|
|
@property
|
|
def is_method(self) -> bool:
|
|
"""
|
|
Whether this function is a normal bound method.
|
|
|
|
In particular, static and class methods have this set to False.
|
|
"""
|
|
assert self.cls
|
|
return not Class._method_type(self.cls.obj, self.name)
|
|
|
|
@property
|
|
def method(self):
|
|
warn('`Function.method` is deprecated. Use: `Function.is_method`', DeprecationWarning,
|
|
stacklevel=2)
|
|
return self.is_method
|
|
|
|
__pdoc__['Function.method'] = False
|
|
|
|
def funcdef(self) -> str:
|
|
"""
|
|
Generates the string of keywords used to define the function,
|
|
for example `def` or `async def`.
|
|
"""
|
|
return 'async def' if self._is_async else 'def'
|
|
|
|
@property
|
|
def _is_async(self):
|
|
"""
|
|
Returns whether is function is asynchronous, either as a coroutine or an async
|
|
generator.
|
|
"""
|
|
try:
|
|
# Both of these are required because coroutines aren't classified as async
|
|
# generators and vice versa.
|
|
obj = inspect.unwrap(self.obj)
|
|
return (inspect.iscoroutinefunction(obj) or
|
|
inspect.isasyncgenfunction(obj))
|
|
except AttributeError:
|
|
return False
|
|
|
|
def return_annotation(self, *, link=None) -> str:
|
|
"""Formatted function return type annotation or empty string if none."""
|
|
annot = ''
|
|
for method in (
|
|
lambda: _get_type_hints(self.obj)['return'],
|
|
# Mainly for non-property variables
|
|
lambda: _get_type_hints(cast(Class, self.cls).obj)[self.name],
|
|
# global variables
|
|
lambda: _get_type_hints(not self.cls and self.module.obj)[self.name],
|
|
lambda: inspect.signature(self.obj).return_annotation,
|
|
# Use raw annotation strings in unmatched forward declarations
|
|
lambda: cast(Class, self.cls).obj.__annotations__[self.name],
|
|
# Extract annotation from the docstring for C builtin function
|
|
lambda: Function._signature_from_string(self).return_annotation,
|
|
):
|
|
try:
|
|
annot = method()
|
|
except Exception:
|
|
continue
|
|
else:
|
|
break
|
|
else:
|
|
# Don't warn on variables. The annotation just isn't available.
|
|
if not isinstance(self, Variable):
|
|
warn(f"Error handling return annotation for {self!r}", stacklevel=3)
|
|
|
|
if annot is inspect.Parameter.empty or not annot:
|
|
return ''
|
|
|
|
if isinstance(annot, str):
|
|
s = annot
|
|
else:
|
|
s = _formatannotation(annot)
|
|
s = re.sub(r'\bForwardRef\((?P<quot>[\"\'])(?P<str>.*?)(?P=quot)\)',
|
|
r'\g<str>', s)
|
|
s = s.replace(' ', '\N{NBSP}') # Better line breaks in html signatures
|
|
|
|
if link:
|
|
from pdoc.html_helpers import _linkify
|
|
s = re.sub(r'[\w\.]+', partial(_linkify, link=link, module=self.module), s)
|
|
return s
|
|
|
|
def params(self, *, annotate: bool = False, link: Callable[[Doc], str] = None) -> List[str]:
|
|
"""
|
|
Returns a list where each element is a nicely formatted
|
|
parameter of this function. This includes argument lists,
|
|
keyword arguments and default values, and it doesn't include any
|
|
optional arguments whose names begin with an underscore.
|
|
|
|
If `annotate` is True, the parameter strings include [PEP 484]
|
|
type hint annotations.
|
|
|
|
[PEP 484]: https://www.python.org/dev/peps/pep-0484/
|
|
"""
|
|
return self._params(self, annotate=annotate, link=link, module=self.module)
|
|
|
|
@staticmethod
|
|
def _params(doc_obj, annotate=False, link=None, module=None):
|
|
try:
|
|
# We want __init__ to actually be implemented somewhere in the
|
|
# MRO to still satisfy https://github.com/pdoc3/pdoc/issues/124
|
|
if (
|
|
_isclass(doc_obj.obj)
|
|
and doc_obj.obj.__init__ is not object.__init__
|
|
):
|
|
# Remove the first argument (self) from __init__ signature
|
|
init_sig = inspect.signature(doc_obj.obj.__init__)
|
|
init_params = list(init_sig.parameters.values())
|
|
signature = init_sig.replace(parameters=init_params[1:])
|
|
else:
|
|
signature = inspect.signature(doc_obj.obj)
|
|
except ValueError:
|
|
signature = Function._signature_from_string(doc_obj)
|
|
if not signature:
|
|
return ['...']
|
|
|
|
def safe_default_value(p: inspect.Parameter):
|
|
value = p.default
|
|
if value is inspect.Parameter.empty:
|
|
return p
|
|
|
|
replacement = next((i for i in ('os.environ',
|
|
'sys.stdin',
|
|
'sys.stdout',
|
|
'sys.stderr',)
|
|
if value is eval(i)), None)
|
|
if not replacement:
|
|
if isinstance(value, enum.Enum):
|
|
replacement = str(value)
|
|
elif _isclass(value):
|
|
replacement = f'{value.__module__ or _UNKNOWN_MODULE}.{value.__qualname__}'
|
|
elif ' at 0x' in repr(value):
|
|
replacement = re.sub(r' at 0x\w+', '', repr(value))
|
|
|
|
nonlocal link
|
|
if link and ('<' in repr(value) or '>' in repr(value)):
|
|
import html
|
|
replacement = html.escape(replacement or repr(value))
|
|
|
|
if replacement:
|
|
class mock:
|
|
def __repr__(self):
|
|
return replacement
|
|
return p.replace(default=mock())
|
|
return p
|
|
|
|
params = []
|
|
kw_only = False
|
|
pos_only = False
|
|
EMPTY = inspect.Parameter.empty
|
|
|
|
if link:
|
|
from pdoc.html_helpers import _linkify
|
|
_linkify = partial(_linkify, link=link, module=module)
|
|
|
|
for p in signature.parameters.values(): # type: inspect.Parameter
|
|
if not _is_public(p.name) and p.default is not EMPTY:
|
|
continue
|
|
|
|
if p.kind == p.POSITIONAL_ONLY:
|
|
pos_only = True
|
|
elif pos_only:
|
|
params.append("/")
|
|
pos_only = False
|
|
|
|
if p.kind == p.VAR_POSITIONAL:
|
|
kw_only = True
|
|
if p.kind == p.KEYWORD_ONLY and not kw_only:
|
|
kw_only = True
|
|
params.append('*')
|
|
|
|
p = safe_default_value(p)
|
|
|
|
if not annotate:
|
|
p = p.replace(annotation=EMPTY)
|
|
|
|
formatted = p.name
|
|
if p.annotation is not EMPTY:
|
|
annotation = _formatannotation(p.annotation).replace(' ', '\N{NBSP}')
|
|
# "Eval" forward-declarations (typing string literals)
|
|
if isinstance(p.annotation, str):
|
|
annotation = annotation.strip("'")
|
|
if link:
|
|
annotation = re.sub(r'[\w\.]+', _linkify, annotation)
|
|
formatted += f':\N{NBSP}{annotation}'
|
|
if p.default is not EMPTY:
|
|
if p.annotation is not EMPTY:
|
|
formatted += f'\N{NBSP}=\N{NBSP}{repr(p.default)}'
|
|
else:
|
|
formatted += f'={repr(p.default)}'
|
|
if p.kind == p.VAR_POSITIONAL:
|
|
formatted = f'*{formatted}'
|
|
elif p.kind == p.VAR_KEYWORD:
|
|
formatted = f'**{formatted}'
|
|
|
|
params.append(formatted)
|
|
|
|
if pos_only:
|
|
params.append("/")
|
|
|
|
return params
|
|
|
|
@staticmethod
|
|
@lru_cache()
|
|
def _signature_from_string(self):
|
|
signature = None
|
|
for expr, cleanup_docstring, filter in (
|
|
# Full proper typed signature, such as one from pybind11
|
|
(r'^{}\(.*\)(?: -> .*)?$', True, lambda s: s),
|
|
# Human-readable, usage-like signature from some Python builtins
|
|
# (e.g. `range` or `slice` or `itertools.repeat` or `numpy.arange`)
|
|
(r'^{}\(.*\)(?= -|$)', False, lambda s: s.replace('[', '').replace(']', '')),
|
|
):
|
|
strings = sorted(re.findall(expr.format(self.name),
|
|
self.docstring, re.MULTILINE),
|
|
key=len, reverse=True)
|
|
if strings:
|
|
string = filter(strings[0])
|
|
_locals, _globals = {}, {}
|
|
_globals.update({'capsule': None}) # pybind11 capsule data type
|
|
_globals.update(typing.__dict__)
|
|
_globals.update(self.module.obj.__dict__)
|
|
# Trim binding module basename from type annotations
|
|
# See: https://github.com/pdoc3/pdoc/pull/148#discussion_r407114141
|
|
module_basename = self.module.name.rsplit('.', maxsplit=1)[-1]
|
|
if module_basename in string and module_basename not in _globals:
|
|
string = re.sub(fr'(?<!\.)\b{module_basename}\.\b', '', string)
|
|
|
|
try:
|
|
exec(f'def {string}: pass', _globals, _locals)
|
|
except SyntaxError:
|
|
continue
|
|
signature = inspect.signature(_locals[self.name])
|
|
if cleanup_docstring and len(strings) == 1:
|
|
# Remove signature from docstring variable
|
|
self.docstring = self.docstring.replace(strings[0], '')
|
|
break
|
|
return signature
|
|
|
|
@property
|
|
def refname(self) -> str:
|
|
return f'{self.cls.refname if self.cls else self.module.refname}.{self.name}'
|
|
|
|
|
|
class Variable(Doc):
|
|
"""
|
|
Representation of a variable's documentation. This includes
|
|
module, class, and instance variables.
|
|
"""
|
|
__slots__ = ('cls', 'instance_var')
|
|
|
|
def __init__(self, name: str, module: Module, docstring, *,
|
|
obj=None, cls: Class = None, instance_var: bool = False):
|
|
"""
|
|
Same as `pdoc.Doc`, except `cls` should be provided
|
|
as a `pdoc.Class` object when this is a class or instance
|
|
variable.
|
|
"""
|
|
super().__init__(name, module, obj, docstring)
|
|
|
|
self.cls = cls
|
|
"""
|
|
The `pdoc.Class` object if this is a class or instance
|
|
variable. If not (i.e. it is a global variable), this is None.
|
|
"""
|
|
|
|
self.instance_var = instance_var
|
|
"""
|
|
True if variable is some class' instance variable (as
|
|
opposed to class variable).
|
|
"""
|
|
|
|
@property
|
|
def qualname(self) -> str:
|
|
if self.cls:
|
|
return f'{self.cls.qualname}.{self.name}'
|
|
return self.name
|
|
|
|
@property
|
|
def refname(self) -> str:
|
|
return f'{self.cls.refname if self.cls else self.module.refname}.{self.name}'
|
|
|
|
def type_annotation(self, *, link=None) -> str:
|
|
"""Formatted variable type annotation or empty string if none."""
|
|
return Function.return_annotation(cast(Function, self), link=link)
|
|
|
|
|
|
class External(Doc):
|
|
"""
|
|
A representation of an external identifier. The textual
|
|
representation is the same as an internal identifier.
|
|
|
|
External identifiers are also used to represent something that is
|
|
not documented but appears somewhere in the public interface (like
|
|
the ancestor list of a class).
|
|
"""
|
|
|
|
__pdoc__["External.docstring"] = """
|
|
An empty string. External identifiers do not have
|
|
docstrings.
|
|
"""
|
|
__pdoc__["External.module"] = """
|
|
Always `None`. External identifiers have no associated
|
|
`pdoc.Module`.
|
|
"""
|
|
__pdoc__["External.name"] = """
|
|
Always equivalent to `pdoc.External.refname` since external
|
|
identifiers are always expressed in their fully qualified
|
|
form.
|
|
"""
|
|
|
|
def __init__(self, name: str):
|
|
"""
|
|
Initializes an external identifier with `name`, where `name`
|
|
should be a fully qualified name.
|
|
"""
|
|
super().__init__(name, None, None)
|
|
|
|
def url(self, *args, **kwargs):
|
|
"""
|
|
`External` objects return absolute urls matching `/{name}.ext`.
|
|
"""
|
|
return f'/{self.name}.ext'
|