Skip to content

Commit

Permalink
python apigen: Support methods/properties/functions that are aliased (#…
Browse files Browse the repository at this point in the history
…357)

Previously, if the same function, method or property was assigned to an
additional name in the same scope, it was documented as an independent
entity.

With this change, it is documented together with the entity that it
aliases as an additional signature.
  • Loading branch information
jbms authored Jul 1, 2024
1 parent e8dc677 commit 5fc98aa
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 41 deletions.
2 changes: 2 additions & 0 deletions docs/tensorstore_demo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ def size(self) -> int:
"""
size_alias = size

__hash__ = None
pass
class DimExpression():
Expand Down
3 changes: 3 additions & 0 deletions docs/tensorstore_demo/_tensorstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ def bar(x: Foo) -> Foo:
pass


bar_also = bar


class FooSubclass(Foo):
"""This is a subclass of :py:obj:`.Foo`.
Expand Down
183 changes: 144 additions & 39 deletions sphinx_immaterial/apidoc/python/apigen.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import sphinx
import sphinx.addnodes
import sphinx.application
from sphinx.domains.python import PythonDomain
import sphinx.environment
import sphinx.ext.autodoc
import sphinx.ext.autodoc.directive
Expand Down Expand Up @@ -356,6 +357,12 @@ def overload_suffix(self) -> str:
base_classes: Optional[List[str]] = None
"""List of base classes, as rST cross references."""

siblings: Optional[List[_ApiEntityMemberReference]] = None
"""List of siblings that should be documented as aliases."""

primary_entity: bool = True
"""Indicates if this is the primary sibling and should be documented."""


def _is_constructor_name(name: str) -> bool:
return name in ("__init__", "__new__", "__class_getitem__")
Expand Down Expand Up @@ -521,11 +528,10 @@ def _generate_entity_desc_node(
state: docutils.parsers.rst.states.RSTState,
member: Optional[_ApiEntityMemberReference] = None,
callback=None,
):
) -> sphinx.addnodes.desc:
api_data = _get_api_data(env)

summary = member is not None
name = api_data.get_name_for_signature(entity, member)

def object_description_transform(
app: sphinx.application.Sphinx,
Expand Down Expand Up @@ -587,13 +593,27 @@ def object_description_transform(
content = entity.content
options = dict(entity.options)
options["nonodeid"] = ""
options["object-ids"] = json.dumps([entity.object_name] * len(entity.signatures))
all_entities_and_members = [
(entity, member),
*[
(
api_data.entities[sibling_member.canonical_object_name],
sibling_member if member is not None else None,
)
for sibling_member in (entity.siblings or [])
],
]
options["object-ids"] = json.dumps(
[e.object_name for e, _ in all_entities_and_members for _ in e.signatures]
)
if summary:
content = _summarize_rst_content(content)
options["noindex"] = ""
options.pop("canonical", None)
else:
options["canonical"] = entity.canonical_object_name
# Avoid "canonical" option because it results in duplicate object warnings
# when combined with multiple signatures that produce different object ids.
#
# Instead, the canonical aliases are handled separately below.
options.pop("canonical", None)
try:
with apigen_utils.save_rst_defaults(env):
rst_input = docutils.statemachine.StringList()
Expand All @@ -605,10 +625,16 @@ def object_description_transform(
"<python_apigen_rst_prolog>",
-2,
)

signatures: List[str] = []
for e, m in all_entities_and_members:
name = api_data.get_name_for_signature(e, m)
signatures.extend(name + sig for sig in e.signatures)

sphinx_utils.append_directive_to_stringlist(
rst_input,
entity.directive,
signatures=[name + sig for sig in entity.signatures],
signatures=signatures,
content="\n".join(content),
source_path=entity.object_name,
source_line=0,
Expand Down Expand Up @@ -637,6 +663,15 @@ def object_description_transform(
raise ValueError("Failed to document entity: %r" % (entity.object_name,))
node = nodes[0]

if not summary:
py = cast(PythonDomain, env.get_domain("py"))

for e, _ in all_entities_and_members:
py.objects.setdefault(
e.canonical_object_name,
py.objects[e.object_name]._replace(aliased=True),
)

return node


Expand Down Expand Up @@ -794,7 +829,6 @@ def run(self) -> List[docutils.nodes.Node]:
)
return []

objtype = entity.objtype
objdesc = _generate_entity_desc_node(
self.env,
entity,
Expand All @@ -806,8 +840,6 @@ def run(self) -> List[docutils.nodes.Node]:
state=self.state,
),
)
if objdesc is None:
return []

for signode in objdesc.children[:-1]:
signode = cast(sphinx.addnodes.desc_signature, signode)
Expand Down Expand Up @@ -843,6 +875,7 @@ def run(self) -> List[docutils.nodes.Node]:

group_id = docutils.nodes.make_id(self.arguments[0])
members = data.top_level_groups.get(group_id)

if members is None:
logger.warning(
"No top-level Python API group named: %r, valid groups are: %r",
Expand Down Expand Up @@ -1240,7 +1273,18 @@ def _get_documenter_direct_members(
documenter.object, "__dict__"
)
member_order = {k: i for i, k in enumerate(member_dict.keys())}
members.sort(key=lambda entry: member_order.get(entry[0], float("inf")))

if sphinx.version_info >= (7, 0):

def member_sort_key(entry):
return member_order.get(entry.__name__, float("inf"))

else:

def member_sort_key(entry):
return member_order.get(entry[0], float("inf"))

members.sort(key=member_sort_key)
except AttributeError:
pass
filtered_members = [
Expand Down Expand Up @@ -1428,6 +1472,7 @@ def __init__(
def collect_entity_recursively(
self,
entry: _MemberDocumenterEntry,
primary_sibling: Optional[_ApiEntity] = None,
) -> str:
canonical_full_name = None
if isinstance(entry.documenter, sphinx.ext.autodoc.ClassDocumenter):
Expand All @@ -1447,17 +1492,33 @@ def collect_entity_recursively(
):
logger.warning("Unspecified overload id: %s", canonical_object_name)

rst_strings = docutils.statemachine.StringList()
entry.documenter.directive.result = rst_strings
_prepare_documenter_docstring(entry)
if primary_sibling is None:
rst_strings = docutils.statemachine.StringList()
entry.documenter.directive.result = rst_strings
_prepare_documenter_docstring(entry)

# Prevent autodoc from also documenting members, since this extension does
# that separately.
def document_members(*args, **kwargs):
return
# Prevent autodoc from also documenting members, since this extension does
# that separately.
def document_members(*args, **kwargs):
return

entry.documenter.document_members = document_members # type: ignore[assignment]
entry.documenter.generate()

split_result = _split_autodoc_rst_output(rst_strings)
split_result.options.pop("module", None)

entry.documenter.document_members = document_members # type: ignore[assignment]
entry.documenter.generate()
group_name = split_result.group_name or ""
order = split_result.order
directive = split_result.directive
options = split_result.options
content = split_result.content
else:
group_name = primary_sibling.group_name
order = primary_sibling.order
directive = primary_sibling.directive
options = primary_sibling.options
content = primary_sibling.content

base_classes: Optional[List[str]] = None

Expand Down Expand Up @@ -1490,10 +1551,6 @@ def document_members(*args, **kwargs):
else:
signatures = entry.documenter.format_signature().split("\n")

split_result = _split_autodoc_rst_output(rst_strings)

split_result.options.pop("module", None)

overload_id: Optional[str] = None
if entry.overload is not None:
overload_id = entry.overload.overload_id
Expand All @@ -1502,12 +1559,12 @@ def document_members(*args, **kwargs):
documented_full_name="",
canonical_full_name=canonical_full_name,
objtype=entry.documenter.objtype,
group_name=split_result.group_name or "",
order=split_result.order,
directive=split_result.directive,
group_name=group_name,
order=order,
directive=directive,
signatures=signatures,
options=split_result.options,
content=split_result.content,
options=options,
content=content,
members=[],
parents=[],
subscript=entry.subscript,
Expand All @@ -1517,9 +1574,13 @@ def document_members(*args, **kwargs):

self.entities[canonical_object_name] = entity

entity.members = self.collect_documenter_members(
entry.documenter,
canonical_object_name=canonical_object_name,
entity.members = (
self.collect_documenter_members(
entry.documenter,
canonical_object_name=canonical_object_name,
)
if primary_sibling is None
else primary_sibling.members
)

return canonical_object_name
Expand All @@ -1531,18 +1592,56 @@ def collect_documenter_members(
) -> List[_ApiEntityMemberReference]:
members: List[_ApiEntityMemberReference] = []

object_to_api_entity_member_map: Dict[
int, Tuple[Any, _ApiEntityMemberReference]
] = {}

for entry in _get_documenter_members(
documenter, canonical_full_name=canonical_object_name
):
member_canonical_object_name = self.collect_entity_recursively(entry)
obj = None
if isinstance(
entry.documenter,
(
sphinx.ext.autodoc.FunctionDocumenter,
sphinx.ext.autodoc.MethodDocumenter,
sphinx.ext.autodoc.PropertyDocumenter,
),
):
obj = entry.documenter.object
obj_and_primary_sibling_member: Optional[
Tuple[Any, _ApiEntityMemberReference]
] = None
primary_sibling_entity: Optional[_ApiEntity] = None
if obj is not None:
obj_and_primary_sibling_member = object_to_api_entity_member_map.get(
id(obj)
)
if obj_and_primary_sibling_member is not None:
primary_sibling_entity = self.entities[
obj_and_primary_sibling_member[1].canonical_object_name
]
member_canonical_object_name = self.collect_entity_recursively(
entry, primary_sibling=primary_sibling_entity
)
child = self.entities[member_canonical_object_name]
member = _ApiEntityMemberReference(
name=entry.name,
parent_canonical_object_name=canonical_object_name,
canonical_object_name=member_canonical_object_name,
inherited=entry.is_inherited,
)
members.append(member)
child = self.entities[member_canonical_object_name]

if primary_sibling_entity is not None:
child.primary_entity = False
if primary_sibling_entity.siblings is None:
primary_sibling_entity.siblings = []
primary_sibling_entity.siblings.append(member)
else:
if obj is not None:
object_to_api_entity_member_map[id(obj)] = (obj, member)
members.append(member)

child.parents.append(member)

return members
Expand Down Expand Up @@ -1706,7 +1805,7 @@ def _builder_inited(app: sphinx.application.Sphinx) -> None:

def get_pages():
for object_name, entity in data.entities.items():
if object_name != entity.object_name:
if object_name != entity.object_name or not entity.primary_entity:
# Alias
continue

Expand Down Expand Up @@ -1740,7 +1839,9 @@ def _monkey_patch_napoleon_to_add_group_field():
def parse_section(
self: sphinx.ext.napoleon.docstring.GoogleDocstring, section: str
) -> List[str]:
lines = self._strip_empty(self._consume_to_next_section()) # pylint: disable=protected-access
lines = self._strip_empty(
self._consume_to_next_section()
) # pylint: disable=protected-access
lines = self._dedent(lines) # pylint: disable=protected-access
name = section.lower()
if len(lines) != 1:
Expand All @@ -1751,8 +1852,12 @@ def load_custom_sections(
self: sphinx.ext.napoleon.docstring.GoogleDocstring,
) -> None:
orig_load_custom_sections(self)
self._sections["group"] = lambda section: parse_section(self, section) # pylint: disable=protected-access
self._sections["order"] = lambda section: parse_section(self, section) # pylint: disable=protected-access
self._sections["group"] = lambda section: parse_section(
self, section
) # pylint: disable=protected-access
self._sections["order"] = lambda section: parse_section(
self, section
) # pylint: disable=protected-access

sphinx.ext.napoleon.docstring.GoogleDocstring._load_custom_sections = ( # type: ignore[assignment]
load_custom_sections # pylint: disable=protected-access
Expand Down
4 changes: 3 additions & 1 deletion sphinx_immaterial/apidoc/python/parameter_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,15 @@ def cross_link_single_parameter(
# Identical declarations in more than one signature will only be
# included once.
unique_decls: Dict[str, Tuple[int, docutils.nodes.Element]] = {}
unique_symbols: Dict[Tuple[str, str], int] = {}
for i, sig_param_nodes in enumerate(sig_param_nodes_for_signature):
desc_param_node = sig_param_nodes.get(param_name)
if desc_param_node is None:
continue
desc_param_node = cast(docutils.nodes.Element, desc_param_node)
decl_text = desc_param_node.astext().strip()
unique_decls.setdefault(decl_text, (i, desc_param_node))
unique_symbols.setdefault((decl_text, symbols[i]), i)
if not unique_decls:
all_params = {}
for sig_param_nodes in sig_param_nodes_for_signature:
Expand Down Expand Up @@ -342,7 +344,7 @@ def cross_link_single_parameter(
param_symbols = set()

# Set ids of the parameter node.
for symbol_i, _ in unique_decls.values():
for symbol_i in unique_symbols.values():
symbol = symbols[symbol_i]
param_symbol = f"{symbol}.{param_name}"
if param_symbol in param_symbols:
Expand Down
8 changes: 7 additions & 1 deletion tests/python_apigen_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,5 +138,11 @@ def test_pure_python_property(apigen_make_app):

data = _get_api_data(app.env)

options = data.entities[f"{testmod}.Example.foo"].options
entity = data.entities[f"{testmod}.Example.foo"]
assert entity.primary_entity
assert entity.siblings is not None
assert len(entity.siblings) == 1
assert entity.siblings[0].name == "bar"
options = entity.options

assert options["type"] == "int"
2 changes: 2 additions & 0 deletions tests/python_apigen_test_modules/property.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ class Example:
@property
def foo(self) -> int:
return 42

bar = foo

0 comments on commit 5fc98aa

Please sign in to comment.