1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196
|
# mypy: ignore-errors
"""
Customisation for docs generation.
"""
# Copyright (C) 2020 The Psycopg Team
from __future__ import annotations
import os
import re
import logging
import importlib
from collections import deque
def process_docstring(app, what, name, obj, options, lines):
pass
def before_process_signature(app, obj, bound_method):
if "return" in (ann := getattr(obj, "__annotations__", {})):
# 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("/", ".")
if (modname := f"{m.__name__}.{modname}") in skip_modules:
continue
with open(fn) as f:
classnames = re.findall(r"^class\s+([^(:]+)", f.read(), re.M)
for cls in classnames:
if (cls := deep_import(f"{modname}.{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 AttributeDocumenter, Documenter
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
|