diff --git a/docs/checking_class_attributes.md b/docs/checking_class_attributes.md index 3f85d2ec..da2f9faf 100644 --- a/docs/checking_class_attributes.md +++ b/docs/checking_class_attributes.md @@ -9,6 +9,7 @@ ______________________________________________________________________ - [1. Numpy style](#1-numpy-style) - [2. Google style](#2-google-style) - [3. Sphinx style](#3-sphinx-style) +- [4. Special note: inline docstrings](#4-special-note-inline-docstrings) ______________________________________________________________________ @@ -186,3 +187,24 @@ class MyPet: def __init__(self, airtag_id: int) -> None: self.airtag_id = airtag_id ``` + +## 4. Special note: inline docstrings + +[PEP-257](https://peps.python.org/pep-0257/) indicates that string literals +located directly after an assign statement may be treated as attribute +documentation. As such, we also optionally support inline docstrings for class +attributes with the `--allow-inline-class-var-docs` option set to True (it is +False by default). + +Attribute type documentation may be specified as the first thing in the +docstring followed by a colon. For example: + +```python +class MyClass: + """My class that does things.""" + + field1 = 5 + """int: My first field""" +``` + +This applies for all 3 styles (numpy, Google, and Sphinx). diff --git a/docs/config_options.md b/docs/config_options.md index 5afd47b2..9da24602 100644 --- a/docs/config_options.md +++ b/docs/config_options.md @@ -31,16 +31,17 @@ ______________________________________________________________________ - [16. `--should-document-private-class-attributes` (shortform: `-sdpca`, default: `False`)](#16---should-document-private-class-attributes-shortform--sdpca-default-false) - [17. `--treat-property-methods-as-class-attributes` (shortform: `-tpmaca`, default: `False`)](#17---treat-property-methods-as-class-attributes-shortform--tpmaca-default-false) - [18. `--only-attrs-with-ClassVar-are-treated-as-class-attrs` (shortform: `-oawcv`, default: `False`)](#18---only-attrs-with-classvar-are-treated-as-class-attrs-shortform--oawcv-default-false) -- [19. `--should-document-star-arguments` (shortform: `-sdsa`, default: `True`)](#19---should-document-star-arguments-shortform--sdsa-default-true) -- [20. `--omit-stars-when-documenting-varargs` (shortform: `-oswdv`, default: `False`)](#20---omit-stars-when-documenting-varargs-shortform--oswdv-default-false) -- [21. `--check-style-mismatch` (shortform: `-csm`, default: `False`)](#21---check-style-mismatch-shortform--csm-default-false) -- [22. `--check-arg-defaults` (shortform: `-cad`, default: `False`)](#22---check-arg-defaults-shortform--cad-default-false) -- [23. `--baseline`](#23---baseline) -- [24. `--generate-baseline` (default: `False`)](#24---generate-baseline-default-false) -- [25. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`)](#25---auto-regenerate-baseline-shortform--arb-default-true) -- [26. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#26---show-filenames-in-every-violation-message-shortform--sfn-default-false) -- [27. `--native-mode-noqa-location` (shortform: `-nmnl`, default: `docstring`)](#27---native-mode-noqa-location-shortform--nmnl-default-docstring) -- [28. `--config` (default: `pyproject.toml`)](#28---config-default-pyprojecttoml) +- [19. `--allow-inline-class-var-docs` (shortform: `-aicvd`, default: `False`)](#19---allow-inline-class-var-docs-shortform--aicvd-default-false) +- [20. `--should-document-star-arguments` (shortform: `-sdsa`, default: `True`)](#20---should-document-star-arguments-shortform--sdsa-default-true) +- [21. `--omit-stars-when-documenting-varargs` (shortform: `-oswdv`, default: `False`)](#21---omit-stars-when-documenting-varargs-shortform--oswdv-default-false) +- [22. `--check-style-mismatch` (shortform: `-csm`, default: `False`)](#22---check-style-mismatch-shortform--csm-default-false) +- [23. `--check-arg-defaults` (shortform: `-cad`, default: `False`)](#23---check-arg-defaults-shortform--cad-default-false) +- [24. `--baseline`](#24---baseline) +- [25. `--generate-baseline` (default: `False`)](#25---generate-baseline-default-false) +- [26. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`)](#26---auto-regenerate-baseline-shortform--arb-default-true) +- [27. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#27---show-filenames-in-every-violation-message-shortform--sfn-default-false) +- [28. `--native-mode-noqa-location` (shortform: `-nmnl`, default: `docstring`)](#28---native-mode-noqa-location-shortform--nmnl-default-docstring) +- [29. `--config` (default: `pyproject.toml`)](#29---config-default-pyprojecttoml) ______________________________________________________________________ @@ -229,20 +230,41 @@ If True, only the attributes whose type annotations are wrapped within `ClassVar` (where `ClassVar` is imported from `typing`) are treated as class attributes, and all other attributes are treated as instance attributes. -## 19. `--should-document-star-arguments` (shortform: `-sdsa`, default: `True`) +## 19. `--allow-inline-class-var-docs` (shortform: `-aicvd`, default: `False`) + +If True, class attributes (a.k.a., +[`ClassVar`](https://typing.python.org/en/latest/spec/class-compat.html#classvar)) +need to have inline documentation. For example: + +```python +class MyClass: + """My class.""" + + field1: int = 5 + """int: Field 1 documentation.""" +``` + +If False, inline documentation are allowed. Similarly, class-level +documentation of class attributes will not be allowed if this option is set to +True. + +Inline docstrings may specify the attribute type as the first token in the +docstring followed by a `:`. + +## 20. `--should-document-star-arguments` (shortform: `-sdsa`, default: `True`) If True, "star arguments" (such as `*args`, `**kwargs`, `**props`, etc.) in the function signature should be documented in the docstring. If False, they should not appear in the docstring. -## 20. `--omit-stars-when-documenting-varargs` (shortform: `-oswdv`, default: `False`) +## 21. `--omit-stars-when-documenting-varargs` (shortform: `-oswdv`, default: `False`) If True, docstring argument entries describing `*args` or `**kwargs` may omit the leading `*`, and pydoclint will still match them against the function signature. Leave this disabled to require docstrings to include the leading `*` characters for varargs. -## 21. `--check-style-mismatch` (shortform: `-csm`, default: `False`) +## 22. `--check-style-mismatch` (shortform: `-csm`, default: `False`) If True, check that style specified in --style matches the detected style of the docstring. If there is a mismatch, `DOC003` will be reported. Setting this @@ -251,13 +273,13 @@ to False will silence all `DOC003` violations. Read more about this config option and `DOC003` at [https://jsh9.github.io/pydoclint/style_mismatch.html](https://jsh9.github.io/pydoclint/style_mismatch.html). -## 22. `--check-arg-defaults` (shortform: `-cad`, default: `False`) +## 23. `--check-arg-defaults` (shortform: `-cad`, default: `False`) If True, docstring type hints should contain default values consistent with the function signature. If False, docstring type hints should not contain default values. (Only applies to numpy style for now.) -## 23. `--baseline` +## 24. `--baseline` Baseline allows you to remember the current project state and then show only new violations, ignoring old ones. This can be very useful when you'd like to @@ -279,12 +301,12 @@ If `--generate-baseline` is not passed to _pydoclint_ (the default is `False`), _pydoclint_ will read your baseline file, and ignore all violations specified in that file. -## 24. `--generate-baseline` (default: `False`) +## 25. `--generate-baseline` (default: `False`) Required to use with `--baseline` option. If `True`, generate the baseline file that contains all current violations. -## 25. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`) +## 26. `--auto-regenerate-baseline` (shortform: `-arb`, default: `True`) If it's set to True, _pydoclint_ will automatically regenerate the baseline file every time you fix violations in the baseline and rerun _pydoclint_. @@ -292,7 +314,7 @@ file every time you fix violations in the baseline and rerun _pydoclint_. This saves you from having to manually regenerate the baseline file by setting `--generate-baseline=True` and run _pydoclint_. -## 26. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) +## 27. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) If False, in the terminal the violation messages are grouped by file names: @@ -326,7 +348,7 @@ This can be convenient if you would like to click on each violation message and go to the corresponding line in your IDE. (Note: not all terminal app offers this functionality.) -## 27. `--native-mode-noqa-location` (shortform: `-nmnl`, default: `docstring`) +## 28. `--native-mode-noqa-location` (shortform: `-nmnl`, default: `docstring`) This option controls where _pydoclint_ looks for inline `# noqa: DOCxxx` comments when running in native mode (i.e., outside of Flake8). Two values are @@ -341,7 +363,7 @@ Only DOC-prefixed violation codes are honored; other codes are ignored by the native parser. This setting has no effect in Flake8 mode, which is controlled by Flake8's own `noqa` handling. -## 28. `--config` (default: `pyproject.toml`) +## 29. `--config` (default: `pyproject.toml`) The full path of the .toml config file that contains the config options. Note that the command line options take precedence over the .toml file. Look at this diff --git a/docs/violation_codes.md b/docs/violation_codes.md index abf8cb2f..2d85cce0 100644 --- a/docs/violation_codes.md +++ b/docs/violation_codes.md @@ -98,13 +98,15 @@ on the top) do not need to have a return section. ## 7. `DOC6xx`: Violations about class attributes -| Code | Explanation | -| -------- | --------------------------------------------------------------------------------- | -| `DOC601` | Class docstring contains fewer class attributes than actual class attributes. | -| `DOC602` | Class docstring contains more class attributes than in actual class attributes. | -| `DOC603` | Class docstring attributes are different from actual class attributes. | -| `DOC604` | Attributes are the same in docstring and class def, but are in a different order. | -| `DOC605` | Attribute names match, but type hints in these attributes do not match | +| Code | Explanation | +| -------- | -------------------------------------------------------------------------------------------------- | +| `DOC601` | Class docstring contains fewer class attributes than actual class attributes. | +| `DOC602` | Class docstring contains more class attributes than in actual class attributes. | +| `DOC603` | Class docstring attributes are different from actual class attributes. | +| `DOC604` | Attributes are the same in docstring and class def, but are in a different order. | +| `DOC605` | Attribute names match, but type hints in these attributes do not match | +| `DOC606` | Attribute should not have an inline docstring; please combine it with the class docstring | +| `DOC607` | The class docstring should not have an "Attributes" section; please document all attributes inline | More about checking class attributes: https://jsh9.github.io/pydoclint/checking_class_attributes.html diff --git a/muff.toml b/muff.toml index 48120264..9340c051 100644 --- a/muff.toml +++ b/muff.toml @@ -139,5 +139,6 @@ max-bool-expr = 10 max-branches = 100 max-locals = 1000 max-nested-blocks = 10 +max-public-methods = 30 max-returns = 30 max-statements = 50 diff --git a/pydoclint/flake8_entry.py b/pydoclint/flake8_entry.py index e3af6440..385f1977 100644 --- a/pydoclint/flake8_entry.py +++ b/pydoclint/flake8_entry.py @@ -248,6 +248,14 @@ def add_options(cls, parser: Any) -> None: # noqa: D102 ' attributes are treated as instance attributes.' ), ) + parser.add_option( + '-aicvd', + '--allow-inline-class-var-docs', + action='store', + default='False', + parse_from_config=True, + help='If True, allow inline documentation for class attributes.', + ) parser.add_option( '-sdsa', '--should-document-star-arguments', @@ -347,6 +355,7 @@ def parse_options(cls, options: Any) -> None: # noqa: D102 cls.only_attrs_with_ClassVar_are_treated_as_class_attrs = ( options.only_attrs_with_ClassVar_are_treated_as_class_attrs ) + cls.allow_inline_class_var_docs = options.allow_inline_class_var_docs cls.should_document_star_arguments = ( options.should_document_star_arguments ) @@ -440,6 +449,10 @@ def run(self) -> Generator[tuple[int, int, str, Any], None, None]: '--only-attrs-with-ClassVar-are-treated-as-class-attrs', self.only_attrs_with_ClassVar_are_treated_as_class_attrs, ) + allowInlineClassVarDocs = self._bool( + '--allow-inline-class-var-docs', + self.allow_inline_class_var_docs, + ) shouldDocumentStarArguments = self._bool( '--should-document-star-arguments', self.should_document_star_arguments, @@ -490,6 +503,7 @@ def run(self) -> Generator[tuple[int, int, str, Any], None, None]: onlyAttrsWithClassVarAreTreatedAsClassAttrs=( onlyAttrsWithClassVarAreTreatedAsClassAttrs ), + allowInlineClassVarDocs=allowInlineClassVarDocs, shouldDocumentStarArguments=shouldDocumentStarArguments, omitStarsWhenDocumentingVarargs=omitStarsWhenDocumentingVarargs, checkStyleMismatch=checkStyleMismatch, diff --git a/pydoclint/main.py b/pydoclint/main.py index 9826a9a8..a7c1829a 100644 --- a/pydoclint/main.py +++ b/pydoclint/main.py @@ -289,6 +289,14 @@ def validateNativeModeNoqaLocation( ' treated as instance attributes.' ), ) +@click.option( + '-aicvd', + '--allow-inline-class-var-docs', + type=bool, + show_default=True, + default=False, + help='If True, allow inline documentation of class attributes.', +) @click.option( '-sdsa', '--should-document-star-arguments', @@ -469,6 +477,7 @@ def main( # noqa: C901, PLR0915 require_return_section_when_returning_nothing: bool, require_yield_section_when_yielding_nothing: bool, only_attrs_with_classvar_are_treated_as_class_attrs: bool, + allow_inline_class_var_docs: bool, should_document_star_arguments: bool, omit_stars_when_documenting_varargs: bool, should_declare_assert_error_if_assert_statement_exists: bool, @@ -580,6 +589,7 @@ def main( # noqa: C901, PLR0915 onlyAttrsWithClassVarAreTreatedAsClassAttrs=( only_attrs_with_classvar_are_treated_as_class_attrs ), + allowInlineClassVarDocs=allow_inline_class_var_docs, requireReturnSectionWhenReturningNothing=( require_return_section_when_returning_nothing ), @@ -736,6 +746,7 @@ def _checkPaths( shouldDocumentPrivateClassAttributes: bool = False, treatPropertyMethodsAsClassAttributes: bool = False, onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, + allowInlineClassVarDocs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, shouldDocumentStarArguments: bool = True, @@ -798,6 +809,7 @@ def _checkPaths( onlyAttrsWithClassVarAreTreatedAsClassAttrs=( onlyAttrsWithClassVarAreTreatedAsClassAttrs ), + allowInlineClassVarDocs=allowInlineClassVarDocs, requireReturnSectionWhenReturningNothing=( requireReturnSectionWhenReturningNothing ), @@ -836,6 +848,7 @@ def _checkFile( shouldDocumentPrivateClassAttributes: bool = False, treatPropertyMethodsAsClassAttributes: bool = False, onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, + allowInlineClassVarDocs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, shouldDocumentStarArguments: bool = True, @@ -893,6 +906,7 @@ def _checkFile( onlyAttrsWithClassVarAreTreatedAsClassAttrs=( onlyAttrsWithClassVarAreTreatedAsClassAttrs ), + allowInlineClassVarDocs=allowInlineClassVarDocs, requireReturnSectionWhenReturningNothing=( requireReturnSectionWhenReturningNothing ), diff --git a/pydoclint/utils/arg.py b/pydoclint/utils/arg.py index 9713ab94..09f37306 100644 --- a/pydoclint/utils/arg.py +++ b/pydoclint/utils/arg.py @@ -431,3 +431,19 @@ def hasTypeHintInAllArgs(self) -> bool: they don't need to have type hints. """ return all(_.hasTypeHint() for _ in self.infoList if _.notStarArg()) + + def insertAt( + self, + index: int, + arg: Arg, + ) -> None: + """Insert an Arg at a specific index.""" + if arg.name in self.lookup: + raise ValueError( + f'Edge case: Arg with name "{arg.name}" already exists in' + ' argList. Please open an Issue on GitHub with a reproducible' + ' example.' + ) + + self.infoList.insert(index, arg) + self.lookup[arg.name] = arg.typeHint diff --git a/pydoclint/utils/violation.py b/pydoclint/utils/violation.py index 213ecefb..5def13b5 100644 --- a/pydoclint/utils/violation.py +++ b/pydoclint/utils/violation.py @@ -21,33 +21,33 @@ ' ).' ), 104: ( - 'Arguments are the same in the docstring and the function signature,' - ' but are in a different order.' + 'Arguments are the same in the docstring and the function' + ' signature, but are in a different order.' ), - 105: 'Argument names match, but type hints in these args do not match:', + 105: ('Argument names match, but type hints in these args do not match:'), 106: ( - 'The option `--arg-type-hints-in-signature` is `True` but there are' - ' no argument type hints in the signature' + 'The option `--arg-type-hints-in-signature` is `True` but' + ' there are no argument type hints in the signature' ), 107: ( 'The option `--arg-type-hints-in-signature` is `True` but not all' ' args in the signature have type hints' ), 108: ( - 'The option `--arg-type-hints-in-signature` is `False` but there are' - ' argument type hints in the signature' + 'The option `--arg-type-hints-in-signature` is `False` but' + ' there are argument type hints in the signature' ), 109: ( - 'The option `--arg-type-hints-in-docstring` is `True` but there are' - ' no type hints in the docstring arg list' + 'The option `--arg-type-hints-in-docstring` is `True` but' + ' there are no type hints in the docstring arg list' ), 110: ( 'The option `--arg-type-hints-in-docstring` is `True` but not all' ' args in the docstring arg list have type hints' ), 111: ( - 'The option `--arg-type-hints-in-docstring` is `False` but there are' - ' type hints in the docstring arg list' + 'The option `--arg-type-hints-in-docstring` is `False` but' + ' there are type hints in the docstring arg list' ), 201: 'does not have a return section in docstring', 202: ( @@ -59,20 +59,20 @@ ' annotation.' ), 301: ( - '__init__() should not have a docstring; please combine it with the' - ' docstring of the class' + '__init__() should not have a docstring; please combine it' + ' with the docstring of the class' ), 302: ( 'The class docstring does not need a "Returns" section, because' ' __init__() cannot return anything' ), 303: ( - 'The __init__() docstring does not need a "Returns" section, because' - ' it cannot return anything' + 'The __init__() docstring does not need a "Returns" section,' + ' because it cannot return anything' ), 304: ( - 'Class docstring has an argument/parameter section; please put it in' - ' the __init__() docstring' + 'Class docstring has an argument/parameter section; please' + ' put it in the __init__() docstring' ), 305: ( 'Class docstring has a "Raises" section; please put it in the' @@ -83,13 +83,13 @@ ' __init__() cannot yield anything' ), 307: ( - 'The __init__() docstring does not need a "Yields" section, because' - ' __init__() cannot yield anything' + 'The __init__() docstring does not need a "Yields" section,' + ' because __init__() cannot yield anything' ), 401: '', # Deprecated 402: ( - 'has "yield" statements, but the docstring does not have a "Yields"' - ' section' + 'has "yield" statements, but the docstring does not have a' + ' "Yields" section' ), 403: ( 'has a "Yields" section in the docstring, but there are no "yield"' @@ -102,49 +102,58 @@ ), 405: ( 'has both "return" and "yield" statements. Please use' - ' Generator[YieldType, SendType, ReturnType] as the return type' - ' annotation, and put your yield type in YieldType and return type' - ' in ReturnType. More details in' + ' Generator[YieldType, SendType, ReturnType] as the' + ' return type annotation, and put your yield type in' + ' YieldType and return type in ReturnType. More details in' ' https://jsh9.github.io/pydoclint/notes_generator_vs_iterator.html' ), 501: ( - 'has raise statements, but the docstring does not have a "Raises"' - ' section' + 'has raise statements, but the docstring does not have a' + ' "Raises" section' ), 502: ( - 'has a "Raises" section in the docstring, but there are not "raise"' - ' statements in the body' + 'has a "Raises" section in the docstring, but there are not' + ' "raise" statements in the body' ), 503: ( 'exceptions in the "Raises" section in the docstring do not match' ' those in the function body.' ), 504: ( - 'has assert statements, but the docstring does not have a "Raises"' - ' section. (Assert statements could raise "AssertError".)' + 'has assert statements, but the docstring does not have a' + ' "Raises" section. (Assert statements could raise' + ' "AssertError".)' ), 601: ( - 'Class docstring contains fewer class attributes than actual class' - ' attributes.' + 'Class docstring contains fewer class attributes than actual' + ' class attributes.' ), 602: ( - 'Class docstring contains more class attributes than in actual class' - ' attributes.' + 'Class docstring contains more class attributes than in actual' + ' class attributes.' ), 603: ( 'Class docstring attributes are different from actual class' ' attributes.' ' (Or could be other formatting issues: ' - 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103' - ' ).' + 'https://jsh9.github.io/pydoclint/violation_codes.html' + '#notes-on-doc103 ).' ), 604: ( 'Attributes are the same in docstring and class def, but are in a' ' different order.' ), 605: ( - 'Attribute names match, but type hints in these attributes do not' - ' match:' + 'Attribute names match, but type hints in these' + ' attributes do not match:' + ), + 606: ( + 'Attribute has an inline docstring, but should be documented in' + ' the class docstring instead.' + ), + 607: ( + 'The class docstring does not need an "Attributes" section,' + ' because the class attributes are documented inline.' ), }) diff --git a/pydoclint/utils/visitor_helper.py b/pydoclint/utils/visitor_helper.py index cf803c0f..e55d6065 100644 --- a/pydoclint/utils/visitor_helper.py +++ b/pydoclint/utils/visitor_helper.py @@ -3,7 +3,7 @@ from __future__ import annotations import ast -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: from pydoclint.utils.return_anno import ReturnAnnotation @@ -47,48 +47,66 @@ def checkClassAttributesAgainstClassDocstring( shouldDocumentPrivateClassAttributes: bool, treatPropertyMethodsAsClassAttributes: bool, onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool, + allowInlineClassVarDocs: bool, checkArgDefaults: bool, ) -> None: - """Check class attribute list against the attribute list in docstring""" - actualArgs: ArgList = extractClassAttributesFromNode( + """ + Check class attribute list against the attribute list in docstring. + + Parameters + ---------- + node : ast.ClassDef + The class definition node. + style : str + The docstring style. + violations : list[Violation] + The list of violations. + lineNum : int + The line number for reporting violations. + msgPrefix : str + The message prefix for violations. + shouldCheckArgOrder : bool + Whether to check the order of arguments. + argTypeHintsInSignature : bool + Whether type hints are in the function signature. + argTypeHintsInDocstring : bool + Whether to include type hints in docstring. + skipCheckingShortDocstrings : bool + Whether to skip checking short docstrings. + shouldDocumentPrivateClassAttributes : bool + Whether to document private class attributes. + treatPropertyMethodsAsClassAttributes : bool + Whether to treat property methods as class attributes. + onlyAttrsWithClassVarAreTreatedAsClassAttrs : bool + Whether only attributes with ClassVar are treated as class attributes. + allowInlineClassVarDocs : bool + Whether to allow inline ClassVar docs. + checkArgDefaults : bool + Whether to check argument defaults. + + Returns + ------- + None + """ + docuemntedAndClassArgs = getDocumentedAndActualClassArgLists( node=node, - shouldDocumentPrivateClassAttributes=( - shouldDocumentPrivateClassAttributes - ), - treatPropertyMethodsAsClassAttrs=treatPropertyMethodsAsClassAttributes, + style=style, + shouldDocumentPrivateClassAttributes=shouldDocumentPrivateClassAttributes, + treatPropertyMethodsAsClassAttributes=treatPropertyMethodsAsClassAttributes, onlyAttrsWithClassVarAreTreatedAsClassAttrs=( onlyAttrsWithClassVarAreTreatedAsClassAttrs ), checkArgDefaults=checkArgDefaults, + violations=violations, + skipCheckingShortDocstrings=skipCheckingShortDocstrings, + allowInlineClassVarDocs=allowInlineClassVarDocs, + argTypeHintsInDocstring=argTypeHintsInDocstring, ) - classDocstring: str = getDocstring(node) - - if classDocstring == '': - # We don't check classes without any docstrings. - # We defer to - # flake8-docstrings (https://github.com/PyCQA/flake8-docstrings) - # or pydocstyle (https://www.pydocstyle.org/en/stable/) - # to determine whether a class needs a docstring. + if docuemntedAndClassArgs is None: return - try: - doc: Doc = Doc(docstring=classDocstring, style=style) - except ParseError as excp: - doc = Doc(docstring='', style=style) - violations.append( - Violation( - code=1, - line=lineNum, - msgPrefix=f'Class `{node.name}`:', - msgPostfix=str(excp).replace('\n', ' '), - ) - ) - - if skipCheckingShortDocstrings and doc.isShortDocstring: - return - - docArgs: ArgList = doc.attrList + docArgs, actualArgs = docuemntedAndClassArgs checkDocArgsLengthAgainstActualArgs( docArgs=docArgs, @@ -128,11 +146,226 @@ def checkClassAttributesAgainstClassDocstring( shouldCheckArgOrder=shouldCheckArgOrder, argTypeHintsInSignature=argTypeHintsInSignature, argTypeHintsInDocstring=argTypeHintsInDocstring, + allowInlineClassVarDocs=allowInlineClassVarDocs, lineNum=lineNum, msgPrefix=msgPrefix, ) +def getDocumentedAndActualClassArgLists( + *, + node: ast.ClassDef, + style: str, + shouldDocumentPrivateClassAttributes: bool, + treatPropertyMethodsAsClassAttributes: bool, + onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool, + checkArgDefaults: bool, + violations: list[Violation], + skipCheckingShortDocstrings: bool, + allowInlineClassVarDocs: bool, + argTypeHintsInDocstring: bool, +) -> tuple[ArgList, ArgList] | None: + """ + Get documented and actual class attribute lists. + + Parameters + ---------- + node : ast.ClassDef + The class definition node. + style : str + The docstring style. + shouldDocumentPrivateClassAttributes : bool + Whether to document private class attributes. + treatPropertyMethodsAsClassAttributes : bool + Whether to treat property methods as class attributes. + onlyAttrsWithClassVarAreTreatedAsClassAttrs : bool + Whether only attributes with ClassVar are treated as class attributes. + checkArgDefaults : bool + Whether to check argument defaults. + violations : list[Violation] + The list of violations. + skipCheckingShortDocstrings : bool + Whether to skip checking short docstrings. + allowInlineClassVarDocs : bool + Whether to allow inline ClassVar docs. + argTypeHintsInDocstring : bool + Whether to include type hints in docstring. + + Returns + ------- + tuple[ArgList, ArgList] | None + A tuple containing the documented and actual class attribute lists, or + None if the class has no docstring or should be skipped. + """ + actualArgs: ArgList = extractClassAttributesFromNode( + node=node, + shouldDocumentPrivateClassAttributes=( + shouldDocumentPrivateClassAttributes + ), + treatPropertyMethodsAsClassAttrs=treatPropertyMethodsAsClassAttributes, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=( + onlyAttrsWithClassVarAreTreatedAsClassAttrs + ), + checkArgDefaults=checkArgDefaults, + ) + + classDocstring: str = getDocstring(node) + + if classDocstring == '': + # We don't check classes without any docstrings. + # We defer to + # flake8-docstrings (https://github.com/PyCQA/flake8-docstrings) + # or pydocstyle (https://www.pydocstyle.org/en/stable/) + # to determine whether a class needs a docstring. + return None + + try: + doc: Doc = Doc(docstring=classDocstring, style=style) + except ParseError as excp: + doc = Doc(docstring='', style=style) + violations.append( + Violation( + code=1, + line=node.lineno, + msgPrefix=f'Class `{node.name}`:', + msgPostfix=str(excp).replace('\n', ' '), + ) + ) + + if skipCheckingShortDocstrings and doc.isShortDocstring: + return None + + docArgs: ArgList = doc.attrList + + if allowInlineClassVarDocs and not docArgs.isEmpty: + violations.append( + Violation( + code=607, + line=node.lineno, + msgPrefix=f'Class `{node.name}`:', + ) + ) + # wipe out the args so we can catch any missing ones from inline docs + docArgs = ArgList([]) + + updateDocumentedArgListWithInlineDocstrings( + node=node, + docArgs=docArgs, + actualArgs=actualArgs, + shouldDocumentPrivateClassAttributes=shouldDocumentPrivateClassAttributes, + argTypeHintsInDocstring=argTypeHintsInDocstring, + allowInlineClassVarDocs=allowInlineClassVarDocs, + violations=violations, + ) + + return docArgs, actualArgs + + +def updateDocumentedArgListWithInlineDocstrings( + *, + node: ast.ClassDef, + docArgs: ArgList, + actualArgs: ArgList, + shouldDocumentPrivateClassAttributes: bool, + argTypeHintsInDocstring: bool, + allowInlineClassVarDocs: bool, + violations: list[Violation], +) -> None: + """ + Check for inline class attribute docstrings and add them to the documented + argument list. + + PEP-257 supports inline documentation for class variables, so we check for + constant string literals after assignments in the class body. + + Parameters + ---------- + node : ast.ClassDef + The class definition node. + docArgs : ArgList + The argument list parsed from the class docstring. + actualArgs : ArgList + The actual class attributes extracted from the class definition. + shouldDocumentPrivateClassAttributes : bool + Whether we should document private class attributes. If ``True``, + private class attributes will be included. + argTypeHintsInDocstring : bool + Whether argument type hints are expected to be in the docstring. + allowInlineClassVarDocs : bool + Whether to allow inline ClassVar docs. + violations : list[Violation] + The list of violations to append to. + + Returns + ------- + None + This function modifies ``docArgs`` in place. + """ + prev = None + idx = -1 + + for element in node.body: + # keep track of assignment index + if isinstance(element, (ast.AnnAssign, ast.Assign)): + idx += 1 + isExprConstantAfterAssign = False + else: + isExprConstantAfterAssign = ( + isinstance(element, ast.Expr) + and isinstance(element.value, ast.Constant) + and isinstance(prev, (ast.AnnAssign, ast.Assign)) + ) + + if isExprConstantAfterAssign: + arg = None + + if isinstance(prev, ast.AnnAssign): + arg = Arg.fromAstAnnAssign(prev) + elif isinstance(prev, ast.Assign): + # technically, ast.Assign supports a list of _multiple_ + # targets, but for class attributes, multiple targets are + # invalid. take the first target as the attribute name. + args = ArgList.fromAstAssign(prev) + if len(args.infoList) == 1: + arg = args.infoList[0] + + # only add if the var is in the actualArgs and + # not already in docArgs, otherwise, it is a violation + if ( + arg is not None + and ( + shouldDocumentPrivateClassAttributes + or not arg.name.startswith('_') + ) + and actualArgs.contains(arg) + ): + if not allowInlineClassVarDocs: + violations.append( + Violation( + line=element.lineno, + code=606, + msgPrefix=( + f'Class `{node.name}`, Attribute `{arg.name}`:' + ), + ) + ) + else: + # pull the type from the doc comment + arg.typeHint = '' + if argTypeHintsInDocstring: + docComment = cast('str', element.value.value) + if ':' in docComment: + # type hint is before the first colon + # on the first line + arg.typeHint = ( + docComment.split('\n')[0].split(':')[0].strip() + ) + + docArgs.insertAt(idx, arg) + + prev = element + + def extractClassAttributesFromNode( *, node: ast.ClassDef, @@ -254,6 +487,7 @@ def checkNameOrderAndTypeHintsOfDocArgsAgainstActualArgs( shouldCheckArgOrder: bool, argTypeHintsInSignature: bool, argTypeHintsInDocstring: bool, + allowInlineClassVarDocs: bool, lineNum: int, msgPrefix: str, ) -> None: @@ -308,14 +542,19 @@ def checkNameOrderAndTypeHintsOfDocArgsAgainstActualArgs( msgPostfixParts: list[str] = [] string0 = ( - 'Attributes in the class definition but not in the' + 'Attributes in the class definition but not ' if actualArgsAreClassAttributes - else 'Arguments in the function signature but not in the' + else 'Arguments in the function signature but not ' ) if argsInFuncNotInDoc: + if allowInlineClassVarDocs and actualArgsAreClassAttributes: + string0 += 'documented inline:' + else: + string0 += 'in the docstring:' + msgPostfixParts.append( - string0 + f' docstring: {sorted(argsInFuncNotInDoc)}.' + string0 + f' {sorted(argsInFuncNotInDoc)}.' ) string1 = ( diff --git a/pydoclint/visitor.py b/pydoclint/visitor.py index 60a9ec55..35e37bb2 100644 --- a/pydoclint/visitor.py +++ b/pydoclint/visitor.py @@ -82,6 +82,7 @@ def __init__( shouldDocumentPrivateClassAttributes: bool = False, treatPropertyMethodsAsClassAttributes: bool = False, onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, + allowInlineClassVarDocs: bool = True, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, shouldDocumentStarArguments: bool = True, @@ -111,6 +112,7 @@ def __init__( self.onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = ( onlyAttrsWithClassVarAreTreatedAsClassAttrs ) + self.allowInlineClassVarDocs: bool = allowInlineClassVarDocs self.requireReturnSectionWhenReturningNothing: bool = ( requireReturnSectionWhenReturningNothing ) @@ -163,6 +165,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: D102 onlyAttrsWithClassVarAreTreatedAsClassAttrs=( self.onlyAttrsWithClassVarAreTreatedAsClassAttrs ), + allowInlineClassVarDocs=self.allowInlineClassVarDocs, checkArgDefaults=self.checkArgDefaults, ) @@ -631,6 +634,7 @@ def checkArguments( # noqa: C901, PLR0915 shouldCheckArgOrder=self.checkArgOrder, argTypeHintsInSignature=considerArgTypeHintsInSignature, argTypeHintsInDocstring=considerArgTypeHintsInDocstring, + allowInlineClassVarDocs=self.allowInlineClassVarDocs, lineNum=lineNum, msgPrefix=msgPrefix, ) diff --git a/tests/test_baseline.py b/tests/test_baseline.py index f478652d..dc8b914b 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -50,7 +50,7 @@ def testBaselineCreation(baselineFile: Path, style: str) -> None: len(violations) == 0 for filename, violations in remainingViolationsInAllFiles.items() ) - assert len(unfixedBaselineViolationsInAllFiles) == 34 + assert len(unfixedBaselineViolationsInAllFiles) == 35 badDocstringFunction = ''' diff --git a/tests/test_data/google/class_attributes/inline_docstring.py b/tests/test_data/google/class_attributes/inline_docstring.py new file mode 100644 index 00000000..f5a732a3 --- /dev/null +++ b/tests/test_data/google/class_attributes/inline_docstring.py @@ -0,0 +1,53 @@ +class MyClass1: + """Simple class that has an inline docstring for its attributes.""" + + field1: int = 5 + """int: An integer field with a default value.""" + + field2: str = "default" + """str: A string field with a default value.""" + + field3: list[int] + """list[int]: A list of integers field without a default value.""" + + +class MyClass2: + """Mix of documented and undocumented attributes.""" + + documented_field: float = 3.14 + """float: A documented float field.""" + + undocumented_field: bool = True + + +class MyClass3: + """ + Mix of inline and block docstrings for attributes. + + This should cause violation DOC606 or DOC607, depending on the setting for inline docstrings. + + Attributes: + field1 (int): First field with block docstring. + field5: Fifth field with block docstring. + """ + + field1: int = 10 + + field2: str = "hello" + """str: Second field with inline docstring.""" + + field3: int = 5 + """int: Third field with inline docstring.""" + + field4: bool + """Fourth field with inline docstring, but no default value.""" + + field5: bool = False + """bool: Fifth field with inline docstring.""" + + +class MyClass4: + """Differing type hint and inline docstring types.""" + + field1: str = "not an int" + """int: This field is actually a string, not an int.""" diff --git a/tests/test_data/numpy/class_attributes/inline_docstring.py b/tests/test_data/numpy/class_attributes/inline_docstring.py new file mode 100644 index 00000000..f5e953d1 --- /dev/null +++ b/tests/test_data/numpy/class_attributes/inline_docstring.py @@ -0,0 +1,59 @@ +class MyClass1: + """Simple class that has an inline docstring for its attributes.""" + + field1: int = 5 + """int: An integer field with a default value.""" + + field2: str = "default" + """str: A string field with a default value.""" + + field3: list[int] + """list[int]: A list of integers field without a default value.""" + + +class MyClass2: + """Mix of documented and undocumented attributes.""" + + documented_field: float = 3.14 + """float: A documented float field.""" + + undocumented_field: bool = True + + +class MyClass3: + """ + Mix of inline and block docstrings for attributes. + + This should cause violation DOC606 or DOC607, depending on the setting for inline docstrings. + + Properties + ---------- + + Attributes + ---------- + field1 : int + First field with block docstring. + field5 + Fifth field with block docstring. + """ + + field1: int = 10 + + field2: str = "hello" + """str: Second field with inline docstring.""" + + field3: int = 5 + """int: Third field with inline docstring.""" + + field4: bool + """Fourth field with inline docstring, but no default value.""" + + field5: bool = False + """bool: Fifth field with inline docstring.""" + + +class MyClass4: + """Differing type hint and inline docstring types.""" + + field1: str = "not an int" + """int: This field is actually a string, not an int.""" diff --git a/tests/test_data/sphinx/class_attributes/inline_docstring.py b/tests/test_data/sphinx/class_attributes/inline_docstring.py new file mode 100644 index 00000000..45711a10 --- /dev/null +++ b/tests/test_data/sphinx/class_attributes/inline_docstring.py @@ -0,0 +1,55 @@ +class MyClass1: + """Simple class that has an inline docstring for its attributes.""" + + field1: int = 5 + """int: An integer field with a default value.""" + + field2: str = "default" + """str: A string field with a default value.""" + + field3: list[int] + """list[int]: A list of integers field without a default value.""" + + +class MyClass2: + """Mix of documented and undocumented attributes.""" + + documented_field: float = 3.14 + """float: A documented float field.""" + + undocumented_field: bool = True + + +class MyClass3: + """ + Mix of inline and block docstrings for attributes. + + This should cause violation DOC606 or DOC607, depending on the setting for inline docstrings. + + .. attribute :: field1 + :type: int + First field with block docstring. + .. attribute :: field5 + Fifth field with block docstring. + """ + + field1: int = 10 + + field2: str = "hello" + """str: Second field with inline docstring.""" + + field3: int = 5 + """int: Third field with inline docstring.""" + + field4: bool + """Fourth field with inline docstring, but no default value.""" + + field5: bool = False + """bool: Fifth field with inline docstring.""" + + +class MyClass4: + """Differing attribute and inline docstring types.""" + + field1: str = "not an int" + """int: This field is actually a string, not an int.""" diff --git a/tests/test_main.py b/tests/test_main.py index 0a7df3a1..0b16d76c 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1544,3 +1544,220 @@ def testArgDefaults( list(map(str, violations)) == expectedViolationsLookup[checkArgDefaults] ) + + +@pytest.mark.parametrize( + ('style', 'allowInlineClassVarDocs', 'argTypeHintsInDocstring'), + list( + itertools.product( + ['google', 'numpy', 'sphinx'], [False, True], [False, True] + ) + ), +) +def testInlineClassAttributeDocs( + style: str, + allowInlineClassVarDocs: bool, + argTypeHintsInDocstring: bool, +) -> None: + violations = _checkFile( + filename=DATA_DIR / style / 'class_attributes/inline_docstring.py', + style=style, + allowInlineClassVarDocs=allowInlineClassVarDocs, + argTypeHintsInDocstring=argTypeHintsInDocstring, + skipCheckingShortDocstrings=False, + ) + + # (allowInlineClassVarDocs, argTypeHintsInDocstring) -> expected violations + expectedViolationsLookup: dict[tuple[bool, bool], list[str]] = { + (False, False): [ + 'DOC606: Class `MyClass1`, Attribute `field1`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass1`, Attribute `field2`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass1`, Attribute `field3`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass1`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass1`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[field1: int, field2: str, field3: list[int]]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC606: Class `MyClass2`, Attribute `documented_field`: Attribute has an ' + 'inline docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass2`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass2`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[documented_field: float, undocumented_field: bool]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC606: Class `MyClass3`, Attribute `field2`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass3`, Attribute `field3`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass3`, Attribute `field4`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass3`, Attribute `field5`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass3`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass3`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[field2: str, field3: int, field4: bool]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC606: Class `MyClass4`, Attribute `field1`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass4`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass4`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[field1: str]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + ], + (False, True): [ + 'DOC606: Class `MyClass1`, Attribute `field1`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass1`, Attribute `field2`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass1`, Attribute `field3`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass1`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass1`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[field1: int, field2: str, field3: list[int]]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC606: Class `MyClass2`, Attribute `documented_field`: Attribute has an ' + 'inline docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass2`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass2`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[documented_field: float, undocumented_field: bool]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC606: Class `MyClass3`, Attribute `field2`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass3`, Attribute `field3`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass3`, Attribute `field4`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC606: Class `MyClass3`, Attribute `field5`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass3`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass3`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[field2: str, field3: int, field4: bool]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC606: Class `MyClass4`, Attribute `field1`: Attribute has an inline ' + 'docstring, but should be documented in the class docstring instead.', + 'DOC601: Class `MyClass4`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass4`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not in the docstring: ' + '[field1: str]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + ], + (True, False): [ + 'DOC601: Class `MyClass2`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass2`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not documented inline: ' + '[undocumented_field: bool]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC607: Class `MyClass3`: The class docstring does not need an "Attributes" ' + 'section, because the class attributes are documented inline.', + 'DOC601: Class `MyClass3`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass3`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not documented inline: ' + '[field1: int]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + ], + (True, True): [ + 'DOC601: Class `MyClass2`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass2`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not documented inline: ' + '[undocumented_field: bool]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC607: Class `MyClass3`: The class docstring does not need an "Attributes" ' + 'section, because the class attributes are documented inline.', + 'DOC601: Class `MyClass3`: Class docstring contains fewer class attributes ' + 'than actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `MyClass3`: Class docstring attributes are different from ' + 'actual class attributes. (Or could be other formatting issues: ' + 'https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). ' + 'Attributes in the class definition but not documented inline: ' + '[field1: int]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC605: Class `MyClass4`: Attribute names match, but type hints in these ' + 'attributes do not match: field1 (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + ], + } + + assert ( + list(map(str, violations)) + == expectedViolationsLookup[ + allowInlineClassVarDocs, argTypeHintsInDocstring + ] + ) diff --git a/tests/utils/test_arg.py b/tests/utils/test_arg.py index 14af9fcf..adaf27d1 100644 --- a/tests/utils/test_arg.py +++ b/tests/utils/test_arg.py @@ -346,3 +346,75 @@ def testArgList_subtract( expected: set[Arg], ) -> None: assert obj1.subtract(obj2) == expected + + +@pytest.mark.parametrize( + ('initialArgList', 'index', 'argToInsert', 'expected'), + [ + ( + ArgList([Arg('a', '1'), Arg('c', '3')]), + 1, + Arg('b', '2'), + ArgList([Arg('a', '1'), Arg('b', '2'), Arg('c', '3')]), + ), + ( + ArgList([Arg('b', '2'), Arg('c', '3')]), + 0, + Arg('a', '1'), + ArgList([Arg('a', '1'), Arg('b', '2'), Arg('c', '3')]), + ), + ( + ArgList([Arg('a', '1'), Arg('b', '2')]), + 2, + Arg('c', '3'), + ArgList([Arg('a', '1'), Arg('b', '2'), Arg('c', '3')]), + ), + ( + ArgList([Arg('a', '1'), Arg('c', '3')]), + -1, # this means "second to last position" + Arg('b', '2'), + ArgList([Arg('a', '1'), Arg('b', '2'), Arg('c', '3')]), + ), + ( + ArgList([Arg('a', '1'), Arg('c', '3')]), + -2, # this means "third to last position" + Arg('b', '2'), + ArgList([Arg('b', '2'), Arg('a', '1'), Arg('c', '3')]), + ), + ( + ArgList([Arg('a', '1'), Arg('c', '3')]), + -123456, + Arg('b', '2'), + ArgList([Arg('b', '2'), Arg('a', '1'), Arg('c', '3')]), + ), + ( + ArgList([Arg('a', '1'), Arg('b', '2')]), + 999, + Arg('c', '3'), + ArgList([Arg('a', '1'), Arg('b', '2'), Arg('c', '3')]), + ), + ], +) +def testArgList_insertAt( + initialArgList: ArgList, + index: int, + argToInsert: Arg, + expected: ArgList, +) -> None: + initialArgList.insertAt(index, argToInsert) + assert initialArgList == expected + + +@pytest.mark.parametrize('index', [1.5, -1.2]) +def testArgList_insertAt_nonIntegerIndex(index: float) -> None: + argList = ArgList([Arg('a', '1'), Arg('b', '2')]) + + with pytest.raises(TypeError, match='integer'): + argList.insertAt(index, Arg('c', '3')) # type: ignore[arg-type] + + +def testArgList_insertAt_duplicateNameRaisesError() -> None: + argList = ArgList([Arg('a', '1'), Arg('b', '2')]) + + with pytest.raises(ValueError, match='Arg with name "a" already exists'): + argList.insertAt(1, Arg('a', '999')) diff --git a/tests/utils/test_visitor_helper.py b/tests/utils/test_visitor_helper.py index 2e8ee3a2..55e0276c 100644 --- a/tests/utils/test_visitor_helper.py +++ b/tests/utils/test_visitor_helper.py @@ -1,4 +1,5 @@ import ast +from typing import TYPE_CHECKING import pytest @@ -8,8 +9,13 @@ extractClassAttributesFromNode, extractReturnTypeFromGenerator, extractYieldTypeFromGeneratorOrIteratorAnnotation, + getDocumentedAndActualClassArgLists, + updateDocumentedArgListWithInlineDocstrings, ) +if TYPE_CHECKING: + from pydoclint.utils.violation import Violation + @pytest.mark.parametrize( ('returnAnnoText', 'hasGen', 'hasIter', 'expected'), @@ -481,3 +487,534 @@ class MyClass: checkArgDefaults=False, ) assert extracted == expected + + +src1 = ''' +class SimpleInlineDoc: + """A class for testing and experimenting with code snippets.""" + + field1 = 5 + """Inline documentation for classvar.""" +''' + +src2 = ''' +class InlineDocAfter: + """Another class for testing.""" + + field1: int = 10 + """int: Inline documentation for classvar.""" + + field2: str + """str: Inline documentation for an instance variable.""" + + field3: int = 5 + """int: Inline documentation for another classvar.""" +''' + +src3 = ''' +class MixedAttributeDoc: + """ + A class with mixed attribute documentation styles. + + {attribute_documentation}Documentation for field2 using the attribute directive. + """ + + field1: int = 42 + """int: Documentation for field1 placed after the declaration.""" + + field2: str = "str" +''' + +sphinx_attr_doc = '.. attribute :: field2\n \n:type: str\n' +google_attr_doc = 'Attributes:\n\tfield2 (str): ' +# the Parameters section is required in numpy style for attribute docs +# even if it is empty +numpy_attr_doc = ( + 'Parameters\n----------\n\nAttributes\n----------\nfield2 : str\n\t' +) + + +@pytest.mark.parametrize( + ('src', 'style', 'attribute_documentation', 'expected_violations'), + [ + (src1, 'sphinx', None, []), + (src1, 'google', None, []), + (src1, 'numpy', None, []), + (src2, 'sphinx', None, []), + (src2, 'google', None, []), + (src2, 'numpy', None, []), + ( + src3, + 'sphinx', + sphinx_attr_doc, + [607], + ), + ( + src3, + 'google', + google_attr_doc, + [607], + ), + ( + src3, + 'numpy', + numpy_attr_doc, + [607], + ), + ], +) +@pytest.mark.parametrize('argTypeHintsInDocstring', [True, False]) +def testAllowInlineClassvarDocs( + src: str, + style: str, + attribute_documentation: str | None, + argTypeHintsInDocstring: bool, + expected_violations: list[int], +) -> None: + final_src = src.format( + attribute_documentation=attribute_documentation or '' + ) + parsed = ast.parse(final_src) + node = parsed.body[0] + assert isinstance(node, ast.ClassDef) + + violations: list[Violation] = [] + attrs = getDocumentedAndActualClassArgLists( + node=node, + style=style, + shouldDocumentPrivateClassAttributes=False, + treatPropertyMethodsAsClassAttributes=False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=False, + checkArgDefaults=False, + violations=violations, + skipCheckingShortDocstrings=False, + allowInlineClassVarDocs=True, + argTypeHintsInDocstring=argTypeHintsInDocstring, + ) + assert attrs is not None + assert [v.code for v in violations] == expected_violations + + +expected_src1 = [Arg(name='field1', typeHint='')] +expected_src2 = [ + Arg(name='field1', typeHint='int'), + Arg(name='field2', typeHint='str'), + Arg(name='field3', typeHint='int'), +] +expected_doc_src3 = [Arg(name='field1', typeHint='int')] +expected_actual_src3 = [ + Arg(name='field1', typeHint='int'), + Arg(name='field2', typeHint='str'), +] + +src4 = ''' +class DocstringMismatch: + """A class where some of the inline docstrings are missing.""" + + field1: int = 42 + + field2: str = "str" + """Documentation for field2""" +''' + +src5 = ''' +class DocstringTypeMismatch: + """A class where the inline docstring types do not match the annotation.""" + + field1: int = 42 + """str: This field is actually a string, not an int.""" +''' + +src4_expected_doc = [Arg(name='field2', typeHint='')] +src4_expected_actual = [ + Arg(name='field1', typeHint='int'), + Arg(name='field2', typeHint='str'), +] +src5_expected_doc = [Arg(name='field1', typeHint='str')] +src5_expected_actual = [Arg(name='field1', typeHint='int')] + + +@pytest.mark.parametrize( + ( + 'src', + 'style', + 'attribute_documentation', + 'expected_docargs', + 'expected_actualargs', + 'expected_violations', + ), + [ + ( + src1, + 'sphinx', + None, + expected_src1, + expected_src1, + [], + ), + ( + src1, + 'google', + None, + expected_src1, + expected_src1, + [], + ), + ( + src1, + 'numpy', + None, + expected_src1, + expected_src1, + [], + ), + (src2, 'sphinx', None, expected_src2, expected_src2, []), + (src2, 'google', None, expected_src2, expected_src2, []), + (src2, 'numpy', None, expected_src2, expected_src2, []), + ( + src3, + 'sphinx', + sphinx_attr_doc, + expected_doc_src3, + expected_actual_src3, + # expect that DOC607 is expected, because we should be enforcing all inline docstrings + [607], + ), + ( + src3, + 'google', + google_attr_doc, + expected_doc_src3, + expected_actual_src3, + [607], + ), + ( + src3, + 'numpy', + numpy_attr_doc, + expected_doc_src3, + expected_actual_src3, + [607], + ), + ( + src4, + 'sphinx', + None, + src4_expected_doc, + src4_expected_actual, + [], + ), + ( + src4, + 'google', + None, + src4_expected_doc, + src4_expected_actual, + [], + ), + ( + src4, + 'numpy', + None, + src4_expected_doc, + src4_expected_actual, + [], + ), + ( + src5, + 'sphinx', + None, + src5_expected_doc, + src5_expected_actual, + # a violation is not thrown at this level for mismatched types + [], + ), + ( + src5, + 'google', + None, + src5_expected_doc, + src5_expected_actual, + # a violation is not thrown at this level for mismatched types + [], + ), + ( + src5, + 'numpy', + None, + src5_expected_doc, + src5_expected_actual, + # a violation is not thrown at this level for mismatched types + [], + ), + ], +) +def testGetDocumentedAndActualClassArgListsWithInlineClassVarDocs( + src: str, + style: str, + attribute_documentation: str | None, + expected_docargs: list[Arg], + expected_actualargs: list[Arg], + expected_violations: list[int], +) -> None: + final_src = src.format( + attribute_documentation=attribute_documentation or '' + ) + parsed = ast.parse(final_src) + node = parsed.body[0] + assert isinstance(node, ast.ClassDef) + + violations: list[Violation] = [] + docArgs, actualArgs = getDocumentedAndActualClassArgLists( + node=node, + style=style, + shouldDocumentPrivateClassAttributes=False, + treatPropertyMethodsAsClassAttributes=False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=False, + checkArgDefaults=False, + violations=violations, + skipCheckingShortDocstrings=False, + allowInlineClassVarDocs=True, + argTypeHintsInDocstring=True, + ) or (None, None) + + assert [v.code for v in violations] == expected_violations + assert docArgs == ArgList(expected_docargs) + assert actualArgs == ArgList(expected_actualargs) + + +expected_doc_src3_no_inline = [Arg(name='field2', typeHint='str')] +expected_actual_src3_no_inline = [ + Arg(name='field1', typeHint='int'), + Arg(name='field2', typeHint='str'), +] + + +@pytest.mark.parametrize( + ( + 'src', + 'style', + 'attribute_documentation', + 'expected_docargs', + 'expected_actualargs', + 'expected_violations', + ), + [ + ( + src3, + 'sphinx', + sphinx_attr_doc, + expected_doc_src3_no_inline, + expected_actual_src3_no_inline, + # Expect DOC606, since we do not allow inline docstrings + [606], + ), + ( + src3, + 'google', + google_attr_doc, + expected_doc_src3_no_inline, + expected_actual_src3_no_inline, + # Expect DOC606, since we do not allow inline docstrings + [606], + ), + ( + src3, + 'numpy', + numpy_attr_doc, + expected_doc_src3_no_inline, + expected_actual_src3_no_inline, + # Expect DOC606, since we do not allow inline docstrings + [606], + ), + ], +) +def testGetDocumentedAndActualClassArgListsWithoutInlinveClassVarDocs( + src: str, + style: str, + attribute_documentation: str | None, + expected_docargs: list[Arg], + expected_actualargs: list[Arg], + expected_violations: list[int], +) -> None: + final_src = src.format( + attribute_documentation=attribute_documentation or '' + ) + parsed = ast.parse(final_src) + node = parsed.body[0] + assert isinstance(node, ast.ClassDef) + + violations: list[Violation] = [] + docArgs, actualArgs = getDocumentedAndActualClassArgLists( + node=node, + style=style, + shouldDocumentPrivateClassAttributes=False, + treatPropertyMethodsAsClassAttributes=False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=False, + checkArgDefaults=False, + violations=violations, + skipCheckingShortDocstrings=False, + allowInlineClassVarDocs=False, + argTypeHintsInDocstring=True, + ) or (None, None) + + assert [v.code for v in violations] == expected_violations + assert docArgs == ArgList(expected_docargs) + assert actualArgs == ArgList(expected_actualargs) + + +@pytest.mark.parametrize( + ( + 'src', + 'initial_docArgs', + 'allowInlineClassVarDocs', + 'shouldDocumentPrivateClassAttributes', + 'argTypeHintsInDocstring', + 'expected_docargs', + 'expected_violations', + ), + [ + # src1: Simple inline doc without type hints + ( + src1, + ArgList([]), + True, + False, + True, + [Arg(name='field1', typeHint='')], + [], + ), + ( + src1, + ArgList([]), + True, + False, + False, + [Arg(name='field1', typeHint='')], + [], + ), + ( + src1, + ArgList([]), + False, + False, + True, + [], + [606], + ), + # src2: Multiple inline docs with type hints + ( + src2, + ArgList([]), + True, + False, + True, + [ + Arg(name='field1', typeHint='int'), + Arg(name='field2', typeHint='str'), + Arg(name='field3', typeHint='int'), + ], + [], + ), + ( + src2, + ArgList([]), + True, + False, + False, + [ + Arg(name='field1', typeHint=''), + Arg(name='field2', typeHint=''), + Arg(name='field3', typeHint=''), + ], + [], + ), + ( + src2, + ArgList([]), + False, + False, + True, + [], + [606, 606, 606], + ), + # src4: Partial inline docs (field1 has no doc, field2 has doc) + ( + src4, + ArgList([]), + True, + False, + True, + [Arg(name='field2', typeHint='')], + [], + ), + ( + src4, + ArgList([]), + False, + False, + True, + [], + [606], + ), + # src5: Type mismatch between annotation and inline doc + ( + src5, + ArgList([]), + True, + False, + True, + [Arg(name='field1', typeHint='str')], + [], + ), + ( + src5, + ArgList([]), + True, + False, + False, + [Arg(name='field1', typeHint='')], + [], + ), + ], +) +def testUpdateDocumentedArgListWithInlineDocstrings( + src: str, + initial_docArgs: ArgList, + allowInlineClassVarDocs: bool, + shouldDocumentPrivateClassAttributes: bool, + argTypeHintsInDocstring: bool, + expected_docargs: list[Arg], + expected_violations: list[int], +) -> None: + parsed = ast.parse(src) + node = parsed.body[0] + assert isinstance(node, ast.ClassDef) + + # Extract actual args from the class + actualArgs = extractClassAttributesFromNode( + node=node, + shouldDocumentPrivateClassAttributes=shouldDocumentPrivateClassAttributes, + treatPropertyMethodsAsClassAttrs=False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=False, + checkArgDefaults=False, + ) + + # Start with initial docArgs + docArgs = ArgList(list(initial_docArgs.infoList)) + violations: list[Violation] = [] + + # Call the function to test + updateDocumentedArgListWithInlineDocstrings( + node=node, + docArgs=docArgs, + actualArgs=actualArgs, + shouldDocumentPrivateClassAttributes=shouldDocumentPrivateClassAttributes, + argTypeHintsInDocstring=argTypeHintsInDocstring, + allowInlineClassVarDocs=allowInlineClassVarDocs, + violations=violations, + ) + + # Verify results + assert [v.code for v in violations] == expected_violations + assert docArgs == ArgList(expected_docargs)