├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .mypy.ini ├── .pre-commit-config.yaml ├── .pytest.ini ├── .ruff.toml ├── CHANGELOG.md ├── CREDITS.md ├── LICENSE.txt ├── LICENSES └── headers │ ├── CC-BY-4.0.txt │ └── MIT.txt ├── README.md ├── pyproject.toml ├── requirements └── test.txt ├── src └── docstring_inheritance │ ├── __init__.py │ ├── class_docstrings_inheritor.py │ ├── docstring_inheritors │ ├── __init__.py │ ├── bases │ │ ├── __init__.py │ │ ├── inheritor.py │ │ ├── parser.py │ │ └── renderer.py │ ├── google.py │ └── numpy.py │ ├── griffe.py │ └── py.typed ├── tests ├── __init__.py ├── test_base_inheritor.py ├── test_base_parser.py ├── test_google_inheritor.py ├── test_inheritance_for_functions.py ├── test_metaclass_google.py ├── test_metaclass_numpy.py └── test_numpy_inheritor.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | plugins = covdefaults 3 | ; source = docstring_inheritance 4 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "*.md" 7 | - "LICENSE**" 8 | - ".gitignore" 9 | pull_request: 10 | paths-ignore: 11 | - "*.md" 12 | - "LICENSE**" 13 | - ".gitignore" 14 | 15 | jobs: 16 | test: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest, macos-latest, windows-latest] 21 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Setup Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install tox 29 | run: pip install tox-uv 30 | - name: Run tox 31 | # Run tox using the version of Python in `PATH`. 32 | run: tox run -e py-coverage 33 | - name: Upload coverage to Codecov 34 | uses: codecov/codecov-action@v4 35 | with: 36 | # fail_ci_if_error: true 37 | files: ./coverage.xml 38 | verbose: true 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /src/*.egg-info 3 | /.tox 4 | /.coverage 5 | /htmlcov 6 | /coverage.xml 7 | /build 8 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | strict = True 3 | 4 | [mypy-griffe.*] 5 | ignore_missing_imports = True 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v5.0.0 5 | hooks: 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | exclude: ^LICENSES/headers 9 | - id: check-added-large-files 10 | - id: check-toml 11 | - id: destroyed-symlinks 12 | - id: check-symlinks 13 | 14 | - repo: https://github.com/astral-sh/ruff-pre-commit 15 | rev: v0.8.1 16 | hooks: 17 | - id: ruff 18 | 19 | - repo: https://github.com/astral-sh/ruff-pre-commit 20 | rev: v0.8.1 21 | hooks: 22 | - id: ruff-format 23 | 24 | - repo: https://github.com/pre-commit/pygrep-hooks 25 | rev: v1.10.0 26 | hooks: 27 | - id: rst-backticks 28 | - id: rst-directive-colons 29 | - id: rst-inline-touching-normal 30 | 31 | - repo: https://github.com/pre-commit/mirrors-mypy 32 | rev: v1.13.0 33 | hooks: 34 | - id: mypy 35 | exclude: ^tests|src/docstring_inheritance/griffe\.py$\a 36 | 37 | - repo: https://github.com/Lucas-C/pre-commit-hooks 38 | rev: v1.5.5 39 | hooks: 40 | - id: insert-license 41 | name: insert MIT license 42 | files: \.py$ 43 | args: 44 | - --license-filepath 45 | - LICENSES/headers/MIT.txt 46 | - id: insert-license 47 | name: insert CC BY license 48 | files: \.md$ 49 | args: 50 | - --license-filepath 51 | - LICENSES/headers/CC-BY-4.0.txt 52 | - --comment-style 53 | - 54 | -------------------------------------------------------------------------------- /.pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | filterwarnings = ignore::docstring_inheritance.docstring_inheritors.bases.inheritor.DocstringInheritanceWarning 4 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | fix = true 2 | unsafe-fixes = true 3 | preview = true 4 | target-version = "py39" 5 | src = ["src"] 6 | 7 | [lint] 8 | task-tags = ["TODO"] 9 | ignore = [ 10 | # Conflicts with ruff format. 11 | "E203", 12 | # D100 Missing docstring in public module. 13 | "D100", 14 | # D100 Missing docstring in public package. 15 | "D104", 16 | # Checks for undocumented magic method definitions. 17 | "D105", 18 | # Missing docstring in `__init__`. 19 | "D107", 20 | # Checks for long exception messages that are not defined in the exception class itself. 21 | "TRY003", 22 | # Avoid unexpected behavior with the formatter. 23 | "ISC001", 24 | # Too many arguments in function definition. 25 | "PLR0913", 26 | # `subprocess.run` without explicit `check` argument. 27 | "PLR2004", 28 | # Too many public methods. 29 | "PLR0904", 30 | # Too many branches. 31 | "PLR0912", 32 | # Too many statements. 33 | "PLR0915", 34 | # Too many return statements. 35 | "PLR0911", 36 | # `for` loop variable `name` overwritten by assignment target. 37 | "PLW1510", 38 | # Magic value used in comparison. 39 | "PLW2901", 40 | # Bad or misspelled dunder method name `_repr_html_`. 41 | "PLW3201", 42 | # Object does not implement `__hash__` method. 43 | "PLW1641", 44 | # Fixture does not return anything, add leading underscore. 45 | "PT004", 46 | ] 47 | select = [ 48 | "A", 49 | # "ANN", 50 | "ASYNC", 51 | "B", 52 | "BLE", 53 | # "C", 54 | "C4", 55 | "D", 56 | # "DOC", 57 | "E", 58 | "EM", 59 | "F", 60 | # "FA", 61 | # "FBT", 62 | "FLY", 63 | "FURB", 64 | "G", 65 | "I", 66 | "ISC", 67 | "INP", 68 | "LOG", 69 | "Q", 70 | "N", 71 | "NPY", 72 | # "PL", 73 | # "PD", 74 | "PT", 75 | "PIE", 76 | "PGH", 77 | "PTH", 78 | "PYI", 79 | "PERF", 80 | "RET", 81 | "RSE", 82 | "RUF", 83 | # "S", 84 | "SIM", 85 | # "SLF", 86 | "SLOT", 87 | "T", 88 | "T10", 89 | "T20", 90 | "TCH", 91 | "TRY", 92 | "W", 93 | "UP", 94 | "YTT", 95 | ] 96 | 97 | [lint.isort] 98 | force-single-line = true 99 | #required-imports = ["from __future__ import annotations"] 100 | 101 | [lint.pydocstyle] 102 | convention = "google" 103 | 104 | [lint.per-file-ignores] 105 | "tests/*.py" = ["D", "PT009","PT011", "PT027", "PTH"] 106 | 107 | [format] 108 | docstring-code-format = true 109 | docstring-code-line-length = 75 110 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # Changelog 11 | All notable changes to this project will be documented in this file. 12 | 13 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 14 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 15 | 16 | ## [2.2.2] - 2024-11 17 | ### Added 18 | - Support for Python 3.12 19 | ### Removed 20 | - Support for Python 3.8 21 | 22 | ## [2.2.1] - 2024-09 23 | ### Fixed 24 | - Compatibility with griffe > 1.0.0. 25 | 26 | ## [2.2.0] - 2024-03 27 | ### Added 28 | - Extension to support building docs with mkdocs. 29 | ### Fixed 30 | - Docstrings inheritance with decorators. 31 | 32 | ## [2.1.2] - 2023-12 33 | ### Fixed 34 | - Warning messages are no longer shown by default. 35 | Set the environment variable `DOCSTRING_INHERITANCE_WARNS` to activate them. 36 | 37 | ## [2.1.1] - 2023-12 38 | ### Changed 39 | - Warning messages are more relevant. 40 | ### Fixed 41 | - Some warnings for missing arguments were spurious. 42 | 43 | ## [2.1.0] - 2023-11 44 | ### Added 45 | - Warnings for missing method arguments in docstrings. 46 | - Duplicated docstrings detection for missing inheritance opportunities. 47 | 48 | ## [2.0.2] - 2023-10 49 | ### Added 50 | - Support for Python 3.12. 51 | 52 | ## [2.0.1] - 2023-09 53 | ### Fixed 54 | - Parsing of Google docstrings with Sphinx directives (like `.. math::`) is no longer considered as a section. 55 | 56 | ## [2.0.0] - 2023-06 57 | ### Changed 58 | - Docstring inheritance for methods only inherit from the first found parent. 59 | - For the Numpy style, the section `OtherParameters` is no longer processed against the arguments of the signature. 60 | ### Fixed 61 | - Docstring inheritance for methods with no argument descriptions. 62 | - Format of the arguments provided with the default description for the Numpy style. 63 | - Docstring inheritance for methods with no arguments. 64 | 65 | ## [1.1.1] - 2023-01 66 | ### Fixed 67 | - Docstring inheritance for methods with multiple parents. 68 | 69 | ## [1.1.0] - 2023-01 70 | ### Added 71 | - Support class docstrings with `__init__` signature description. 72 | ### Changed 73 | - Some performance improvements. 74 | ### Fixed 75 | - Metaclasses API no longer leaks into the classes they create. 76 | 77 | ## [1.0.1] - 2022-11 78 | ### Added 79 | - Support for Python 3.11. 80 | 81 | ## [1.0.0] - 2021-11 82 | ### Changed 83 | - Metaclasses naming. 84 | ### Fixed 85 | - Handling of *object* in the class hierarchy. 86 | - Rendering of Google docstrings with no summaries. 87 | - Formatting of items without descriptions. 88 | - Inheritance of a metaclass'd class from its grandparents. 89 | 90 | ## [0.1] - 2021-11 91 | ### Added 92 | - Initial release. 93 | -------------------------------------------------------------------------------- /CREDITS.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | # External Dependencies 11 | 12 | - [Python](http://python.org/): Python Software License 13 | 14 | # External applications 15 | 16 | - [build](https://pypa-build.readthedocs.io): MIT 17 | - [docformatter](https://github.com/myint/docformatter): MIT 18 | - [Lucas-C/pre-commit-hooks](https://github.com/Lucas-C/pre-commit-hooks): MIT 19 | - [mypy](http://www.mypy-lang.org/): MIT 20 | - [pre-commit](https://pre-commit.com): MIT 21 | - [pygrep-hooks](https://github.com/pre-commit/pygrep-hooks): MIT 22 | - [pytest](https://pytest.org): MIT 23 | - [pytest-cov](https://pytest-cov.readthedocs.io): MIT 24 | - [ruff](https://docs.astral.sh/ruff): MIT 25 | - [setuptools](https://setuptools.readthedocs.io/): MIT 26 | - [setuptools_scm](https://github.com/pypa/setuptools_scm/): MIT 27 | - [tox](https://tox.wiki): MIT 28 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Antoine DECHAUME 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /LICENSES/headers/CC-BY-4.0.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Antoine DECHAUME 2 | 3 | This work is licensed under the Creative Commons Attribution 4.0 4 | International License. To view a copy of this license, visit 5 | http://creativecommons.org/licenses/by/4.0/ or send a letter to Creative 6 | Commons, PO Box 1866, Mountain View, CA 94042, USA. 7 | -------------------------------------------------------------------------------- /LICENSES/headers/MIT.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 Antoine DECHAUME 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/docstring-inheritance) 11 | ![PyPI](https://img.shields.io/pypi/v/docstring-inheritance) 12 | ![Conda (channel only)](https://img.shields.io/conda/vn/conda-forge/docstring-inheritance) 13 | ![Codecov branch](https://img.shields.io/codecov/c/gh/AntoineD/docstring-inheritance/main) 14 | 15 | `docstring-inheritance` is a python package to avoid writing and maintaining duplicated python docstrings. 16 | The typical usage is to enable the inheritance of the docstrings from a base class 17 | such that its derived classes fully or partly inherit the docstrings. 18 | 19 | # Features 20 | 21 | - Handle numpy and google docstring formats (i.e. sections based docstrings): 22 | - [NumPy docstring format specification](https://numpydoc.readthedocs.io/en/latest/format.html) 23 | - [Google docstring format specification](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) 24 | - Handle docstrings for functions, classes, methods, class methods, static methods, properties. 25 | - Handle docstrings for classes with multiple or multi-level inheritance. 26 | - Docstring sections are inherited individually, 27 | like methods. 28 | - For docstring sections documenting signatures, 29 | the signature arguments are inherited individually. 30 | - Minimum performance cost: the inheritance is performed at import time, 31 | not for each call. 32 | - Compatible with rendering the documentation with [Sphinx](http://www.sphinx-doc.org/) and [mkdocs](https://www.mkdocs.org/) (See [below](#mkdocs)). 33 | - Missing docstring sections for signature arguments can be notified by warnings 34 | when the environment variable `DOCSTRING_INHERITANCE_WARNS` is set. 35 | - Docstring sections can be compared to detect duplicated or similar contents that could be inherited. 36 | 37 | # Licenses 38 | 39 | The source code is distributed under the MIT license. 40 | The documentation is distributed under the CC BY 4.0 license. 41 | The dependencies, with their licenses, are given in the CREDITS.md file. 42 | 43 | # Installation 44 | 45 | Install with pip: 46 | 47 | ```commandline 48 | pip install docstring-inheritance 49 | ``` 50 | 51 | Or with conda: 52 | 53 | ```commandline 54 | conda install -c conda-forge docstring-inheritance 55 | ``` 56 | 57 | # Basic Usage 58 | 59 | ## Inheriting docstrings for classes 60 | 61 | `docstring-inheritance` provides 62 | [metaclasses](https://docs.python.org/3/reference/datamodel.html#customizing-class-creation) 63 | to enable the docstrings of a class to be inherited from its base classes. 64 | This feature is automatically transmitted to its derived classes as well. 65 | The docstring inheritance is performed for the docstrings of the: 66 | - class 67 | - methods 68 | - classmethods 69 | - staticmethods 70 | - properties 71 | 72 | Use the `NumpyDocstringInheritanceMeta` metaclass to inherit docstrings in numpy format 73 | if `__init__` method is documented in its own docstring. 74 | Otherwise, if `__init__` method is documented in the class docstring, 75 | use the `NumpyDocstringInheritanceInitMeta` metaclass. 76 | 77 | Use the `GoogleDocstringInheritanceMeta` metaclass to inherit docstrings in google format. 78 | if `__init__` method is documented in its own docstring. 79 | Otherwise, if `__init__` method is documented in the class docstring, 80 | use the `GoogleDocstringInheritanceInitMeta` metaclass. 81 | 82 | ```python 83 | from docstring_inheritance import NumpyDocstringInheritanceMeta 84 | 85 | 86 | class Parent(metaclass=NumpyDocstringInheritanceMeta): 87 | def method(self, x, y=None): 88 | """Parent summary. 89 | 90 | Parameters 91 | ---------- 92 | x: 93 | Description for x. 94 | y: 95 | Description for y. 96 | 97 | Notes 98 | ----- 99 | Parent notes. 100 | """ 101 | 102 | 103 | class Child(Parent): 104 | def method(self, x, z): 105 | """ 106 | Parameters 107 | ---------- 108 | z: 109 | Description for z. 110 | 111 | Returns 112 | ------- 113 | Something. 114 | 115 | Notes 116 | ----- 117 | Child notes. 118 | """ 119 | 120 | 121 | # The inherited docstring is 122 | Child.method.__doc__ == """Parent summary. 123 | 124 | Parameters 125 | ---------- 126 | x: 127 | Description for x. 128 | z: 129 | Description for z. 130 | 131 | Returns 132 | ------- 133 | Something. 134 | 135 | Notes 136 | ----- 137 | Child notes. 138 | """ 139 | ``` 140 | 141 | ## Inheriting docstrings for functions 142 | 143 | `docstring-inheritance` provides functions to inherit the docstring of a callable from a string. 144 | This is typically used to inherit the docstring of a function from another function. 145 | 146 | Use the `inherit_google_docstring` function to inherit docstrings in google format. 147 | 148 | Use the `inherit_numpy_docstring` function to inherit docstrings in numpy format. 149 | 150 | ```python 151 | from docstring_inheritance import inherit_google_docstring 152 | 153 | 154 | def parent(): 155 | """Parent summary. 156 | 157 | Args: 158 | x: Description for x. 159 | y: Description for y. 160 | 161 | Notes: 162 | Parent notes. 163 | """ 164 | 165 | 166 | def child(): 167 | """ 168 | Args: 169 | z: Description for z. 170 | 171 | Returns: 172 | Something. 173 | 174 | Notes: 175 | Child notes. 176 | """ 177 | 178 | 179 | inherit_google_docstring(parent.__doc__, child) 180 | 181 | # The inherited docstring is 182 | child.__doc__ == """Parent summary. 183 | 184 | Args: 185 | x: Description for x. 186 | z: Description for z. 187 | 188 | Returns: 189 | Something. 190 | 191 | Notes: 192 | Child notes. 193 | """ 194 | ``` 195 | 196 | # Docstring inheritance specification 197 | 198 | ## Sections order 199 | 200 | The sections of an inherited docstring are sorted according to order defined in the 201 | [NumPy docstring format specification](https://numpydoc.readthedocs.io/en/latest/format.html): 202 | - `Summary` 203 | - `Extended summary` 204 | - `Parameters` for the NumPy format or `Args` for the Google format 205 | - `Returns` 206 | - `Yields` 207 | - `Receives` 208 | - `Other Parameters` 209 | - `Attributes` 210 | - `Methods` 211 | - `Raises` 212 | - `Warns` 213 | - `Warnings` 214 | - `See Also` 215 | - `Notes` 216 | - `References` 217 | - `Examples` 218 | - sections with other names come next 219 | 220 | This ordering is also used for the docstring written with the 221 | [Google docstring format specification](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) 222 | even though it does not define all of these sections. 223 | 224 | ## Sections with items 225 | 226 | Those sections are: 227 | - `Other Parameters` 228 | - `Methods` 229 | - `Attributes` 230 | 231 | The inheritance is done at the key level, 232 | i.e. a section of the inheritor will not fully override the parent one: 233 | - the keys in the parent section and not in the child section are inherited, 234 | - the keys in the child section and not in the parent section are kept, 235 | - for keys that are both in the parent and child section, 236 | the child ones are kept. 237 | 238 | This allows to only document the new keys in such a section of an inheritor. 239 | For instance: 240 | 241 | ```python 242 | from docstring_inheritance import NumpyDocstringInheritanceMeta 243 | 244 | 245 | class Parent(metaclass=NumpyDocstringInheritanceMeta): 246 | """ 247 | Attributes 248 | ---------- 249 | x: 250 | Description for x 251 | y: 252 | Description for y 253 | """ 254 | 255 | 256 | class Child(Parent): 257 | """ 258 | Attributes 259 | ---------- 260 | y: 261 | Overridden description for y 262 | z: 263 | Description for z 264 | """ 265 | 266 | 267 | # The inherited docstring is 268 | Child.__doc__ == """ 269 | Attributes 270 | ---------- 271 | x: 272 | Description for x 273 | y: 274 | Overridden description for y 275 | z: 276 | Description for z 277 | """ 278 | ``` 279 | 280 | Here the keys are the attribute names. 281 | The description for the attribute `y` has been overridden 282 | and the description for the attribute `z` has been added. 283 | The only remaining description from the parent is for the attribute `x`. 284 | 285 | ### Sections documenting signatures 286 | 287 | Those sections are: 288 | - `Parameters` (numpy format only) 289 | - `Args` (google format only) 290 | 291 | In addition to the inheritance behavior described [above](#sections-with-items): 292 | - the arguments not existing in the inheritor signature are removed, 293 | - the arguments are sorted according the inheritor signature, 294 | - the arguments with no description are provided with a dummy description. 295 | 296 | ```python 297 | from docstring_inheritance import GoogleDocstringInheritanceMeta 298 | 299 | 300 | class Parent(metaclass=GoogleDocstringInheritanceMeta): 301 | def method(self, w, x, y): 302 | """ 303 | Args: 304 | w: Description for w 305 | x: Description for x 306 | y: Description for y 307 | """ 308 | 309 | 310 | class Child(Parent): 311 | def method(self, w, y, z): 312 | """ 313 | Args: 314 | z: Description for z 315 | y: Overridden description for y 316 | """ 317 | 318 | 319 | # The inherited docstring is 320 | Child.method.__doc__ == """ 321 | Args: 322 | w: Description for w 323 | y: Overridden description for y 324 | z: Description for z 325 | """ 326 | ``` 327 | 328 | Here the keys are the argument names. 329 | The description for the argument `y` has been overridden 330 | and the description for the argument `z` has been added. 331 | The only remaining description from the parent is for the argument `w`. 332 | 333 | # Advanced usage 334 | 335 | ## Abstract base class 336 | 337 | To create a parent class that both is abstract and has docstring inheritance, 338 | an additional metaclass is required: 339 | 340 | ```python 341 | import abc 342 | from docstring_inheritance import NumpyDocstringInheritanceMeta 343 | 344 | 345 | class Meta(abc.ABCMeta, NumpyDocstringInheritanceMeta): 346 | pass 347 | 348 | 349 | class Parent(metaclass=Meta): 350 | pass 351 | ``` 352 | 353 | ## Detecting similar docstrings 354 | 355 | Duplicated docstrings that could benefit from inheritance can be detected 356 | by setting the environment variable `DOCSTRING_INHERITANCE_SIMILARITY_RATIO` to a value between `0` and `1`. 357 | When set, the docstring sections of a child and its parent are compared and warnings are issued when the docstrings are 358 | similar. 359 | The docstring sections are compared with 360 | [difflib ratio](https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio) 361 | from the standard library. 362 | If the ratio is higher or equal to the value of `DOCSTRING_INHERITANCE_SIMILARITY_RATIO`, 363 | the docstring sections are considered similar. 364 | Use a ratio of `1` to detect identical docstring sections. 365 | Use a ratio lower than `1` to detect similar docstring sections. 366 | 367 | # Mkdocs 368 | 369 | To render the documentation with `mkdocs`, 370 | the package `mkdocstring[python]` is required and 371 | the package `griffe-inherited-docstrings` is recommended, 372 | finally the following shall be added to `mkdocs.yml`: 373 | 374 | ```yaml 375 | plugins: 376 | - mkdocstrings: 377 | handlers: 378 | python: 379 | options: 380 | extensions: 381 | - griffe_inherited_docstrings 382 | - docstring_inheritance.griffe 383 | ``` 384 | 385 | # Similar projects 386 | 387 | [custom_inherit](https://github.com/rsokl/custom_inherit): 388 | `docstring-inherit` started as fork of this project before being re-written, 389 | we thank its author. 390 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "docstring-inheritance" 3 | dynamic = ["version"] 4 | description = "Avoid writing and maintaining duplicated docstrings." 5 | readme = "README.md" 6 | license = {text = "MIT"} 7 | requires-python = ">=3.9,<4" 8 | authors = [ 9 | {name = "Antoine Dechaume"}, 10 | ] 11 | classifiers = [ 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: MacOS", 14 | "Operating System :: Microsoft :: Windows", 15 | "Operating System :: POSIX :: Linux", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | ] 23 | 24 | [project.optional-dependencies] 25 | test = [ 26 | "covdefaults", 27 | "pytest", 28 | "pytest-cov", 29 | ] 30 | 31 | [project.urls] 32 | Documentation = "https://antoined.github.io/docstring-inheritance" 33 | Homepage = "https://github.com/AntoineD/docstring-inheritance" 34 | Source = "https://github.com/AntoineD/docstring-inheritance" 35 | Tracker = "https://github.com/AntoineD/docstring-inheritance/issues" 36 | 37 | [tool.setuptools_scm] 38 | 39 | [build-system] 40 | requires = ["setuptools", "setuptools_scm"] 41 | build-backend = "setuptools.build_meta" 42 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv pip compile --extra test -o requirements/test.txt pyproject.toml 3 | covdefaults==2.3.0 4 | # via docstring-inheritance (pyproject.toml) 5 | coverage==7.6.8 6 | # via 7 | # covdefaults 8 | # pytest-cov 9 | exceptiongroup==1.2.2 10 | # via pytest 11 | iniconfig==2.0.0 12 | # via pytest 13 | packaging==24.2 14 | # via pytest 15 | pluggy==1.5.0 16 | # via pytest 17 | pytest==8.3.3 18 | # via 19 | # docstring-inheritance (pyproject.toml) 20 | # pytest-cov 21 | pytest-cov==6.0.0 22 | # via docstring-inheritance (pyproject.toml) 23 | tomli==2.2.1 24 | # via 25 | # coverage 26 | # pytest 27 | -------------------------------------------------------------------------------- /src/docstring_inheritance/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | """Docstring inheritance entry point.""" 21 | 22 | from __future__ import annotations 23 | 24 | import os 25 | from typing import Any 26 | from typing import Callable 27 | from warnings import simplefilter 28 | 29 | from .class_docstrings_inheritor import ClassDocstringsInheritor 30 | from .class_docstrings_inheritor import DocstringInheritorClass 31 | from .docstring_inheritors.bases.inheritor import DocstringInheritanceWarning 32 | from .docstring_inheritors.google import GoogleDocstringInheritor 33 | from .docstring_inheritors.numpy import NumpyDocstringInheritor 34 | 35 | 36 | def inherit_google_docstring( 37 | parent_doc: str | None, 38 | child_func: Callable[..., Any], 39 | ) -> None: 40 | """Inherit the docstring in Google format of a function. 41 | 42 | Args: 43 | parent_doc: The docstring of the parent. 44 | child_func: The child function which docstring inherit from the parent. 45 | """ 46 | return GoogleDocstringInheritor.inherit(parent_doc, child_func) 47 | 48 | 49 | def inherit_numpy_docstring( 50 | parent_doc: str | None, 51 | child_func: Callable[..., Any], 52 | ) -> None: 53 | """Inherit the docstring in NumPy format of a function. 54 | 55 | Args: 56 | parent_doc: The docstring of the parent. 57 | child_func: The child function which docstring inherit from the parent. 58 | """ 59 | return NumpyDocstringInheritor.inherit(parent_doc, child_func) 60 | 61 | 62 | class _BaseDocstringInheritanceMeta(type): 63 | """Base metaclass for inheriting class docstrings.""" 64 | 65 | def __init__( 66 | cls, 67 | class_name: str, 68 | class_bases: tuple[type], 69 | class_dict: dict[str, Any], 70 | docstring_inheritor: DocstringInheritorClass, 71 | init_in_class: bool, 72 | ) -> None: 73 | super().__init__(class_name, class_bases, class_dict) 74 | if class_bases: 75 | ClassDocstringsInheritor.inherit_docstrings( 76 | cls, docstring_inheritor, init_in_class 77 | ) 78 | 79 | 80 | class GoogleDocstringInheritanceMeta(_BaseDocstringInheritanceMeta): 81 | """Metaclass for inheriting docstrings in Google format.""" 82 | 83 | def __init__( 84 | cls, 85 | class_name: str, 86 | class_bases: tuple[type], 87 | class_dict: dict[str, Any], 88 | ) -> None: 89 | super().__init__( 90 | class_name, 91 | class_bases, 92 | class_dict, 93 | GoogleDocstringInheritor, 94 | init_in_class=False, 95 | ) 96 | 97 | 98 | class GoogleDocstringInheritanceInitMeta(_BaseDocstringInheritanceMeta): 99 | """Metaclass for inheriting docstrings in Google format with init-in-class.""" 100 | 101 | def __init__( 102 | cls, 103 | class_name: str, 104 | class_bases: tuple[type], 105 | class_dict: dict[str, Any], 106 | ) -> None: 107 | super().__init__( 108 | class_name, 109 | class_bases, 110 | class_dict, 111 | GoogleDocstringInheritor, 112 | init_in_class=True, 113 | ) 114 | 115 | 116 | class NumpyDocstringInheritanceMeta(_BaseDocstringInheritanceMeta): 117 | """Metaclass for inheriting docstrings in Numpy format.""" 118 | 119 | def __init__( 120 | cls, 121 | class_name: str, 122 | class_bases: tuple[type], 123 | class_dict: dict[str, Any], 124 | ) -> None: 125 | super().__init__( 126 | class_name, 127 | class_bases, 128 | class_dict, 129 | NumpyDocstringInheritor, 130 | init_in_class=False, 131 | ) 132 | 133 | 134 | class NumpyDocstringInheritanceInitMeta(_BaseDocstringInheritanceMeta): 135 | """Metaclass for inheriting docstrings in Numpy format with init-in-class.""" 136 | 137 | def __init__( 138 | cls, 139 | class_name: str, 140 | class_bases: tuple[type], 141 | class_dict: dict[str, Any], 142 | ) -> None: 143 | super().__init__( 144 | class_name, 145 | class_bases, 146 | class_dict, 147 | NumpyDocstringInheritor, 148 | init_in_class=True, 149 | ) 150 | 151 | 152 | # Ignore our warnings unless explicitly asked. 153 | if not { 154 | "DOCSTRING_INHERITANCE_WARNS", 155 | "DOCSTRING_INHERITANCE_SIMILARITY_RATIO", 156 | }.intersection(os.environ.keys()): 157 | simplefilter("ignore", DocstringInheritanceWarning) 158 | -------------------------------------------------------------------------------- /src/docstring_inheritance/class_docstrings_inheritor.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | """Docstrings inheritor class.""" 21 | 22 | from __future__ import annotations 23 | 24 | from types import FunctionType 25 | from types import WrapperDescriptorType 26 | from typing import Any 27 | from typing import Callable 28 | 29 | from docstring_inheritance.docstring_inheritors.bases.inheritor import ( 30 | BaseDocstringInheritor, 31 | ) 32 | 33 | DocstringInheritorClass = type[BaseDocstringInheritor] 34 | 35 | 36 | class ClassDocstringsInheritor: 37 | """A class for inheriting class docstrings.""" 38 | 39 | _cls: type 40 | """The class to process.""" 41 | 42 | _docstring_inheritor: DocstringInheritorClass 43 | """The docstring inheritor.""" 44 | 45 | _init_in_class: bool 46 | """Whether the ``__init__`` arguments documentation is in the class docstring.""" 47 | 48 | __mro_classes: list[type] 49 | """The MRO classes.""" 50 | 51 | def __init__( 52 | self, 53 | cls: type, 54 | docstring_inheritor: DocstringInheritorClass, 55 | init_in_class: bool, 56 | ) -> None: 57 | """ 58 | Args: 59 | cls: The class to process. 60 | docstring_inheritor: The docstring inheritor. 61 | init_in_class: Whether the ``__init__`` arguments documentation is in the 62 | class docstring. 63 | """ # noqa: D205, D212 64 | # Remove the new class itself and the object class from the mro, 65 | # object's docstrings have no interest. 66 | self.__mro_classes = cls.mro()[1:-1] 67 | self._cls = cls 68 | self._docstring_inheritor = docstring_inheritor 69 | self._init_in_class = init_in_class 70 | 71 | @classmethod 72 | def inherit_docstrings( 73 | cls, 74 | class_: type, 75 | docstring_inheritor: DocstringInheritorClass, 76 | init_in_class: bool, 77 | ) -> None: 78 | """Inherit all the docstrings of the class. 79 | 80 | Args: 81 | class_: The class to process. 82 | docstring_inheritor: The docstring inheritor. 83 | init_in_class: Whether the ``__init__`` arguments documentation is in the 84 | class docstring. 85 | """ 86 | inheritor = cls(class_, docstring_inheritor, init_in_class) 87 | inheritor._inherit_attrs_docstrings() 88 | inheritor._inherit_class_docstring() 89 | 90 | def _inherit_class_docstring( 91 | self, 92 | ) -> None: 93 | """Create the inherited docstring for the class docstring.""" 94 | func = None 95 | old_init_doc = None 96 | init_doc_changed = False 97 | 98 | if self._init_in_class: 99 | init_method: Callable[..., None] = self._cls.__init__ # type: ignore[misc] 100 | # Ignore the case when __init__ is from object since there is no docstring 101 | # and its __doc__ cannot be assigned. 102 | if not isinstance(init_method, WrapperDescriptorType): 103 | old_init_doc = init_method.__doc__ 104 | init_method.__doc__ = self._cls.__doc__ 105 | func = init_method 106 | init_doc_changed = True 107 | 108 | if func is None: 109 | func = self._create_dummy_func_with_doc(self._cls.__doc__) 110 | 111 | for parent_cls in self.__mro_classes: 112 | # As opposed to the attribute inheritance, and following the way a class is 113 | # assembled by type(), the docstring of a class is the combination of the 114 | # docstrings of its parents. 115 | self._docstring_inheritor.inherit(parent_cls.__doc__, func) 116 | 117 | self._cls.__doc__ = func.__doc__ 118 | 119 | if self._init_in_class and init_doc_changed: 120 | init_method.__doc__ = old_init_doc 121 | 122 | def _inherit_attrs_docstrings( 123 | self, 124 | ) -> None: 125 | """Create the inherited docstrings for the class attributes.""" 126 | for attr_name, attr in self._cls.__dict__.items(): 127 | if not isinstance(attr, FunctionType): 128 | continue 129 | 130 | for parent_cls in self.__mro_classes: 131 | parent_method = getattr(parent_cls, attr_name, None) 132 | if parent_method is not None: 133 | parent_doc = parent_method.__doc__ 134 | if parent_doc is not None: 135 | self._docstring_inheritor.inherit(parent_doc, attr) 136 | # As opposed to the class docstring inheritance, and following 137 | # the MRO for methods, 138 | # we inherit only from the first found parent. 139 | break 140 | # TODO: else WARN that no docstring is defined and 141 | # none can be inherited. 142 | 143 | @staticmethod 144 | def _create_dummy_func_with_doc(docstring: str | None) -> Callable[..., Any]: 145 | """Create a dummy function with a given docstring. 146 | 147 | Args: 148 | docstring: The docstring to be assigned. 149 | 150 | Returns: 151 | The function with the given docstring. 152 | """ 153 | 154 | def func() -> None: # pragma: no cover 155 | pass 156 | 157 | func.__doc__ = docstring 158 | return func 159 | -------------------------------------------------------------------------------- /src/docstring_inheritance/docstring_inheritors/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | -------------------------------------------------------------------------------- /src/docstring_inheritance/docstring_inheritors/bases/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | """Base classes sub-package.""" 21 | 22 | from __future__ import annotations 23 | 24 | from typing import Union 25 | 26 | SubSectionType = Union[str, dict[str, str]] 27 | SectionsType = dict[str, SubSectionType] 28 | 29 | SUMMARY_SECTION_NAME = "" 30 | """The internal name of the un-named summary sections (short and extended).""" 31 | -------------------------------------------------------------------------------- /src/docstring_inheritance/docstring_inheritors/bases/inheritor.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | """Base class for docstrings inheritors.""" 21 | 22 | from __future__ import annotations 23 | 24 | import difflib 25 | import os 26 | import warnings 27 | from inspect import getfile 28 | from inspect import getfullargspec 29 | from inspect import getmodule 30 | from inspect import getsourcelines 31 | from inspect import unwrap 32 | from textwrap import indent 33 | from typing import TYPE_CHECKING 34 | from typing import Any 35 | from typing import Callable 36 | from typing import ClassVar 37 | from typing import cast 38 | 39 | if TYPE_CHECKING: 40 | from . import SectionsType 41 | from .parser import BaseDocstringParser 42 | from .renderer import BaseDocstringRenderer 43 | 44 | 45 | def get_similarity_ratio(env_ratio: str | None) -> float: 46 | """Check the value of the similarity ratio. 47 | 48 | If the passed ratio is ``None`` then the default value of 0. is returned. 49 | 50 | Args: 51 | env_ratio: The raw value of the ratio from the environment variable. 52 | 53 | Returns: 54 | The value of the ratio. 55 | 56 | Raises: 57 | ValueError: If the ratio cannot be determined or has a bad value. 58 | """ 59 | if env_ratio is None: 60 | return 0.0 61 | try: 62 | ratio = float(env_ratio) 63 | except ValueError: 64 | msg = ( 65 | "The docstring inheritance similarity ratio cannot be determined from " 66 | f"'{env_ratio}'." 67 | ) 68 | raise ValueError(msg) from None 69 | if not (0.0 <= ratio <= 1.0): 70 | msg = "The docstring inheritance similarity ratio must be in [0,1]." 71 | raise ValueError(msg) 72 | return ratio 73 | 74 | 75 | class DocstringInheritanceWarning(UserWarning): 76 | """A warning for docstring inheritance.""" 77 | 78 | 79 | class BaseDocstringInheritor: 80 | """Base class for inheriting a docstring.""" 81 | 82 | MISSING_ARG_DESCRIPTION: ClassVar[str] = "The description is missing." 83 | """The fall back description stub for a method argument without a description.""" 84 | 85 | _MISSING_ARG_TEXT: ClassVar[str] 86 | """The actual formatted text bound to a missing method argument.""" 87 | 88 | _DOCSTRING_PARSER: ClassVar[type[BaseDocstringParser]] 89 | """The docstring parser.""" 90 | 91 | _DOCSTRING_RENDERER: ClassVar[type[BaseDocstringRenderer]] 92 | """The docstring renderer.""" 93 | 94 | __child_func: Callable[..., Any] 95 | """The function or method to inherit the docstrings of.""" 96 | 97 | __similarity_ratio: ClassVar[float] = get_similarity_ratio( 98 | os.environ.get("DOCSTRING_INHERITANCE_SIMILARITY_RATIO") 99 | ) 100 | """The similarity ratio for comparing child to parent docstrings.""" 101 | 102 | def __init__( 103 | self, 104 | child_func: Callable[..., Any], 105 | ) -> None: 106 | self.__child_func = child_func 107 | 108 | @classmethod 109 | def inherit( 110 | cls, 111 | parent_doc: str | None, 112 | child_func: Callable[..., Any], 113 | ) -> None: 114 | """ 115 | Args: 116 | parent_doc: The docstring of the parent. 117 | child_func: The child function which docstring inherit from the parent. 118 | """ # noqa: D205, D212 119 | if parent_doc is not None: 120 | cls(child_func)._inherit(parent_doc) 121 | 122 | def _inherit(self, parent_doc: str) -> None: 123 | """Inherit the docstrings of a class. 124 | 125 | Args: 126 | parent_doc: The docstring of the parent. 127 | """ 128 | parse = self._DOCSTRING_PARSER.parse 129 | parent_sections = parse(parent_doc) 130 | child_sections = parse(self.__child_func.__doc__) 131 | self._warn_similar_sections(parent_sections, child_sections) 132 | self._inherit_sections( 133 | parent_sections, 134 | child_sections, 135 | ) 136 | # Get the original function eventually behind decorators. 137 | unwrap(self.__child_func).__doc__ = self._DOCSTRING_RENDERER.render( 138 | child_sections 139 | ) 140 | 141 | def _warn_similar_sections( 142 | self, 143 | parent_sections: SectionsType | dict[str, str], 144 | child_sections: SectionsType | dict[str, str], 145 | super_section_name: str = "", 146 | ) -> None: 147 | """Issue a warning when the parent and child sections are similar. 148 | 149 | Args: 150 | parent_sections: The parent sections. 151 | child_sections: The child sections. 152 | super_section_name: The name of the parent section. 153 | """ 154 | if self.__similarity_ratio == 0.0: 155 | return 156 | 157 | for section_name, child_section in child_sections.items(): 158 | parent_section = parent_sections.get(section_name) 159 | if parent_section is None: 160 | continue 161 | 162 | # TODO: add Raises section? 163 | if section_name in self._DOCSTRING_PARSER.SECTION_NAMES_WITH_ITEMS: 164 | self._warn_similar_sections( 165 | cast("dict[str, str]", parent_section), 166 | cast("dict[str, str]", child_section), 167 | super_section_name=section_name, 168 | ) 169 | else: 170 | self._warn_similar_section( 171 | cast("str", parent_section), 172 | cast("str", child_section), 173 | super_section_name, 174 | section_name, 175 | ) 176 | 177 | def _warn_similar_section( 178 | self, 179 | parent_doc: str, 180 | child_doc: str, 181 | super_section_name: str, 182 | section_name: str, 183 | ) -> None: 184 | """Issue a warning when the parent and child docs are similar. 185 | 186 | Args: 187 | parent_doc: The parent documentation. 188 | child_doc: The child documentation. 189 | super_section_name: The name of the parent section. 190 | section_name: The name of the section. 191 | """ 192 | ratio = difflib.SequenceMatcher(None, parent_doc, child_doc).ratio() 193 | if ratio >= self.__similarity_ratio: 194 | if super_section_name: 195 | parent_doc = f"{super_section_name}: {parent_doc}" 196 | child_doc = f"{super_section_name}: {child_doc}" 197 | msg = ( 198 | f"the docstrings have a similarity ratio of {ratio}, " 199 | f"the parent doc is\n{indent(parent_doc, ' ' * 4)}\n" 200 | f"the child doc is\n{indent(child_doc, ' ' * 4)}" 201 | ) 202 | self._warn(section_name, msg) 203 | 204 | def _warn(self, section_path: str, msg: str) -> None: 205 | """Issue a warning. 206 | 207 | Args: 208 | section_path: The hierarchy of section names. 209 | msg: The warning message. 210 | """ 211 | msg = f"in {self.__child_func.__qualname__}: section {section_path}: {msg}" 212 | module = getmodule(self.__child_func) 213 | module_name = module.__name__ if module is not None else None 214 | warnings.warn_explicit( 215 | msg, 216 | DocstringInheritanceWarning, 217 | getfile(self.__child_func), 218 | getsourcelines(self.__child_func)[1], 219 | module=module_name, 220 | ) 221 | 222 | def _inherit_sections( 223 | self, 224 | parent_sections: SectionsType, 225 | child_sections: SectionsType, 226 | ) -> None: 227 | """Inherit the sections of a child from the parent sections. 228 | 229 | Args: 230 | parent_sections: The parent docstring sections. 231 | child_sections: The child docstring sections. 232 | """ 233 | # TODO: 234 | # prnt_only_raises = "Raises" in parent_sections and not ( 235 | # "Returns" in parent_sections or "Yields" in parent_sections 236 | # ) 237 | # 238 | # if prnt_only_raises and ( 239 | # "Returns" in sections or "Yields" in sections 240 | # ): 241 | # parent_sections["Raises"] = None 242 | parent_section_names = parent_sections.keys() 243 | child_section_names = child_sections.keys() 244 | 245 | temp_sections = {} 246 | 247 | # Sections in parent but not child. 248 | parent_section_names_to_copy = parent_section_names - child_section_names 249 | for section_name in parent_section_names_to_copy: 250 | temp_sections[section_name] = parent_sections[section_name] 251 | 252 | # Remaining sections in child. 253 | child_sections_names_to_copy = ( 254 | child_section_names - parent_section_names_to_copy 255 | ) 256 | for section_name in child_sections_names_to_copy: 257 | temp_sections[section_name] = child_sections[section_name] 258 | 259 | # For sections with items, the sections common to parent and child are merged. 260 | common_section_names_with_items = ( 261 | parent_section_names 262 | & child_section_names 263 | & self._DOCSTRING_PARSER.SECTION_NAMES_WITH_ITEMS 264 | ) 265 | 266 | for section_name in common_section_names_with_items: 267 | temp_section_items = cast( 268 | "dict[str, str]", parent_sections[section_name] 269 | ).copy() 270 | temp_section_items.update( 271 | cast("dict[str, str]", child_sections[section_name]) 272 | ) 273 | 274 | temp_sections[section_name] = temp_section_items 275 | 276 | # Args section shall be filtered. 277 | args_section = self._filter_args_section( 278 | self._MISSING_ARG_TEXT, 279 | cast( 280 | "dict[str, str]", 281 | temp_sections.get(self._DOCSTRING_PARSER.ARGS_SECTION_NAME, {}), 282 | ), 283 | self._DOCSTRING_PARSER.ARGS_SECTION_NAME, 284 | ) 285 | 286 | if args_section: 287 | temp_sections[self._DOCSTRING_PARSER.ARGS_SECTION_NAME] = args_section 288 | elif self._DOCSTRING_PARSER.ARGS_SECTION_NAME in temp_sections: 289 | # The args section is empty, there is nothing to document. 290 | del temp_sections[self._DOCSTRING_PARSER.ARGS_SECTION_NAME] 291 | 292 | # Reorder the standard sections. 293 | child_sections.clear() 294 | child_sections.update({ 295 | section_name: temp_sections.pop(section_name) 296 | for section_name in self._DOCSTRING_PARSER.SECTION_NAMES 297 | if section_name in temp_sections 298 | }) 299 | 300 | # Add the remaining non-standard sections. 301 | child_sections.update(temp_sections) 302 | 303 | def _filter_args_section( 304 | self, 305 | missing_arg_text: str, 306 | section_items: dict[str, str], 307 | section_name: str = "", 308 | ) -> dict[str, str]: 309 | """Filter the args section items with the args of a signature. 310 | 311 | The argument ``self`` is removed. The arguments are ordered according to the 312 | signature of ``func``. An argument of ``func`` missing in ``section_items`` gets 313 | a default description defined in :attr:`._MISSING_ARG_TEXT`. 314 | 315 | Args: 316 | missing_arg_text: This text for the missing arguments. 317 | section_name: The name of the section. 318 | section_items: The docstring section items. 319 | 320 | Returns: 321 | The section items filtered with the function signature. 322 | """ 323 | full_arg_spec = getfullargspec(self.__child_func) 324 | 325 | all_args = full_arg_spec.args 326 | if "self" in all_args: 327 | all_args.remove("self") 328 | 329 | if full_arg_spec.varargs is not None: 330 | all_args += [f"*{full_arg_spec.varargs}"] 331 | 332 | all_args += full_arg_spec.kwonlyargs 333 | 334 | if full_arg_spec.varkw is not None: 335 | all_args += [f"**{full_arg_spec.varkw}"] 336 | 337 | ordered_section = {} 338 | for arg in all_args: 339 | if arg in section_items: 340 | doc = section_items[arg] 341 | else: 342 | doc = missing_arg_text 343 | self._warn( 344 | section_name, f"the docstring for the argument '{arg}' is missing." 345 | ) 346 | ordered_section[arg] = doc 347 | 348 | return ordered_section 349 | -------------------------------------------------------------------------------- /src/docstring_inheritance/docstring_inheritors/bases/parser.py: -------------------------------------------------------------------------------- 1 | # noqa: A005 2 | # Copyright 2021 Antoine DECHAUME 3 | # 4 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | # this software and associated documentation files (the "Software"), to deal in 6 | # the Software without restriction, including without limitation the rights to 7 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | # of the Software, and to permit persons to whom the Software is furnished to do 9 | # so, subject to the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be included in all 12 | # copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | # SOFTWARE. 21 | """Base class for docstrings parsers.""" 22 | 23 | from __future__ import annotations 24 | 25 | import inspect 26 | import operator 27 | import re 28 | import sys 29 | from abc import ABC 30 | from abc import abstractmethod 31 | from itertools import dropwhile 32 | from itertools import tee 33 | from typing import TYPE_CHECKING 34 | from typing import ClassVar 35 | 36 | from . import SUMMARY_SECTION_NAME 37 | 38 | if TYPE_CHECKING: 39 | from . import SectionsType 40 | 41 | if sys.version_info >= (3, 10): # pragma: >=3.10 cover 42 | from itertools import pairwise 43 | else: # pragma: <3.10 cover 44 | # See https://docs.python.org/3/library/itertools.html#itertools.pairwise 45 | def pairwise(iterable): # noqa: D103 46 | a, b = tee(iterable) 47 | next(b, None) 48 | return zip(a, b) 49 | 50 | 51 | class NoSectionFound(BaseException): 52 | """Exception raised when no section has been found when parsing one section.""" 53 | 54 | 55 | class BaseDocstringParser(ABC): 56 | """The base class for docstring parsers.""" 57 | 58 | SECTION_NAMES: ClassVar[list[str]] = [ 59 | SUMMARY_SECTION_NAME, 60 | "Parameters", 61 | "Returns", 62 | "Yields", 63 | "Receives", 64 | "Other Parameters", 65 | "Attributes", 66 | "Methods", 67 | "Raises", 68 | "Warns", 69 | "Warnings", 70 | "See Also", 71 | "Notes", 72 | "References", 73 | "Examples", 74 | ] 75 | """Names of the sections.""" 76 | 77 | ARGS_SECTION_NAME: ClassVar[str] 78 | """The name of the section with methods arguments.""" 79 | 80 | SECTION_NAMES_WITH_ITEMS: ClassVar[set[str]] 81 | """The Names of all the sections with items, including `ARGS_SECTION_NAME`.""" 82 | 83 | _SECTION_ITEMS_REGEX: ClassVar[re.Pattern[str]] = re.compile( 84 | r"(\**\w+)(.*?)(?:$|(?=\n\**\w+))", flags=re.DOTALL 85 | ) 86 | 87 | @classmethod 88 | @abstractmethod 89 | def _parse_one_section( 90 | cls, 91 | line1: str, 92 | line2_rstripped: str, 93 | reversed_section_body_lines: list[str], 94 | ) -> tuple[str, str]: 95 | """Parse the name and body of a docstring section. 96 | 97 | It does not parse section_items items. 98 | 99 | Returns: 100 | The name and docstring body parts of a section. 101 | 102 | Raises: 103 | NoSectionFound: If no section is found. 104 | """ 105 | 106 | @classmethod 107 | def _get_section_body( 108 | cls, 109 | reversed_section_body_lines: list[str], 110 | ) -> str: 111 | """Return the docstring of a section. 112 | 113 | Args: 114 | reversed_section_body_lines: The lines of docstrings in reversed order. 115 | 116 | Returns: 117 | The docstring of a section. 118 | """ 119 | reversed_section_body_lines = list( 120 | dropwhile(operator.not_, reversed_section_body_lines) 121 | ) 122 | reversed_section_body_lines.reverse() 123 | return "\n".join(reversed_section_body_lines) 124 | 125 | @classmethod 126 | def parse(cls, docstring: str | None) -> SectionsType: 127 | """Parse the sections of a docstring. 128 | 129 | Args: 130 | docstring: The docstring to parse. 131 | 132 | Returns: 133 | The parsed sections. 134 | """ 135 | if not docstring: 136 | return {} 137 | 138 | lines = inspect.cleandoc(docstring).splitlines() 139 | 140 | # It seems easier to work reversed. 141 | lines_pairs = iter(pairwise(reversed(lines))) 142 | 143 | reversed_section_body_lines: list[str] = [] 144 | reversed_sections: SectionsType = {} 145 | 146 | # Iterate 2 lines at a time to look for the section_items headers 147 | # that are underlined. 148 | for line2, line1 in lines_pairs: 149 | line2_rstripped = line2.rstrip() 150 | 151 | try: 152 | section_name, section_body = cls._parse_one_section( 153 | line1, line2_rstripped, reversed_section_body_lines 154 | ) 155 | except NoSectionFound: 156 | pass 157 | else: 158 | if section_name in cls.SECTION_NAMES_WITH_ITEMS: 159 | reversed_sections[section_name] = cls._parse_section_items( 160 | section_body 161 | ) 162 | else: 163 | reversed_sections[section_name] = section_body 164 | 165 | # We took into account line1 in addition to line2, 166 | # we no longer need to process line1. 167 | try: 168 | next(lines_pairs) 169 | except StopIteration: 170 | # The docstring has no summary section_items. 171 | has_summary = False 172 | break 173 | 174 | reversed_section_body_lines = [] 175 | continue 176 | 177 | reversed_section_body_lines += [line2_rstripped] 178 | else: 179 | has_summary = True 180 | 181 | sections: SectionsType = {} 182 | 183 | if has_summary: 184 | # Add the missing first line because it is not taken into account 185 | # by the above loop. 186 | reversed_section_body_lines += [lines[0]] 187 | 188 | # Add the section_items with the short and extended summaries. 189 | sections[SUMMARY_SECTION_NAME] = cls._get_section_body( 190 | reversed_section_body_lines 191 | ) 192 | 193 | for section_name_, section_body_ in reversed(reversed_sections.items()): 194 | sections[section_name_] = section_body_ 195 | 196 | return sections 197 | 198 | @classmethod 199 | def _parse_section_items(cls, section_body: str) -> dict[str, str]: 200 | """Parse the section items for numpy and google docstrings. 201 | 202 | Args: 203 | section_body: The body of a docstring section. 204 | 205 | Returns: 206 | The parsed section body. 207 | """ 208 | return dict(cls._SECTION_ITEMS_REGEX.findall(section_body)) 209 | -------------------------------------------------------------------------------- /src/docstring_inheritance/docstring_inheritors/bases/renderer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | from abc import ABC 23 | from abc import abstractmethod 24 | from typing import TYPE_CHECKING 25 | 26 | from . import SUMMARY_SECTION_NAME 27 | from . import SubSectionType 28 | 29 | if TYPE_CHECKING: 30 | from . import SectionsType 31 | 32 | 33 | class BaseDocstringRenderer(ABC): 34 | """The docstring base class renderer.""" 35 | 36 | @classmethod 37 | def render(cls, sections: SectionsType) -> str: 38 | """Render a docstring. 39 | 40 | Args: 41 | sections: The docstring sections to render. 42 | 43 | Returns: 44 | The rendered docstring. 45 | """ 46 | if not sections: 47 | return "" 48 | 49 | rendered_sections = [] 50 | 51 | for section_name, section_body in sections.items(): 52 | rendered_sections += [cls._render_section(section_name, section_body)] 53 | 54 | rendered = "\n\n".join(rendered_sections) 55 | 56 | if SUMMARY_SECTION_NAME not in sections: 57 | # Add an empty summary line, 58 | # Sphinx will not behave correctly otherwise with the Google format. 59 | return "\n" + rendered 60 | 61 | return rendered 62 | 63 | @staticmethod 64 | @abstractmethod 65 | def _render_section( 66 | section_name: str, 67 | section_body: SubSectionType, 68 | ) -> str: 69 | """Return a rendered docstring section. 70 | 71 | Args: 72 | section_name: The name of a docstring section. 73 | section_body: The body of a docstring section. 74 | 75 | Returns: 76 | The rendered docstring. 77 | """ 78 | -------------------------------------------------------------------------------- /src/docstring_inheritance/docstring_inheritors/google.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | """Classes for inheriting Google docstrings.""" 21 | 22 | from __future__ import annotations 23 | 24 | import textwrap 25 | from typing import ClassVar 26 | 27 | from .bases import SUMMARY_SECTION_NAME 28 | from .bases import SubSectionType 29 | from .bases.inheritor import BaseDocstringInheritor 30 | from .bases.parser import BaseDocstringParser 31 | from .bases.parser import NoSectionFound 32 | from .bases.renderer import BaseDocstringRenderer 33 | 34 | 35 | class DocstringRenderer(BaseDocstringRenderer): 36 | """The renderer for Google docstrings.""" 37 | 38 | @staticmethod 39 | def _render_section( 40 | section_name: str, 41 | section_body: SubSectionType, 42 | ) -> str: 43 | if section_name is SUMMARY_SECTION_NAME: 44 | assert isinstance(section_body, str) 45 | return section_body 46 | if isinstance(section_body, dict): 47 | section_body = "\n".join( 48 | f"{key}{value}" for key, value in section_body.items() 49 | ) 50 | section_body = textwrap.indent(section_body, " " * 4) 51 | return f"{section_name}:\n{section_body}" 52 | 53 | 54 | class DocstringParser(BaseDocstringParser): 55 | """The parser for Google docstrings.""" 56 | 57 | ARGS_SECTION_NAME: ClassVar[str] = "Args" 58 | SECTION_NAMES: ClassVar[list[str]] = list(BaseDocstringParser.SECTION_NAMES) 59 | SECTION_NAMES[1] = ARGS_SECTION_NAME 60 | SECTION_NAMES_WITH_ITEMS: ClassVar[set[str]] = { 61 | ARGS_SECTION_NAME, 62 | "Attributes", 63 | "Methods", 64 | } 65 | 66 | @classmethod 67 | def _get_section_body( 68 | cls, 69 | reversed_section_body_lines: list[str], 70 | ) -> str: 71 | return textwrap.dedent(super()._get_section_body(reversed_section_body_lines)) 72 | 73 | @classmethod 74 | def _parse_one_section( 75 | cls, 76 | line1: str, 77 | line2_rstripped: str, 78 | reversed_section_body_lines: list[str], 79 | ) -> tuple[str, str]: 80 | # See https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings # noqa: E501 81 | # The parsing of a section is complete when the first line line1 has: 82 | # - no leading blank spaces, 83 | # - ends with :, 84 | # - has a second line indented by at least 2 blank spaces, 85 | # - has a section name. 86 | line1_rstripped = line1.rstrip() 87 | if ( 88 | not line1_rstripped.startswith(" ") 89 | and line1_rstripped.endswith(":") 90 | and line2_rstripped.startswith(" ") 91 | and line1_rstripped[:-1].strip() in cls.SECTION_NAMES 92 | ): 93 | reversed_section_body_lines += [line2_rstripped] 94 | return line1_rstripped.rstrip(" :"), cls._get_section_body( 95 | reversed_section_body_lines 96 | ) 97 | raise NoSectionFound 98 | 99 | 100 | class GoogleDocstringInheritor(BaseDocstringInheritor): 101 | """The inheritor for Google docstrings.""" 102 | 103 | _MISSING_ARG_TEXT = f": {BaseDocstringInheritor.MISSING_ARG_DESCRIPTION}" 104 | _DOCSTRING_PARSER = DocstringParser 105 | _DOCSTRING_RENDERER = DocstringRenderer 106 | -------------------------------------------------------------------------------- /src/docstring_inheritance/docstring_inheritors/numpy.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | """Classes for inheriting NumPy docstrings.""" 21 | 22 | from __future__ import annotations 23 | 24 | from typing import ClassVar 25 | 26 | from .bases import SUMMARY_SECTION_NAME 27 | from .bases import SubSectionType 28 | from .bases.inheritor import BaseDocstringInheritor 29 | from .bases.parser import BaseDocstringParser 30 | from .bases.parser import NoSectionFound 31 | from .bases.renderer import BaseDocstringRenderer 32 | 33 | 34 | class DocstringRenderer(BaseDocstringRenderer): 35 | """The renderer for NumPy docstrings.""" 36 | 37 | @staticmethod 38 | def _render_section( 39 | section_name: str, 40 | section_body: SubSectionType, 41 | ) -> str: 42 | if section_name is SUMMARY_SECTION_NAME: 43 | assert isinstance(section_body, str) 44 | return section_body 45 | if isinstance(section_body, dict): 46 | section_body = "\n".join( 47 | f"{key}{value}" for key, value in section_body.items() 48 | ) 49 | return f"{section_name}\n{'-' * len(section_name)}\n{section_body}" 50 | 51 | 52 | class DocstringParser(BaseDocstringParser): 53 | """The parser for NumPy docstrings.""" 54 | 55 | ARGS_SECTION_NAME: ClassVar[str] = "Parameters" 56 | 57 | SECTION_NAMES_WITH_ITEMS: ClassVar[set[str]] = { 58 | ARGS_SECTION_NAME, 59 | "Other Parameters", 60 | "Attributes", 61 | "Methods", 62 | } 63 | 64 | @classmethod 65 | def _parse_one_section( 66 | cls, 67 | line1: str, 68 | line2_rstripped: str, 69 | reversed_section_body_lines: list[str], 70 | ) -> tuple[str, str]: 71 | # See https://github.com/numpy/numpydoc/blob/d85f54ea342c1d223374343be88da94ce9f58dec/numpydoc/docscrape.py#L179 # noqa: E501 72 | if len(line2_rstripped) >= 3 and (set(line2_rstripped) in ({"-"}, {"="})): 73 | line1s = line1.rstrip() 74 | min_line_length = len(line1s) 75 | if line2_rstripped.startswith(( 76 | "-" * min_line_length, 77 | "=" * min_line_length, 78 | )): 79 | return line1s, cls._get_section_body(reversed_section_body_lines) 80 | raise NoSectionFound 81 | 82 | 83 | class NumpyDocstringInheritor(BaseDocstringInheritor): 84 | """The inheritor for NumPy docstrings.""" 85 | 86 | _MISSING_ARG_TEXT = f"\n {BaseDocstringInheritor.MISSING_ARG_DESCRIPTION}" 87 | _DOCSTRING_PARSER = DocstringParser 88 | _DOCSTRING_RENDERER = DocstringRenderer 89 | -------------------------------------------------------------------------------- /src/docstring_inheritance/griffe.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | from __future__ import annotations 22 | 23 | import inspect 24 | from typing import TYPE_CHECKING 25 | from typing import Any 26 | from typing import ClassVar 27 | from typing import Literal 28 | 29 | from griffe import Alias 30 | from griffe import Attribute 31 | from griffe import Class 32 | from griffe import Docstring 33 | from griffe import Extension 34 | from griffe import Inspector 35 | from griffe import Object 36 | from griffe import ObjectNode 37 | from griffe import Visitor 38 | from griffe import dynamic_import 39 | from griffe import get_logger 40 | 41 | if TYPE_CHECKING: 42 | import ast 43 | 44 | from griffe import Parser 45 | 46 | _logger = get_logger(__name__) 47 | 48 | 49 | class DocstringInheritance(Extension): 50 | """Inherit docstrings when the package docstring-inheritance is used.""" 51 | 52 | __parser: Literal["google", "numpy", "sphinx"] | Parser | None = None 53 | """The docstring parser.""" 54 | 55 | __parser_options: ClassVar[dict[str, Any]] = {} 56 | """The docstring parser options.""" 57 | 58 | def on_class_members( # noqa: D102 59 | self, 60 | *, 61 | node: ast.AST | ObjectNode, 62 | cls: Class, 63 | agent: Visitor | Inspector, 64 | **kwargs: Any, 65 | ) -> None: 66 | if isinstance(node, ObjectNode): 67 | # Skip runtime objects, their docstrings are already OK. 68 | return 69 | 70 | runtime_cls = self.__import_dynamically(cls) 71 | 72 | if not self.__has_docstring_inheritance(runtime_cls): 73 | return 74 | 75 | # Inherit the class docstring. 76 | self.__set_docstring(cls, runtime_cls) 77 | 78 | # Inherit the methods docstrings. 79 | for member in cls.members.values(): 80 | if not isinstance(member, Attribute): 81 | runtime_obj = self.__import_dynamically(member) 82 | self.__set_docstring(member, runtime_obj) 83 | 84 | @staticmethod 85 | def __import_dynamically(obj: Object | Alias) -> Any: 86 | """Import dynamically and return an object.""" 87 | try: 88 | return dynamic_import(obj.path) 89 | except ImportError: 90 | _logger.debug("Could not get dynamic docstring for %s", obj.path) 91 | 92 | @classmethod 93 | def __set_docstring(cls, obj: Object | Alias, runtime_obj: Any) -> None: 94 | """Set the docstring from a runtime object. 95 | 96 | Args: 97 | obj: The griffe object. 98 | runtime_obj: The runtime object. 99 | """ 100 | if runtime_obj is None: 101 | return 102 | 103 | try: 104 | docstring = runtime_obj.__doc__ 105 | except AttributeError: 106 | _logger.debug("Object %s does not have a __doc__ attribute", obj.path) 107 | return 108 | 109 | if docstring is None: 110 | return 111 | 112 | # Update the object instance with the evaluated docstring. 113 | if obj.docstring: 114 | obj.docstring.value = inspect.cleandoc(docstring) 115 | else: 116 | assert not isinstance(obj, Alias) 117 | cls.__find_parser(obj) 118 | obj.docstring = Docstring( 119 | docstring, 120 | parent=obj, 121 | parser=cls.__parser, 122 | parser_options=cls.__parser_options, 123 | ) 124 | 125 | @staticmethod 126 | def __has_docstring_inheritance(cls: type[Any]) -> bool: 127 | """Return whether a class has docstring inheritance.""" 128 | for base in cls.__class__.__mro__: 129 | if base.__name__.endswith("DocstringInheritanceMeta"): 130 | return True 131 | return False 132 | 133 | @classmethod 134 | def __find_parser(cls, obj: Object) -> None: 135 | """Search a docstring parser recursively from an object parents.""" 136 | if cls.__parser is not None: 137 | return 138 | 139 | parent = obj.parent 140 | if parent is None: 141 | msg = f"Cannot find a parent of the object {obj}" 142 | raise RuntimeError(msg) 143 | 144 | if parent.docstring is None: 145 | msg = f"Cannot find a docstring for the parent of the object {obj}" 146 | raise RuntimeError(msg) 147 | 148 | parser = parent.docstring.parser 149 | 150 | if parser is None: 151 | cls.__find_parser(parent) 152 | else: 153 | cls.__parser = parser 154 | cls.__parser_options = parent.docstring.parser_options 155 | -------------------------------------------------------------------------------- /src/docstring_inheritance/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AntoineD/docstring-inheritance/7320744bb7bcec4aee8d1c86e3dcf8383065a753/src/docstring_inheritance/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | -------------------------------------------------------------------------------- /tests/test_base_inheritor.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | import re 23 | import warnings 24 | from typing import ClassVar 25 | 26 | import pytest 27 | 28 | from docstring_inheritance.docstring_inheritors.bases.inheritor import ( 29 | BaseDocstringInheritor, 30 | ) 31 | from docstring_inheritance.docstring_inheritors.bases.inheritor import ( 32 | DocstringInheritanceWarning, 33 | ) 34 | from docstring_inheritance.docstring_inheritors.bases.inheritor import ( 35 | get_similarity_ratio, 36 | ) 37 | from docstring_inheritance.docstring_inheritors.bases.parser import BaseDocstringParser 38 | 39 | 40 | def func_none(): # pragma: no cover 41 | pass 42 | 43 | 44 | def func_with_self(self): # pragma: no cover 45 | pass 46 | 47 | 48 | def func_args(arg): # pragma: no cover 49 | pass 50 | 51 | 52 | def func_args_kwonlyargs(arg1, arg2=None): # pragma: no cover 53 | pass 54 | 55 | 56 | def func_kwonlyargs(arg=None): # pragma: no cover 57 | pass 58 | 59 | 60 | def func_varargs(*varargs): # pragma: no cover 61 | pass 62 | 63 | 64 | def func_varkw(**varkw): # pragma: no cover 65 | pass 66 | 67 | 68 | def func_args_varargs(arg, *varargs): # pragma: no cover 69 | pass 70 | 71 | 72 | def func_varargs_varkw(*varargs, **varkw): # pragma: no cover 73 | pass 74 | 75 | 76 | def func_args_varkw(arg, **varkw): # pragma: no cover 77 | pass 78 | 79 | 80 | def func_all(arg1, arg2=None, *varargs, **varkw): # pragma: no cover 81 | pass 82 | 83 | 84 | ARGS_SECTION_NAME = "DummyArgs" 85 | ARGS_SECTION_NAMES = {"DummyArgs"} 86 | METHODS_SECTION_NAME = "MethodsArgs" 87 | SECTION_NAMES_WITH_ITEMS = {ARGS_SECTION_NAME, METHODS_SECTION_NAME} 88 | MISSING_ARG_TEXT = "The description is missing." 89 | 90 | 91 | class DummyParser(BaseDocstringParser): 92 | ARGS_SECTION_NAME = "DummyArgs" 93 | ARGS_SECTION_NAMES: ClassVar[set[str]] = {"DummyArgs"} 94 | METHODS_SECTION_NAME = "MethodsArgs" 95 | SECTION_NAMES_WITH_ITEMS: ClassVar[set[str]] = { 96 | ARGS_SECTION_NAME, 97 | METHODS_SECTION_NAME, 98 | } 99 | 100 | 101 | @pytest.fixture 102 | def patch_class(): 103 | """Monkey patch BaseDocstringInheritor with docstring parser constants.""" 104 | BaseDocstringInheritor._DOCSTRING_PARSER = DummyParser 105 | BaseDocstringInheritor._MISSING_ARG_TEXT = MISSING_ARG_TEXT 106 | yield 107 | delattr(BaseDocstringInheritor, "_DOCSTRING_PARSER") 108 | delattr(BaseDocstringInheritor, "_MISSING_ARG_TEXT") 109 | 110 | 111 | @pytest.mark.parametrize( 112 | ("parent_section", "child_section", "func", "expected"), 113 | [ 114 | ({}, {}, func_none, {}), 115 | # Non-existing section in child. 116 | ({"Section": "parent"}, {}, func_none, {"Section": "parent"}), 117 | # Non-existing section in parent. 118 | ({}, {"Section": "child"}, func_none, {"Section": "child"}), 119 | # Child section updates the parent one (no items). 120 | ({"Section": "parent"}, {"Section": "child"}, func_none, {"Section": "child"}), 121 | # Child section updates the parent one (no items), with other sections. 122 | ( 123 | {"Section": "parent", "ParentSection": "parent"}, 124 | {"Section": "child", "ChildSection": "child"}, 125 | func_none, 126 | {"Section": "child", "ParentSection": "parent", "ChildSection": "child"}, 127 | ), 128 | # Section reordering. 129 | ( 130 | {"Section": "parent", "Returns": "", "Parameters": ""}, 131 | {}, 132 | func_none, 133 | {"Parameters": "", "Returns": "", "Section": "parent"}, 134 | ), 135 | # Sections with items (not Args). 136 | # Non-existing item in child. 137 | ( 138 | {METHODS_SECTION_NAME: {"parent_m": ""}}, 139 | {}, 140 | func_none, 141 | {METHODS_SECTION_NAME: {"parent_m": ""}}, 142 | ), 143 | # Non-existing item in parent. 144 | ( 145 | {}, 146 | {METHODS_SECTION_NAME: {"child_m": ""}}, 147 | func_none, 148 | {METHODS_SECTION_NAME: {"child_m": ""}}, 149 | ), 150 | # Child item updates the parent one (no common items). 151 | ( 152 | {METHODS_SECTION_NAME: {"parent_m": ""}}, 153 | {METHODS_SECTION_NAME: {"child_m": ""}}, 154 | func_none, 155 | {METHODS_SECTION_NAME: {"parent_m": "", "child_m": ""}}, 156 | ), 157 | # Child item updates the parent one (common items). 158 | ( 159 | {METHODS_SECTION_NAME: {"method": "parent"}}, 160 | {METHODS_SECTION_NAME: {"method": "child"}}, 161 | func_none, 162 | {METHODS_SECTION_NAME: {"method": "child"}}, 163 | ), 164 | # Sections with args items. 165 | # Non-existing section in child for function without args. 166 | ({ARGS_SECTION_NAME: {"parent_a": ""}}, {}, func_none, {}), 167 | # Non-existing section in parent for function without args. 168 | ({}, {ARGS_SECTION_NAME: {"child_a": ""}}, func_none, {}), 169 | # Missing argument description. 170 | ({}, {}, func_args, {ARGS_SECTION_NAME: {"arg": MISSING_ARG_TEXT}}), 171 | ( 172 | {}, 173 | {ARGS_SECTION_NAME: {"child_a": ""}}, 174 | func_args, 175 | {ARGS_SECTION_NAME: {"arg": MISSING_ARG_TEXT}}, 176 | ), 177 | ( 178 | {ARGS_SECTION_NAME: {"parent_a": ""}}, 179 | {}, 180 | func_args, 181 | {ARGS_SECTION_NAME: {"arg": MISSING_ARG_TEXT}}, 182 | ), 183 | ( 184 | {ARGS_SECTION_NAME: {"parent_a": ""}}, 185 | {ARGS_SECTION_NAME: {"child_a": ""}}, 186 | func_args, 187 | {ARGS_SECTION_NAME: {"arg": MISSING_ARG_TEXT}}, 188 | ), 189 | # Argument description in parent. 190 | ( 191 | {ARGS_SECTION_NAME: {"arg": "parent"}}, 192 | {}, 193 | func_args, 194 | {ARGS_SECTION_NAME: {"arg": "parent"}}, 195 | ), 196 | ( 197 | {ARGS_SECTION_NAME: {"arg": "parent"}}, 198 | {ARGS_SECTION_NAME: {"child_a": ""}}, 199 | func_args, 200 | {ARGS_SECTION_NAME: {"arg": "parent"}}, 201 | ), 202 | # Argument description in child. 203 | ( 204 | {}, 205 | {ARGS_SECTION_NAME: {"arg": "child"}}, 206 | func_args, 207 | {ARGS_SECTION_NAME: {"arg": "child"}}, 208 | ), 209 | ( 210 | {ARGS_SECTION_NAME: {"parent_a": ""}}, 211 | {ARGS_SECTION_NAME: {"arg": "child"}}, 212 | func_args, 213 | {ARGS_SECTION_NAME: {"arg": "child"}}, 214 | ), 215 | # Argument description in both parent and child. 216 | ( 217 | {ARGS_SECTION_NAME: {"arg": "parent"}}, 218 | {ARGS_SECTION_NAME: {"arg": "child"}}, 219 | func_args, 220 | {ARGS_SECTION_NAME: {"arg": "child"}}, 221 | ), 222 | # Section ordering. 223 | ( 224 | {}, 225 | {"Returns": "", None: ""}, 226 | func_none, 227 | {None: "", "Returns": ""}, 228 | ), 229 | ( 230 | {}, 231 | {"Returns": "", ARGS_SECTION_NAME: {"arg": ""}, None: ""}, 232 | func_args, 233 | {None: "", ARGS_SECTION_NAME: {"arg": ""}, "Returns": ""}, 234 | ), 235 | ], 236 | ) 237 | def test_inherit_items(patch_class, parent_section, child_section, func, expected): 238 | base_inheritor = BaseDocstringInheritor(func) 239 | base_inheritor._inherit_sections(parent_section, child_section) 240 | assert child_section == expected 241 | 242 | 243 | @pytest.mark.parametrize( 244 | ("func", "section_items", "expected"), 245 | [ 246 | (func_none, {}, {}), 247 | # Non-existing args are removed. 248 | (func_none, {"arg": ""}, {}), 249 | # Self arg is removed. 250 | (func_with_self, {"self": ""}, {}), 251 | # Missing arg description. 252 | (func_args_kwonlyargs, {"arg1": ""}, {"arg1": "", "arg2": MISSING_ARG_TEXT}), 253 | # Args are ordered according to the signature. 254 | ( 255 | func_args_kwonlyargs, 256 | {"arg2": "", "arg1": ""}, 257 | {"arg1": "", "arg2": ""}, 258 | ), 259 | # Varargs alone. 260 | (func_varargs, {}, {"*varargs": MISSING_ARG_TEXT}), 261 | ( 262 | func_varargs, 263 | {"*varargs": ""}, 264 | {"*varargs": ""}, 265 | ), 266 | # Varkw alone. 267 | (func_varkw, {}, {"**varkw": MISSING_ARG_TEXT}), 268 | ( 269 | func_varkw, 270 | {"**varkw": ""}, 271 | {"**varkw": ""}, 272 | ), 273 | # Kwonlyargs alone. 274 | (func_kwonlyargs, {}, {"arg": MISSING_ARG_TEXT}), 275 | ( 276 | func_kwonlyargs, 277 | {"arg": ""}, 278 | {"arg": ""}, 279 | ), 280 | # Args and Kwonlyargs. 281 | ( 282 | func_args_varkw, 283 | {"**varkw": "", "arg": ""}, 284 | {"arg": "", "**varkw": ""}, 285 | ), 286 | # Args and varargs. 287 | ( 288 | func_args_varargs, 289 | {"*varargs": "", "arg": ""}, 290 | {"arg": "", "*varargs": ""}, 291 | ), 292 | # Args and varargs. 293 | ( 294 | func_varargs_varkw, 295 | {"**varkw": "", "*varargs": ""}, 296 | {"*varargs": "", "**varkw": ""}, 297 | ), 298 | # All kinds of arguments. 299 | ( 300 | func_all, 301 | {"**varkw": "", "*varargs": "", "arg2": "", "arg1": ""}, 302 | {"arg1": "", "arg2": "", "*varargs": "", "**varkw": ""}, 303 | ), 304 | ], 305 | ) 306 | def test_inherit_section_items_with_args(func, section_items, expected): 307 | base_inheritor = BaseDocstringInheritor(func) 308 | assert ( 309 | base_inheritor._filter_args_section(MISSING_ARG_TEXT, section_items) == expected 310 | ) 311 | 312 | 313 | def func_missing_arg(arg1, arg2): 314 | """ 315 | Args: 316 | arg1: foo. 317 | """ 318 | 319 | 320 | def test_warning_for_missing_arg(): 321 | base_inheritor = BaseDocstringInheritor(func_missing_arg) 322 | match = ( 323 | r"in func_missing_arg: section : " 324 | r"the docstring for the argument 'arg2' is missing\." 325 | ) 326 | with pytest.warns(DocstringInheritanceWarning, match=match): 327 | base_inheritor._filter_args_section("", {}) 328 | 329 | 330 | def test_no_warning_for_missing_arg(): 331 | base_inheritor = BaseDocstringInheritor(func_args) 332 | base_inheritor._filter_args_section("", {"args": ""}) 333 | 334 | 335 | @pytest.mark.parametrize( 336 | ("similarity_ratio", "warn", "parent_sections", "child_sections"), 337 | [ 338 | (1.0, True, {"X": "x"}, {"X": "x"}), 339 | (0.1, False, {}, {"X": "x"}), 340 | (0.0, False, {"X": "x"}, {"X": "x"}), 341 | (0.6, True, {"X": "xx"}, {"X": "x"}), 342 | (0.7, False, {"X": "xx"}, {"X": "x"}), 343 | # Subsections 344 | (1.0, True, {"DummyArgs": {"X": "x"}}, {"DummyArgs": {"X": "x"}}), 345 | (0.1, False, {"DummyArgs": {}}, {"DummyArgs": {"X": "x"}}), 346 | (0.0, False, {"DummyArgs": {"X": "x"}}, {"DummyArgs": {"X": "x"}}), 347 | (0.6, True, {"DummyArgs": {"X": "xx"}}, {"DummyArgs": {"X": "x"}}), 348 | (0.7, False, {"DummyArgs": {"X": "xx"}}, {"DummyArgs": {"X": "x"}}), 349 | ], 350 | ) 351 | def test_warning_for_similar_sections( 352 | patch_class, similarity_ratio, warn, parent_sections, child_sections 353 | ): 354 | if warn: 355 | try: 356 | parent = parent_sections["X"] 357 | child = child_sections["X"] 358 | except KeyError: 359 | parent = f"DummyArgs: {parent_sections['DummyArgs']['X']}" 360 | child = f"DummyArgs: {child_sections['DummyArgs']['X']}" 361 | match = ( 362 | rf"in func_args: section X: " 363 | r"the docstrings have a similarity ratio of \d\.\d*, " 364 | rf"the parent doc is\n {parent}\n" 365 | rf"the child doc is\n {child}" 366 | ) 367 | context = pytest.warns(DocstringInheritanceWarning, match=match) 368 | else: 369 | context = warnings.catch_warnings() 370 | 371 | base_inheritor = BaseDocstringInheritor(func_args) 372 | base_inheritor._BaseDocstringInheritor__similarity_ratio = similarity_ratio 373 | 374 | with context: 375 | base_inheritor._warn_similar_sections(parent_sections, child_sections) 376 | 377 | 378 | ERROR_RANGE = "The docstring inheritance similarity ratio must be in [0,1]." 379 | ERROR_VALUE = ( 380 | "The docstring inheritance similarity ratio cannot be determined from '{}'." 381 | ) 382 | 383 | 384 | @pytest.mark.parametrize( 385 | ("ratio", "error"), 386 | [ 387 | ("-0.1", ERROR_RANGE), 388 | ("1.1", ERROR_RANGE), 389 | ("", ERROR_VALUE), 390 | ("x", ERROR_VALUE), 391 | ], 392 | ) 393 | def test_check_similarity_ratio_error(ratio, error): 394 | with pytest.raises(ValueError, match=re.escape(error.format(ratio))): 395 | get_similarity_ratio(ratio) 396 | 397 | 398 | @pytest.mark.parametrize( 399 | ("ratio", "expected"), 400 | [ 401 | ("0", 0.0), 402 | ("1", 1.0), 403 | ("0.5", 0.5), 404 | ], 405 | ) 406 | def test_check_similarity_ratio(ratio, expected): 407 | assert get_similarity_ratio(ratio) == expected 408 | -------------------------------------------------------------------------------- /tests/test_base_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | import textwrap 23 | 24 | import pytest 25 | 26 | from docstring_inheritance.docstring_inheritors.bases.parser import BaseDocstringParser 27 | 28 | 29 | @pytest.mark.parametrize( 30 | ("section_body", "expected"), 31 | [ 32 | ([], ""), 33 | (["foo"], "foo"), 34 | (["", "foo"], "foo"), 35 | (["bar", "foo"], "foo\nbar"), 36 | ], 37 | ) 38 | def test_get_section_body(section_body, expected): 39 | assert BaseDocstringParser._get_section_body(section_body) == expected 40 | 41 | 42 | @pytest.mark.parametrize( 43 | ("section_body", "expected_matches"), 44 | [ 45 | ("foo", {"foo": ""}), 46 | ("foo : str\n Foo.", {"foo": " : str\n Foo."}), 47 | ("foo\nbar", {"foo": "", "bar": ""}), 48 | ("foo : str\n Foo.\nbar", {"foo": " : str\n Foo.", "bar": ""}), 49 | ( 50 | "foo : str\n Foo.\nbar : int\n Bar.", 51 | {"foo": " : str\n Foo.", "bar": " : int\n Bar."}, 52 | ), 53 | ], 54 | ) 55 | def test_section_items_regex(section_body, expected_matches): 56 | assert BaseDocstringParser._parse_section_items(section_body) == expected_matches 57 | 58 | 59 | def _test_parse_sections(parse_sections, unindented_docstring, expected_sections): 60 | """Verify the parsing of the sections of a docstring.""" 61 | # Indent uniformly. 62 | docstring = textwrap.indent(unindented_docstring, " " * 4, lambda line: True) 63 | # But the first line. 64 | docstring = docstring.lstrip(" \t") 65 | outcome = parse_sections(docstring) 66 | assert outcome == expected_sections 67 | # Verify the order of the keys. 68 | assert list(outcome.keys()) == list(expected_sections.keys()) 69 | -------------------------------------------------------------------------------- /tests/test_google_inheritor.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | import pytest 23 | 24 | from docstring_inheritance.docstring_inheritors.bases import SUMMARY_SECTION_NAME 25 | from docstring_inheritance.docstring_inheritors.bases.parser import NoSectionFound 26 | from docstring_inheritance.docstring_inheritors.google import DocstringParser 27 | from docstring_inheritance.docstring_inheritors.google import DocstringRenderer 28 | 29 | from .test_base_parser import _test_parse_sections 30 | 31 | 32 | @pytest.mark.parametrize( 33 | ("unindented_docstring", "expected_sections"), 34 | [ 35 | ("", {}), 36 | ( 37 | "Short summary.", 38 | { 39 | SUMMARY_SECTION_NAME: "Short summary.", 40 | }, 41 | ), 42 | ( 43 | """Short summary. 44 | 45 | Extended summary. 46 | """, 47 | { 48 | SUMMARY_SECTION_NAME: """Short summary. 49 | 50 | Extended summary.""", 51 | }, 52 | ), 53 | ( 54 | """ 55 | Args: 56 | arg 57 | """, 58 | { 59 | "Args": {"arg": ""}, 60 | }, 61 | ), 62 | ( 63 | """Short summary. 64 | 65 | Extended summary. 66 | 67 | Args: 68 | arg 69 | """, 70 | { 71 | SUMMARY_SECTION_NAME: """Short summary. 72 | 73 | Extended summary.""", 74 | "Args": {"arg": ""}, 75 | }, 76 | ), 77 | ( 78 | """Short summary. 79 | 80 | Extended summary. 81 | 82 | Args: 83 | arg 84 | 85 | Notes: 86 | Section body. 87 | 88 | Indented line. 89 | """, 90 | { 91 | SUMMARY_SECTION_NAME: """Short summary. 92 | 93 | Extended summary.""", 94 | "Args": {"arg": ""}, 95 | "Notes": """\ 96 | Section body. 97 | 98 | Indented line.""", 99 | }, 100 | ), 101 | ], 102 | ) 103 | def test_parse_sections(unindented_docstring, expected_sections): 104 | _test_parse_sections( 105 | DocstringParser.parse, 106 | unindented_docstring, 107 | expected_sections, 108 | ) 109 | 110 | 111 | @pytest.mark.parametrize( 112 | ("section_name", "section_body", "expected_docstring"), 113 | [ 114 | ( 115 | SUMMARY_SECTION_NAME, 116 | "Short summary.", 117 | "Short summary.", 118 | ), 119 | ( 120 | "Section name", 121 | """\ 122 | Section body. 123 | 124 | Indented line.""", 125 | """\ 126 | Section name: 127 | Section body. 128 | 129 | Indented line.""", 130 | ), 131 | ( 132 | "Section name", 133 | {"arg": ": Description."}, 134 | """\ 135 | Section name: 136 | arg: Description.""", 137 | ), 138 | ], 139 | ) 140 | def test_render_section(section_name, section_body, expected_docstring): 141 | assert ( 142 | DocstringRenderer._render_section(section_name, section_body) 143 | == expected_docstring 144 | ) 145 | 146 | 147 | @pytest.mark.parametrize( 148 | ("line1", "line2s"), 149 | [ 150 | ( 151 | " Args", 152 | " body", 153 | ), 154 | ( 155 | " Args:", 156 | " body", 157 | ), 158 | ( 159 | "Args", 160 | " body", 161 | ), 162 | ( 163 | "Args:", 164 | " body", 165 | ), 166 | ( 167 | "Dummy:", 168 | " body", 169 | ), 170 | ( 171 | "Dummy :", 172 | " body", 173 | ), 174 | ( 175 | "Dummy:", 176 | " body", 177 | ), 178 | ], 179 | ) 180 | def test_parse_one_section_no_section(line1, line2s): 181 | with pytest.raises(NoSectionFound): 182 | DocstringParser._parse_one_section(line1, line2s, []) 183 | 184 | 185 | @pytest.mark.parametrize( 186 | ("line1", "line2s", "expected"), 187 | [ 188 | ("Args:", " body", ("Args", "body")), 189 | ("Args :", " body", ("Args", "body")), 190 | ("Args:", " body", ("Args", "body")), 191 | ], 192 | ) 193 | def test_parse_one_section(line1, line2s, expected): 194 | assert DocstringParser._parse_one_section(line1, line2s, []) == expected 195 | 196 | 197 | @pytest.mark.parametrize( 198 | ("sections", "expected"), 199 | [ 200 | ({}, ""), 201 | ( 202 | {SUMMARY_SECTION_NAME: "body"}, 203 | """body""", 204 | ), 205 | ( 206 | {SUMMARY_SECTION_NAME: "body", "Args": "body"}, 207 | """body 208 | 209 | Args: 210 | body""", 211 | ), 212 | ( 213 | {"Args": "body"}, 214 | """ 215 | Args: 216 | body""", 217 | ), 218 | ], 219 | ) 220 | def test_render_docstring(sections, expected): 221 | assert DocstringRenderer.render(sections) == expected 222 | 223 | 224 | # TODO: test section order and all sections items 225 | -------------------------------------------------------------------------------- /tests/test_inheritance_for_functions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | import inspect 23 | 24 | import pytest 25 | 26 | from docstring_inheritance import inherit_google_docstring 27 | from docstring_inheritance import inherit_numpy_docstring 28 | from docstring_inheritance.class_docstrings_inheritor import ClassDocstringsInheritor 29 | 30 | 31 | def test_side_effect(): 32 | def f(x, y=None, **kwargs): # pragma: no cover 33 | pass 34 | 35 | ref_signature = inspect.signature(f) 36 | 37 | inherit_numpy_docstring(None, f) 38 | assert inspect.signature(f) == ref_signature 39 | 40 | 41 | def test_google(): 42 | def parent(arg, *parent_varargs, **parent_kwargs): 43 | """Parent summary. 44 | 45 | Args: 46 | arg: desc 47 | *parent_varargs: Parent *args 48 | **parent_kwargs: Parent **kwargs 49 | 50 | Examples: 51 | Parent examples 52 | 53 | Returns: 54 | Parent returns 55 | 56 | See Also: 57 | Parent see also 58 | 59 | References: 60 | Parent references 61 | 62 | Yields: 63 | Parent yields 64 | """ 65 | 66 | def child(x, missing_doc, *child_varargs, **child_kwargs): 67 | """Child summary. 68 | 69 | Yields: 70 | Child yields 71 | 72 | Raises: 73 | Child raises 74 | 75 | Notes: 76 | Child notes 77 | 78 | Examples: 79 | Child examples 80 | 81 | Warns: 82 | Child warns 83 | 84 | Warnings: 85 | Child warnings 86 | 87 | Args: 88 | x: X 89 | child_varargs: Not *args 90 | *child_varargs: Child *args 91 | **child_kwargs: Child **kwargs 92 | """ 93 | 94 | expected = """Child summary. 95 | 96 | Args: 97 | x: X 98 | missing_doc: The description is missing. 99 | *child_varargs: Child *args 100 | **child_kwargs: Child **kwargs 101 | 102 | Returns: 103 | Parent returns 104 | 105 | Yields: 106 | Child yields 107 | 108 | Raises: 109 | Child raises 110 | 111 | Warns: 112 | Child warns 113 | 114 | Warnings: 115 | Child warnings 116 | 117 | See Also: 118 | Parent see also 119 | 120 | Notes: 121 | Child notes 122 | 123 | References: 124 | Parent references 125 | 126 | Examples: 127 | Child examples 128 | """ 129 | 130 | inherit_google_docstring(parent.__doc__, child) 131 | assert child.__doc__ == expected.strip("\n") 132 | 133 | 134 | @pytest.mark.parametrize( 135 | "inherit_docstring", [inherit_numpy_docstring, inherit_google_docstring] 136 | ) 137 | @pytest.mark.parametrize( 138 | ("parent_docstring", "child_docstring", "expected_docstring"), 139 | [(None, None, None), ("parent", None, "parent"), (None, "child", "child")], 140 | ) 141 | def test_simple( 142 | inherit_docstring, parent_docstring, child_docstring, expected_docstring 143 | ): 144 | dummy_func = ClassDocstringsInheritor._create_dummy_func_with_doc(child_docstring) 145 | inherit_docstring(parent_docstring, dummy_func) 146 | assert dummy_func.__doc__ == expected_docstring 147 | -------------------------------------------------------------------------------- /tests/test_metaclass_google.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | import textwrap 23 | 24 | import pytest 25 | 26 | from docstring_inheritance import GoogleDocstringInheritanceInitMeta 27 | from docstring_inheritance import GoogleDocstringInheritanceMeta 28 | 29 | parametrize_inheritance = pytest.mark.parametrize( 30 | "inheritance_class", 31 | [GoogleDocstringInheritanceMeta, GoogleDocstringInheritanceInitMeta], 32 | ) 33 | 34 | 35 | @parametrize_inheritance 36 | def test_args_inheritance(inheritance_class): 37 | class Parent(metaclass=inheritance_class): 38 | def method(self, w, x, *args, y=None, **kwargs): 39 | """ 40 | Args: 41 | w 42 | x: int 43 | *args: int 44 | y: float 45 | **kwargs: int 46 | """ 47 | 48 | class Child(Parent): 49 | def method(self, xx, x, *args, yy=None, y=None, **kwargs): 50 | """ 51 | Args: 52 | xx: int 53 | """ 54 | 55 | expected = """ 56 | Args: 57 | xx: int 58 | x: int 59 | *args: int 60 | yy: The description is missing. 61 | y: float 62 | **kwargs: int""" 63 | 64 | assert Child.method.__doc__ == expected 65 | 66 | 67 | @parametrize_inheritance 68 | def test_class_doc_inheritance(inheritance_class): 69 | class GrandParent(metaclass=inheritance_class): 70 | """Class GrandParent. 71 | 72 | Attributes: 73 | a: From GrandParent. 74 | 75 | Methods: 76 | a: From GrandParent. 77 | 78 | Notes: 79 | From GrandParent. 80 | """ 81 | 82 | class Parent(GrandParent): 83 | """Class Parent. 84 | 85 | Attributes: 86 | b: From Parent. 87 | 88 | Methods: 89 | b: From Parent. 90 | """ 91 | 92 | class Child(Parent): 93 | """Class Child. 94 | 95 | Attributes: 96 | a: From Child. 97 | c : From Child. 98 | 99 | Notes: 100 | From Child. 101 | """ 102 | 103 | expected = """\ 104 | Class Child. 105 | 106 | Attributes: 107 | a: From Child. 108 | b: From Parent. 109 | c : From Child. 110 | 111 | Methods: 112 | a: From GrandParent. 113 | b: From Parent. 114 | 115 | Notes: 116 | From Child.\ 117 | """ 118 | 119 | assert Child.__doc__ == expected 120 | 121 | 122 | @parametrize_inheritance 123 | def test_do_not_inherit_from_object(inheritance_class): 124 | class Parent(metaclass=inheritance_class): 125 | def __init__(self): # pragma: no cover 126 | pass 127 | 128 | assert Parent.__init__.__doc__ is None 129 | 130 | 131 | def test_class_doc_inheritance_with_init(): 132 | class Parent(metaclass=GoogleDocstringInheritanceInitMeta): 133 | """Class Parent. 134 | 135 | Args: 136 | a: a from Parent. 137 | b: b from Parent. 138 | """ 139 | 140 | def __init__(self, a, b): # pragma: no cover 141 | pass 142 | 143 | class Child(Parent): 144 | """Class Child. 145 | 146 | Args: 147 | c: c from Child. 148 | 149 | Notes: 150 | From Child. 151 | """ 152 | 153 | def __init__(self, b, c): # pragma: no cover 154 | pass 155 | 156 | expected = """\ 157 | Class Child. 158 | 159 | Args: 160 | b: b from Parent. 161 | c: c from Child. 162 | 163 | Notes: 164 | From Child.\ 165 | """ 166 | 167 | assert Child.__doc__ == expected 168 | assert Child.__init__.__doc__ is None 169 | 170 | 171 | def test_class_doc_inheritance_with_init_attr(): 172 | class Parent(metaclass=GoogleDocstringInheritanceInitMeta): 173 | """Class Parent. 174 | 175 | Args: 176 | a: a from Parent. 177 | b: b from Parent. 178 | 179 | Attributes: 180 | a: a attribute. 181 | b: b attribute. 182 | """ 183 | 184 | def __init__(self, a, b): # pragma: no cover 185 | pass 186 | 187 | class Child(Parent): 188 | """Class Child. 189 | 190 | Args: 191 | c: c from Child. 192 | 193 | Attributes: 194 | c: c attribute. 195 | 196 | Notes: 197 | From Child. 198 | """ 199 | 200 | def __init__(self, b, c): # pragma: no cover 201 | pass 202 | 203 | expected = """\ 204 | Class Child. 205 | 206 | Args: 207 | b: b from Parent. 208 | c: c from Child. 209 | 210 | Attributes: 211 | a: a attribute. 212 | b: b attribute. 213 | c: c attribute. 214 | 215 | Notes: 216 | From Child.\ 217 | """ 218 | 219 | assert Child.__doc__ == expected 220 | assert Child.__init__.__doc__ is None 221 | 222 | 223 | def test_class_doc_inheritance_with_empty_parent_doc(): 224 | class Parent(metaclass=GoogleDocstringInheritanceInitMeta): 225 | def __init__(self, a, b): # pragma: no cover 226 | pass 227 | 228 | class Child(Parent): 229 | def __init__(self, b, c): # pragma: no cover 230 | """ 231 | Args: 232 | b: n 233 | """ 234 | 235 | expected = """ 236 | Args: 237 | b: n 238 | """ 239 | assert textwrap.dedent(Child.__init__.__doc__) == expected 240 | -------------------------------------------------------------------------------- /tests/test_metaclass_numpy.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | from inspect import getdoc 23 | 24 | import pytest 25 | 26 | from docstring_inheritance import NumpyDocstringInheritanceInitMeta 27 | from docstring_inheritance import NumpyDocstringInheritanceMeta 28 | 29 | parametrize_inheritance = pytest.mark.parametrize( 30 | "inheritance_class", 31 | [NumpyDocstringInheritanceMeta, NumpyDocstringInheritanceInitMeta], 32 | ) 33 | 34 | 35 | def assert_args_inheritance(cls): 36 | excepted = """ 37 | Parameters 38 | ---------- 39 | xx: int 40 | x: int 41 | *args: int 42 | yy 43 | The description is missing. 44 | y: float 45 | **kwargs: int""" 46 | 47 | assert cls.method.__doc__ == excepted 48 | 49 | 50 | @parametrize_inheritance 51 | def test_args_inheritance_parent_meta(inheritance_class): 52 | class Parent(metaclass=inheritance_class): 53 | def method(self, w, x, *args, y=None, **kwargs): 54 | """ 55 | Parameters 56 | ---------- 57 | w 58 | x: int 59 | *args: int 60 | y: float 61 | **kwargs: int 62 | """ 63 | 64 | class Child(Parent): 65 | def method(self, xx, x, *args, yy=None, y=None, **kwargs): 66 | """ 67 | Parameters 68 | ---------- 69 | xx: int 70 | """ 71 | 72 | assert_args_inheritance(Child) 73 | 74 | 75 | @parametrize_inheritance 76 | def test_args_inheritance_child_meta(inheritance_class): 77 | class Parent: 78 | def method(self, w, x, *args, y=None, **kwargs): 79 | """ 80 | Parameters 81 | ---------- 82 | w 83 | x: int 84 | *args: int 85 | y: float 86 | **kwargs: int 87 | """ 88 | 89 | class Child(Parent, metaclass=inheritance_class): 90 | def method(self, xx, x, *args, yy=None, y=None, **kwargs): 91 | """ 92 | Parameters 93 | ---------- 94 | xx: int 95 | """ 96 | 97 | assert_args_inheritance(Child) 98 | 99 | 100 | def assert_docstring(cls): 101 | excepted = "Summary" 102 | 103 | assert cls.method.__doc__ == excepted 104 | assert cls.class_method.__doc__ == excepted 105 | assert cls.static_method.__doc__ == excepted 106 | assert cls.prop.__doc__ == excepted 107 | 108 | 109 | @parametrize_inheritance 110 | def test_missing_parent_attr_parent_meta(inheritance_class): 111 | class Parent(metaclass=inheritance_class): 112 | pass 113 | 114 | class Child(Parent): 115 | def method(self, xx, x, *args, yy=None, y=None, **kwargs): 116 | """Summary""" 117 | 118 | @classmethod 119 | def class_method(cls): 120 | """Summary""" 121 | 122 | @staticmethod 123 | def static_method(): 124 | """Summary""" 125 | 126 | @property 127 | def prop(self): 128 | """Summary""" 129 | 130 | assert_docstring(Child) 131 | 132 | 133 | @parametrize_inheritance 134 | def test_missing_parent_attr_child_meta(inheritance_class): 135 | class Parent: 136 | pass 137 | 138 | class Child(Parent, metaclass=inheritance_class): 139 | def method(self, xx, x, *args, yy=None, y=None, **kwargs): 140 | """Summary""" 141 | 142 | @classmethod 143 | def class_method(cls): 144 | """Summary""" 145 | 146 | @staticmethod 147 | def static_method(): 148 | """Summary""" 149 | 150 | @property 151 | def prop(self): 152 | """Summary""" 153 | 154 | assert_docstring(Child) 155 | 156 | 157 | @parametrize_inheritance 158 | def test_missing_parent_doc_for_attr_parent_meta(inheritance_class): 159 | class Parent(metaclass=inheritance_class): 160 | def method(self): # pragma: no cover 161 | pass 162 | 163 | @classmethod 164 | def class_method(cls): # pragma: no cover 165 | pass 166 | 167 | @staticmethod 168 | def static_method(): # pragma: no cover 169 | pass 170 | 171 | @property 172 | def prop(self): # pragma: no cover 173 | pass 174 | 175 | class Child(Parent): 176 | def method(self, xx, x, *args, yy=None, y=None, **kwargs): # pragma: no cover 177 | """Summary""" 178 | 179 | @classmethod 180 | def class_method(cls): # pragma: no cover 181 | """Summary""" 182 | 183 | @staticmethod 184 | def static_method(): # pragma: no cover 185 | """Summary""" 186 | 187 | @property 188 | def prop(self): # pragma: no cover 189 | """Summary""" 190 | 191 | assert_docstring(Child) 192 | 193 | 194 | @parametrize_inheritance 195 | def test_missing_parent_doc_for_attr_child_meta(inheritance_class): 196 | class Parent: 197 | def method(self): # pragma: no cover 198 | pass 199 | 200 | @classmethod 201 | def class_method(cls): # pragma: no cover 202 | pass 203 | 204 | @staticmethod 205 | def static_method(): # pragma: no cover 206 | pass 207 | 208 | @property 209 | def prop(self): # pragma: no cover 210 | pass 211 | 212 | class Child(Parent, metaclass=inheritance_class): 213 | def method(self, xx, x, *args, yy=None, y=None, **kwargs): # pragma: no cover 214 | """Summary""" 215 | 216 | @classmethod 217 | def class_method(cls): # pragma: no cover 218 | """Summary""" 219 | 220 | @staticmethod 221 | def static_method(): # pragma: no cover 222 | """Summary""" 223 | 224 | @property 225 | def prop(self): # pragma: no cover 226 | """Summary""" 227 | 228 | assert_docstring(Child) 229 | 230 | 231 | def assert_multiple_inheritance(cls): 232 | excepted = """Parent summary 233 | 234 | Attributes 235 | ---------- 236 | attr1 237 | attr2 238 | 239 | Methods 240 | ------- 241 | method1 242 | method2""" 243 | assert getdoc(cls) == excepted 244 | 245 | 246 | @parametrize_inheritance 247 | def test_multiple_inheritance_parent_meta(inheritance_class): 248 | class Parent1(metaclass=inheritance_class): 249 | """Parent summary 250 | 251 | Attributes 252 | ---------- 253 | attr1 254 | """ 255 | 256 | class Parent2: 257 | """Parent2 summary 258 | 259 | Methods 260 | ------- 261 | method1 262 | """ 263 | 264 | class Child(Parent1, Parent2): 265 | """ 266 | Attributes 267 | ---------- 268 | attr2 269 | 270 | Methods 271 | ------- 272 | method2 273 | """ 274 | 275 | assert_multiple_inheritance(Child) 276 | 277 | 278 | @parametrize_inheritance 279 | def test_multiple_inheritance_child_meta(inheritance_class): 280 | class Parent1: 281 | """Parent summary 282 | 283 | Attributes 284 | ---------- 285 | attr1 286 | """ 287 | 288 | class Parent2: 289 | """Parent2 summary 290 | 291 | Methods 292 | ------- 293 | method1 294 | """ 295 | 296 | class Child(Parent1, Parent2, metaclass=inheritance_class): 297 | """ 298 | Attributes 299 | ---------- 300 | attr2 301 | 302 | Methods 303 | ------- 304 | method2 305 | """ 306 | 307 | assert_multiple_inheritance(Child) 308 | 309 | 310 | @parametrize_inheritance 311 | def test_multiple_inheritance_child_meta_method(inheritance_class): 312 | class Parent1: 313 | def method(self, w, x): # pragma: no cover 314 | """Summary 1 315 | 316 | Parameters 317 | ---------- 318 | w: w doc 319 | x: x doc 320 | 321 | Returns 322 | ------- 323 | int 324 | """ 325 | 326 | class Child(Parent1, metaclass=inheritance_class): 327 | def method(self, w, x, *args, y=None, **kwargs): # pragma: no cover 328 | pass 329 | 330 | excepted = """Summary 1 331 | 332 | Parameters 333 | ---------- 334 | w: w doc 335 | x: x doc 336 | *args 337 | The description is missing. 338 | y 339 | The description is missing. 340 | **kwargs 341 | The description is missing. 342 | 343 | Returns 344 | ------- 345 | int""" 346 | 347 | assert Child.method.__doc__ == excepted 348 | 349 | 350 | @parametrize_inheritance 351 | def test_several_parents_parent_meta(inheritance_class): 352 | class GrandParent(metaclass=inheritance_class): 353 | """GrandParent summary 354 | 355 | Attributes 356 | ---------- 357 | attr1 358 | """ 359 | 360 | class Parent(GrandParent): 361 | """Parent summary 362 | 363 | Methods 364 | ------- 365 | method1 366 | """ 367 | 368 | class Child(Parent): 369 | """ 370 | Attributes 371 | ---------- 372 | attr2 373 | 374 | Methods 375 | ------- 376 | method2 377 | """ 378 | 379 | assert_multiple_inheritance(Child) 380 | 381 | 382 | @parametrize_inheritance 383 | def test_several_parents_child_meta(inheritance_class): 384 | class GrandParent: 385 | """GrandParent summary 386 | 387 | Attributes 388 | ---------- 389 | attr1 390 | """ 391 | 392 | class Parent(GrandParent): 393 | """Parent summary 394 | 395 | Methods 396 | ------- 397 | method1 398 | """ 399 | 400 | class Child(Parent, metaclass=inheritance_class): 401 | """ 402 | Attributes 403 | ---------- 404 | attr2 405 | 406 | Methods 407 | ------- 408 | method2 409 | """ 410 | 411 | assert_multiple_inheritance(Child) 412 | 413 | 414 | @parametrize_inheritance 415 | def test_do_not_inherit_object_child_meta(inheritance_class): 416 | class Parent: 417 | def __init__(self): # pragma: no cover 418 | pass 419 | 420 | class Child(Parent, metaclass=inheritance_class): 421 | pass 422 | 423 | assert Child.__init__.__doc__ is None 424 | 425 | 426 | @parametrize_inheritance 427 | def test_do_not_inherit_from_object(inheritance_class): 428 | class Parent(metaclass=inheritance_class): 429 | def __init__(self): # pragma: no cover 430 | pass 431 | 432 | assert Parent.__init__.__doc__ is None 433 | -------------------------------------------------------------------------------- /tests/test_numpy_inheritor.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 Antoine DECHAUME 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | # this software and associated documentation files (the "Software"), to deal in 5 | # the Software without restriction, including without limitation the rights to 6 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | # of the Software, and to permit persons to whom the Software is furnished to do 8 | # so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | from __future__ import annotations 21 | 22 | import pytest 23 | 24 | from docstring_inheritance import NumpyDocstringInheritor 25 | from docstring_inheritance.docstring_inheritors.bases import SUMMARY_SECTION_NAME 26 | from docstring_inheritance.docstring_inheritors.bases.parser import NoSectionFound 27 | from docstring_inheritance.docstring_inheritors.numpy import DocstringParser 28 | from docstring_inheritance.docstring_inheritors.numpy import DocstringRenderer 29 | 30 | from .test_base_parser import _test_parse_sections 31 | 32 | 33 | @pytest.mark.parametrize( 34 | ("unindented_docstring", "expected_sections"), 35 | [ 36 | ("", {}), 37 | ( 38 | "Short summary.", 39 | { 40 | SUMMARY_SECTION_NAME: "Short summary.", 41 | }, 42 | ), 43 | ( 44 | """Short summary. 45 | 46 | Extended summary. 47 | """, 48 | { 49 | SUMMARY_SECTION_NAME: """Short summary. 50 | 51 | Extended summary.""", 52 | }, 53 | ), 54 | ( 55 | """ 56 | Parameters 57 | ---------- 58 | arg 59 | """, 60 | { 61 | "Parameters": {"arg": ""}, 62 | }, 63 | ), 64 | ( 65 | """Short summary. 66 | 67 | Extended summary. 68 | 69 | Parameters 70 | ---------- 71 | arg 72 | """, 73 | { 74 | SUMMARY_SECTION_NAME: """Short summary. 75 | 76 | Extended summary.""", 77 | "Parameters": {"arg": ""}, 78 | }, 79 | ), 80 | ( 81 | """Short summary. 82 | 83 | Extended summary. 84 | 85 | Parameters 86 | ---------- 87 | arg 88 | 89 | Section name 90 | ------------ 91 | Section body. 92 | 93 | Indented line. 94 | """, 95 | { 96 | SUMMARY_SECTION_NAME: """Short summary. 97 | 98 | Extended summary.""", 99 | "Parameters": {"arg": ""}, 100 | "Section name": """\ 101 | Section body. 102 | 103 | Indented line.""", 104 | }, 105 | ), 106 | ], 107 | ) 108 | def test_parse_sections(unindented_docstring, expected_sections): 109 | _test_parse_sections(DocstringParser.parse, unindented_docstring, expected_sections) 110 | 111 | 112 | @pytest.mark.parametrize( 113 | ("section_name", "section_body", "expected_docstring"), 114 | [ 115 | ( 116 | SUMMARY_SECTION_NAME, 117 | "Short summary.", 118 | "Short summary.", 119 | ), 120 | ( 121 | "Section name", 122 | """\ 123 | Section body. 124 | 125 | Indented line.""", 126 | """\ 127 | Section name 128 | ------------ 129 | Section body. 130 | 131 | Indented line.""", 132 | ), 133 | ( 134 | "Section name", 135 | {"arg": "\n Description."}, 136 | """\ 137 | Section name 138 | ------------ 139 | arg 140 | Description.""", 141 | ), 142 | ], 143 | ) 144 | def test_render_section(section_name, section_body, expected_docstring): 145 | assert ( 146 | DocstringRenderer._render_section(section_name, section_body) 147 | == expected_docstring 148 | ) 149 | 150 | 151 | @pytest.mark.parametrize( 152 | ("line1", "line2s"), 153 | [ 154 | ( 155 | "", 156 | "--", 157 | ), 158 | ( 159 | "", 160 | "***", 161 | ), 162 | ( 163 | "too long", 164 | "---", 165 | ), 166 | ( 167 | "too long", 168 | "--- ", 169 | ), 170 | ( 171 | "too long", 172 | "===", 173 | ), 174 | ( 175 | "too long", 176 | "=== ", 177 | ), 178 | ], 179 | ) 180 | def test_parse_one_section_no_section(line1, line2s): 181 | with pytest.raises(NoSectionFound): 182 | DocstringParser._parse_one_section(line1, line2s, []) 183 | 184 | 185 | @pytest.mark.parametrize( 186 | ("line1", "line2s", "expected"), 187 | [ 188 | ("name", "----", ("name", "")), 189 | ("name ", "----", ("name", "")), 190 | ("name", "-----", ("name", "")), 191 | ("name", "====", ("name", "")), 192 | ("name", "=====", ("name", "")), 193 | ], 194 | ) 195 | def test_parse_one_section(line1, line2s, expected): 196 | assert DocstringParser._parse_one_section(line1, line2s, []) == expected 197 | 198 | 199 | # The following are test for methods of AbstractDocstringInheritor that depend on 200 | # concrete implementation of abstract methods. 201 | 202 | 203 | @pytest.mark.parametrize( 204 | ("parent_sections", "child_sections", "expected_sections"), 205 | [ 206 | # Section missing in child. 207 | ({0: 0}, {}, {0: 0}), 208 | # Section missing in parent. 209 | ({}, {0: 0}, {0: 0}), 210 | # Child override parent when section_items has no items. 211 | ({0: 0}, {0: 1}, {0: 1}), 212 | # Merge sections that are not common. 213 | ({0: 0}, {1: 0}, {0: 0, 1: 0}), 214 | # Section with items missing in child. 215 | ({"Methods": {0: 0}}, {}, {"Methods": {0: 0}}), 216 | # Section with items missing in parent. 217 | ({}, {"Methods": {0: 0}}, {"Methods": {0: 0}}), 218 | # Child override parent when section_items has items. 219 | ( 220 | {"Methods": {0: 0}}, 221 | {"Methods": {0: 1}}, 222 | {"Methods": {0: 1}}, 223 | ), 224 | # Merge section_items with common items that are not args. 225 | ( 226 | {"Methods": {0: 0}}, 227 | {"Methods": {1: 0}}, 228 | {"Methods": {0: 0, 1: 0}}, 229 | ), 230 | # Merge section_items with common items that are args. 231 | ( 232 | {"Parameters": {}}, 233 | {"Parameters": {}}, 234 | {}, 235 | ), 236 | # Standard section_items names come before non-standard ones. 237 | ({0: 0, "Notes": 0}, {}, {"Notes": 0, 0: 0}), 238 | ], 239 | ) 240 | def test_inherit_sections(parent_sections, child_sections, expected_sections): 241 | NumpyDocstringInheritor(lambda: None)._inherit_sections( # pragma: no cover 242 | parent_sections, 243 | child_sections, 244 | ) 245 | assert child_sections == expected_sections 246 | # Verify the order of the keys. 247 | assert list(child_sections.keys()) == list(expected_sections.keys()) 248 | 249 | 250 | @pytest.mark.parametrize( 251 | ("sections", "expected"), 252 | [ 253 | ({}, ""), 254 | ( 255 | {SUMMARY_SECTION_NAME: "body"}, 256 | """body""", 257 | ), 258 | ( 259 | {SUMMARY_SECTION_NAME: "body", "name": "body"}, 260 | """body 261 | 262 | name 263 | ---- 264 | body""", 265 | ), 266 | ( 267 | {"name": "body"}, 268 | """ 269 | name 270 | ---- 271 | body""", 272 | ), 273 | ], 274 | ) 275 | def test_render_docstring(sections, expected): 276 | assert DocstringRenderer.render(sections) == expected 277 | 278 | 279 | # TODO: test section order and all sections items 280 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | min_version = 4 3 | env_list = py{39,310,311,312,313} 4 | 5 | [testenv] 6 | package = wheel 7 | wheel_build_env = {package_env} 8 | deps = 9 | -r requirements/test.txt 10 | set_env = 11 | coverage: __COVERAGE_POSARGS=--cov --cov-report=xml --cov-report=html 12 | commands = 13 | pytest {env:__COVERAGE_POSARGS:} {posargs} 14 | 15 | [testenv:create-dist] 16 | description = create the pypi distribution 17 | deps = 18 | twine 19 | build 20 | skip_install = true 21 | allowlist_externals = rm 22 | commands = 23 | rm -rf dist build 24 | python -m build 25 | twine check dist/* 26 | 27 | [testenv:update-deps-test] 28 | description = update the test envs dependencies 29 | base_python = python3.9 30 | set_env = 31 | deps = uv 32 | skip_install = true 33 | commands = 34 | uv pip compile --upgrade --extra test -o requirements/test.txt pyproject.toml 35 | --------------------------------------------------------------------------------