diff options
Diffstat (limited to 'sphinx/pycode/__init__.py')
-rw-r--r-- | sphinx/pycode/__init__.py | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/sphinx/pycode/__init__.py b/sphinx/pycode/__init__.py new file mode 100644 index 0000000..55835ec --- /dev/null +++ b/sphinx/pycode/__init__.py @@ -0,0 +1,152 @@ +"""Utilities parsing and analyzing Python code.""" + +from __future__ import annotations + +import tokenize +from importlib import import_module +from os import path +from typing import TYPE_CHECKING, Any + +from sphinx.errors import PycodeError +from sphinx.pycode.parser import Parser + +if TYPE_CHECKING: + from inspect import Signature + + +class ModuleAnalyzer: + annotations: dict[tuple[str, str], str] + attr_docs: dict[tuple[str, str], list[str]] + finals: list[str] + overloads: dict[str, list[Signature]] + tagorder: dict[str, int] + tags: dict[str, tuple[str, int, int]] + + # cache for analyzer objects -- caches both by module and file name + cache: dict[tuple[str, str], Any] = {} + + @staticmethod + def get_module_source(modname: str) -> tuple[str | None, str | None]: + """Try to find the source code for a module. + + Returns ('filename', 'source'). One of it can be None if + no filename or source found + """ + try: + mod = import_module(modname) + except Exception as err: + raise PycodeError('error importing %r' % modname, err) from err + loader = getattr(mod, '__loader__', None) + filename = getattr(mod, '__file__', None) + if loader and getattr(loader, 'get_source', None): + # prefer Native loader, as it respects #coding directive + try: + source = loader.get_source(modname) + if source: + # no exception and not None - it must be module source + return filename, source + except ImportError: + pass # Try other "source-mining" methods + if filename is None and loader and getattr(loader, 'get_filename', None): + # have loader, but no filename + try: + filename = loader.get_filename(modname) + except ImportError as err: + raise PycodeError('error getting filename for %r' % modname, err) from err + if filename is None: + # all methods for getting filename failed, so raise... + raise PycodeError('no source found for module %r' % modname) + filename = path.normpath(path.abspath(filename)) + if filename.lower().endswith(('.pyo', '.pyc')): + filename = filename[:-1] + if not path.isfile(filename) and path.isfile(filename + 'w'): + filename += 'w' + elif not filename.lower().endswith(('.py', '.pyw')): + raise PycodeError('source is not a .py file: %r' % filename) + + if not path.isfile(filename): + raise PycodeError('source file is not present: %r' % filename) + return filename, None + + @classmethod + def for_string(cls, string: str, modname: str, srcname: str = '<string>', + ) -> ModuleAnalyzer: + return cls(string, modname, srcname) + + @classmethod + def for_file(cls, filename: str, modname: str) -> ModuleAnalyzer: + if ('file', filename) in cls.cache: + return cls.cache['file', filename] + try: + with tokenize.open(filename) as f: + string = f.read() + obj = cls(string, modname, filename) + cls.cache['file', filename] = obj + except Exception as err: + raise PycodeError('error opening %r' % filename, err) from err + return obj + + @classmethod + def for_module(cls, modname: str) -> ModuleAnalyzer: + if ('module', modname) in cls.cache: + entry = cls.cache['module', modname] + if isinstance(entry, PycodeError): + raise entry + return entry + + try: + filename, source = cls.get_module_source(modname) + if source is not None: + obj = cls.for_string(source, modname, filename or '<string>') + elif filename is not None: + obj = cls.for_file(filename, modname) + except PycodeError as err: + cls.cache['module', modname] = err + raise + cls.cache['module', modname] = obj + return obj + + def __init__(self, source: str, modname: str, srcname: str) -> None: + self.modname = modname # name of the module + self.srcname = srcname # name of the source file + + # cache the source code as well + self.code = source + + self._analyzed = False + + def analyze(self) -> None: + """Analyze the source code.""" + if self._analyzed: + return + + try: + parser = Parser(self.code) + parser.parse() + + self.attr_docs = {} + for (scope, comment) in parser.comments.items(): + if comment: + self.attr_docs[scope] = comment.splitlines() + [''] + else: + self.attr_docs[scope] = [''] + + self.annotations = parser.annotations + self.finals = parser.finals + self.overloads = parser.overloads + self.tags = parser.definitions + self.tagorder = parser.deforders + self._analyzed = True + except Exception as exc: + msg = f'parsing {self.srcname!r} failed: {exc!r}' + raise PycodeError(msg) from exc + + def find_attr_docs(self) -> dict[tuple[str, str], list[str]]: + """Find class and module-level attributes and their documentation.""" + self.analyze() + return self.attr_docs + + def find_tags(self) -> dict[str, tuple[str, int, int]]: + """Find class, function and method definitions and their location.""" + self.analyze() + return self.tags |