Skip to content

Commit

Permalink
Add mypy static type checking to pydoclint (#185)
Browse files Browse the repository at this point in the history
  • Loading branch information
jsh9 authored Dec 15, 2024
1 parent c9d017f commit 903aa91
Show file tree
Hide file tree
Showing 16 changed files with 149 additions and 101 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- Changed
- Dropped support for Python 3.8
- Added
- Added static type checking using `mypy`

## [0.5.11] - 2024-12-14

Expand Down
6 changes: 4 additions & 2 deletions pydoclint/flake8_entry.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# mypy: disable-error-code=attr-defined

import ast
import importlib.metadata as importlib_metadata
from typing import Any, Generator, Tuple
Expand All @@ -15,7 +17,7 @@ def __init__(self, tree: ast.AST) -> None:
self._tree = tree

@classmethod
def add_options(cls, parser): # noqa: D102
def add_options(cls, parser: Any) -> None: # noqa: D102
parser.add_option(
'--style',
action='store',
Expand Down Expand Up @@ -196,7 +198,7 @@ def add_options(cls, parser): # noqa: D102
)

@classmethod
def parse_options(cls, options): # noqa: D102
def parse_options(cls, options: Any) -> None: # noqa: D102
cls.type_hints_in_signature = options.type_hints_in_signature
cls.type_hints_in_docstring = options.type_hints_in_docstring
cls.arg_type_hints_in_signature = options.arg_type_hints_in_signature
Expand Down
2 changes: 1 addition & 1 deletion pydoclint/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ def main( # noqa: C901
ctx.exit(1)

# it means users supply this option
if require_return_section_when_returning_none != 'None':
if require_return_section_when_returning_none != 'None': # type:ignore[comparison-overlap]
click.echo(
click.style(
''.join([
Expand Down
6 changes: 3 additions & 3 deletions pydoclint/parse_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ def findCommonParentFolder(
makeAbsolute: bool = True, # allow makeAbsolute=False just for testing
) -> Path:
"""Find the common parent folder of the given ``paths``"""
paths = [Path(path) for path in paths]
paths_: Sequence[Path] = [Path(path) for path in paths]

common_parent = paths[0]
for path in paths[1:]:
common_parent = paths_[0]
for path in paths_[1:]:
if len(common_parent.parts) > len(path.parts):
common_parent, path = path, common_parent

Expand Down
35 changes: 23 additions & 12 deletions pydoclint/utils/arg.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def __repr__(self) -> str:
def __str__(self) -> str:
return f'{self.name}: {self.typeHint}'

def __eq__(self, other: 'Arg') -> bool:
def __eq__(self, other: object) -> bool:
if not isinstance(other, Arg):
return False

Expand Down Expand Up @@ -84,17 +84,22 @@ def fromDocstringAttr(cls, attr: DocstringAttr) -> 'Arg':
@classmethod
def fromAstArg(cls, astArg: ast.arg) -> 'Arg':
"""Construct an Arg object from a Python AST argument object"""
anno = astArg.annotation
typeHint: str = '' if anno is None else unparseName(anno)
anno: Optional[ast.expr] = astArg.annotation
typeHint: Optional[str] = '' if anno is None else unparseName(anno)
assert typeHint is not None # to help mypy better understand type
return Arg(name=astArg.arg, typeHint=typeHint)

@classmethod
def fromAstAnnAssign(cls, astAnnAssign: ast.AnnAssign) -> 'Arg':
"""Construct an Arg object from a Python ast.AnnAssign object"""
return Arg(
name=unparseName(astAnnAssign.target),
typeHint=unparseName(astAnnAssign.annotation),
)
unparsedArgName = unparseName(astAnnAssign.target)
unparsedTypeHint = unparseName(astAnnAssign.annotation)

# These assertions are to help mypy better interpret types
assert unparsedArgName is not None
assert unparsedTypeHint is not None

return Arg(name=unparsedArgName, typeHint=unparsedTypeHint)

@classmethod
def _str(cls, typeName: Optional[str]) -> str:
Expand All @@ -113,12 +118,12 @@ def _typeHintsEq(cls, hint1: str, hint2: str) -> bool:
# >>> "ghi",
# >>> ]
try:
hint1_: str = unparseName(ast.parse(stripQuotes(hint1)))
hint1_: str = unparseName(ast.parse(stripQuotes(hint1))) # type:ignore[arg-type,assignment]
except SyntaxError:
hint1_ = hint1

try:
hint2_: str = unparseName(ast.parse(stripQuotes(hint2)))
hint2_: str = unparseName(ast.parse(stripQuotes(hint2))) # type:ignore[arg-type,assignment]
except SyntaxError:
hint2_ = hint2

Expand Down Expand Up @@ -156,7 +161,7 @@ def __repr__(self) -> str:
def __str__(self) -> str:
return '[' + ', '.join(str(_) for _ in self.infoList) + ']'

def __eq__(self, other: 'ArgList') -> bool:
def __eq__(self, other: object) -> bool:
if not isinstance(other, ArgList):
return False

Expand Down Expand Up @@ -221,7 +226,9 @@ def fromAstAssign(cls, astAssign: ast.Assign) -> 'ArgList':
elif isinstance(target, ast.Name): # such as `a = 1` or `a = b = 2`
infoList.append(Arg(name=target.id, typeHint=''))
elif isinstance(target, ast.Attribute): # e.g., uvw.xyz = 1
infoList.append(Arg(name=unparseName(target), typeHint=''))
unparsedTarget: Optional[str] = unparseName(target)
assert unparsedTarget is not None # to help mypy understand type
infoList.append(Arg(name=unparsedTarget, typeHint=''))
else:
raise EdgeCaseError(
f'astAssign.targets[{i}] is of type {type(target)}'
Expand Down Expand Up @@ -303,7 +310,11 @@ def findArgsWithDifferentTypeHints(self, other: 'ArgList') -> List[Arg]:

return result

def subtract(self, other: 'ArgList', checkTypeHint=True) -> Set[Arg]:
def subtract(
self,
other: 'ArgList',
checkTypeHint: bool = True,
) -> Set[Arg]:
"""Find the args that are in this object but not in `other`."""
if checkTypeHint:
return set(self.infoList) - set(other.infoList)
Expand Down
9 changes: 0 additions & 9 deletions pydoclint/utils/astTypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,6 @@

FuncOrAsyncFuncDef = Union[ast.AsyncFunctionDef, ast.FunctionDef]
ClassOrFunctionDef = Union[ast.ClassDef, ast.AsyncFunctionDef, ast.FunctionDef]
AnnotationType = Union[
ast.Name,
ast.Subscript,
ast.Index,
ast.Tuple,
ast.Constant,
ast.BinOp,
ast.Attribute,
]

LegacyBlockTypes = [
ast.If,
Expand Down
17 changes: 9 additions & 8 deletions pydoclint/utils/doc.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pprint
from typing import Any, List
from typing import Any, List, Optional, Union

from docstring_parser.common import (
Docstring,
Expand All @@ -23,6 +23,7 @@ def __init__(self, docstring: str, style: str = 'numpy') -> None:
self.docstring = docstring
self.style = style

parser: Union[NumpydocParser, GoogleParser]
if style == 'numpy':
parser = NumpydocParser()
self.parsed = parser.parse(docstring)
Expand All @@ -38,7 +39,7 @@ def __repr__(self) -> str:
return pprint.pformat(self.__dict__, indent=2)

@property
def isShortDocstring(self) -> bool:
def isShortDocstring(self) -> bool: # type:ignore[return]
"""Is the docstring a short one (containing only a summary)"""
if self.style in {'google', 'numpy', 'sphinx'}:
# API documentation:
Expand All @@ -60,32 +61,32 @@ def isShortDocstring(self) -> bool:
self._raiseException() # noqa: R503

@property
def argList(self) -> ArgList:
def argList(self) -> ArgList: # type:ignore[return]
"""The argument info in the docstring, presented as an ArgList"""
if self.style in {'google', 'numpy', 'sphinx'}:
return ArgList.fromDocstringParam(self.parsed.params)

self._raiseException() # noqa: R503

@property
def attrList(self) -> ArgList:
def attrList(self) -> ArgList: # type:ignore[return]
"""The attributes info in the docstring, presented as an ArgList"""
if self.style in {'google', 'numpy', 'sphinx'}:
return ArgList.fromDocstringAttr(self.parsed.attrs)

self._raiseException() # noqa: R503

@property
def hasReturnsSection(self) -> bool:
def hasReturnsSection(self) -> bool: # type:ignore[return]
"""Whether the docstring has a 'Returns' section"""
if self.style in {'google', 'numpy', 'sphinx'}:
retSection: DocstringReturns = self.parsed.returns
retSection: Optional[DocstringReturns] = self.parsed.returns
return retSection is not None and not retSection.is_generator

self._raiseException() # noqa: R503

@property
def hasYieldsSection(self) -> bool:
def hasYieldsSection(self) -> bool: # type:ignore[return]
"""Whether the docstring has a 'Yields' section"""
if self.style in {'google', 'numpy', 'sphinx'}:
yieldSection: DocstringYields = self.parsed.yields
Expand All @@ -94,7 +95,7 @@ def hasYieldsSection(self) -> bool:
self._raiseException() # noqa: R503

@property
def hasRaisesSection(self) -> bool:
def hasRaisesSection(self) -> bool: # type:ignore[return]
"""Whether the docstring has a 'Raises' section"""
if self.style in {'google', 'numpy', 'sphinx'}:
return len(self.parsed.raises) > 0
Expand Down
27 changes: 19 additions & 8 deletions pydoclint/utils/generic.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from __future__ import annotations

import ast
import copy
import re
from typing import List, Match, Optional, Tuple, Union
from typing import TYPE_CHECKING, List, Match, Optional, Tuple, Union

from pydoclint.utils.astTypes import ClassOrFunctionDef, FuncOrAsyncFuncDef
from pydoclint.utils.method_type import MethodType
from pydoclint.utils.violation import Violation

if TYPE_CHECKING:
from pydoclint.utils.arg import Arg, ArgList


def collectFuncArgs(node: FuncOrAsyncFuncDef) -> List[ast.arg]:
"""
Expand Down Expand Up @@ -70,7 +75,7 @@ def getFunctionId(node: FuncOrAsyncFuncDef) -> Tuple[int, int, str]:
return node.lineno, node.col_offset, node.name


def detectMethodType(node: ast.FunctionDef) -> MethodType:
def detectMethodType(node: FuncOrAsyncFuncDef) -> MethodType:
"""
Detect whether the function def is an instance method,
a classmethod, or a staticmethod.
Expand Down Expand Up @@ -159,11 +164,17 @@ def getNodeName(node: ast.AST) -> str:
if node is None:
return ''

return node.name if 'name' in node.__dict__ else ''
return getattr(node, 'name', '')


def stringStartsWith(string: str, substrings: Tuple[str, ...]) -> bool:
def stringStartsWith(
string: Optional[str],
substrings: Tuple[str, ...],
) -> bool:
"""Check whether the string starts with any of the substrings"""
if string is None:
return False

for substring in substrings:
if string.startswith(substring):
return True
Expand Down Expand Up @@ -202,11 +213,11 @@ def _replacer(match: Match[str]) -> str:
def appendArgsToCheckToV105(
*,
original_v105: Violation,
funcArgs: 'ArgList', # noqa: F821
docArgs: 'ArgList', # noqa: F821
funcArgs: ArgList,
docArgs: ArgList,
) -> Violation:
"""Append the arg names to check to the error message of v105 or v605"""
argsToCheck: List['Arg'] = funcArgs.findArgsWithDifferentTypeHints(docArgs) # noqa: F821
argsToCheck: List[Arg] = funcArgs.findArgsWithDifferentTypeHints(docArgs)
argNames: str = ', '.join(_.name for _ in argsToCheck)
return original_v105.appendMoreMsg(moreMsg=argNames)

Expand Down Expand Up @@ -244,4 +255,4 @@ def getFullAttributeName(node: Union[ast.Attribute, ast.Name]) -> str:
if isinstance(node, ast.Name):
return node.id

return getFullAttributeName(node.value) + '.' + node.attr
return getFullAttributeName(node.value) + '.' + node.attr # type:ignore[arg-type]
14 changes: 9 additions & 5 deletions pydoclint/utils/return_anno.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ def decompose(self) -> List[str]:
When the annotation string has strange values
"""
if self._isTuple(): # noqa: R506
assert self.annotation is not None # to help mypy understand type

if not self.annotation.endswith(']'):
raise EdgeCaseError('Return annotation not ending with `]`')

Expand All @@ -49,23 +51,25 @@ def decompose(self) -> List[str]:

insideTuple: str = self.annotation[6:-1]
if insideTuple.endswith('...'): # like this: Tuple[int, ...]
return [self.annotation] # b/c we don't know the tuple's length
# because we don't know the tuple's length
return [self.annotation]

parsedBody0: ast.Expr = ast.parse(insideTuple).body[0]
parsedBody0: ast.Expr = ast.parse(insideTuple).body[0] # type:ignore[assignment]
if isinstance(parsedBody0.value, ast.Name): # like this: Tuple[int]
return [insideTuple]

if isinstance(parsedBody0.value, ast.Tuple): # like Tuple[int, str]
elts: List = parsedBody0.value.elts
return [unparseName(_) for _ in elts]
elts: List[ast.expr] = parsedBody0.value.elts
return [unparseName(_) for _ in elts] # type:ignore[misc]

raise EdgeCaseError('decompose(): This should not have happened')
else:
return self.putAnnotationInList()

def _isTuple(self) -> bool:
try:
annoHead = ast.parse(self.annotation).body[0].value.value.id
assert self.annotation is not None # to help mypy understand type
annoHead = ast.parse(self.annotation).body[0].value.value.id # type:ignore[attr-defined]
return annoHead in {'tuple', 'Tuple'}
except Exception:
return False
Expand Down
Loading

0 comments on commit 903aa91

Please sign in to comment.