Passed
Push — master ( 63ff1a...3c0f18 )
by Jan
07:51 queued 14s
created

sphinxarg.ext   F

Complexity

Total Complexity 126

Size/Duplication

Total Lines 541
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 394
dl 0
loc 541
rs 2
c 0
b 0
f 0
wmc 126

How to fix   Complexity   

Complexity

Complex classes like sphinxarg.ext often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import os
2
import sys
3
from argparse import ArgumentParser
4
5
from docutils import nodes
6
from docutils.frontend import OptionParser
7
from docutils.parsers.rst import Directive, Parser
8
from docutils.parsers.rst.directives import flag, unchanged
9
from docutils.statemachine import StringList
10
from docutils.utils import new_document
11
from sphinx.util.nodes import nested_parse_with_titles
12
13
from sphinxarg.parser import parse_parser, parser_navigate
14
15
from . import __version__
16
17
18
def map_nested_definitions(nested_content):
19
    if nested_content is None:
20
        raise Exception('Nested content should be iterable, not null')
21
    # build definition dictionary
22
    definitions = {}
23
    for item in nested_content:
24
        if not isinstance(item, nodes.definition_list):
25
            continue
26
        for subitem in item:
27
            if not isinstance(subitem, nodes.definition_list_item):
28
                continue
29
            if not len(subitem.children) > 0:
30
                continue
31
            classifier = '@after'
32
            idx = subitem.first_child_matching_class(nodes.classifier)
33
            if idx is not None:
34
                ci = subitem[idx]
35
                if len(ci.children) > 0:
36
                    classifier = ci.children[0].astext()
37
            if classifier is not None and classifier not in (
38
                '@replace',
39
                '@before',
40
                '@after',
41
                '@skip',
42
            ):
43
                raise Exception(f'Unknown classifier: {classifier}')
44
            idx = subitem.first_child_matching_class(nodes.term)
45
            if idx is not None:
46
                term = subitem[idx]
47
                if len(term.children) > 0:
48
                    term = term.children[0].astext()
49
                    idx = subitem.first_child_matching_class(nodes.definition)
50
                    if idx is not None:
51
                        subcontent = []
52
                        for _ in subitem[idx]:
53
                            if isinstance(_, nodes.definition_list):
54
                                subcontent.append(_)
55
                        definitions[term] = (classifier, subitem[idx], subcontent)
56
57
    return definitions
58
59
60
def render_list(l, markdown_help, settings=None):
61
    """
62
    Given a list of reStructuredText or MarkDown sections, return a docutils node list
63
    """
64
    if len(l) == 0:
65
        return []
66
    if markdown_help:
67
        from sphinxarg.markdown import parse_markdown_block
68
69
        return parse_markdown_block('\n\n'.join(l) + '\n')
70
    else:
71
        all_children = []
72
        for element in l:
73
            if isinstance(element, str):
74
                if settings is None:
75
                    settings = OptionParser(components=(Parser,)).get_default_values()
76
                document = new_document(None, settings)
77
                Parser().parse(element + '\n', document)
78
                all_children += document.children
79
            elif isinstance(element, nodes.definition):
80
                all_children += element
81
82
        return all_children
83
84
85
def print_action_groups(data, nested_content, markdown_help=False, settings=None):
86
    """
87
    Process all 'action groups', which are also include 'Options' and 'Required
88
    arguments'. A list of nodes is returned.
89
    """
90
    definitions = map_nested_definitions(nested_content)
91
    nodes_list = []
92
    if 'action_groups' in data:
93
        for action_group in data['action_groups']:
94
            # Every action group is comprised of a section, holding a title, the description, and the option group (members)
95
            section = nodes.section(ids=[action_group['title']])
96
            section += nodes.title(action_group['title'], action_group['title'])
97
98
            desc = []
99
            if action_group['description']:
100
                desc.append(action_group['description'])
101
            # Replace/append/prepend content to the description according to nested content
102
            subcontent = []
103
            if action_group['title'] in definitions:
104
                classifier, s, subcontent = definitions[action_group['title']]
105
                if classifier == '@replace':
106
                    desc = [s]
107
                elif classifier == '@after':
108
                    desc.append(s)
109
                elif classifier == '@before':
110
                    desc.insert(0, s)
111
                elif classifier == '@skip':
112
                    continue
113
                if len(subcontent) > 0:
114
                    for k, v in map_nested_definitions(subcontent).items():
115
                        definitions[k] = v
116
            # Render appropriately
117
            for element in render_list(desc, markdown_help):
118
                section += element
119
120
            local_definitions = definitions
121
            if len(subcontent) > 0:
122
                local_definitions = {k: v for k, v in definitions.items()}
123
                for k, v in map_nested_definitions(subcontent).items():
124
                    local_definitions[k] = v
125
126
            items = []
127
            # Iterate over action group members
128
            for entry in action_group['options']:
129
                # Members will include:
130
                #    default	The default value. This may be ==SUPPRESS==
131
                #    name	A list of option names (e.g., ['-h', '--help']
132
                #    help	The help message string
133
                # There may also be a 'choices' member.
134
                # Build the help text
135
                arg = []
136
                if 'choices' in entry:
137
                    arg.append(f"Possible choices: {', '.join(str(c) for c in entry['choices'])}\n")
138
                if 'help' in entry:
139
                    arg.append(entry['help'])
140
                if entry['default'] is not None and entry['default'] not in [
141
                    '"==SUPPRESS=="',
142
                    '==SUPPRESS==',
143
                ]:
144
                    if entry['default'] == '':
145
                        arg.append('Default: ""')
146
                    else:
147
                        arg.append(f"Default: {entry['default']}")
148
149
                # Handle nested content, the term used in the dict has the comma removed for simplicity
150
                desc = arg
151
                term = ' '.join(entry['name'])
152
                if term in local_definitions:
153
                    classifier, s, subcontent = local_definitions[term]
154
                    if classifier == '@replace':
155
                        desc = [s]
156
                    elif classifier == '@after':
157
                        desc.append(s)
158
                    elif classifier == '@before':
159
                        desc.insert(0, s)
160
                term = ', '.join(entry['name'])
161
162
                n = nodes.option_list_item(
163
                    '',
164
                    nodes.option_group('', nodes.option_string(text=term)),
165
                    nodes.description('', *render_list(desc, markdown_help, settings)),
166
                )
167
                items.append(n)
168
169
            section += nodes.option_list('', *items)
170
            nodes_list.append(section)
171
172
    return nodes_list
173
174
175
def print_subcommands(data, nested_content, markdown_help=False, settings=None):  # noqa: N803
176
    """
177
    Each subcommand is a dictionary with the following keys:
178
179
    ['usage', 'action_groups', 'bare_usage', 'name', 'help']
180
181
    In essence, this is all tossed in a new section with the title 'name'.
182
    Apparently there can also be a 'description' entry.
183
    """
184
185
    definitions = map_nested_definitions(nested_content)
186
    items = []
187
    if 'children' in data:
188
        subcommands = nodes.section(ids=["Sub-commands:"])
189
        subcommands += nodes.title('Sub-commands:', 'Sub-commands:')
190
191
        for child in data['children']:
192
            sec = nodes.section(ids=[child['name']])
193
            sec += nodes.title(child['name'], child['name'])
194
195
            if 'description' in child and child['description']:
196
                desc = [child['description']]
197
            elif child['help']:
198
                desc = [child['help']]
199
            else:
200
                desc = ['Undocumented']
201
202
            # Handle nested content
203
            subcontent = []
204
            if child['name'] in definitions:
205
                classifier, s, subcontent = definitions[child['name']]
206
                if classifier == '@replace':
207
                    desc = [s]
208
                elif classifier == '@after':
209
                    desc.append(s)
210
                elif classifier == '@before':
211
                    desc.insert(0, s)
212
213
            for element in render_list(desc, markdown_help):
214
                sec += element
215
            sec += nodes.literal_block(text=child['bare_usage'])
216
            for x in print_action_groups(child, nested_content + subcontent, markdown_help, settings=settings):
217
                sec += x
218
219
            for x in print_subcommands(child, nested_content + subcontent, markdown_help, settings=settings):
220
                sec += x
221
222
            if 'epilog' in child and child['epilog']:
223
                for element in render_list([child['epilog']], markdown_help):
224
                    sec += element
225
226
            subcommands += sec
227
        items.append(subcommands)
228
229
    return items
230
231
232
def ensure_unique_ids(items):
233
    """
234
    If action groups are repeated, then links in the table of contents will
235
    just go to the first of the repeats. This may not be desirable, particularly
236
    in the case of subcommands where the option groups have different members.
237
    This function updates the title IDs by adding _repeatX, where X is a number
238
    so that the links are then unique.
239
    """
240
    s = set()
241
    for item in items:
242
        for n in item.traverse(descend=True, siblings=True, ascend=False):
243
            if isinstance(n, nodes.section):
244
                ids = n['ids']
245
                for idx, id in enumerate(ids):
246
                    if id not in s:
247
                        s.add(id)
248
                    else:
249
                        i = 1
250
                        while f"{id}_repeat{i}" in s:
251
                            i += 1
252
                        ids[idx] = f"{id}_repeat{i}"
253
                        s.add(ids[idx])
254
                n['ids'] = ids
255
256
257
class ArgParseDirective(Directive):
258
    has_content = True
259
    option_spec = dict(
260
        module=unchanged,
261
        func=unchanged,
262
        ref=unchanged,
263
        prog=unchanged,
264
        path=unchanged,
265
        nodefault=flag,
266
        nodefaultconst=flag,
267
        filename=unchanged,
268
        manpage=unchanged,
269
        nosubcommands=unchanged,
270
        passparser=flag,
271
        noepilog=unchanged,
272
        nodescription=unchanged,
273
        markdown=flag,
274
        markdownhelp=flag,
275
    )
276
277
    def _construct_manpage_specific_structure(self, parser_info):
278
        """
279
        Construct a typical man page consisting of the following elements:
280
            NAME (automatically generated, out of our control)
281
            SYNOPSIS
282
            DESCRIPTION
283
            OPTIONS
284
            FILES
285
            SEE ALSO
286
            BUGS
287
        """
288
        items = []
289
        # SYNOPSIS section
290
        synopsis_section = nodes.section(
291
            '',
292
            nodes.title(text='Synopsis'),
293
            nodes.literal_block(text=parser_info["bare_usage"]),
294
            ids=['synopsis-section'],
295
        )
296
        items.append(synopsis_section)
297
        # DESCRIPTION section
298
        if 'nodescription' not in self.options:
299
            description_section = nodes.section(
300
                '',
301
                nodes.title(text='Description'),
302
                nodes.paragraph(
303
                    text=parser_info.get(
304
                        'description',
305
                        parser_info.get('help', "undocumented").capitalize(),
306
                    )
307
                ),
308
                ids=['description-section'],
309
            )
310
            nested_parse_with_titles(self.state, self.content, description_section)
311
            items.append(description_section)
312
        if parser_info.get('epilog') and 'noepilog' not in self.options:
313
            # TODO: do whatever sphinx does to understand ReST inside
314
            # docstrings magically imported from other places. The nested
315
            # parse method invoked above seem to be able to do this but
316
            # I haven't found a way to do it for arbitrary text
317
            if description_section:
318
                description_section += nodes.paragraph(text=parser_info['epilog'])
319
            else:
320
                description_section = nodes.paragraph(text=parser_info['epilog'])
321
                items.append(description_section)
322
        # OPTIONS section
323
        options_section = nodes.section('', nodes.title(text='Options'), ids=['options-section'])
324
        if 'args' in parser_info:
325
            options_section += nodes.paragraph()
326
            options_section += nodes.subtitle(text='Positional arguments:')
327
            options_section += self._format_positional_arguments(parser_info)
328
        for action_group in parser_info['action_groups']:
329
            if 'options' in parser_info:
330
                options_section += nodes.paragraph()
331
                options_section += nodes.subtitle(text=action_group['title'])
332
                options_section += self._format_optional_arguments(action_group)
333
334
        # NOTE: we cannot generate NAME ourselves. It is generated by
335
        # docutils.writers.manpage
336
        # TODO: items.append(files)
337
        # TODO: items.append(see also)
338
        # TODO: items.append(bugs)
339
340
        if len(options_section.children) > 1:
341
            items.append(options_section)
342
        if 'nosubcommands' not in self.options:
343
            # SUBCOMMANDS section (non-standard)
344
            subcommands_section = nodes.section('', nodes.title(text='Sub-Commands'), ids=['subcommands-section'])
345
            if 'children' in parser_info:
346
                subcommands_section += self._format_subcommands(parser_info)
347
            if len(subcommands_section) > 1:
348
                items.append(subcommands_section)
349
        if os.getenv("INCLUDE_DEBUG_SECTION"):
350
            import json
351
352
            # DEBUG section (non-standard)
353
            debug_section = nodes.section(
354
                '',
355
                nodes.title(text="Argparse + Sphinx Debugging"),
356
                nodes.literal_block(text=json.dumps(parser_info, indent='  ')),
357
                ids=['debug-section'],
358
            )
359
            items.append(debug_section)
360
        return items
361
362
    def _format_positional_arguments(self, parser_info):
363
        assert 'args' in parser_info
364
        items = []
365
        for arg in parser_info['args']:
366
            arg_items = []
367
            if arg['help']:
368
                arg_items.append(nodes.paragraph(text=arg['help']))
369
            elif 'choices' not in arg:
370
                arg_items.append(nodes.paragraph(text='Undocumented'))
371
            if 'choices' in arg:
372
                arg_items.append(nodes.paragraph(text='Possible choices: ' + ', '.join(arg['choices'])))
373
            items.append(
374
                nodes.option_list_item(
375
                    '',
376
                    nodes.option_group('', nodes.option('', nodes.option_string(text=arg['metavar']))),
377
                    nodes.description('', *arg_items),
378
                )
379
            )
380
        return nodes.option_list('', *items)
381
382
    def _format_optional_arguments(self, parser_info):
383
        assert 'options' in parser_info
384
        items = []
385
        for opt in parser_info['options']:
386
            names = []
387
            opt_items = []
388
            for name in opt['name']:
389
                option_declaration = [nodes.option_string(text=name)]
390
                if opt['default'] is not None and opt['default'] not in [
391
                    '"==SUPPRESS=="',
392
                    '==SUPPRESS==',
393
                ]:
394
                    option_declaration += nodes.option_argument('', text='=' + str(opt['default']))
395
                names.append(nodes.option('', *option_declaration))
396
            if opt['help']:
397
                opt_items.append(nodes.paragraph(text=opt['help']))
398
            elif 'choices' not in opt:
399
                opt_items.append(nodes.paragraph(text='Undocumented'))
400
            if 'choices' in opt:
401
                opt_items.append(nodes.paragraph(text='Possible choices: ' + ', '.join(opt['choices'])))
402
            items.append(
403
                nodes.option_list_item(
404
                    '',
405
                    nodes.option_group('', *names),
406
                    nodes.description('', *opt_items),
407
                )
408
            )
409
        return nodes.option_list('', *items)
410
411
    def _format_subcommands(self, parser_info):
412
        assert 'children' in parser_info
413
        items = []
414
        for subcmd in parser_info['children']:
415
            subcmd_items = []
416
            if subcmd['help']:
417
                subcmd_items.append(nodes.paragraph(text=subcmd['help']))
418
            else:
419
                subcmd_items.append(nodes.paragraph(text='Undocumented'))
420
            items.append(
421
                nodes.definition_list_item(
422
                    '',
423
                    nodes.term('', '', nodes.strong(text=subcmd['bare_usage'])),
424
                    nodes.definition('', *subcmd_items),
425
                )
426
            )
427
        return nodes.definition_list('', *items)
428
429
    def _nested_parse_paragraph(self, text):
430
        content = nodes.paragraph()
431
        self.state.nested_parse(StringList(text.split("\n")), 0, content)
432
        return content
433
434
    def run(self):
435
        if 'module' in self.options and 'func' in self.options:
436
            module_name = self.options['module']
437
            attr_name = self.options['func']
438
        elif 'ref' in self.options:
439
            _parts = self.options['ref'].split('.')
440
            module_name = '.'.join(_parts[0:-1])
441
            attr_name = _parts[-1]
442
        elif 'filename' in self.options and 'func' in self.options:
443
            mod = {}
444
            try:
445
                f = open(self.options['filename'])
446
            except OSError:
447
                # try open with abspath
448
                f = open(os.path.abspath(self.options['filename']))
449
            code = compile(f.read(), self.options['filename'], 'exec')
450
            exec(code, mod)
451
            attr_name = self.options['func']
452
            func = mod[attr_name]
453
        else:
454
            raise self.error(':module: and :func: should be specified, or :ref:, or :filename: and :func:')
455
456
        # Skip this if we're dealing with a local file, since it obviously can't be imported
457
        if 'filename' not in self.options:
458
            try:
459
                mod = __import__(module_name, globals(), locals(), [attr_name])
460
            except ImportError:
461
                raise self.error(f'Failed to import "{attr_name}" from "{module_name}".\n{sys.exc_info()[1]}')
462
463
            if not hasattr(mod, attr_name):
464
                raise self.error(('Module "%s" has no attribute "%s"\n' 'Incorrect argparse :module: or :func: values?') % (module_name, attr_name))
465
            func = getattr(mod, attr_name)
466
467
        if isinstance(func, ArgumentParser):
468
            parser = func
469
        elif 'passparser' in self.options:
470
            parser = ArgumentParser()
471
            func(parser)
472
        else:
473
            parser = func()
474
        if 'path' not in self.options:
475
            self.options['path'] = ''
476
        path = str(self.options['path'])
477
        if 'prog' in self.options:
478
            parser.prog = self.options['prog']
479
        result = parse_parser(
480
            parser,
481
            skip_default_values='nodefault' in self.options,
482
            skip_default_const_values='nodefaultconst' in self.options,
483
        )
484
        result = parser_navigate(result, path)
485
        if 'manpage' in self.options:
486
            return self._construct_manpage_specific_structure(result)
487
488
        # Handle nested content, where markdown needs to be preprocessed
489
        items = []
490
        nested_content = nodes.paragraph()
491
        if 'markdown' in self.options:
492
            from sphinxarg.markdown import parse_markdown_block
493
494
            items.extend(parse_markdown_block('\n'.join(self.content) + '\n'))
495
        else:
496
            self.state.nested_parse(self.content, self.content_offset, nested_content)
497
            nested_content = nested_content.children
498
        # add common content between
499
        for item in nested_content:
500
            if not isinstance(item, nodes.definition_list):
501
                items.append(item)
502
503
        markdown_help = False
504
        if 'markdownhelp' in self.options:
505
            markdown_help = True
506
        if 'description' in result and 'nodescription' not in self.options:
507
            if markdown_help:
508
                items.extend(render_list([result['description']], True))
509
            else:
510
                items.append(self._nested_parse_paragraph(result['description']))
511
        items.append(nodes.literal_block(text=result['usage']))
512
        items.extend(
513
            print_action_groups(
514
                result,
515
                nested_content,
516
                markdown_help,
517
                settings=self.state.document.settings,
518
            )
519
        )
520
        if 'nosubcommands' not in self.options:
521
            items.extend(
522
                print_subcommands(
523
                    result,
524
                    nested_content,
525
                    markdown_help,
526
                    settings=self.state.document.settings,
527
                )
528
            )
529
        if 'epilog' in result and 'noepilog' not in self.options:
530
            items.append(self._nested_parse_paragraph(result['epilog']))
531
532
        # Traverse the returned nodes, modifying the title IDs as necessary to avoid repeats
533
        ensure_unique_ids(items)
534
535
        return items
536
537
538
def setup(app):
539
    app.add_directive('argparse', ArgParseDirective)
540
    return {'parallel_read_safe': True, 'version': __version__}
541