• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

domdfcoding / enum_tools / 15072251616

16 May 2025 03:43PM CUT coverage: 87.452% (+0.03%) from 87.427%
15072251616

push

github

web-flow
Updated files with 'repo_helper'. (#108)

Co-authored-by: repo-helper[bot] <74742576+repo-helper[bot]@users.noreply.github.com>

453 of 518 relevant lines covered (87.45%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.41
/enum_tools/documentation.py
1
#!/usr/bin/env python3
2
#
3
#  documentation.py
4
"""
5
Decorators to add docstrings to enum members from comments.
6
"""
7
#
8
#  Copyright (c) 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
9
#
10
#  This program is free software; you can redistribute it and/or modify
11
#  it under the terms of the GNU Lesser General Public License as published by
12
#  the Free Software Foundation; either version 3 of the License, or
13
#  (at your option) any later version.
14
#
15
#  This program is distributed in the hope that it will be useful,
16
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
17
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
#  GNU Lesser General Public License for more details.
19
#
20
#  You should have received a copy of the GNU Lesser General Public License
21
#  along with this program; if not, write to the Free Software
22
#  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
23
#  MA 02110-1301, USA.
24
#
25

26
# stdlib
27
import ast
1✔
28
import inspect
1✔
29
import re
1✔
30
import sys
1✔
31
import tokenize
1✔
32
import warnings
1✔
33
from enum import Enum, EnumMeta
1✔
34
from textwrap import dedent
1✔
35
from typing import Iterable, Iterator, List, Optional, Sequence, Tuple, TypeVar, Union
1✔
36

37
# 3rd party
38
import pygments.token  # type: ignore[import-untyped]
1✔
39
from pygments.lexers.python import PythonLexer  # type: ignore[import-untyped]
1✔
40

41
__all__ = [
1✔
42
                "get_tokens",
43
                "document_enum",
44
                "document_member",
45
                "parse_tokens",
46
                "get_base_indent",
47
                "DocumentedEnum",
48
                "get_dedented_line",
49
                "MultipleDocstringsWarning",
50
                ]
51

52
_lexer = PythonLexer()
1✔
53

54
INTERACTIVE = bool(getattr(sys, "ps1", sys.flags.interactive))
1✔
55

56
EnumType = TypeVar("EnumType", bound=EnumMeta)
1✔
57

58

59
def get_tokens(line: str) -> List[Tuple]:
1✔
60
        """
61
        Returns a list ot tokens generated from the given Python code.
62

63
        :param line: Line of Python code to tokenise.
64
        """
65

66
        return list(_lexer.get_tokens(line))
1✔
67

68

69
def _docstring_from_expr(expr: ast.Expr) -> Optional[str]:
1✔
70
        """
71
        Check if the expression is a docstring.
72

73
        :param expr:
74

75
        :returns: The cleaned docstring text if it is a docstring, or :py:obj:`None` if it isn't.
76
        """
77

78
        # might be docstring
79
        docstring_node = expr.value
1✔
80
        if isinstance(docstring_node, ast.Constant) and isinstance(docstring_node.value, str):
1✔
81
                text = docstring_node.value
1✔
82
        elif isinstance(docstring_node, ast.Str):
1✔
83
                text = docstring_node.s
1✔
84
        else:
85
                # not a docstring
86
                return None
×
87

88
        return inspect.cleandoc(text)
1✔
89

90

91
def _docstring_from_eol_comment(
1✔
92
                source: str,
93
                node: Union[ast.Assign, ast.AnnAssign],
94
                ) -> Optional[str]:
95
        """
96
        Search for an end-of-line docstring comment (starts with ``# doc:``).
97

98
        :param source: The source of the Enum class.
99
        :param node: The AST node for the Enum member.
100
        """
101

102
        toks = _tokenize_line(source.split('\n')[node.lineno - 1])
1✔
103
        comment_toks = [x for x in list(toks) if x.type == tokenize.COMMENT]
1✔
104
        if comment_toks:
1✔
105
                for match in re.finditer(r"(doc:\s*)([^#]*)(#|$)", comment_toks[0].string):
1✔
106
                        if match.group(2):
1✔
107
                                return match.group(2).rstrip()
1✔
108
        return None
1✔
109

110

111
def _docstring_from_sphinx_comment(
1✔
112
                source: str,
113
                node: Union[ast.Assign, ast.AnnAssign],
114
                ) -> Optional[str]:
115
        """
116
        Search for a Sphinx-style docstring comment (starts with ``#:``).
117

118
        :param source: The source of the Enum class.
119
        :param node: The AST node for the Enum member.
120
        """
121

122
        for offset in range(node.lineno - 1, 0, -1):
1✔
123
                line = source.split('\n')[offset - 1]
1✔
124
                if line.strip():
1✔
125
                        # contains non-whitespace
126

127
                        try:
1✔
128
                                toks = _tokenize_line(line)
1✔
129
                        except (tokenize.TokenError, SyntaxError):
1✔
130
                                return None
1✔
131

132
                        # print(list(toks))
133
                        comment_toks = [x for x in list(toks) if x.type == tokenize.COMMENT]
1✔
134
                        if comment_toks:
1✔
135
                                for match in re.finditer(r"(#:\s*)(.*)", comment_toks[0].string):
1✔
136
                                        if match.group(2):
1✔
137
                                                return match.group(2).rstrip()
1✔
138

139
                        return None
1✔
140

141
        return None
×
142

143

144
def _tokenize_line(line: str) -> List[tokenize.TokenInfo]:
1✔
145
        """
146
        Tokenize a single line of Python source code.
147

148
        :param line:
149
        """
150

151
        def yielder() -> Iterator[str]:
1✔
152
                yield line
1✔
153

154
        return list(tokenize.generate_tokens(yielder().__next__))
1✔
155

156

157
class MultipleDocstringsWarning(UserWarning):
1✔
158
        """
159
        Warning emitted when multiple docstrings are found for a single Enum member.
160

161
        .. versionadded:: 0.8.0
162

163
        :param member:
164
        :param docstrings: The list of docstrings found for the member.
165
        """
166

167
        #: The member with multiple docstrings.
168
        member: Enum
1✔
169

170
        #: The list of docstrings found for the member.
171
        docstrings: Iterable[str]
1✔
172

173
        def __init__(self, member: Enum, docstrings: Iterable[str] = ()):
1✔
174
                self.member = member
1✔
175
                self.docstrings = docstrings
1✔
176

177
        def __str__(self) -> str:
1✔
178
                member_full_name = '.'.join([
1✔
179
                                self.member.__class__.__module__,
180
                                self.member.__class__.__name__,
181
                                self.member.name,
182
                                ])
183
                return f"Found multiple docstrings for enum member <{member_full_name}>"
1✔
184

185

186
def document_enum(an_enum: EnumType) -> EnumType:
1✔
187
        """
188
        Document all members of an enum by parsing a docstring from the Python source..
189

190
        The docstring can be added in several ways:
191

192
        #. A comment at the end the line, starting with ``doc:``:
193

194
           .. code-block:: python
195

196
               Running = 1  # doc: The system is running.
197

198
        #. A comment on the previous line, starting with ``#:``. This is the format used by Sphinx.
199

200
           .. code-block:: python
201

202
               #: The system is running.
203
               Running = 1
204

205
        #. A string on the line *after* the attribute. This can be used for multiline docstrings.
206

207
           .. code-block:: python
208

209
               Running = 1
210
               \"\"\"
211
               The system is running.
212

213
               Hello World
214
               \"\"\"
215

216
        If more than one docstring format is found for an enum member
217
        a :exc:`MultipleDocstringsWarning` is emitted.
218

219
        :param an_enum: An :class:`~enum.Enum` subclass
220
        :type an_enum: :class:`enum.Enum`
221

222
        :returns: The same object passed as ``an_enum``. This allows this function to be used as a decorator.
223
        :rtype: :class:`enum.Enum`
224

225
        .. versionchanged:: 0.8.0  Added support for other docstring formats and multiline docstrings.
226
        """
227

228
        if not isinstance(an_enum, EnumMeta):
1✔
229
                raise TypeError(f"'an_enum' must be an 'Enum', not {type(an_enum)}!")
1✔
230

231
        if not INTERACTIVE:
1✔
232
                return an_enum
1✔
233

234
        func_source = dedent(inspect.getsource(an_enum))
1✔
235
        func_source_tree = ast.parse(func_source)
1✔
236

237
        assert len(func_source_tree.body) == 1
1✔
238
        module_body = func_source_tree.body[0]
1✔
239
        assert isinstance(module_body, ast.ClassDef)
1✔
240
        class_body = module_body.body
1✔
241

242
        for idx, node in enumerate(class_body):
1✔
243
                targets = []
1✔
244

245
                if isinstance(node, ast.Assign):
1✔
246
                        for t in node.targets:
1✔
247
                                assert isinstance(t, ast.Name)
1✔
248
                                targets.append(t.id)
1✔
249

250
                elif isinstance(node, ast.AnnAssign):
1✔
251
                        assert isinstance(node.target, ast.Name)
×
252
                        targets.append(node.target.id)
×
253
                else:
254
                        continue
1✔
255

256
                assert isinstance(node, (ast.Assign, ast.AnnAssign))
1✔
257
                # print(targets)
258

259
                if idx + 1 == len(class_body):
1✔
260
                        next_node = None
1✔
261
                else:
262
                        next_node = class_body[idx + 1]
1✔
263

264
                docstring_candidates = []
1✔
265

266
                if isinstance(next_node, ast.Expr):
1✔
267
                        # might be docstring
268
                        docstring_candidates.append(_docstring_from_expr(next_node))
1✔
269

270
                # maybe no luck with """ docstring? look for EOL comment.
271
                docstring_candidates.append(_docstring_from_eol_comment(func_source, node))
1✔
272

273
                # check non-whitespace lines above for Sphinx-style comment.
274
                docstring_candidates.append(_docstring_from_sphinx_comment(func_source, node))
1✔
275

276
                docstring_candidates_nn = list(filter(None, docstring_candidates))
1✔
277
                if len(docstring_candidates_nn) > 1:
1✔
278
                        # Multiple docstrings found, warn
279
                        warnings.warn(MultipleDocstringsWarning(getattr(an_enum, targets[0]), docstring_candidates_nn))
1✔
280

281
                if docstring_candidates_nn:
1✔
282
                        docstring = docstring_candidates_nn[0]
1✔
283

284
                        for target in targets:
1✔
285
                                getattr(an_enum, target).__doc__ = docstring
1✔
286

287
        return an_enum
1✔
288

289

290
def document_member(enum_member: Enum) -> None:
1✔
291
        """
292
        Document a member of an enum by adding a comment to the end of the line that starts with ``doc:``.
293

294
        :param enum_member: A member of an :class:`~enum.Enum` subclass
295
        """
296

297
        if not isinstance(enum_member, Enum):
1✔
298
                raise TypeError(f"'an_enum' must be an 'Enum', not {type(enum_member)}!")
1✔
299

300
        if not INTERACTIVE:
1✔
301
                return None
×
302

303
        func_source = dedent(inspect.getsource(enum_member.__class__))
1✔
304

305
        in_docstring = False
1✔
306
        base_indent = None
1✔
307

308
        for line in func_source.split('\n'):
1✔
309

310
                indent, line = get_dedented_line(line)
1✔
311

312
                if line.startswith("class") or not line:
1✔
313
                        continue
1✔
314

315
                all_tokens = get_tokens(line)
1✔
316
                base_indent = get_base_indent(base_indent, all_tokens, indent)
1✔
317
                # print(all_tokens)
318

319
                if enum_member.name not in line:
1✔
320
                        continue
×
321

322
                if all_tokens[0][0] in pygments.token.Literal.String:
1✔
323
                        if all_tokens[0][1] in {'"""', "'''"}:  # TODO: handle the other quotes appearing in docstring
×
324
                                in_docstring = not in_docstring
×
325

326
                if all_tokens[0][0] in pygments.token.Name and in_docstring:
1✔
327
                        continue
×
328
                elif all_tokens[0][0] not in pygments.token.Name:
1✔
329
                        continue
×
330
                else:
331
                        if indent > base_indent:  # type: ignore[operator]
1✔
332
                                continue
×
333
                enum_vars, doc = parse_tokens(all_tokens)
1✔
334

335
                for var in enum_vars:
1✔
336
                        # print(repr(var))
337
                        if not var.startswith('@'):
1✔
338
                                if var == enum_member.name:
1✔
339
                                        enum_member.__doc__ = doc
1✔
340

341
        return None
1✔
342

343

344
def parse_tokens(all_tokens: Iterable["pygments.Token"]) -> Tuple[List, Optional[str]]:
1✔
345
        """
346
        Parse the tokens representing a line of code to identify Enum members and ``doc:`` comments.
347

348
        :param all_tokens:
349

350
        :return: A list of the Enum members' names, and the docstring for them.
351
        """
352

353
        enum_vars = []
1✔
354
        doc = None
1✔
355
        comment = ''
1✔
356

357
        for token in all_tokens:
1✔
358
                if token[0] in pygments.token.Name:
1✔
359
                        enum_vars.append(token[1])
1✔
360
                elif token[0] in pygments.token.Comment:
1✔
361
                        comment = token[1]
1✔
362
                        break
1✔
363

364
        for match in re.finditer(r"(doc:\s*)([^#]*)(#|$)", comment):
1✔
365
                if match.group(2):
1✔
366
                        doc = match.group(2).rstrip()
1✔
367
                        break
1✔
368

369
        return enum_vars, doc
1✔
370

371

372
def get_base_indent(
1✔
373
                base_indent: Optional[int],
374
                all_tokens: Sequence[Sequence],
375
                indent: int,
376
                ) -> Optional[int]:
377
        """
378
        Determine the base level of indentation (i.e. one level of indentation in from the ``c`` of ``class``).
379

380
        :param base_indent: The current base level of indentation
381
        :param all_tokens:
382
        :param indent: The current level of indentation
383

384
        :returns: The base level of indentation
385
        """
386

387
        if not base_indent:
1✔
388
                if all_tokens[0][0] in pygments.token.Literal.String:
1✔
389
                        if all_tokens[0][1] in {'"""', "'''"}:
×
390
                                base_indent = indent
×
391
                elif all_tokens[0][0] in pygments.token.Keyword:
1✔
392
                        base_indent = indent
×
393
                elif all_tokens[0][0] in pygments.token.Name:
1✔
394
                        base_indent = indent
1✔
395

396
        return base_indent
1✔
397

398

399
class DocumentedEnum(Enum):
1✔
400
        """
401
        An enum where docstrings are automatically added to members from comments starting with ``doc:``.
402

403
        .. note:: This class does not (yet) support the other docstring formats :deco:`~.document_enum` does.
404
        """
405

406
        def __init__(self, value):  # noqa: MAN001
1✔
407
                document_member(self)
1✔
408
                # super().__init__(value)
409

410

411
def get_dedented_line(line: str) -> Tuple[int, str]:
1✔
412
        """
413
        Returns the line without indentation, and the amount of indentation.
414

415
        :param line: A line of Python source code
416
        """
417

418
        dedented_line = dedent(line)
1✔
419
        indent = len(line) - len(dedented_line)
1✔
420
        line = dedented_line.strip()
1✔
421

422
        return indent, line
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc