diff options
Diffstat (limited to 'docs/lib/pg3_docs.py')
-rw-r--r-- | docs/lib/pg3_docs.py | 197 |
1 files changed, 197 insertions, 0 deletions
diff --git a/docs/lib/pg3_docs.py b/docs/lib/pg3_docs.py new file mode 100644 index 0000000..05a6876 --- /dev/null +++ b/docs/lib/pg3_docs.py @@ -0,0 +1,197 @@ +""" +Customisation for docs generation. +""" + +# Copyright (C) 2020 The Psycopg Team + +import os +import re +import logging +import importlib +from typing import Dict +from collections import deque + + +def process_docstring(app, what, name, obj, options, lines): + pass + + +def before_process_signature(app, obj, bound_method): + ann = getattr(obj, "__annotations__", {}) + if "return" in ann: + # Drop "return: None" from the function signatures + if ann["return"] is None: + del ann["return"] + + +def process_signature(app, what, name, obj, options, signature, return_annotation): + pass + + +def setup(app): + app.connect("autodoc-process-docstring", process_docstring) + app.connect("autodoc-process-signature", process_signature) + app.connect("autodoc-before-process-signature", before_process_signature) + + import psycopg # type: ignore + + recover_defined_module( + psycopg, skip_modules=["psycopg._dns", "psycopg.types.shapely"] + ) + monkeypatch_autodoc() + + # Disable warnings in sphinx_autodoc_typehints because it doesn't seem that + # there is a workaround for: "WARNING: Cannot resolve forward reference in + # type annotations" + logger = logging.getLogger("sphinx.sphinx_autodoc_typehints") + logger.setLevel(logging.ERROR) + + +# Classes which may have __module__ overwritten +recovered_classes: Dict[type, str] = {} + + +def recover_defined_module(m, skip_modules=()): + """ + Find the module where classes with __module__ attribute hacked were defined. + + Autodoc will get confused and will fail to inspect attribute docstrings + (e.g. from enums and named tuples). + + Save the classes recovered in `recovered_classes`, to be used by + `monkeypatch_autodoc()`. + + """ + mdir = os.path.split(m.__file__)[0] + for fn in walk_modules(mdir): + assert fn.startswith(mdir) + modname = os.path.splitext(fn[len(mdir) + 1 :])[0].replace("/", ".") + modname = f"{m.__name__}.{modname}" + if modname in skip_modules: + continue + with open(fn) as f: + classnames = re.findall(r"^class\s+([^(:]+)", f.read(), re.M) + for cls in classnames: + cls = deep_import(f"{modname}.{cls}") + if cls.__module__ != modname: + recovered_classes[cls] = modname + + +def monkeypatch_autodoc(): + """ + Patch autodoc in order to use information found by `recover_defined_module`. + """ + from sphinx.ext.autodoc import Documenter, AttributeDocumenter + + orig_doc_get_real_modname = Documenter.get_real_modname + orig_attr_get_real_modname = AttributeDocumenter.get_real_modname + orig_attr_add_content = AttributeDocumenter.add_content + + def fixed_doc_get_real_modname(self): + if self.object in recovered_classes: + return recovered_classes[self.object] + return orig_doc_get_real_modname(self) + + def fixed_attr_get_real_modname(self): + if self.parent in recovered_classes: + return recovered_classes[self.parent] + return orig_attr_get_real_modname(self) + + def fixed_attr_add_content(self, more_content): + """ + Replace a docstring such as:: + + .. py:attribute:: ConnectionInfo.dbname + :module: psycopg + + The database name of the connection. + + :rtype: :py:class:`str` + + into: + + .. py:attribute:: ConnectionInfo.dbname + :type: str + :module: psycopg + + The database name of the connection. + + which creates a more compact representation of a property. + + """ + orig_attr_add_content(self, more_content) + if not isinstance(self.object, property): + return + iret, mret = match_in_lines(r"\s*:rtype: (.*)", self.directive.result) + iatt, matt = match_in_lines(r"\.\.", self.directive.result) + if not (mret and matt): + return + self.directive.result.pop(iret) + self.directive.result.insert( + iatt + 1, + f"{self.indent}:type: {unrest(mret.group(1))}", + source=self.get_sourcename(), + ) + + Documenter.get_real_modname = fixed_doc_get_real_modname + AttributeDocumenter.get_real_modname = fixed_attr_get_real_modname + AttributeDocumenter.add_content = fixed_attr_add_content + + +def match_in_lines(pattern, lines): + """Match a regular expression against a list of strings. + + Return the index of the first matched line and the match object. + None, None if nothing matched. + """ + for i, line in enumerate(lines): + m = re.match(pattern, line) + if m: + return i, m + else: + return None, None + + +def unrest(s): + r"""remove the reST markup from a string + + e.g. :py:data:`~typing.Optional`\[:py:class:`int`] -> Optional[int] + + required because :type: does the types lookup itself apparently. + """ + s = re.sub(r":[^`]*:`~?([^`]*)`", r"\1", s) # drop role + s = re.sub(r"\\(.)", r"\1", s) # drop escape + + # note that ~psycopg.pq.ConnStatus is converted to pq.ConnStatus + # which should be interpreted well if currentmodule is set ok. + s = re.sub(r"(?:typing|psycopg)\.", "", s) # drop unneeded modules + s = re.sub(r"~", "", s) # drop the tilde + + return s + + +def walk_modules(d): + for root, dirs, files in os.walk(d): + for f in files: + if f.endswith(".py"): + yield f"{root}/{f}" + + +def deep_import(name): + parts = deque(name.split(".")) + seen = [] + if not parts: + raise ValueError("name must be a dot-separated name") + + seen.append(parts.popleft()) + thing = importlib.import_module(seen[-1]) + while parts: + attr = parts.popleft() + seen.append(attr) + + if hasattr(thing, attr): + thing = getattr(thing, attr) + else: + thing = importlib.import_module(".".join(seen)) + + return thing |