You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
153 lines
5.5 KiB
153 lines
5.5 KiB
"""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: type[ModuleAnalyzer], string: str, modname: str, srcname: str = '<string>',
|
|
) -> ModuleAnalyzer:
|
|
return cls(string, modname, srcname)
|
|
|
|
@classmethod
|
|
def for_file(cls: type[ModuleAnalyzer], 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: type[ModuleAnalyzer], 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
|
|
|