Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handled overloaded methods correctly #178

Merged
merged 12 commits into from
Sep 11, 2024
4 changes: 2 additions & 2 deletions autoautosummary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
31 changes: 27 additions & 4 deletions conf.in.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import sys
from pathlib import Path

import sphinx_rtd_theme
import yaml
Expand Down Expand Up @@ -220,18 +221,38 @@
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_maps[module] = yaml.safe_load(f)
qgis_module_path = None
for path in user_paths:
candidate_path = path / "qgis" / "core"
if candidate_path.exists():
qgis_module_path = candidate_path
break

if qgis_module_path:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will be silently skipped, should we at least raise a warning?
I'm a bit concerned that for whatever reason, there is no more class_map file from QGIS and the docs are built without error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I've reverted that bit, but have revised the class map download to use a user-writable location instead.

for module in ("3d", "analysis", "core", "gui", "server"):
class_map_path = qgis_module_path / module / "class_map.yaml"
if not class_map_path.exists():
print(f"Cannot find class_map.yaml for {module}, skipping...")
continue
with open(qgis_module_path / module / "class_map.yaml") as f:
class_maps[module] = yaml.safe_load(f)


def linkcode_resolve(domain, info):
if domain != "py":
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:
Expand All @@ -247,6 +268,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,
Expand All @@ -255,6 +277,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)
Expand Down
85 changes: 85 additions & 0 deletions documenters.py
Original file line number Diff line number Diff line change
@@ -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)
76 changes: 46 additions & 30 deletions process_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 0 additions & 6 deletions scripts/build-docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,6 @@ 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
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
done

if [[ -n ${QGIS_BUILD_DIR} ]]; then
export PYTHONPATH=${PYTHONPATH}:$QGIS_BUILD_DIR/output/python
#export PATH=$PATH:/usr/local/bin/:$QGIS_BUILD_DIR/build/output/bin
Expand Down