docstringify 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docstringify/__init__.py CHANGED
@@ -3,4 +3,4 @@ Flag missing docstrings and, optionally, generate them from signatures and
3
3
  type annotations.
4
4
  """
5
5
 
6
- __version__ = '0.2.0'
6
+ __version__ = '0.4.0'
docstringify/cli.py CHANGED
@@ -2,14 +2,21 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import sys
5
+ from functools import partial
5
6
  from typing import Sequence
6
7
 
7
8
  from . import __version__
8
9
  from .converters import GoogleDocstringConverter, NumpydocDocstringConverter
9
- from .visitor import DocstringVisitor
10
+ from .traversal import DocstringTransformer, DocstringVisitor
10
11
 
11
12
  PROG = __package__
12
13
 
14
+ STYLES = {
15
+ 'google': GoogleDocstringConverter,
16
+ 'numpydoc': NumpydocDocstringConverter,
17
+ }
18
+ CLI_DEFAULTS = {'threshold': 1.0}
19
+
13
20
 
14
21
  def main(argv: Sequence[str] | None = None) -> int:
15
22
  parser = argparse.ArgumentParser(prog=PROG)
@@ -22,40 +29,55 @@ def main(argv: Sequence[str] | None = None) -> int:
22
29
  '--version', action='version', version=f'%(prog)s {__version__}'
23
30
  )
24
31
 
25
- parser.add_argument(
32
+ run_group = parser.add_argument_group('Run options')
33
+ handle_missing_docstring = run_group.add_mutually_exclusive_group(required=False)
34
+ handle_missing_docstring.add_argument(
35
+ '--make-changes',
36
+ choices=STYLES.keys(),
37
+ help='Whether to insert docstring templates for items missing docstrings',
38
+ )
39
+ handle_missing_docstring.add_argument(
40
+ '--make-changes-inplace',
41
+ choices=STYLES.keys(),
42
+ help=(
43
+ 'Whether to insert docstring templates for items missing docstrings, '
44
+ 'overwriting the original file'
45
+ ),
46
+ )
47
+ handle_missing_docstring.add_argument(
26
48
  '--suggest-changes',
27
- action='store_true',
49
+ choices=STYLES.keys(),
28
50
  help='Whether to print out docstring templates for items missing docstrings',
29
51
  )
30
- parser.add_argument(
31
- '--style',
32
- choices=['google', 'numpydoc'],
33
- default='numpydoc',
34
- help='The style of docstring to use (only used when --suggest-changes is passed)',
35
- )
36
- parser.add_argument(
52
+ run_group.add_argument(
37
53
  '--threshold',
38
54
  type=float,
39
- default=1,
55
+ default=CLI_DEFAULTS['threshold'],
40
56
  help='The percentage of docstrings that must be present to pass',
41
57
  )
42
58
  args = parser.parse_args(argv)
43
59
 
44
- if args.style is None or not args.suggest_changes:
45
- converter = None
46
- elif args.style == 'google':
47
- converter = GoogleDocstringConverter()
48
- elif args.style == 'numpydoc':
49
- converter = NumpydocDocstringConverter()
60
+ if style := args.make_changes or args.make_changes_inplace or args.suggest_changes:
61
+ converter = STYLES[style]
50
62
  else:
51
- raise NotImplementedError(f'{args.style} is not a supported option')
63
+ converter = None
64
+
65
+ get_docstring_processor = (
66
+ partial(
67
+ DocstringTransformer,
68
+ converter=converter,
69
+ **{'overwrite': bool(args.make_changes_inplace)},
70
+ )
71
+ if args.make_changes or args.make_changes_inplace
72
+ else partial(DocstringVisitor, converter=converter)
73
+ )
52
74
 
53
75
  docstrings_processed = missing_docstrings = 0
54
76
  for file in args.filenames:
55
- visitor = DocstringVisitor(file, converter=converter)
56
- visitor.process_file()
57
- missing_docstrings += visitor.missing_docstrings
58
- docstrings_processed += visitor.docstrings_inspected
77
+ processor = get_docstring_processor(file)
78
+ processor.process_file()
79
+ missing_docstrings += processor.missing_docstrings
80
+ docstrings_processed += processor.docstrings_inspected
59
81
 
60
82
  if docstrings_processed and (
61
83
  missing_percentage := (missing_docstrings / docstrings_processed)
@@ -2,26 +2,36 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ast
6
+ import textwrap
5
7
  from abc import ABC, abstractmethod
6
8
  from typing import TYPE_CHECKING
7
9
 
10
+ from ..components import Function
11
+ from ..exceptions import InvalidDocstringError
12
+
8
13
  if TYPE_CHECKING:
9
- from ..components import Function, Parameter
14
+ from ..components import Parameter
15
+ from ..nodes.base import DocstringNode
10
16
 
11
17
 
12
18
  class DocstringConverter(ABC):
13
19
  def __init__(
14
- self, parameters_section_template: str, returns_section_template: str
20
+ self,
21
+ parameters_section_template: str,
22
+ returns_section_template: str,
23
+ quote: bool,
15
24
  ) -> None:
16
25
  self.parameters_section_template = parameters_section_template
17
26
  self.returns_section_template = returns_section_template
27
+ self._quote = quote
18
28
 
19
29
  @abstractmethod
20
- def to_class_docstring(self, class_name: str) -> str:
30
+ def to_class_docstring(self, class_name: str, indent: int) -> str:
21
31
  pass
22
32
 
23
33
  @abstractmethod
24
- def to_function_docstring(self, function: Function) -> str:
34
+ def to_function_docstring(self, function: Function, indent: int) -> str:
25
35
  pass
26
36
 
27
37
  @abstractmethod
@@ -49,3 +59,36 @@ class DocstringConverter(ABC):
49
59
  if return_text := self.format_return(return_type):
50
60
  return self.returns_section_template.format(returns=return_text)
51
61
  return ''
62
+
63
+ def quote_docstring(self, docstring: str | list[str], indent: int) -> str:
64
+ quote = '"""' if self._quote else ''
65
+ prefix = sep = ''
66
+ if isinstance(docstring, str):
67
+ docstring = [quote, docstring, quote]
68
+ elif isinstance(docstring, list):
69
+ if len(docstring) > 1:
70
+ prefix = ' ' * indent if indent else ''
71
+ sep = '\n'
72
+ docstring = [quote, *docstring, f'{prefix}{quote}']
73
+ else:
74
+ raise InvalidDocstringError(type(docstring).__name__)
75
+
76
+ return textwrap.indent(sep.join(docstring), prefix)
77
+
78
+ def suggest_docstring(
79
+ self,
80
+ docstring_node: DocstringNode,
81
+ indent: int = 0,
82
+ ) -> str:
83
+ if isinstance(docstring_node.ast_node, ast.AsyncFunctionDef | ast.FunctionDef):
84
+ return self.to_function_docstring(
85
+ Function(
86
+ docstring_node.extract_arguments(), docstring_node.extract_returns()
87
+ ),
88
+ indent=indent,
89
+ )
90
+
91
+ if isinstance(docstring_node.ast_node, ast.Module):
92
+ return self.to_module_docstring(docstring_node.module_name)
93
+
94
+ return self.to_class_docstring(docstring_node.name, indent=indent)
@@ -7,15 +7,15 @@ from .base import DocstringConverter
7
7
 
8
8
 
9
9
  class GoogleDocstringConverter(DocstringConverter):
10
- def __init__(self) -> None:
10
+ def __init__(self, quote: bool) -> None:
11
11
  super().__init__(
12
12
  parameters_section_template='Args:\n{parameters}',
13
13
  returns_section_template='Returns:\n {returns}',
14
+ quote=quote,
14
15
  )
15
16
 
16
- def to_function_docstring(self, function: Function) -> str:
17
- # TODO: the visitor needs the triple quotes but the transformer does not
18
- docstring = [f'"""{DESCRIPTION_PLACEHOLDER}']
17
+ def to_function_docstring(self, function: Function, indent: int) -> str:
18
+ docstring = [DESCRIPTION_PLACEHOLDER]
19
19
 
20
20
  if parameters_section := self.parameters_section(function.parameters):
21
21
  docstring.extend(['', parameters_section])
@@ -23,14 +23,12 @@ class GoogleDocstringConverter(DocstringConverter):
23
23
  if returns_section := self.returns_section(function.return_type):
24
24
  docstring.extend(['', returns_section])
25
25
 
26
- sep = '' if len(docstring) == 1 else '\n'
27
-
28
- return sep.join([*docstring, '"""'])
26
+ return self.quote_docstring(docstring, indent=indent)
29
27
 
30
28
  def format_parameter(self, parameter: Parameter) -> str:
31
29
  category = f'{f", {parameter.category}" if parameter.category else ""}'
32
30
  return (
33
- f' {parameter.name} ({parameter.type_}{category}): {DESCRIPTION_PLACEHOLDER} '
31
+ f' {parameter.name} ({parameter.type_}{category}): {DESCRIPTION_PLACEHOLDER}'
34
32
  f'{f" Defaults to {parameter.default}." if parameter.default != NO_DEFAULT else ""}'
35
33
  )
36
34
 
@@ -40,7 +38,7 @@ class GoogleDocstringConverter(DocstringConverter):
40
38
  return ''
41
39
 
42
40
  def to_module_docstring(self, module_name: str) -> str:
43
- return f'"""{DESCRIPTION_PLACEHOLDER}"""'
41
+ return self.quote_docstring(DESCRIPTION_PLACEHOLDER, indent=0)
44
42
 
45
- def to_class_docstring(self, class_name: str) -> str:
46
- return f'"""{DESCRIPTION_PLACEHOLDER}"""'
43
+ def to_class_docstring(self, class_name: str, indent: int) -> str:
44
+ return self.quote_docstring(DESCRIPTION_PLACEHOLDER, indent=indent)
@@ -7,15 +7,15 @@ from .base import DocstringConverter
7
7
 
8
8
 
9
9
  class NumpydocDocstringConverter(DocstringConverter):
10
- def __init__(self) -> None:
10
+ def __init__(self, quote: bool) -> None:
11
11
  super().__init__(
12
12
  parameters_section_template='Parameters\n----------\n{parameters}',
13
13
  returns_section_template='Returns\n-------\n{returns}',
14
+ quote=quote,
14
15
  )
15
16
 
16
- def to_function_docstring(self, function: Function) -> str:
17
- # TODO: the visitor needs the triple quotes but the transformer does not
18
- docstring = ['"""', DESCRIPTION_PLACEHOLDER]
17
+ def to_function_docstring(self, function: Function, indent: int) -> str:
18
+ docstring = [DESCRIPTION_PLACEHOLDER]
19
19
 
20
20
  if parameters_section := self.parameters_section(function.parameters):
21
21
  docstring.extend(['', parameters_section])
@@ -23,9 +23,7 @@ class NumpydocDocstringConverter(DocstringConverter):
23
23
  if returns_section := self.returns_section(function.return_type):
24
24
  docstring.extend(['', returns_section])
25
25
 
26
- sep = '' if len(docstring) == 2 else '\n'
27
-
28
- return sep.join([*docstring, '"""'])
26
+ return self.quote_docstring(docstring, indent=indent)
29
27
 
30
28
  def format_parameter(self, parameter: Parameter) -> str:
31
29
  return (
@@ -41,7 +39,7 @@ class NumpydocDocstringConverter(DocstringConverter):
41
39
  return ''
42
40
 
43
41
  def to_module_docstring(self, module_name: str) -> str:
44
- return f'"""{DESCRIPTION_PLACEHOLDER}"""'
42
+ return self.quote_docstring(DESCRIPTION_PLACEHOLDER, indent=0)
45
43
 
46
- def to_class_docstring(self, class_name: str) -> str:
47
- return f'"""{DESCRIPTION_PLACEHOLDER}"""'
44
+ def to_class_docstring(self, class_name: str, indent: int) -> str:
45
+ return self.quote_docstring(DESCRIPTION_PLACEHOLDER, indent=indent)
@@ -0,0 +1,6 @@
1
+ """Docstringify exceptions."""
2
+
3
+
4
+ class InvalidDocstringError(ValueError):
5
+ def __init__(self, docstring_class: str) -> None:
6
+ super().__init__(f'Expected str or list[str] docstring, got {docstring_class}')
@@ -0,0 +1 @@
1
+ """Docstringify nodes."""
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from functools import partial
5
+ from typing import Callable, overload
6
+
7
+
8
+ class DocstringNode:
9
+ @overload
10
+ def __init__(
11
+ self,
12
+ node: ast.Module,
13
+ module_name: str,
14
+ source_code: str,
15
+ parent: None = None,
16
+ ) -> None: ...
17
+
18
+ @overload
19
+ def __init__(
20
+ self,
21
+ node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
22
+ module_name: str,
23
+ source_code: str,
24
+ parent: DocstringNode,
25
+ ) -> None: ...
26
+
27
+ def __init__(
28
+ self,
29
+ node: ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef,
30
+ module_name: str,
31
+ source_code: str,
32
+ parent: DocstringNode | None = None,
33
+ ) -> None:
34
+ self.module_name: str = module_name
35
+ self.parent: DocstringNode | None = parent
36
+
37
+ self.ast_node: (
38
+ ast.Module | ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef
39
+ ) = node
40
+ self.name: str = getattr(node, 'name', self.module_name)
41
+
42
+ self.docstring = ast.get_docstring(node)
43
+ self.get_source_segment: Callable[[ast.AST], str] = partial(
44
+ ast.get_source_segment, source_code
45
+ )
46
+
47
+ @property
48
+ def fully_qualified_name(self) -> str:
49
+ return (
50
+ f'{self.parent.fully_qualified_name}.{self.name}'
51
+ if self.parent
52
+ else self.name
53
+ )
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import itertools
5
+ from typing import Literal
6
+
7
+ from ..components import (
8
+ NO_DEFAULT,
9
+ PARAMETER_TYPE_PLACEHOLDER,
10
+ RETURN_TYPE_PLACEHOLDER,
11
+ Parameter,
12
+ )
13
+ from .base import DocstringNode
14
+
15
+
16
+ class FunctionDocstringNode(DocstringNode):
17
+ def __init__(
18
+ self,
19
+ node: ast.FunctionDef | ast.AsyncFunctionDef,
20
+ module_name: str,
21
+ source_code: str,
22
+ parent: DocstringNode,
23
+ ) -> None:
24
+ super().__init__(node, module_name, source_code, parent)
25
+ self.decorators: list[str] = [
26
+ self.get_source_segment(decorator) for decorator in node.decorator_list
27
+ ]
28
+
29
+ self.is_method: bool = self.parent and isinstance(
30
+ self.parent.ast_node, ast.ClassDef
31
+ )
32
+ self.is_abstract_method: bool = self.is_method and (
33
+ 'abstractmethod' in self.decorators
34
+ or 'abc.abstractmethod' in self.decorators
35
+ )
36
+ self.is_class_method: bool = self.is_method and 'classmethod' in self.decorators
37
+ self.is_static_method: bool = (
38
+ self.is_method and 'staticmethod' in self.decorators
39
+ )
40
+ self.is_instance_method: bool = (
41
+ self.is_method and not self.is_class_method and not self.is_static_method
42
+ )
43
+
44
+ self.arguments: ast.arguments | None = getattr(node, 'args', None)
45
+ self.return_annotation: str | None = self._extract_return_annotation()
46
+ self.return_statements: list[ast.Return] = []
47
+
48
+ def _extract_default_values(
49
+ self, default: ast.Constant | None | Literal[NO_DEFAULT], is_keyword_only: bool
50
+ ) -> str | Literal[NO_DEFAULT]:
51
+ if (not is_keyword_only and default is not NO_DEFAULT) or (
52
+ is_keyword_only and default
53
+ ):
54
+ try:
55
+ default_value = default.value
56
+ except AttributeError:
57
+ default_value = f'`{default.id}`'
58
+
59
+ return (
60
+ f'"{default_value}"'
61
+ if isinstance(default_value, str) and not default_value.startswith('`')
62
+ else default_value
63
+ )
64
+ return NO_DEFAULT
65
+
66
+ def _extract_star_args(self) -> list[Parameter | None, Parameter | None]:
67
+ return [
68
+ Parameter(
69
+ name=f'*{arg.arg}' if arg_type == 'vararg' else f'**{arg.arg}',
70
+ type_=getattr(arg.annotation, 'id', PARAMETER_TYPE_PLACEHOLDER),
71
+ category=None,
72
+ default=NO_DEFAULT,
73
+ )
74
+ if arg
75
+ else None
76
+ for arg_type in ['vararg', 'kwarg']
77
+ for arg in [getattr(self.arguments, arg_type)]
78
+ ]
79
+
80
+ def _extract_positional_args(self) -> list[Parameter, ...]:
81
+ if (default_count := len(positional_defaults := self.arguments.defaults)) < (
82
+ positional_arguments_count := len(self.arguments.posonlyargs)
83
+ + len(self.arguments.args)
84
+ ):
85
+ positional_defaults = [NO_DEFAULT] * (
86
+ positional_arguments_count - default_count
87
+ ) + positional_defaults
88
+
89
+ return [
90
+ Parameter(
91
+ name=arg.arg,
92
+ type_=getattr(arg.annotation, 'id', PARAMETER_TYPE_PLACEHOLDER),
93
+ category=category,
94
+ default=self._extract_default_values(default, False),
95
+ )
96
+ for (arg, category), default in zip(
97
+ itertools.chain(
98
+ zip(
99
+ self.arguments.posonlyargs, itertools.repeat('positional-only')
100
+ ),
101
+ zip(self.arguments.args, itertools.repeat(None)),
102
+ ),
103
+ positional_defaults,
104
+ )
105
+ ]
106
+
107
+ def _extract_keyword_args(self) -> list[Parameter, ...]:
108
+ return [
109
+ Parameter(
110
+ name=arg.arg,
111
+ type_=getattr(arg.annotation, 'id', PARAMETER_TYPE_PLACEHOLDER),
112
+ category='keyword-only',
113
+ default=self._extract_default_values(default, True),
114
+ )
115
+ for arg, default in zip(
116
+ self.arguments.kwonlyargs, self.arguments.kw_defaults
117
+ )
118
+ ]
119
+
120
+ def extract_arguments(self) -> tuple[Parameter, ...]:
121
+ params = self._extract_positional_args()
122
+
123
+ varargs, kwargs = self._extract_star_args()
124
+
125
+ if varargs:
126
+ params.append(varargs)
127
+
128
+ params.extend(self._extract_keyword_args())
129
+
130
+ if kwargs:
131
+ params.append(kwargs)
132
+
133
+ params = tuple(params)
134
+ if params and (
135
+ (self.is_class_method and params[0].name == 'cls')
136
+ or (self.is_instance_method and params[0].name == 'self')
137
+ ):
138
+ return params[1:]
139
+ return params
140
+
141
+ def _extract_return_annotation(self) -> str | None:
142
+ if return_annotation_node := self.ast_node.returns:
143
+ if isinstance(return_annotation_node, ast.Constant):
144
+ return return_annotation_node.value
145
+ if isinstance(return_annotation_node, ast.Name):
146
+ return return_annotation_node.id
147
+ return self.get_source_segment(return_annotation_node)
148
+ return None
149
+
150
+ def extract_returns(self) -> str | None:
151
+ if self.return_annotation:
152
+ return self.return_annotation
153
+ if any(
154
+ not isinstance(return_value := return_node.value, ast.Constant)
155
+ or return_value.value
156
+ for return_node in self.return_statements
157
+ ):
158
+ return RETURN_TYPE_PLACEHOLDER
159
+ return None
@@ -0,0 +1,9 @@
1
+ """AST traversal for docstrings."""
2
+
3
+ from .transformer import DocstringTransformer
4
+ from .visitor import DocstringVisitor
5
+
6
+ __all__ = [
7
+ 'DocstringTransformer',
8
+ 'DocstringVisitor',
9
+ ]
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from typing import TYPE_CHECKING
5
+
6
+ from .visitor import DocstringVisitor
7
+
8
+ if TYPE_CHECKING:
9
+ from ..converters import DocstringConverter
10
+ from ..nodes.base import DocstringNode
11
+
12
+
13
+ class DocstringTransformer(ast.NodeTransformer, DocstringVisitor):
14
+ def __init__(
15
+ self,
16
+ filename: str,
17
+ converter: type[DocstringConverter],
18
+ overwrite: bool = False,
19
+ ) -> None:
20
+ super().__init__(filename, converter)
21
+ self.overwrite = overwrite
22
+
23
+ def save(self) -> None:
24
+ if self.missing_docstrings:
25
+ output = (
26
+ self.source_file
27
+ if self.overwrite
28
+ else self.source_file.parent
29
+ / (
30
+ self.source_file.stem
31
+ + '_docstringify'
32
+ + ''.join(self.source_file.suffixes)
33
+ )
34
+ )
35
+ edited_code = ast.unparse(self.tree)
36
+ output.write_text(edited_code)
37
+ print(f'Docstring templates written to {output}')
38
+
39
+ def handle_missing_docstring(self, docstring_node: DocstringNode) -> DocstringNode:
40
+ suggested_docstring = self.docstring_converter.suggest_docstring(
41
+ docstring_node,
42
+ indent=0
43
+ if isinstance(docstring_node.ast_node, ast.Module)
44
+ else docstring_node.ast_node.col_offset + 4,
45
+ )
46
+ docstring_ast_node = ast.Expr(ast.Constant(suggested_docstring))
47
+
48
+ if (
49
+ docstring_node.docstring is not None
50
+ and docstring_node.docstring.strip() == ''
51
+ ):
52
+ # If the docstring is empty, we replace it with the suggested docstring
53
+ docstring_node.ast_node.body[0] = docstring_ast_node
54
+ else:
55
+ # If the docstring is missing, we insert the suggested docstring
56
+ docstring_node.ast_node.body.insert(0, docstring_ast_node)
57
+
58
+ docstring_node.ast_node = ast.fix_missing_locations(docstring_node.ast_node)
59
+
60
+ return docstring_node
61
+
62
+ def process_file(self) -> None:
63
+ super().process_file()
64
+ self.save()
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..nodes.base import DocstringNode
9
+ from ..nodes.function import FunctionDocstringNode
10
+
11
+ if TYPE_CHECKING:
12
+ from ..converters import DocstringConverter
13
+
14
+
15
+ class DocstringVisitor(ast.NodeVisitor):
16
+ def __init__(
17
+ self, filename: str, converter: type[DocstringConverter] | None = None
18
+ ) -> None:
19
+ self.source_file: Path = Path(filename).expanduser().resolve()
20
+ self.source_code: str = self.source_file.read_text()
21
+ self.tree: ast.Module = ast.parse(self.source_code)
22
+
23
+ self.docstrings_inspected: int = 0
24
+ self.missing_docstrings: list[DocstringNode] = []
25
+
26
+ self.module_name: str = self.source_file.stem
27
+ self.stack: list[DocstringNode] = []
28
+
29
+ self.docstring_converter: DocstringConverter | None = (
30
+ converter(quote=not issubclass(self.__class__, ast.NodeTransformer))
31
+ if converter
32
+ else None
33
+ )
34
+
35
+ def report_missing_docstrings(self) -> None:
36
+ if not self.missing_docstrings:
37
+ print(f'No missing docstrings found in {self.source_file}.')
38
+ else:
39
+ for docstring_node in self.missing_docstrings:
40
+ # TODO: need to print full path for node name
41
+ print(
42
+ f'{docstring_node.fully_qualified_name} is missing a docstring',
43
+ file=sys.stderr,
44
+ )
45
+ self.handle_missing_docstring(docstring_node)
46
+
47
+ def handle_missing_docstring(self, docstring_node: DocstringNode) -> DocstringNode:
48
+ if self.docstring_converter:
49
+ print('Hint:')
50
+ print(self.docstring_converter.suggest_docstring(docstring_node))
51
+ print()
52
+
53
+ def process_docstring(self, docstring_node: DocstringNode) -> DocstringNode:
54
+ if docstring_node.docstring is None or docstring_node.docstring.strip() == '':
55
+ self.missing_docstrings.append(docstring_node)
56
+
57
+ self.docstrings_inspected += 1
58
+ return docstring_node
59
+
60
+ def visit_docstring(
61
+ self,
62
+ node: ast.AsyncFunctionDef | ast.ClassDef | ast.FunctionDef | ast.Module,
63
+ docstring_class: type[DocstringNode],
64
+ ) -> ast.AsyncFunctionDef | ast.ClassDef | ast.FunctionDef | ast.Module:
65
+ docstring_node = docstring_class(
66
+ node,
67
+ self.module_name,
68
+ self.source_code,
69
+ parent=self.stack[-1] if self.stack else None,
70
+ )
71
+
72
+ self.stack.append(docstring_node)
73
+
74
+ docstring_node = self.process_docstring(docstring_node)
75
+ self.generic_visit(docstring_node.ast_node)
76
+ self.stack.pop()
77
+ return docstring_node.ast_node
78
+
79
+ def visit_Module(self, node: ast.Module) -> ast.Module: # noqa: N802
80
+ return self.visit_docstring(node, DocstringNode)
81
+
82
+ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef: # noqa: N802
83
+ return self.visit_docstring(node, DocstringNode)
84
+
85
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef: # noqa: N802
86
+ return self.visit_docstring(node, FunctionDocstringNode)
87
+
88
+ def visit_AsyncFunctionDef( # noqa: N802
89
+ self, node: ast.AsyncFunctionDef
90
+ ) -> ast.AsyncFunctionDef:
91
+ return self.visit_docstring(node, FunctionDocstringNode)
92
+
93
+ def visit_Return(self, node: ast.Return) -> ast.Return: # noqa: N802
94
+ if isinstance(self.stack[-1], FunctionDocstringNode):
95
+ self.stack[-1].return_statements.append(node)
96
+ return node
97
+
98
+ def process_file(self) -> None:
99
+ self.visit(self.tree)
100
+ self.report_missing_docstrings()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docstringify
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Flag missing docstrings and, optionally, generate them from signatures and type annotations.
5
5
  Author-email: Stefanie Molin <docstringify@stefaniemolin.com>
6
6
  License: MIT License
@@ -60,7 +60,7 @@ Add the following to your `.pre-commit-config.yaml` file to block commits with m
60
60
 
61
61
  ```yaml
62
62
  - repo: https://github.com/stefmolin/docstringify
63
- rev: 0.2.0
63
+ rev: 0.4.0
64
64
  hooks:
65
65
  - id: docstringify
66
66
  ```
@@ -69,32 +69,34 @@ By default, all docstrings are required. If you want to be more lenient, you can
69
69
 
70
70
  ```yaml
71
71
  - repo: https://github.com/stefmolin/docstringify
72
- rev: 0.2.0
72
+ rev: 0.4.0
73
73
  hooks:
74
74
  - id: docstringify
75
75
  args: [--threshold=0.75]
76
76
  ```
77
77
 
78
- If you would like to see suggested docstring templates (inferred from type annotations for functions and methods), provide the `--suggest-changes` argument. By default, these will be [numpydoc-style docstrings](https://numpydoc.readthedocs.io/en/latest/format.html#):
78
+ If you would like to see suggested docstring templates (inferred from type annotations for functions and methods), provide the `--suggest-changes` argument, along with the docstring style you want to use (options are `google` and `numpydoc`). Here, we ask for [numpydoc-style docstring](https://numpydoc.readthedocs.io/en/latest/format.html#) suggestions:
79
79
 
80
80
  ```yaml
81
81
  - repo: https://github.com/stefmolin/docstringify
82
- rev: 0.2.0
82
+ rev: 0.4.0
83
83
  hooks:
84
84
  - id: docstringify
85
- args: [--suggest-changes]
85
+ args: [--suggest-changes=numpydoc]
86
86
  ```
87
87
 
88
- For [Google-style docstrings](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html), specify `--style google`:
88
+ Use `--make-changes` to create a copy of each file with docstring templates. Here, we ask for changes using the [Google docstring style](https://www.sphinx-doc.org/en/master/usage/extensions/example_google.html):
89
89
 
90
90
  ```yaml
91
91
  - repo: https://github.com/stefmolin/docstringify
92
- rev: 0.2.0
92
+ rev: 0.4.0
93
93
  hooks:
94
94
  - id: docstringify
95
- args: [--suggest-changes, --style=google]
95
+ args: [--make-changes=google]
96
96
  ```
97
97
 
98
+ If you want the changes to be made in place, change `--make-changes` to `--make-changes-inplace` &ndash; make sure you only operate on files that are in version control with this setting. Note that the resulting format of your file may be a little different (spacing, newlines, *etc.*).
99
+
98
100
  Be sure to check out the [pre-commit documentation](https://pre-commit.com/#pre-commit-configyaml---hooks) for additional configuration options.
99
101
 
100
102
  ### Command line
@@ -124,7 +126,7 @@ $ python -m pip install docstringify
124
126
  Then, use the `DocstringVisitor()` class on individual files to see spots where docstrings are missing:
125
127
 
126
128
  ```pycon
127
- >>> from docstringify.visitor import DocstringVisitor
129
+ >>> from docstringify.traversal import DocstringVisitor
128
130
  >>> visitor = DocstringVisitor('test.py')
129
131
  >>> visitor.process_file()
130
132
  test is missing a docstring
@@ -135,8 +137,8 @@ If you would like to see suggested docstring templates (inferred from type annot
135
137
 
136
138
  ```pycon
137
139
  >>> from docstringify.converters import NumpydocDocstringConverter
138
- >>> from docstringify.visitor import DocstringVisitor
139
- >>> visitor = DocstringVisitor('test.py', converter=NumpydocDocstringConverter())
140
+ >>> from docstringify.traversal import DocstringVisitor
141
+ >>> visitor = DocstringVisitor('test.py', converter=NumpydocDocstringConverter)
140
142
  >>> visitor.process_file()
141
143
  test is missing a docstring
142
144
  Hint:
@@ -154,3 +156,17 @@ name : str, default="World"
154
156
  """
155
157
 
156
158
  ```
159
+
160
+ To make changes to your files, you will need to use the `DocstringTransformer` instead. With the `DocstringTransformer`, the converter is required:
161
+
162
+ ```pycon
163
+ >>> from docstringify.converters import GoogleDocstringConverter
164
+ >>> from docstringify.traversal import DocstringTransformer
165
+ >>> transformer = DocstringTransformer('test.py', converter=GoogleDocstringConverter)
166
+ >>> transformer.process_file()
167
+ test is missing a docstring
168
+ test.say_hello is missing a docstring
169
+ Docstring templates written to /.../test_docstringify.py
170
+ ```
171
+
172
+ If you want to overwrite the file with the edits, pass `overwrite=True` to `DocstringTransformer()`.
@@ -0,0 +1,21 @@
1
+ docstringify/__init__.py,sha256=ORdK5paDVN7Lbf_kfyo-3tlnIQvFidCXMqyn70WhisI,124
2
+ docstringify/__main__.py,sha256=A5Xq9jCZwHnQVVGklF9ueL-ad-hInsceHQNgze3Yq6E,96
3
+ docstringify/cli.py,sha256=0zUoDDj8f60NDw5cnL_otSiymorMIwq5XfaJbRCrEHQ,2986
4
+ docstringify/components.py,sha256=7Q__cuBTWNAqLDTil_91Q7T-zIzr7YpbX2TcmXrfph0,471
5
+ docstringify/exceptions.py,sha256=lt9p6bUGGqwsKCBk0hv4LrMqOLzNuk-E_3rXgjVKGpc,216
6
+ docstringify/converters/__init__.py,sha256=iZXA6m689j3yqpfZjkoAcONXxAR7s-4GUQFEYxDPAyc,267
7
+ docstringify/converters/base.py,sha256=nIUgfGTjwFSXqcGHfEm3iljo4YcsteCs7iL5o5BNceY,2989
8
+ docstringify/converters/google.py,sha256=puOCpmCK4InimfjR8TAcvCT_ZdpF_U0z_4E8a5l1UWk,1738
9
+ docstringify/converters/numpydoc.py,sha256=VDQGvknBJMP9o6D9wh3cKsL7-uGNuDP4Ow7C3-Tf08c,1762
10
+ docstringify/nodes/__init__.py,sha256=nNCajaWkQJGRv5cKdWyo43JDfSsBUrlnCL8JRTaW4cI,26
11
+ docstringify/nodes/base.py,sha256=JMAHvJm7FRl-NmP6hbO8QzgTMHqT-5DnVo8C5-xSLeo,1415
12
+ docstringify/nodes/function.py,sha256=HfkAHy5Mb6--mxtR-6Swpd9iw_qfdzdH-wkN2lJy5T0,5593
13
+ docstringify/traversal/__init__.py,sha256=fy9DEvmXm4sJyf4dAJI-patQPoL8_zPgPpda84q0a-4,188
14
+ docstringify/traversal/transformer.py,sha256=SvyLQsQ3WrOKFSzIPYegA6vacp7Tp1gjNdPAFwoH8jg,2130
15
+ docstringify/traversal/visitor.py,sha256=Uewzh4esB9XY5tQ9QfRt1TbwDEpIq4kW7Kdqm8E4IDA,3688
16
+ docstringify-0.4.0.dist-info/licenses/LICENSE,sha256=E5RHv_qfCZPwnlJ_ZQb-WKOOy07LaJBA2a160OGtT3U,1071
17
+ docstringify-0.4.0.dist-info/METADATA,sha256=wUOlXNPNwUyszzYWhPvtZZqn7thccQvN77PIjiVrlzA,6448
18
+ docstringify-0.4.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
19
+ docstringify-0.4.0.dist-info/entry_points.txt,sha256=dJVPsbclB7stLE8g0d4WbO2NaZQLJXSLEVl6oeROAqI,55
20
+ docstringify-0.4.0.dist-info/top_level.txt,sha256=3RFmaFG0rYs4HkqRljatGQtY4BcNN7mqjK4tSznYdnQ,13
21
+ docstringify-0.4.0.dist-info/RECORD,,
docstringify/visitor.py DELETED
@@ -1,196 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import ast
4
- import sys
5
- from pathlib import Path
6
- from typing import TYPE_CHECKING, Literal
7
-
8
- from .components import (
9
- NO_DEFAULT,
10
- PARAMETER_TYPE_PLACEHOLDER,
11
- RETURN_TYPE_PLACEHOLDER,
12
- Function,
13
- Parameter,
14
- )
15
-
16
- if TYPE_CHECKING:
17
- from .converters import DocstringConverter
18
-
19
-
20
- class DocstringVisitor(ast.NodeVisitor):
21
- def __init__(
22
- self, filename: str, converter: DocstringConverter | None = None
23
- ) -> None:
24
- self.source_file: Path = Path(filename)
25
- self.source_code: str = self.source_file.read_text()
26
-
27
- self.docstrings_inspected: int = 0
28
- self.missing_docstrings: int = 0
29
-
30
- self.module_name: str = self.source_file.stem
31
- self.stack: list[str] = []
32
-
33
- self.provide_hints: bool = converter is not None
34
- if self.provide_hints:
35
- self.converter: DocstringVisitor = converter # TODO: consider whether this should be instantiated here instead of outside
36
-
37
- def _extract_default_values(
38
- self, default: ast.Constant | None | Literal[NO_DEFAULT], is_keyword_only: bool
39
- ) -> str | Literal[NO_DEFAULT]:
40
- if (not is_keyword_only and default is not NO_DEFAULT) or (
41
- is_keyword_only and default
42
- ):
43
- try:
44
- default_value = default.value
45
- except AttributeError:
46
- default_value = f'`{default.id}`'
47
-
48
- return (
49
- f'"{default_value}"'
50
- if isinstance(default_value, str) and not default_value.startswith('`')
51
- else default_value
52
- )
53
- return NO_DEFAULT
54
-
55
- def extract_arguments(
56
- self, node: ast.AsyncFunctionDef | ast.FunctionDef
57
- ) -> tuple[Parameter, ...]:
58
- modifiers = {
59
- 'posonlyargs': 'positional-only',
60
- 'kwonlyargs': 'keyword-only',
61
- }
62
- params = []
63
-
64
- positional_arguments_count = len(node.args.posonlyargs) + len(node.args.args)
65
- if (
66
- default_count := len(positional_defaults := node.args.defaults)
67
- ) < positional_arguments_count:
68
- positional_defaults = [NO_DEFAULT] * (
69
- positional_arguments_count - default_count
70
- ) + positional_defaults
71
-
72
- keyword_defaults = node.args.kw_defaults
73
-
74
- processed_positional_args = 0
75
- for arg_type, args in ast.iter_fields(node.args):
76
- if arg_type.endswith('defaults'):
77
- continue
78
- modifier = modifiers.get(arg_type)
79
- if arg_type in ['vararg', 'kwarg']:
80
- if args:
81
- params.append(
82
- Parameter(
83
- name=f'*{args.arg}'
84
- if arg_type == 'vararg'
85
- else f'**{args.arg}',
86
- type_=getattr(
87
- args.annotation, 'id', PARAMETER_TYPE_PLACEHOLDER
88
- ),
89
- category=modifier,
90
- default=NO_DEFAULT,
91
- )
92
- )
93
- else:
94
- is_keyword_only = arg_type.startswith('kw')
95
- params.extend(
96
- [
97
- Parameter(
98
- name=arg.arg,
99
- type_=getattr(
100
- arg.annotation, 'id', PARAMETER_TYPE_PLACEHOLDER
101
- ),
102
- category=modifier,
103
- default=self._extract_default_values(
104
- default, is_keyword_only
105
- ),
106
- )
107
- for arg, default in zip(
108
- args,
109
- keyword_defaults
110
- if is_keyword_only
111
- else positional_defaults[processed_positional_args:],
112
- )
113
- ]
114
- )
115
- if not is_keyword_only:
116
- processed_positional_args += len(args)
117
-
118
- params = tuple(params)
119
- if (
120
- params
121
- and isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
122
- and params[0].name.startswith(('self', 'cls'))
123
- ):
124
- return params[1:]
125
- return params
126
-
127
- def extract_returns(
128
- self, node: ast.AsyncFunctionDef | ast.FunctionDef
129
- ) -> str | None:
130
- if return_node := node.returns:
131
- if isinstance(return_node, ast.Constant):
132
- return return_node.value
133
- if isinstance(return_node, ast.Name):
134
- return return_node.id
135
- return ast.get_source_segment(self.source_code, return_node)
136
- if (
137
- return_nodes := [
138
- body_node
139
- for body_node in node.body
140
- if isinstance(body_node, ast.Return)
141
- ]
142
- ) and any(
143
- not isinstance(return_value := body_return_node.value, ast.Constant)
144
- or return_value.value
145
- for body_return_node in return_nodes
146
- ):
147
- return RETURN_TYPE_PLACEHOLDER
148
- return return_node
149
-
150
- def report_missing_docstring(self) -> None:
151
- self.missing_docstrings += 1
152
- print(f'{".".join(self.stack)} is missing a docstring', file=sys.stderr)
153
-
154
- def suggest_docstring(
155
- self, node: ast.AsyncFunctionDef | ast.ClassDef | ast.FunctionDef | ast.Module
156
- ) -> str:
157
- if isinstance(node, ast.AsyncFunctionDef | ast.FunctionDef):
158
- return self.converter.to_function_docstring(
159
- Function(self.extract_arguments(node), self.extract_returns(node))
160
- )
161
-
162
- if isinstance(node, ast.Module):
163
- return self.converter.to_module_docstring(self.module_name)
164
-
165
- # TODO: with the stack, I should be able to let __init__() not have a docstring
166
- # and suggest those parameters as the docstring for the class
167
- # (may need to keep the class node in another stack)
168
- return self.converter.to_class_docstring(node.name)
169
-
170
- def process_docstring(
171
- self, node: ast.AsyncFunctionDef | ast.ClassDef | ast.FunctionDef | ast.Module
172
- ) -> None:
173
- if not ast.get_docstring(node):
174
- self.report_missing_docstring()
175
- if self.provide_hints:
176
- print('Hint:')
177
- print(self.suggest_docstring(node))
178
- print()
179
-
180
- self.docstrings_inspected += 1
181
-
182
- def visit(self, node: ast.AST) -> None:
183
- if isinstance(
184
- node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)
185
- ):
186
- self.stack.append(
187
- self.module_name if isinstance(node, ast.Module) else node.name
188
- )
189
-
190
- self.process_docstring(node)
191
-
192
- self.generic_visit(node)
193
- _ = self.stack.pop()
194
-
195
- def process_file(self) -> None:
196
- self.visit(ast.parse(self.source_code))
@@ -1,15 +0,0 @@
1
- docstringify/__init__.py,sha256=F8wAs3yjndnNfOVnCZIiCk86BQIwxiAGx7fXe15mxNE,124
2
- docstringify/__main__.py,sha256=A5Xq9jCZwHnQVVGklF9ueL-ad-hInsceHQNgze3Yq6E,96
3
- docstringify/cli.py,sha256=U2G97RihJC2HMR2MCWDNadcU5erBuFDlIDrIOQ4lJSM,2230
4
- docstringify/components.py,sha256=7Q__cuBTWNAqLDTil_91Q7T-zIzr7YpbX2TcmXrfph0,471
5
- docstringify/visitor.py,sha256=qF6T6lqbK93RArbkDhqZYOQNAuvi6miMehO0U9tdNJM,7019
6
- docstringify/converters/__init__.py,sha256=iZXA6m689j3yqpfZjkoAcONXxAR7s-4GUQFEYxDPAyc,267
7
- docstringify/converters/base.py,sha256=1QWFE5ckS_Rbwi9TyRM-rKCA7MCwHgZ4Ok8ugMyvMgA,1507
8
- docstringify/converters/google.py,sha256=ytel_pqqUNV5wR8nVV1F8yczFA3Tj1f9_nkfJA4Kq04,1751
9
- docstringify/converters/numpydoc.py,sha256=iv5smFrHFBc0rHvLhgh66PaV2OEI3va_piRZ3_y2Ig8,1773
10
- docstringify-0.2.0.dist-info/licenses/LICENSE,sha256=E5RHv_qfCZPwnlJ_ZQb-WKOOy07LaJBA2a160OGtT3U,1071
11
- docstringify-0.2.0.dist-info/METADATA,sha256=QvPrAwX5TnbG6y7XpK6grHqohENJ2doN3i0WiHipECk,5362
12
- docstringify-0.2.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
13
- docstringify-0.2.0.dist-info/entry_points.txt,sha256=dJVPsbclB7stLE8g0d4WbO2NaZQLJXSLEVl6oeROAqI,55
14
- docstringify-0.2.0.dist-info/top_level.txt,sha256=3RFmaFG0rYs4HkqRljatGQtY4BcNN7mqjK4tSznYdnQ,13
15
- docstringify-0.2.0.dist-info/RECORD,,