diff --git a/docs/usage/configuration/docstrings.md b/docs/usage/configuration/docstrings.md index d88cbbfb..6c6456cc 100644 --- a/docs/usage/configuration/docstrings.md +++ b/docs/usage/configuration/docstrings.md @@ -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" }** diff --git a/src/mkdocstrings_handlers/python/handler.py b/src/mkdocstrings_handlers/python/handler.py index 0aac3cdc..4bee3901 100644 --- a/src/mkdocstrings_handlers/python/handler.py +++ b/src/mkdocstrings_handlers/python/handler.py @@ -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, @@ -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`. @@ -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 diff --git a/src/mkdocstrings_handlers/python/rendering.py b/src/mkdocstrings_handlers/python/rendering.py index a7ea38f7..3e8b6ed9 100644 --- a/src/mkdocstrings_handlers/python/rendering.py +++ b/src/mkdocstrings_handlers/python/rendering.py @@ -8,6 +8,7 @@ import string import sys import warnings +from copy import deepcopy from functools import lru_cache from pathlib import Path from re import Match, Pattern @@ -15,6 +16,7 @@ from griffe import ( Alias, + Docstring, DocstringAttribute, DocstringClass, DocstringFunction, @@ -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 @@ -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], *, diff --git a/src/mkdocstrings_handlers/python/templates/material/_base/children.html.jinja b/src/mkdocstrings_handlers/python/templates/material/_base/children.html.jinja index c9c23156..07ed4dc8 100644 --- a/src/mkdocstrings_handlers/python/templates/material/_base/children.html.jinja +++ b/src/mkdocstrings_handlers/python/templates/material/_base/children.html.jinja @@ -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) }} @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 1bab29d7..9330c78b 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -164,3 +164,74 @@ def __init__(self, name: str, lineno: int | None = None, *, is_alias: bool = Fal members = [Obj("a", 10, is_alias=True), Obj("b", 9, is_alias=False), Obj("c", 8, is_alias=True)] ordered = rendering.do_order_members(members, order, members_list) # type: ignore[arg-type] assert [obj.name for obj in ordered] == expected_names + + +@pytest.mark.parametrize( + ("strategy", "docstrings_list", "expected_docstrings_list"), + [ + (rendering.DocstringInheritStrategy.default, ['"""base"""', "", ""], ["base", None, None]), + ( + rendering.DocstringInheritStrategy.if_not_present, + ['"""base"""', '"""main"""', ""], + ["base", "main", "main"], + ), # main: stays the same (no merge); sub: main is taken (not base) + ( + rendering.DocstringInheritStrategy.merge, + ['"""base"""', '"""main"""', ""], + ["base", "base+main", "base+main"], + ), # main: is merged with base; sub: empty is merged with base+main (not base+main+) + ( + rendering.DocstringInheritStrategy.merge, + ["", '"""main"""', ""], + [None, "main", "main"], + ), # Base class has no docstring after merging (as opposed to an empty one) + ], +) +def test_do_optionally_inherit_docstrings( + strategy: rendering.DocstringInheritStrategy, docstrings_list: list[str], expected_docstrings_list: list[str], +) -> None: + """Test the inheritance strategies of docstrings for members. + + Parameters: + strategy: The docstring inheritance strategy to use. + docstrings_list: The list of docstrings for the base, main, and sub classes. Needs triple quotes. + expected_docstrings_list: The expected list of docstrings for the base, main, and sub classes. Just the content, i.e. without triple quotes. None for no docstring at all. + """ + docstring_base, docstring_main, docstring_sub = docstrings_list + + collection = ModulesCollection() + with temporary_visited_module( + f""" + class Obj: + # No base method to verify that this doesn't break anything. + ... + + class Base(Obj): + def base(self): + {docstring_base} # Without triple quotes so we can control between empty docstring and no docstring. + ... + + class Main(Base): + def base(self): + {docstring_main} + ... + + class Sub(Main): + def base(self): + {docstring_sub} + ... + """, + modules_collection=collection, + ) as module: + collection["module"] = module + + classes = ["Base", "Main", "Sub"] + result = rendering.do_optionally_inherit_docstrings( + objects={class_: collection["module"][class_] for class_ in classes}, + docstring_inherit_strategy=strategy, + docstring_merge_delimiter="+", + ) + docstrings = [result[class_].members["base"].docstring for class_ in classes] + docstring_values = [docstring.value if docstring else None for docstring in docstrings] + + assert docstring_values == expected_docstrings_list