Skip to content
Closed
122 changes: 122 additions & 0 deletions docs/usage/configuration/docstrings.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,128 @@ class ClassWithoutDocstring:
////
///


## `docstring_inherit_strategy`

- **:octicons-package-24: Type [`str`][] :material-equal: `"default"`{ title="default value" } | `"if_not_present"` | `"merge"`**

This setting controls how docstrings from parent classes are handled in the auto-generated API documentation when subclass methods override parent methods.

- `default`: The default behavior, where the subclass docstring is used if present. If the subclass does not provide a docstring, no docstring is inherited from the parent class. This option is useful if you want to document each method independently, even when subclassing. The default behavior is also the fastest, as it does not require any traversal of the class hierarchy.
- `if_not_present`: This option allows the docstring of the parent method to be inherited if the subclass method does not provide its own docstring. It ensures that a method without documentation in a subclass still displays useful inherited information from its parent.
- `merge`: This setting merges the docstrings from parent methods with overriding subclass methods, concatenating all parent classes' docstrings with any additional text provided by the subclass. This is useful for methods where the subclass adds supplementary notes or overrides part of the behavior but still shares the general purpose of the parent method. To control the concatenation delimiter, you can use the `docstring_inherit_delimiter` option.

```yaml title="in mkdocs.yml (global configuration)"
plugins:
- mkdocstrings:
handlers:
python:
options:
docstring_inherit_strategy: default
```

/// admonition | Preview
type: preview

//// tab | default
```python
class Shape:
contour: list[Point]
def surface_area(self):
"""Return the surface area in square meters."""
return numerical_integration(self.contour)

class Rectange(Shape)
def surface_area(self):
return distance(self.cotour[2], self.contour[0]) * distance(self.contour[3], self.contour[1])
```

Should output a documentation like this:
```
Shape
surface_area
Return the surface area in square meters.
...

Rectangle
...
```
////


//// tab | if_not_present
```python
class Shape:
contour: list[Point]
def surface_area(self):
"""Return the surface area in square meters."""
return numerical_integration(self.contour)

class Rectange(Shape)
def surface_area(self):
return distance(self.cotour[2], self.contour[0]) * distance(self.contour[3], self.contour[1])
```

Should output a documentation like this:
```
Shape
surface_area
Return the surface area in square meters.
...

Rectangle
surface_area
Return the surface area in square meters.
...
```
////

//// tab | merge
```python
class Shape:
contour: list[Point]
def surface_area(self):
"""Return the surface area in square meters."""
return numerical_integration(self.contour)

class Rectange(Shape)
def surface_area(self):
"""Note: This is way faster than the calculation for general shapes!"""
return distance(self.cotour[2], self.contour[0]) * distance(self.contour[3], self.contour[1])
```

Should output a documentation like this:
```
Shape
surface_area
Return the surface area in square meters.
...

Rectangle
surface_area
Return the surface area in square meters.
Note: This is way faster than the calculation for general shapes!
...
```
////
///

## `docstring_merge_delimiter`

- **:octicons-package-24: Type [`str`][] :material-equal: `"\n\n"`{ title="default value" }**

The delimiter is used to join docstrings when the `docstring_inherit_strategy` is set to `merge`.

```yaml title="in mkdocs.yml (global configuration)"
plugins:
- mkdocstrings:
handlers:
python:
options:
docstring_merge_delimiter: "\n\n"
```


## `show_docstring_attributes`

- **:octicons-package-24: Type [`bool`][] :material-equal: `True`{ title="default value" }**
Expand Down
5 changes: 5 additions & 0 deletions src/mkdocstrings_handlers/python/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ class PythonHandler(BaseHandler):
"docstring_section_style": "table",
"members": None,
"inherited_members": False,
"docstring_inherit_strategy": rendering.DocstringInheritStrategy.default.value,
"docstring_merge_delimiter": "\n\n",
"filters": ["!^_[^_]"],
"annotations_path": "brief",
"preload_modules": None,
Expand Down Expand Up @@ -179,6 +181,8 @@ class PythonHandler(BaseHandler):
merge_init_into_class (bool): Whether to merge the `__init__` method into the class' signature and docstring. Default: `False`.
relative_crossrefs (bool): Whether to enable the relative crossref syntax. Default: `False`.
scoped_crossrefs (bool): Whether to enable the scoped crossref ability. Default: `False`.
docstring_inherit_strategy (str): The strategy to inherit docstrings of members from their implementation in parent classes. Options: `default`, `if_not_present`, `merge`. Default: `"default"`.
docstring_merge_delimiter (str): The delimiter used to merge docstrings, only applies when `docstring_inherit_strategy` is set to `merge`. Default: `"\n\n"`.
show_if_no_docstring (bool): Show the object heading even if it has no docstring or children with docstrings. Default: `False`.
show_docstring_attributes (bool): Whether to display the "Attributes" section in the object's docstring. Default: `True`.
show_docstring_functions (bool): Whether to display the "Functions" or "Methods" sections in the object's docstring. Default: `True`.
Expand Down Expand Up @@ -432,6 +436,7 @@ def update_env(self, md: Markdown, config: dict) -> None:
self.env.filters["format_signature"] = rendering.do_format_signature
self.env.filters["format_attribute"] = rendering.do_format_attribute
self.env.filters["filter_objects"] = rendering.do_filter_objects
self.env.filters["optionally_inherit_docstring"] = rendering.do_optionally_inherit_docstrings
self.env.filters["stash_crossref"] = rendering.do_stash_crossref
self.env.filters["get_template"] = rendering.do_get_template
self.env.filters["as_attributes_section"] = rendering.do_as_attributes_section
Expand Down
104 changes: 104 additions & 0 deletions src/mkdocstrings_handlers/python/rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
import string
import sys
import warnings
from copy import deepcopy
from functools import lru_cache
from pathlib import Path
from re import Match, Pattern
from typing import TYPE_CHECKING, Any, Callable, ClassVar

from griffe import (
Alias,
Docstring,
DocstringAttribute,
DocstringClass,
DocstringFunction,
Expand Down Expand Up @@ -50,6 +52,17 @@ class Order(enum.Enum):
"""Source code order."""


class DocstringInheritStrategy(str, enum.Enum):
"""Enumeration for the possible docstring inheritance strategies."""

default = "default"
"""Do not inherit docstrings. If a method is overriden and no docstring is present, it will stay empty in the rendered docs."""
if_not_present = "if_not_present"
"""Inherit docstrings of members if not present. This takes the first docstring of a member according to the MRO."""
merge = "merge"
"""Merge docstrings of members in parent classes with given docstring. This proceeds down the inheritance tree, going from general docstrings to more specific ones."""


def _sort_key_alphabetical(item: CollectorItem) -> Any:
# chr(sys.maxunicode) is a string that contains the final unicode
# character, so if 'name' isn't found on the object, the item will go to
Expand Down Expand Up @@ -368,6 +381,97 @@ def _keep_object(name: str, filters: Sequence[tuple[Pattern, bool]]) -> bool:
return keep


def _construct_docstring_according_to_strategy(
name: str, obj: Class | Alias, strategy: DocstringInheritStrategy, merge_delimiter: str = "\n",
) -> Docstring | None:
"""Construct a docstring object according to the strategy.

Parameters:
name: The name of the member. Needed to lookup the member in the parent classes.
obj: The object that contains the member. Needed to access parent classes.
strategy: The strategy to use: default, if_not_present, merge.
merge_delimiter: The delimiter to use when merging docstrings.

Returns:
A new docstring object that just contains the docstring content.
"""
if strategy == DocstringInheritStrategy.default:
# Base case: we don't want to inherit docstrings
return None

if strategy == DocstringInheritStrategy.if_not_present:
for parent in list(obj.mro()):
# this traverses the parents in the order of the MRO, i.e. the first entry is the most direct parent
if parent.members and name in parent.members and parent.members[name].docstring:
return Docstring(value=parent.members[name].docstring.value) # type: ignore[union-attr]
return None

if strategy == DocstringInheritStrategy.merge:
docstrings = []
traversal_order: list[Class | Alias] = [*list(reversed(obj.mro())), obj]
# Here we traverse the parents in the reverse order to build the docstring from the most general to the most specific annotations
# Addtionally, we include the object itself because we don't want to miss the docstring of the object itself if present

for parent in traversal_order: # type: ignore[assignment]
if parent.members and name in parent.members and parent.members[name].docstring:
docstrings.append(parent.members[name].docstring.value) # type: ignore[union-attr]

if not docstrings:
# This guarantees that no empty docstring is constructed for a member that shouldn't have one at all
return None

return Docstring(merge_delimiter.join(docstrings))

raise ValueError(f"Unknown docstring inherit strategy: {strategy}")


def do_optionally_inherit_docstrings(
objects: dict[str, Class | Alias],
*,
docstring_inherit_strategy: str = DocstringInheritStrategy.default.value,
docstring_merge_delimiter: str = "\n\n",
) -> dict[str, Class | Alias]:
"""Optionally inherit docstrings for members in the given .

Parameters:
objects: The objects to inherit docstrings from.
"""
try:
strategy = DocstringInheritStrategy(docstring_inherit_strategy)
except ValueError as e:
raise ValueError(
f"Unknown docstring inherit strategy: '{docstring_inherit_strategy}', allowed options are: {', '.join(strategy.value for strategy in DocstringInheritStrategy)}",
) from e

if strategy == DocstringInheritStrategy.default:
return objects

new_objects = deepcopy(objects)
# It is important to operate on the original objects while modifying the new ones
# otherwise, this would lead to inconsistencies due to side-effects during the merging process

for obj, new_obj in zip(objects.values(), new_objects.values()):
for member_name, new_member in new_obj.members.items():
docstring = _construct_docstring_according_to_strategy(
member_name, obj, strategy=strategy, merge_delimiter=docstring_merge_delimiter,
)

if not docstring:
continue

if not new_member.docstring and strategy == DocstringInheritStrategy.if_not_present:
new_member.docstring = docstring
continue

if strategy == DocstringInheritStrategy.merge:
# This is also applied when the docstring is given as we want to merge it with the parents' docstrings.
# The merging process takes care of integrating the existing docstring into the new one.
new_member.docstring = docstring
continue

return new_objects


def do_filter_objects(
objects_dictionary: dict[str, Object | Alias],
*,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ Context:
{% if obj.all_members %}
{% block logs scoped %}
{#- Logging block.

This block can be used to log debug messages, deprecation messages, warnings, etc.
-#}
{{ log.debug("Rendering children of " + obj.path) }}
Expand All @@ -37,7 +36,11 @@ Context:
{% set extra_level = 0 %}
{% endif %}

{% with attributes = obj.attributes|filter_objects(
{% with attributes = obj.attributes|optionally_inherit_docstring(
docstring_inherit_strategy=config.docstring_inherit_strategy,
docstring_merge_delimiter=config.docstring_merge_delimiter
)
|filter_objects(
filters=config.filters,
members_list=members_list,
inherited_members=config.inherited_members,
Expand All @@ -57,7 +60,11 @@ Context:
{% endif %}
{% endwith %}

{% with classes = obj.classes|filter_objects(
{% with classes = obj.classes|optionally_inherit_docstring(
docstring_inherit_strategy=config.docstring_inherit_strategy,
docstring_merge_delimiter=config.docstring_merge_delimiter
)
|filter_objects(
filters=config.filters,
members_list=members_list,
inherited_members=config.inherited_members,
Expand All @@ -77,7 +84,11 @@ Context:
{% endif %}
{% endwith %}

{% with functions = obj.functions|filter_objects(
{% with functions = obj.functions|optionally_inherit_docstring(
docstring_inherit_strategy=config.docstring_inherit_strategy,
docstring_merge_delimiter=config.docstring_merge_delimiter
)
|filter_objects(
filters=config.filters,
members_list=members_list,
inherited_members=config.inherited_members,
Expand Down Expand Up @@ -126,6 +137,10 @@ Context:
{% else %}

{% for child in obj.all_members
|optionally_inherit_docstring(
docstring_inherit_strategy=config.docstring_inherit_strategy,
docstring_merge_delimiter=config.docstring_merge_delimiter
)
|filter_objects(
filters=config.filters,
members_list=members_list,
Expand Down
Loading
Loading