Passed
Pull Request — develop (#516)
by
unknown
04:22
created

doorstop.cli.commands.run_reorder()   C

Complexity

Conditions 9

Size

Total Lines 56
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 32
dl 0
loc 56
rs 6.6666
c 0
b 0
f 0
cc 9
nop 5

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
# SPDX-License-Identifier: LGPL-3.0-only
2
3
"""Command functions."""
4
5
import os
6
import time
7
from typing import Set
8
9
from doorstop import common, server
10
from doorstop.cli import utilities
11
from doorstop.core import editor, exporter, importer, publisher
12
from doorstop.core.builder import build
13
14
log = common.logger(__name__)
15
16
17
class CycleTracker:
18
    """A cycle tracker to detect cyclic references between items.
19
20
    The cycle tracker uses a standard algorithm to detect cycles in a directed
21
    graph (not necessarily connected) using a depth first search with a bit of
22
    graph colouring.  The time complexity is O(|V| + |E|).  The vertices are
23
    the items.  The edges are the links between items.
24
25
    """
26
27
    def __init__(self):
28
        """Initialize a cycle tracker."""
29
        self.discovered: Set[str] = set()
30
        self.finished: Set[str] = set()
31
32
    def _dfs_visit(self, uid, tree):
33
        """Do a depth first search visit of the specified item.
34
35
        :param uid: the UID of the item to visit
36
        :param tree: the document hierarchy tree
37
38
        :return: generator of :class:`~doorstop.common.DoorstopWarning`
39
40
        """
41
        self.discovered.add(uid)
42
        item = tree.find_item(uid)
43
44
        for pid in item.links:
45
            # Detect cycles via a back edge
46
            if pid in self.discovered:
47
                msg = "detected a cycle with a back edge from {} to {}".format(pid, uid)
48
                yield common.DoorstopWarning(msg)
49
50
            # Recurse, if this a fresh item
51
            if pid not in self.discovered and pid not in self.finished:
52
                yield from self._dfs_visit(pid, tree)
53
54
        self.discovered.remove(uid)
55
        self.finished.add(uid)
56
57
    def __call__(self, item, document, tree):
58
        """Get cycles which include the specified item.
59
60
        :param item: the UID of the item to get the cycles for
61
        :param document: unused
62
        :param tree: the document hierarchy tree
63
64
        :return: generator of :class:`~doorstop.common.DoorstopWarning`
65
66
        """
67
        if item not in self.discovered and item not in self.finished:
68
            yield from self._dfs_visit(item, tree)
69
70
71
def get(name):
72
    """Get a command function by name."""
73
    if name:
74
        log.debug("running command '{}'...".format(name))
75
        return globals()['run_' + name]
76
    else:
77
        log.debug("launching main command...")
78
        return run
79
80
81
def run(args, cwd, error, catch=True):  # pylint: disable=W0613
82
    """Process arguments and run the `doorstop` subcommand.
83
84
    :param args: Namespace of CLI arguments
85
    :param cwd: current working directory
86
    :param error: function to call for CLI errors
87
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
88
89
    """
90
    with utilities.capture(catch=catch) as success:
91
92
        # get the tree
93
        tree = _get_tree(args, cwd, load=True)
94
95
        # validate it
96
        utilities.show("validating items...", flush=True)
97
        cycle_tracker = CycleTracker()
98
        valid = tree.validate(skip=args.skip, item_hook=cycle_tracker)
99
100
    if not success:
101
        return False
102
103
    if len(tree) > 1 and valid:
104
        utilities.show('\n' + tree.draw() + '\n')
105
106
    return valid
107
108
109
def run_create(args, cwd, _, catch=True):
110
    """Process arguments and run the `doorstop create` subcommand.
111
112
    :param args: Namespace of CLI arguments
113
    :param cwd: current working directory
114
    :param error: function to call for CLI errors
115
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
116
117
    """
118
    with utilities.capture(catch=catch) as success:
119
120
        # get the tree
121
        tree = _get_tree(args, cwd)
122
123
        # create a new document
124
        document = tree.create_document(
125
            args.path,
126
            args.prefix,
127
            parent=args.parent,
128
            digits=args.digits,
129
            sep=args.separator,
130
        )
131
132
    if not success:
133
        return False
134
135
    utilities.show(
136
        "created document: {} ({})".format(document.prefix, document.relpath)
137
    )
138
    return True
139
140
141
def run_delete(args, cwd, _, catch=True):
142
    """Process arguments and run the `doorstop delete` subcommand.
143
144
    :param args: Namespace of CLI arguments
145
    :param cwd: current working directory
146
    :param error: function to call for CLI errors
147
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
148
149
    """
150
    with utilities.capture(catch=catch) as success:
151
152
        # get the document
153
        tree = _get_tree(args, cwd)
154
        document = tree.find_document(args.prefix)
155
156
        # delete it
157
        prefix, relpath = document.prefix, document.relpath
158
        document.delete()
159
160
    if not success:
161
        return False
162
163
    utilities.show("deleted document: {} ({})".format(prefix, relpath))
164
165
    return True
166
167
168
def run_add(args, cwd, _, catch=True):
169
    """Process arguments and run the `doorstop add` subcommand.
170
171
    :param args: Namespace of CLI arguments
172
    :param cwd: current working directory
173
    :param error: function to call for CLI errors
174
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
175
176
    """
177
    with utilities.capture(catch=catch) as success:
178
179
        # get the document
180
        request_next_number = _request_next_number(args)
181
        tree = _get_tree(args, cwd, request_next_number=request_next_number)
182
        document = tree.find_document(args.prefix)
183
184
        # add items to it
185
        for _ in range(args.count):
186
            item = document.add_item(
187
                level=args.level, defaults=args.defaults, name=args.name
188
            )
189
            utilities.show("added item: {} ({})".format(item.uid, item.relpath))
190
191
        # Edit item if requested
192
        if args.edit:
193
            item.edit(tool=args.tool)
0 ignored issues
show
introduced by
The variable item does not seem to be defined in case the for loop on line 185 is not entered. Are you sure this can never be the case?
Loading history...
194
195
    if not success:
196
        return False
197
198
    return True
199
200
201
def run_remove(args, cwd, _, catch=True):
202
    """Process arguments and run the `doorstop remove` subcommand.
203
204
    :param args: Namespace of CLI arguments
205
    :param cwd: current working directory
206
    :param error: function to call for CLI errors
207
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
208
209
    """
210
    with utilities.capture(catch=catch) as success:
211
212
        # get the item
213
        tree = _get_tree(args, cwd)
214
        item = tree.find_item(args.uid)
215
216
        # delete it
217
        item.delete()
218
219
    if not success:
220
        return False
221
222
    utilities.show("removed item: {} ({})".format(item.uid, item.relpath))
223
224
    return True
225
226
227
def run_edit(args, cwd, error, catch=True):
228
    """Process arguments and run the `doorstop edit` subcommand.
229
230
    :param args: Namespace of CLI arguments
231
    :param cwd: current working directory
232
    :param error: function to call for CLI errors
233
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
234
235
    """
236
    item = document = None
237
    ext = utilities.get_ext(args, error, '.yml', '.yml', whole_tree=False)
238
239
    with utilities.capture(catch=catch) as success:
240
241
        # get the item or document
242
        request_next_number = _request_next_number(args)
243
        tree = _get_tree(args, cwd, request_next_number=request_next_number)
244
        if not args.document:
245
            try:
246
                item = tree.find_item(args.label)
247
            except common.DoorstopError as exc:
248
                if args.item:
249
                    raise exc from None  # pylint: disable=raising-bad-type
250
        if not item:
251
            document = tree.find_document(args.label)
252
253
        # edit it
254
        if item:
255
            item.edit(tool=args.tool, edit_all=args.all)
256
        else:
257
            _export_import(args, cwd, error, document, ext)
258
259
    if not success:
260
        return False
261
262
    if item:
263
        utilities.show("opened item: {} ({})".format(item.uid, item.relpath))
264
265
    return True
266
267
268
def run_reorder(args, cwd, error, catch=True, _tree=None):
269
    """Process arguments and run the `doorstop reorder` subcommand.
270
271
    :param args: Namespace of CLI arguments
272
    :param cwd: current working directory
273
    :param error: function to call for CLI errors
274
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
275
276
    """
277
    reordered = False
278
279
    with utilities.capture(catch=catch) as success:
280
281
        # get the document
282
        tree = _tree or _get_tree(args, cwd)
283
        document = tree.find_document(args.prefix)
284
285
    if not success:
286
        return False
287
288
    with utilities.capture(catch=catch) as success:
289
290
        # automatically order
291
        if args.auto:
292
            msg = "reordering document {}...".format(document)
293
            utilities.show(msg, flush=True)
294
            document.reorder(manual=False)
295
            reordered = True
296
297
        # or, reorder from a previously updated index
298
        elif document.index:
299
            relpath = os.path.relpath(document.index, cwd)
300
            if utilities.ask("reorder from '{}'?".format(relpath)):
301
                msg = "reordering document {}...".format(document)
302
                utilities.show(msg, flush=True)
303
                document.reorder(automatic=not args.manual)
304
                reordered = True
305
            else:
306
                del document.index
307
308
        # or, create a new index to update
309
        else:
310
            document.index = True  # create index
311
            relpath = os.path.relpath(document.index, cwd)
312
            editor.edit(relpath, tool=args.tool)
313
            get('reorder')(args, cwd, error, catch=False, _tree=tree)
314
315
    if not success:
316
        msg = "after fixing the error: doorstop reorder {}".format(document)
317
        utilities.show(msg)
318
        return False
319
320
    if reordered:
321
        utilities.show("reordered document: {}".format(document))
322
323
    return True
324
325
326
def run_link(args, cwd, _, catch=True):
327
    """Process arguments and run the `doorstop link` subcommand.
328
329
    :param args: Namespace of CLI arguments
330
    :param cwd: current working directory
331
    :param error: function to call for CLI errors
332
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
333
334
    """
335
    with utilities.capture(catch=catch) as success:
336
337
        # get the tree
338
        tree = _get_tree(args, cwd)
339
340
        # link items
341
        child, parent = tree.link_items(args.child, args.parent)
342
343
    if not success:
344
        return False
345
346
    msg = "linked items: {} ({}) -> {} ({})".format(
347
        child.uid, child.relpath, parent.uid, parent.relpath
348
    )
349
    utilities.show(msg)
350
351
    return True
352
353
354
def run_unlink(args, cwd, _, catch=True):
355
    """Process arguments and run the `doorstop unlink` subcommand.
356
357
    :param args: Namespace of CLI arguments
358
    :param cwd: current working directory
359
    :param error: function to call for CLI errors
360
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
361
362
    """
363
    with utilities.capture(catch=catch) as success:
364
365
        # get the tree
366
        tree = _get_tree(args, cwd)
367
368
        # unlink items
369
        child, parent = tree.unlink_items(args.child, args.parent)
370
371
    if not success:
372
        return False
373
374
    msg = "unlinked items: {} ({}) -> {} ({})".format(
375
        child.uid, child.relpath, parent.uid, parent.relpath
376
    )
377
    utilities.show(msg)
378
379
    return True
380
381
382
def run_clear(args, cwd, error, catch=True):
383
    """Process arguments and run the `doorstop clear` subcommand.
384
385
    :param args: Namespace of CLI arguments
386
    :param cwd: current working directory
387
    :param error: function to call for CLI errors
388
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
389
390
    """
391
    with utilities.capture(catch=catch) as success:
392
        tree = _get_tree(args, cwd)
393
394
        if args.parents:
395
            # Check that the parent item UIDs exist
396
            for pid in args.parents:
397
                tree.find_item(pid)
398
399
            pids = " to " + ", ".join(args.parents)
400
        else:
401
            pids = ""
402
403
        for item in _iter_items(args, tree, error):
404
            msg = "clearing item {}'s suspect links{}...".format(item.uid, pids)
405
            utilities.show(msg)
406
            item.clear(parents=args.parents)
407
408
    if not success:
409
        return False
410
411
    return True
412
413
414
def run_review(args, cwd, error, catch=True):
415
    """Process arguments and run the `doorstop review` subcommand.
416
417
    :param args: Namespace of CLI arguments
418
    :param cwd: current working directory
419
    :param error: function to call for CLI errors
420
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
421
422
    """
423
    with utilities.capture(catch=catch) as success:
424
        tree = _get_tree(args, cwd)
425
426
        for item in _iter_items(args, tree, error):
427
            utilities.show("marking item {} as reviewed...".format(item.uid))
428
            item.review()
429
430
    if not success:
431
        return False
432
433
    return True
434
435
436
def run_import(args, cwd, error, catch=True, _tree=None):
437
    """Process arguments and run the `doorstop import` subcommand.
438
439
    :param args: Namespace of CLI arguments
440
    :param cwd: current working directory
441
    :param error: function to call for CLI errors
442
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
443
444
    """
445
    document = item = None
446
    attrs = utilities.literal_eval(args.attrs, error)
447
    mapping = utilities.literal_eval(args.map, error)
448
    if args.path:
449
        if not args.prefix:
450
            error("when [path] specified, [prefix] is also required")
451
        elif args.document:
452
            error("'--document' cannot be used with [path] [prefix]")
453
        elif args.item:
454
            error("'--item' cannot be used with [path] [prefix]")
455
        ext = utilities.get_ext(args, error, None, None)
456
    elif not (args.document or args.item):
457
        error("specify [path], '--document', or '--item' to import")
458
459
    with utilities.capture(catch=catch) as success:
460
461
        if args.path:
462
463
            # get the document
464
            request_next_number = _request_next_number(args)
465
            tree = _tree or _get_tree(
466
                args, cwd, request_next_number=request_next_number
467
            )
468
            document = tree.find_document(args.prefix)
469
470
            # import items into it
471
            msg = "importing '{}' into document {}...".format(args.path, document)
472
            utilities.show(msg, flush=True)
473
            importer.import_file(args.path, document, ext, mapping=mapping)
0 ignored issues
show
introduced by
The variable ext does not seem to be defined for all execution paths.
Loading history...
474
475
        elif args.document:
476
            prefix, path = args.document
477
            document = importer.create_document(prefix, path, parent=args.parent)
478
        elif args.item:
479
            prefix, uid = args.item
480
            request_next_number = _request_next_number(args)
481
            item = importer.add_item(
482
                prefix, uid, attrs=attrs, request_next_number=request_next_number
483
            )
484
    if not success:
485
        if args.qdc or args.qdpx:
486
            print("""QDC/QDPX import works on an existing Doostop Tree. Please change current directory or create one with the following steps:
487
# git init
488
# doorstop create ROOT_DOC some_directory
489
# doorstop import -b file.qdc ROOT_DOC
490
491
Further help in http://doorstop.readthedocs.io/ , "importing and exporting" section.
492
""")
493
        return False
494
495
    if document:
496
        utilities.show(
497
            "imported document: {} ({})".format(document.prefix, document.relpath)
498
        )
499
    else:
500
        assert item
501
        utilities.show("imported item: {} ({})".format(item.uid, item.relpath))
502
503
    return True
504
505
506
def run_export(args, cwd, error, catch=True, auto=False, _tree=None):
507
    """Process arguments and run the `doorstop export` subcommand.
508
509
    :param args: Namespace of CLI arguments
510
    :param cwd: current working directory
511
    :param error: function to call for CLI errors
512
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
513
514
    :param auto: include placeholders for new items on import
515
516
    """
517
    whole_tree = args.prefix == 'all'
518
    ext = utilities.get_ext(args, error, '.yml', '.csv', whole_tree=whole_tree)
519
520
    # Get the tree or document
521
    with utilities.capture(catch=catch) as success:
522
523
        exporter.check(ext)
524
        tree = _tree or _get_tree(args, cwd, load=whole_tree)
525
        if not whole_tree:
526
            document = tree.find_document(args.prefix)
527
528
    if not success:
529
        return False
530
531
    # Write to output file(s)
532
    if args.path:
533
        if whole_tree:
534
            msg = "exporting tree to '{}'...".format(args.path)
535
            utilities.show(msg, flush=True)
536
            path = exporter.export(tree, args.path, ext, auto=auto, whole_tree=whole_tree)
537
        else:
538
            msg = "exporting document {} to '{}'...".format(document, args.path)
0 ignored issues
show
introduced by
The variable document does not seem to be defined in case BooleanNotNode on line 525 is False. Are you sure this can never be the case?
Loading history...
539
            utilities.show(msg, flush=True)
540
            path = exporter.export(document, args.path, ext, auto=auto, whole_tree=whole_tree)
541
        if path:
542
            utilities.show("exported: {}".format(path))
543
544
    # Or, display to standard output
545
    else:
546
        if whole_tree:
547
            error("only single documents can be displayed")
548
        for line in exporter.export_lines(document, ext):
549
            utilities.show(line)
550
551
    return True
552
553
554
def run_publish(args, cwd, error, catch=True):
555
    """Process arguments and run the `doorstop publish` subcommand.
556
557
    :param args: Namespace of CLI arguments
558
    :param cwd: current working directory
559
    :param error: function to call for CLI errors
560
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
561
562
    """
563
    whole_tree = args.prefix == 'all'
564
    ext = utilities.get_ext(args, error, '.txt', '.html', whole_tree)
565
566
    # Get the tree or document
567
    with utilities.capture(catch=catch) as success:
568
569
        publisher.check(ext)
570
        tree = _get_tree(args, cwd, load=whole_tree)
571
        if not whole_tree:
572
            document = tree.find_document(args.prefix)
573
574
    if not success:
575
        return False
576
577
    # Set publishing arguments
578
    kwargs = {}
579
    if args.width:
580
        kwargs['width'] = args.width
581
582
    # Write to output file(s)
583
    if args.path:
584
        path = os.path.abspath(os.path.join(cwd, args.path))
585
        if whole_tree:
586
            msg = "publishing tree to '{}'...".format(path)
587
            utilities.show(msg, flush=True)
588
            published_path = publisher.publish(
589
                tree, path, ext, template=args.template, **kwargs
590
            )
591
        else:
592
            msg = "publishing document {} to '{}'...".format(document, path)
0 ignored issues
show
introduced by
The variable document does not seem to be defined in case BooleanNotNode on line 571 is False. Are you sure this can never be the case?
Loading history...
593
            utilities.show(msg, flush=True)
594
            published_path = publisher.publish(
595
                document, path, ext, template=args.template, **kwargs
596
            )
597
        if published_path:
598
            utilities.show("published: {}".format(published_path))
599
600
    # Or, display to standard output
601
    else:
602
        if whole_tree:
603
            error("only single documents can be displayed")
604
        for line in publisher.publish_lines(document, ext, **kwargs):
605
            utilities.show(line)
606
607
    return True
608
609
610
def _request_next_number(args):
611
    """Get the server's "next number" method if a server exists."""
612
    if args.force:
613
        log.warning("creating items without the server...")
614
        return None
615
    else:
616
        server.check()
617
        return server.get_next_number
618
619
620
def _get_tree(args, cwd, request_next_number=None, load=False):
621
    """Build a tree and optionally load all documents.
622
623
    :param args: Namespace of CLI arguments
624
    :param cwd: current working directory
625
    :param request_next_number: server method to get a document's next number
626
    :param load: force the early loading of all documents
627
628
    :return: built :class:`~doorstop.core.tree.Tree`
629
630
    """
631
    utilities.show("building tree...", flush=True)
632
    tree = build(cwd=cwd, root=args.project, request_next_number=request_next_number)
633
634
    if load:
635
        utilities.show("loading documents...", flush=True)
636
        tree.load()
637
638
    return tree
639
640
641
def _iter_items(args, tree, error):
642
    """Iterate through items.
643
644
    :param args: Namespace of CLI arguments
645
    :param tree: the document hierarchy tree
646
    :param error: function to call for CLI errors
647
648
    Items are filtered to:
649
650
    - `args.label` == 'all': all items
651
    - `args.label` == document prefix: the document's items
652
    - `args.label` == item UID: a single item
653
654
    Documents and items are inferred unless flagged by:
655
656
    - `args.document`: `args.label` is a prefix
657
    - `args.item`: `args.label` is an UID
658
659
    """
660
    # Parse arguments
661
    if args.label == 'all':
662
        if args.item:
663
            error("argument -i/--item: not allowed with 'all'")
664
        if args.document:
665
            error("argument -d/--document: not allowed with 'all'")
666
667
    # Build tree
668
    item = None
669
    document = None
670
671
    # Determine if tree, document, or item was requested
672
    if args.label != 'all':
673
        if not args.item:
674
            try:
675
                document = tree.find_document(args.label)
676
            except common.DoorstopError as exc:
677
                if args.document:
678
                    raise exc from None  # pylint: disable=raising-bad-type
679
        if not document:
680
            item = tree.find_item(args.label)
681
682
    # Yield items from the requested object
683
    if item:
684
        yield item
685
    elif document:
686
        for item in document:
687
            yield item
688
    else:
689
        for document in tree:
690
            for item in document:
691
                yield item
692
693
694
def _export_import(args, cwd, error, document, ext):
695
    """Edit a document by calling export followed by import.
696
697
    :param args: Namespace of CLI arguments
698
    :param cwd: current working directory
699
    :param error: function to call for CLI errors
700
    :param document: :class:`~doorstop.core.document.Document` to edit
701
    :param ext: extension for export format
702
703
    """
704
    # Export the document to file
705
    args.prefix = document.prefix
706
    path = "{}-{}{}".format(args.prefix, int(time.time()), ext)
707
    args.path = path
708
    get('export')(args, cwd, error, catch=False, auto=True, _tree=document.tree)
709
710
    # Open the exported file
711
    editor.edit(path, tool=args.tool)
712
713
    # Import the file to the same document
714
    if utilities.ask("import from '{}'?".format(path)):
715
        args.attrs = {}
716
        args.map = {}
717
        get('import')(args, cwd, error, catch=False, _tree=document.tree)
718
        common.delete(path)
719
    else:
720
        utilities.show("import canceled")
721
        if utilities.ask("delete '{}'?".format(path)):
722
            common.delete(path)
723
        else:
724
            msg = "to manually import: doorstop import {0}".format(path)
725
            utilities.show(msg)
726