summaryrefslogtreecommitdiffstats
path: root/docs/lib
diff options
context:
space:
mode:
Diffstat (limited to 'docs/lib')
-rw-r--r--docs/lib/libpq_docs.py182
-rw-r--r--docs/lib/pg3_docs.py197
-rw-r--r--docs/lib/sql_role.py23
-rw-r--r--docs/lib/ticket_role.py50
4 files changed, 452 insertions, 0 deletions
diff --git a/docs/lib/libpq_docs.py b/docs/lib/libpq_docs.py
new file mode 100644
index 0000000..b8e01f0
--- /dev/null
+++ b/docs/lib/libpq_docs.py
@@ -0,0 +1,182 @@
+"""
+Sphinx plugin to link to the libpq documentation.
+
+Add the ``:pq:`` role, to create a link to a libpq function, e.g. ::
+
+ :pq:`PQlibVersion()`
+
+will link to::
+
+ https://www.postgresql.org/docs/current/libpq-misc.html #LIBPQ-PQLIBVERSION
+
+"""
+
+# Copyright (C) 2020 The Psycopg Team
+
+import os
+import logging
+import urllib.request
+from pathlib import Path
+from functools import lru_cache
+from html.parser import HTMLParser
+
+from docutils import nodes, utils
+from docutils.parsers.rst import roles
+
+logger = logging.getLogger("sphinx.libpq_docs")
+
+
+class LibpqParser(HTMLParser):
+ def __init__(self, data, version="current"):
+ super().__init__()
+ self.data = data
+ self.version = version
+
+ self.section_id = None
+ self.varlist_id = None
+ self.in_term = False
+ self.in_func = False
+
+ def handle_starttag(self, tag, attrs):
+ if tag == "sect1":
+ self.handle_sect1(tag, attrs)
+ elif tag == "varlistentry":
+ self.handle_varlistentry(tag, attrs)
+ elif tag == "term":
+ self.in_term = True
+ elif tag == "function":
+ self.in_func = True
+
+ def handle_endtag(self, tag):
+ if tag == "term":
+ self.in_term = False
+ elif tag == "function":
+ self.in_func = False
+
+ def handle_data(self, data):
+ if not (self.in_term and self.in_func):
+ return
+
+ self.add_function(data)
+
+ def handle_sect1(self, tag, attrs):
+ attrs = dict(attrs)
+ if "id" in attrs:
+ self.section_id = attrs["id"]
+
+ def handle_varlistentry(self, tag, attrs):
+ attrs = dict(attrs)
+ if "id" in attrs:
+ self.varlist_id = attrs["id"]
+
+ def add_function(self, func_name):
+ self.data[func_name] = self.get_func_url()
+
+ def get_func_url(self):
+ assert self.section_id, "<sect1> tag not found"
+ assert self.varlist_id, "<varlistentry> tag not found"
+ return self._url_pattern.format(
+ version=self.version,
+ section=self.section_id,
+ func_id=self.varlist_id.upper(),
+ )
+
+ _url_pattern = "https://www.postgresql.org/docs/{version}/{section}.html#{func_id}"
+
+
+class LibpqReader:
+ # must be set before using the rest of the class.
+ app = None
+
+ _url_pattern = (
+ "https://raw.githubusercontent.com/postgres/postgres/REL_{ver}_STABLE"
+ "/doc/src/sgml/libpq.sgml"
+ )
+
+ data = None
+
+ def get_url(self, func):
+ if not self.data:
+ self.parse()
+
+ return self.data[func]
+
+ def parse(self):
+ if not self.local_file.exists():
+ self.download()
+
+ logger.info("parsing libpq docs from %s", self.local_file)
+ self.data = {}
+ parser = LibpqParser(self.data, version=self.version)
+ with self.local_file.open("r") as f:
+ parser.feed(f.read())
+
+ def download(self):
+ filename = os.environ.get("LIBPQ_DOCS_FILE")
+ if filename:
+ logger.info("reading postgres libpq docs from %s", filename)
+ with open(filename, "rb") as f:
+ data = f.read()
+ else:
+ logger.info("downloading postgres libpq docs from %s", self.sgml_url)
+ data = urllib.request.urlopen(self.sgml_url).read()
+
+ with self.local_file.open("wb") as f:
+ f.write(data)
+
+ @property
+ def local_file(self):
+ return Path(self.app.doctreedir) / f"libpq-{self.version}.sgml"
+
+ @property
+ def sgml_url(self):
+ return self._url_pattern.format(ver=self.version)
+
+ @property
+ def version(self):
+ return self.app.config.libpq_docs_version
+
+
+@lru_cache()
+def get_reader():
+ return LibpqReader()
+
+
+def pq_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
+ text = utils.unescape(text)
+
+ reader = get_reader()
+ if "(" in text:
+ func, noise = text.split("(", 1)
+ noise = "(" + noise
+
+ else:
+ func = text
+ noise = ""
+
+ try:
+ url = reader.get_url(func)
+ except KeyError:
+ msg = inliner.reporter.warning(
+ f"function {func} not found in libpq {reader.version} docs"
+ )
+ prb = inliner.problematic(rawtext, rawtext, msg)
+ return [prb], [msg]
+
+ # For a function f(), include the () in the signature for consistency
+ # with a normal `thing()`
+ if noise == "()":
+ func, noise = func + noise, ""
+
+ the_nodes = []
+ the_nodes.append(nodes.reference(func, func, refuri=url))
+ if noise:
+ the_nodes.append(nodes.Text(noise))
+
+ return [nodes.literal("", "", *the_nodes, **options)], []
+
+
+def setup(app):
+ app.add_config_value("libpq_docs_version", "14", "html")
+ roles.register_local_role("pq", pq_role)
+ get_reader().app = app
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
diff --git a/docs/lib/sql_role.py b/docs/lib/sql_role.py
new file mode 100644
index 0000000..a40c9f4
--- /dev/null
+++ b/docs/lib/sql_role.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+"""
+ sql role
+ ~~~~~~~~
+
+ An interpreted text role to style SQL syntax in Psycopg documentation.
+
+ :copyright: Copyright 2010 by Daniele Varrazzo.
+ :copyright: Copyright 2020 The Psycopg Team.
+"""
+
+from docutils import nodes, utils
+from docutils.parsers.rst import roles
+
+
+def sql_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
+ text = utils.unescape(text)
+ options["classes"] = ["sql"]
+ return [nodes.literal(rawtext, text, **options)], []
+
+
+def setup(app):
+ roles.register_local_role("sql", sql_role)
diff --git a/docs/lib/ticket_role.py b/docs/lib/ticket_role.py
new file mode 100644
index 0000000..24ec873
--- /dev/null
+++ b/docs/lib/ticket_role.py
@@ -0,0 +1,50 @@
+# type: ignore
+"""
+ ticket role
+ ~~~~~~~~~~~
+
+ An interpreted text role to link docs to tickets issues.
+
+ :copyright: Copyright 2013 by Daniele Varrazzo.
+ :copyright: Copyright 2021 The Psycopg Team
+"""
+
+import re
+from docutils import nodes, utils
+from docutils.parsers.rst import roles
+
+
+def ticket_role(name, rawtext, text, lineno, inliner, options={}, content=[]):
+ cfg = inliner.document.settings.env.app.config
+ if cfg.ticket_url is None:
+ msg = inliner.reporter.warning(
+ "ticket not configured: please configure ticket_url in conf.py"
+ )
+ prb = inliner.problematic(rawtext, rawtext, msg)
+ return [prb], [msg]
+
+ rv = [nodes.Text(name + " ")]
+ tokens = re.findall(r"(#?\d+)|([^\d#]+)", text)
+ for ticket, noise in tokens:
+ if ticket:
+ num = int(ticket.replace("#", ""))
+
+ url = cfg.ticket_url % num
+ roles.set_classes(options)
+ node = nodes.reference(
+ ticket, utils.unescape(ticket), refuri=url, **options
+ )
+
+ rv.append(node)
+
+ else:
+ assert noise
+ rv.append(nodes.Text(noise))
+
+ return rv, []
+
+
+def setup(app):
+ app.add_config_value("ticket_url", None, "env")
+ app.add_role("ticket", ticket_role)
+ app.add_role("tickets", ticket_role)