summaryrefslogtreecommitdiffstats
path: root/sphinx/pycode/__init__.py
diff options
context:
space:
mode:
Diffstat (limited to 'sphinx/pycode/__init__.py')
-rw-r--r--sphinx/pycode/__init__.py152
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