From da14ebd4bf2d635011a4bdb516f73c9b0a92cf71 Mon Sep 17 00:00:00 2001 From: jsh9 <25124332+jsh9@users.noreply.github.com> Date: Sun, 15 Dec 2024 20:42:10 -0500 Subject: [PATCH] Add new config option to treat ClassVar attrs as class attrs (#188) --- CHANGELOG.md | 2 + docs/config_options.md | 23 +++++--- pydoclint/flake8_entry.py | 16 +++++ pydoclint/main.py | 25 ++++++++ pydoclint/utils/visitor_helper.py | 24 +++++++- pydoclint/visitor.py | 9 ++- tests/data/edge_cases/17_ClassVar/cases.py | 53 +++++++++++++++++ tests/test_main.py | 68 ++++++++++++++++++++++ tests/utils/test_visitor_helper.py | 46 +++++++++++++++ 9 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 tests/data/edge_cases/17_ClassVar/cases.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 437ce3b..e20ba2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - Use "modern" type annotation, such as `list` and `str | None` - Added - Added static type checking using `mypy` + - A new config option, + `--only-attrs-with-ClassVar-are-treated-as-class-attrs` ## [0.5.11] - 2024-12-14 diff --git a/docs/config_options.md b/docs/config_options.md index 24441a3..c82160e 100644 --- a/docs/config_options.md +++ b/docs/config_options.md @@ -27,10 +27,11 @@ page: - [14. `--check-class-attributes` (shortform: `-cca`, default: `True`)](#14---check-class-attributes-shortform--cca-default-true) - [15. `--should-document-private-class-attributes` (shortform: `-sdpca`, default: `False`)](#15---should-document-private-class-attributes-shortform--sdpca-default-false) - [16. `--treat-property-methods-as-class-attributes` (shortform: `-tpmaca`, default: `False`)](#16---treat-property-methods-as-class-attributes-shortform--tpmaca-default-false) -- [17. `--baseline`](#17---baseline) -- [18. `--generate-baseline` (default: `False`)](#18---generate-baseline-default-false) -- [19. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#19---show-filenames-in-every-violation-message-shortform--sfn-default-false) -- [20. `--config` (default: `pyproject.toml`)](#20---config-default-pyprojecttoml) +- [17. `--only-attrs-with-ClassVar-are-treated-as-class-attrs` (shortform: `-oawcv`, default: `False)](#17---only-attrs-with-classvar-are-treated-as-class-attrs-shortform--oawcv-default-false) +- [18. `--baseline`](#18---baseline) +- [19. `--generate-baseline` (default: `False`)](#19---generate-baseline-default-false) +- [20. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`)](#20---show-filenames-in-every-violation-message-shortform--sfn-default-false) +- [21. `--config` (default: `pyproject.toml`)](#21---config-default-pyprojecttoml) @@ -202,7 +203,13 @@ need to be documented in the "Attributes" section of the class docstring, and there cannot be any docstring under the @property methods. This option is only effective when --check-class-attributes is True. -## 17. `--baseline` +## 17. `--only-attrs-with-ClassVar-are-treated-as-class-attrs` (shortform: `-oawcv`, default: `False) + +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. + +## 18. `--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 @@ -222,12 +229,12 @@ project. If `--generate-baseline` is not passed (default value is `False`), _pydoclint_ will read your baseline file, and ignore all violations specified in that file. -## 18. `--generate-baseline` (default: `False`) +## 19. `--generate-baseline` (default: `False`) Required to use with `--baseline` option. If `True`, generate the baseline file that contains all current violations. -## 19. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) +## 20. `--show-filenames-in-every-violation-message` (shortform: `-sfn`, default: `False`) If False, in the terminal the violation messages are grouped by file names: @@ -261,7 +268,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.) -## 20. `--config` (default: `pyproject.toml`) +## 21. `--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/pydoclint/flake8_entry.py b/pydoclint/flake8_entry.py index cab2f1a..8b22eb2 100644 --- a/pydoclint/flake8_entry.py +++ b/pydoclint/flake8_entry.py @@ -197,6 +197,19 @@ def add_options(cls, parser: Any) -> None: # noqa: D102 ' this option and --check-class-attributes to True.' ), ) + parser.add_option( + '-oawcv', + '--only-attrs-with-ClassVar-are-treated-as-class-attrs', + action='store', + default='False', + parse_from_config=True, + help=( + '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.' + ), + ) @classmethod def parse_options(cls, options: Any) -> None: # noqa: D102 @@ -229,6 +242,9 @@ def parse_options(cls, options: Any) -> None: # noqa: D102 cls.treat_property_methods_as_class_attributes = ( options.treat_property_methods_as_class_attributes ) + cls.only_attrs_with_ClassVar_are_treated_as_class_attrs = ( + options.only_attrs_with_ClassVar_are_treated_as_class_attrs + ) cls.style = options.style def run(self) -> Generator[tuple[int, int, str, Any], None, None]: diff --git a/pydoclint/main.py b/pydoclint/main.py index 04dbc55..b9a2200 100644 --- a/pydoclint/main.py +++ b/pydoclint/main.py @@ -235,6 +235,19 @@ def validateStyleValue( ' this option and --check-class-attributes to True.' ), ) +@click.option( + '-oawcv', + '--only-attrs-with-ClassVar-are-treated-as-class-attrs', + type=bool, + show_default=True, + default=False, + help=( + '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.' + ), +) @click.option( '--baseline', type=click.Path( @@ -325,6 +338,7 @@ def main( # noqa: C901 require_return_section_when_returning_none: bool, require_return_section_when_returning_nothing: bool, require_yield_section_when_yielding_nothing: bool, + only_attrs_with_classvar_are_treated_as_class_attrs: bool, generate_baseline: bool, baseline: str, show_filenames_in_every_violation_message: bool, @@ -414,6 +428,9 @@ def main( # noqa: C901 treatPropertyMethodsAsClassAttributes=( treat_property_methods_as_class_attributes ), + onlyAttrsWithClassVarAreTreatedAsClassAttrs=( + only_attrs_with_classvar_are_treated_as_class_attrs + ), requireReturnSectionWhenReturningNothing=( require_return_section_when_returning_nothing ), @@ -531,6 +548,7 @@ def _checkPaths( checkClassAttributes: bool = True, shouldDocumentPrivateClassAttributes: bool = False, treatPropertyMethodsAsClassAttributes: bool = False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, quiet: bool = False, @@ -583,6 +601,9 @@ def _checkPaths( treatPropertyMethodsAsClassAttributes=( treatPropertyMethodsAsClassAttributes ), + onlyAttrsWithClassVarAreTreatedAsClassAttrs=( + onlyAttrsWithClassVarAreTreatedAsClassAttrs + ), requireReturnSectionWhenReturningNothing=( requireReturnSectionWhenReturningNothing ), @@ -610,6 +631,7 @@ def _checkFile( checkClassAttributes: bool = True, shouldDocumentPrivateClassAttributes: bool = False, treatPropertyMethodsAsClassAttributes: bool = False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, ) -> list[Violation]: @@ -638,6 +660,9 @@ def _checkFile( treatPropertyMethodsAsClassAttributes=( treatPropertyMethodsAsClassAttributes ), + onlyAttrsWithClassVarAreTreatedAsClassAttrs=( + onlyAttrsWithClassVarAreTreatedAsClassAttrs + ), requireReturnSectionWhenReturningNothing=( requireReturnSectionWhenReturningNothing ), diff --git a/pydoclint/utils/visitor_helper.py b/pydoclint/utils/visitor_helper.py index c08b8a0..ba8f3b8 100644 --- a/pydoclint/utils/visitor_helper.py +++ b/pydoclint/utils/visitor_helper.py @@ -40,6 +40,7 @@ def checkClassAttributesAgainstClassDocstring( skipCheckingShortDocstrings: bool, shouldDocumentPrivateClassAttributes: bool, treatPropertyMethodsAsClassAttributes: bool, + onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool, ) -> None: """Check class attribute list against the attribute list in docstring""" actualArgs: ArgList = extractClassAttributesFromNode( @@ -48,6 +49,9 @@ def checkClassAttributesAgainstClassDocstring( shouldDocumentPrivateClassAttributes ), treatPropertyMethodsAsClassAttrs=treatPropertyMethodsAsClassAttributes, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=( + onlyAttrsWithClassVarAreTreatedAsClassAttrs + ), ) classDocstring: str = getDocstring(node) @@ -126,6 +130,7 @@ def extractClassAttributesFromNode( node: ast.ClassDef, shouldDocumentPrivateClassAttributes: bool, treatPropertyMethodsAsClassAttrs: bool, + onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool, ) -> ArgList: """ Extract class attributes from an AST node. @@ -140,6 +145,11 @@ def extractClassAttributesFromNode( treatPropertyMethodsAsClassAttrs : bool Whether we'd like to treat property methods as class attributes. If ``True``, property methods will be included in the return value. + onlyAttrsWithClassVarAreTreatedAsClassAttrs : bool + 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. Returns ------- @@ -149,7 +159,7 @@ def extractClassAttributesFromNode( Raises ------ EdgeCaseError - When the length of item.targets is 0 + When the length of ``item.targets`` is 0 """ if 'body' not in node.__dict__ or len(node.body) == 0: return ArgList([]) @@ -178,6 +188,18 @@ def extractClassAttributesFromNode( if not shouldDocumentPrivateClassAttributes: atl = [_ for _ in atl if not _.name.startswith('_')] + if onlyAttrsWithClassVarAreTreatedAsClassAttrs: + atl = [ + Arg( + name=_.name, + typeHint=_.typeHint[9:-1], # remove "ClassVar[" and "]" + ) + for _ in atl + if ( + _.typeHint.startswith('ClassVar[') and _.typeHint.endswith(']') + ) + ] + return ArgList(infoList=atl) diff --git a/pydoclint/visitor.py b/pydoclint/visitor.py index 33c8692..d6139e5 100644 --- a/pydoclint/visitor.py +++ b/pydoclint/visitor.py @@ -63,7 +63,8 @@ def __init__( ignoreUnderscoreArgs: bool = True, checkClassAttributes: bool = True, shouldDocumentPrivateClassAttributes: bool = False, - treatPropertyMethodsAsClassAttributes: bool = True, + treatPropertyMethodsAsClassAttributes: bool = False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = False, requireReturnSectionWhenReturningNothing: bool = False, requireYieldSectionWhenYieldingNothing: bool = False, ) -> None: @@ -84,6 +85,9 @@ def __init__( self.treatPropertyMethodsAsClassAttributes: bool = ( treatPropertyMethodsAsClassAttributes ) + self.onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool = ( + onlyAttrsWithClassVarAreTreatedAsClassAttrs + ) self.requireReturnSectionWhenReturningNothing: bool = ( requireReturnSectionWhenReturningNothing ) @@ -115,6 +119,9 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: D102 treatPropertyMethodsAsClassAttributes=( self.treatPropertyMethodsAsClassAttributes ), + onlyAttrsWithClassVarAreTreatedAsClassAttrs=( + self.onlyAttrsWithClassVarAreTreatedAsClassAttrs + ), ) self.generic_visit(node) diff --git a/tests/data/edge_cases/17_ClassVar/cases.py b/tests/data/edge_cases/17_ClassVar/cases.py new file mode 100644 index 0000000..ecbc52c --- /dev/null +++ b/tests/data/edge_cases/17_ClassVar/cases.py @@ -0,0 +1,53 @@ +# This edge case comes from: +# https://github.com/jsh9/pydoclint/issues/140#issuecomment-2426031940 + +from dataclasses import dataclass +from typing import ClassVar + +from attrs import define, field +from pydantic import BaseModel, Field + + +@define +class AttrsClass: + """ + My class. + + Attributes: + a (bool): my class attribute + c (float): This is y + """ + + a: ClassVar[bool] = True # class attribute + b: int # instance attribute + c: float = 1.0 # instance attribute + d: str = field(default='abc') # instance attribute + + +@dataclass +class DataClass: + """ + My class. + + Attributes: + e (ClassVar[bool]): my class attribute + """ + + e: ClassVar[bool] = True # class attribute + f: int # instance attribute + g: float = 1.0 # instance attribute + h: str = field(default='abc') # instance attribute + + +class PydanticClass(BaseModel): + """ + My class. + + Attributes: + i (bool): my class attribute + """ + + i: ClassVar[bool] = True # class attribute + j: int # instance attribute + k: float = 1.0 # instance attribute + l: str = Field(default='abc') # instance attribute diff --git a/tests/test_main.py b/tests/test_main.py index 770985e..ed31ac0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1462,6 +1462,74 @@ def testNonAscii() -> None: ('16_assign_to_attr/cases.py', {'style': 'sphinx'}, []), ('16_assign_to_attr/cases.py', {'style': 'google'}, []), ('16_assign_to_attr/cases.py', {'style': 'numpy'}, []), + ( + '17_ClassVar/cases.py', + { + 'style': 'google', + 'checkClassAttributes': True, + 'onlyAttrsWithClassVarAreTreatedAsClassAttrs': False, + }, + [ + 'DOC601: Class `AttrsClass`: 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 `AttrsClass`: 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: [b: int, d: ' + 'str]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC601: Class `DataClass`: 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 `DataClass`: 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: [f: int, g: ' + 'float, h: str]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC601: Class `PydanticClass`: 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 `PydanticClass`: 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: [j: int, k: ' + 'float, l: str]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + ], + ), + ( + '17_ClassVar/cases.py', + { + 'style': 'google', + 'checkClassAttributes': True, + 'onlyAttrsWithClassVarAreTreatedAsClassAttrs': True, + }, + [ + 'DOC602: Class `AttrsClass`: Class docstring contains more class attributes ' + 'than in actual class attributes. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC603: Class `AttrsClass`: 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 ). ' + 'Arguments in the docstring but not in the actual class attributes: [c: ' + 'float]. (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + 'DOC605: Class `DataClass`: Attribute names match, but type hints in these ' + 'attributes do not match: e (Please read ' + 'https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to ' + 'correctly document class attributes.)', + ], + ), ], ) def testEdgeCases( diff --git a/tests/utils/test_visitor_helper.py b/tests/utils/test_visitor_helper.py index 67346db..fffe186 100644 --- a/tests/utils/test_visitor_helper.py +++ b/tests/utils/test_visitor_helper.py @@ -283,5 +283,51 @@ def nonProperty(self) -> int: node=parsed.body[0], shouldDocumentPrivateClassAttributes=docPriv, treatPropertyMethodsAsClassAttrs=treatProp, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=False, + ) + assert extracted == expected + + +@pytest.mark.parametrize( + 'onlyAttrsWithClassVarAreTreatedAsClassAttrs, expected', + [ + ( + True, + ArgList( + [Arg(name='a', typeHint='int'), Arg(name='c', typeHint='str')] + ), + ), + ( + False, + ArgList([ + Arg(name='a', typeHint='ClassVar[int]'), + Arg(name='b', typeHint='bool'), + Arg(name='c', typeHint='ClassVar[str]'), + Arg(name='d', typeHint='float'), + ]), + ), + ], +) +def testExtractClassAttributesFromNode_ClassVarOnly( + onlyAttrsWithClassVarAreTreatedAsClassAttrs: bool, + expected: ArgList, +) -> None: + code: str = """ +from typing import ClassVar + +class MyClass: + a: ClassVar[int] + b: bool + c: ClassVar[str] + d: float = 1.0 +""" + parsed = ast.parse(code) + extracted: ArgList = extractClassAttributesFromNode( + node=parsed.body[1], + shouldDocumentPrivateClassAttributes=False, + treatPropertyMethodsAsClassAttrs=False, + onlyAttrsWithClassVarAreTreatedAsClassAttrs=( + onlyAttrsWithClassVarAreTreatedAsClassAttrs + ), ) assert extracted == expected