diff --git a/.gitignore b/.gitignore index e38c3b4bdb5c..b93bf409498f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ source/i18n/*/conf.py source/static/ sphinx_rtd_theme/ .token* +temp/ diff --git a/autoautosummary.py b/autoautosummary.py index 0d0f64fa7834..4d7e1d8ba096 100644 --- a/autoautosummary.py +++ b/autoautosummary.py @@ -10,7 +10,7 @@ from docutils import nodes from docutils.parsers.rst import directives from sphinx.ext import autosummary -from sphinx.ext.autosummary import Autosummary, ImportExceptionGroup, get_documenter +from sphinx.ext.autosummary import Autosummary, ImportExceptionGroup from sphinx.locale import __ from sphinx.util import logging from sphinx.util.inspect import isstaticmethod, safe_getattr @@ -100,7 +100,7 @@ def get_members( continue try: chobj = safe_getattr(obj, name) - documenter = get_documenter(doc.settings.env.app, chobj, obj) + documenter = autosummary.get_documenter(doc.settings.env.app, chobj, obj) # cl = get_class_that_defined_method(chobj) # print(name, chobj.__qualname__, type(chobj), issubclass(chobj, Enum), documenter.objtype) if documenter.objtype == typ: diff --git a/conf.in.py b/conf.in.py index edca8ffe0206..30cd741ceb36 100644 --- a/conf.in.py +++ b/conf.in.py @@ -1,5 +1,6 @@ import os import sys +from pathlib import Path import sphinx_rtd_theme import yaml @@ -220,9 +221,17 @@ locale_dirs = ["../i18n/"] gettext_compact = False +try: + user_paths = [Path(p) for p in os.environ["PYTHONPATH"].split(os.pathsep)] +except KeyError: + user_paths = [] + class_maps = {} + for module in ("3d", "analysis", "core", "gui", "server"): - with open(f"/usr/lib/python3/dist-packages/qgis/{module}/class_map.yaml") as f: + class_map_path = Path("..") / ".." / "temp" / module / "class_map.yaml" + assert class_map_path.exists(), f"Cannot find {class_map_path.resolve()}" + with open(class_map_path) as f: class_maps[module] = yaml.safe_load(f) @@ -231,7 +240,10 @@ def linkcode_resolve(domain, info): return None if not info["module"]: return None - module = info["module"].split(".")[1] + try: + module = info["module"].split(".")[1] + except IndexError: + return None if module == "_3d": module = "3d" try: @@ -247,6 +259,7 @@ def linkcode_resolve(domain, info): def setup(app): try: from autoautosummary import AutoAutoSummary + from documenters import OverloadedPythonMethodDocumenter from process_links import ( process_bases, process_docstring, @@ -255,6 +268,7 @@ def setup(app): ) app.add_directive("autoautosummary", AutoAutoSummary) + app.add_autodocumenter(OverloadedPythonMethodDocumenter) app.connect("autodoc-process-signature", process_signature) app.connect("autodoc-process-docstring", process_docstring) app.connect("autodoc-skip-member", skip_member) diff --git a/documenters.py b/documenters.py new file mode 100644 index 000000000000..ed6fc7fdb513 --- /dev/null +++ b/documenters.py @@ -0,0 +1,85 @@ +import inspect +import re + +from sphinx.ext.autodoc import MethodDocumenter + + +class OverloadedPythonMethodDocumenter(MethodDocumenter): + """ + A method documenter which handles overloaded methods, via processing + the docstrings generated by SIP + """ + + objtype = "method" + priority = MethodDocumenter.priority + + @classmethod + def can_document_member(cls, member, membername, isattr, parent): + return MethodDocumenter.can_document_member(member, membername, isattr, parent) + + def parse_signature_blocks(self, docstring): + """ + Extracts each signature from a sip generated docstring, and + returns each signature in a tuple with the docs for just + that signature. + """ + res = [] + current_sig = "" + current_desc = "" + for line in docstring.split("\n"): + match = re.match(r"^\w+(\([^)]*\)(?:\s*->\s*[^:\n]+)?)\s*((?:(?!\w+\().)*)\s*$", line) + if match: + if current_sig: + res.append((current_sig, current_desc)) + current_sig = match.group(1) + current_desc = match.group(2) + if current_desc: + current_desc += "\n" + else: + current_desc += line + "\n" + + if current_sig: + res.append((current_sig, current_desc)) + + return res + + def add_content(self, more_content): + """ + Parse the docstring to get all signatures and their descriptions + """ + sourcename = self.get_sourcename() + docstring = inspect.getdoc(self.object) + if docstring: + # does this method have multiple overrides? + signature_blocks = self.parse_signature_blocks(docstring) + + if len(signature_blocks) <= 1: + # nope, just use standard formatter then! + super().add_content(more_content) + return + + # add a method output for EVERY override + for i, (signature, description) in enumerate(signature_blocks): + if i > 0: + self.add_line("", sourcename) + + # this pattern is used in the autodoc source! + old_indent = self.indent + new_indent = ( + " " + * len(self.content_indent) + * (len(self.indent) // len(self.content_indent) - 1) + ) + # skip the signature for the first overload. This will already + # have been included by the base class Documenter logic! + if i > 0: + self.indent = new_indent + self.add_directive_header(signature) + self.indent = old_indent + # we can only index the first signature! + self.add_line(":no-index:", sourcename) + self.add_line("", sourcename) + + doc_for_this_override = self.object_name + signature + "\n" + description + for line in self.process_doc([doc_for_this_override.split("\n")]): + self.add_line(line, sourcename) diff --git a/process_links.py b/process_links.py index 55c20939c3f3..23221d40b74b 100644 --- a/process_links.py +++ b/process_links.py @@ -183,38 +183,54 @@ def inject_args(_args, _lines): # add return type and param type elif what != "class" and not isinstance(obj, enum.EnumMeta) and obj.__doc__: + # default to taking the signature from the lines we've already processed. + # This is because we want the output processed earlier via the + # OverloadedPythonMethodDocumenter class, so that we are only + # looking at the docs relevant to the specific overload we are + # currently processing + signature = None + match = None + if lines: + signature = lines[0] + if signature: + match = py_ext_sig_re.match(signature) + if match: + del lines[0] - signature = obj.__doc__.split("\n")[0] - if signature != "": + if match is None: + signature = obj.__doc__.split("\n")[0] + if signature == "": + return match = py_ext_sig_re.match(signature) - if not match: - # print(obj) - if name not in cfg["non-instantiable"]: - raise Warning(f"invalid signature for {name}: {signature}") - else: - exmod, path, base, args, retann, signal = match.groups() - - if args: - args = args.split(", ") - inject_args(args, lines) - - if retann: - insert_index = len(lines) - for i, line in enumerate(lines): - if line.startswith(":rtype:"): - insert_index = None - break - elif line.startswith(":return:") or line.startswith(":returns:"): - insert_index = i - - if insert_index is not None: - if insert_index == len(lines): - # Ensure that :rtype: doesn't get joined with a paragraph of text, which - # prevents it being interpreted. - lines.append("") - insert_index += 1 - - lines.insert(insert_index, f":rtype: {create_links(retann)}") + + if match is None: + if name not in cfg["non-instantiable"]: + raise Warning(f"invalid signature for {name}: {signature}") + + else: + exmod, path, base, args, retann, signal = match.groups() + + if args: + args = args.split(", ") + inject_args(args, lines) + + if retann: + insert_index = len(lines) + for i, line in enumerate(lines): + if line.startswith(":rtype:"): + insert_index = None + break + elif line.startswith(":return:") or line.startswith(":returns:"): + insert_index = i + + if insert_index is not None: + if insert_index == len(lines): + # Ensure that :rtype: doesn't get joined with a paragraph of text, which + # prevents it being interpreted. + lines.append("") + insert_index += 1 + + lines.insert(insert_index, f":rtype: {create_links(retann)}") def process_signature(app, what, name, obj, options, signature, return_annotation): diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index a11eb0acb124..b9aa9b619932 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -66,10 +66,17 @@ echo "RELEASE TAG: ${RELEASE_TAG}" echo "PACKAGE LIMIT: ${PACKAGE}" echo "SINGLE CLASS: ${CLASS}" -# download class_map until correctly installed -# TODO: remove this when https://github.com/qgis/QGIS/pull/58200 is merged +# download current class_map file, if it hasn't already been +mkdir -p temp for module in "3d" "analysis" "core" "gui" "server"; do - wget -O /usr/lib/python3/dist-packages/qgis/${module}/class_map.yaml https://raw.githubusercontent.com/qgis/QGIS/${RELEASE_TAG}/python/${module}/class_map.yaml + CLASS_MAP_FILE="temp/${module}/class_map.yaml" + if [ -f "$CLASS_MAP_FILE" ]; then + echo "${module} class map file already downloaded" + else + echo "Need to fetch ${module} class map file" + mkdir -p temp/${module} + wget -O "$CLASS_MAP_FILE" https://raw.githubusercontent.com/qgis/QGIS/${RELEASE_TAG}/python/${module}/class_map.yaml + fi done if [[ -n ${QGIS_BUILD_DIR} ]]; then