Passed
Push — develop ( cb7fd7...de7145 )
by Jace
01:17
created

doorstop/cli/commands.py (4 issues)

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
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
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
        return False
486
487
    if document:
488
        utilities.show(
489
            "imported document: {} ({})".format(document.prefix, document.relpath)
490
        )
491
    else:
492
        assert item
493
        utilities.show("imported item: {} ({})".format(item.uid, item.relpath))
494
495
    return True
496
497
498
def run_export(args, cwd, error, catch=True, auto=False, _tree=None):
499
    """Process arguments and run the `doorstop export` subcommand.
500
501
    :param args: Namespace of CLI arguments
502
    :param cwd: current working directory
503
    :param error: function to call for CLI errors
504
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
505
506
    :param auto: include placeholders for new items on import
507
508
    """
509
    whole_tree = args.prefix == 'all'
510
    ext = utilities.get_ext(args, error, '.yml', '.csv', whole_tree=whole_tree)
511
512
    # Get the tree or document
513
    with utilities.capture(catch=catch) as success:
514
515
        exporter.check(ext)
516
        tree = _tree or _get_tree(args, cwd, load=whole_tree)
517
        if not whole_tree:
518
            document = tree.find_document(args.prefix)
519
520
    if not success:
521
        return False
522
523
    # Write to output file(s)
524
    if args.path:
525
        if whole_tree:
526
            msg = "exporting tree to '{}'...".format(args.path)
527
            utilities.show(msg, flush=True)
528
            path = exporter.export(tree, args.path, ext, auto=auto)
529
        else:
530
            msg = "exporting document {} to '{}'...".format(document, args.path)
0 ignored issues
show
The variable document does not seem to be defined in case BooleanNotNode on line 517 is False. Are you sure this can never be the case?
Loading history...
531
            utilities.show(msg, flush=True)
532
            path = exporter.export(document, args.path, ext, auto=auto)
533
        if path:
534
            utilities.show("exported: {}".format(path))
535
536
    # Or, display to standard output
537
    else:
538
        if whole_tree:
539
            error("only single documents can be displayed")
540
        for line in exporter.export_lines(document, ext):
541
            utilities.show(line)
542
543
    return True
544
545
546
def run_publish(args, cwd, error, catch=True):
547
    """Process arguments and run the `doorstop publish` subcommand.
548
549
    :param args: Namespace of CLI arguments
550
    :param cwd: current working directory
551
    :param error: function to call for CLI errors
552
    :param catch: catch and log :class:`~doorstop.common.DoorstopError`
553
554
    """
555
    whole_tree = args.prefix == 'all'
556
    ext = utilities.get_ext(args, error, '.txt', '.html', whole_tree)
557
558
    # Get the tree or document
559
    with utilities.capture(catch=catch) as success:
560
561
        publisher.check(ext)
562
        tree = _get_tree(args, cwd, load=whole_tree)
563
        if not whole_tree:
564
            document = tree.find_document(args.prefix)
565
566
    if not success:
567
        return False
568
569
    # Set publishing arguments
570
    kwargs = {}
571
    if args.width:
572
        kwargs['width'] = args.width
573
574
    # Write to output file(s)
575
    if args.path:
576
        path = os.path.abspath(os.path.join(cwd, args.path))
577
        if whole_tree:
578
            msg = "publishing tree to '{}'...".format(path)
579
            utilities.show(msg, flush=True)
580
            published_path = publisher.publish(
581
                tree, path, ext, template=args.template, **kwargs
582
            )
583
        else:
584
            msg = "publishing document {} to '{}'...".format(document, path)
0 ignored issues
show
The variable document does not seem to be defined in case BooleanNotNode on line 563 is False. Are you sure this can never be the case?
Loading history...
585
            utilities.show(msg, flush=True)
586
            published_path = publisher.publish(
587
                document, path, ext, template=args.template, **kwargs
588
            )
589
        if published_path:
590
            utilities.show("published: {}".format(published_path))
591
592
    # Or, display to standard output
593
    else:
594
        if whole_tree:
595
            error("only single documents can be displayed")
596
        for line in publisher.publish_lines(document, ext, **kwargs):
597
            utilities.show(line)
598
599
    return True
600
601
602
def _request_next_number(args):
603
    """Get the server's "next number" method if a server exists."""
604
    if args.force:
605
        log.warning("creating items without the server...")
606
        return None
607
    else:
608
        server.check()
609
        return server.get_next_number
610
611
612
def _get_tree(args, cwd, request_next_number=None, load=False):
613
    """Build a tree and optionally load all documents.
614
615
    :param args: Namespace of CLI arguments
616
    :param cwd: current working directory
617
    :param request_next_number: server method to get a document's next number
618
    :param load: force the early loading of all documents
619
620
    :return: built :class:`~doorstop.core.tree.Tree`
621
622
    """
623
    utilities.show("building tree...", flush=True)
624
    tree = build(cwd=cwd, root=args.project, request_next_number=request_next_number)
625
626
    if load:
627
        utilities.show("loading documents...", flush=True)
628
        tree.load()
629
630
    return tree
631
632
633
def _iter_items(args, tree, error):
634
    """Iterate through items.
635
636
    :param args: Namespace of CLI arguments
637
    :param tree: the document hierarchy tree
638
    :param error: function to call for CLI errors
639
640
    Items are filtered to:
641
642
    - `args.label` == 'all': all items
643
    - `args.label` == document prefix: the document's items
644
    - `args.label` == item UID: a single item
645
646
    Documents and items are inferred unless flagged by:
647
648
    - `args.document`: `args.label` is a prefix
649
    - `args.item`: `args.label` is an UID
650
651
    """
652
    # Parse arguments
653
    if args.label == 'all':
654
        if args.item:
655
            error("argument -i/--item: not allowed with 'all'")
656
        if args.document:
657
            error("argument -d/--document: not allowed with 'all'")
658
659
    # Build tree
660
    item = None
661
    document = None
662
663
    # Determine if tree, document, or item was requested
664
    if args.label != 'all':
665
        if not args.item:
666
            try:
667
                document = tree.find_document(args.label)
668
            except common.DoorstopError as exc:
669
                if args.document:
670
                    raise exc from None  # pylint: disable=raising-bad-type
671
        if not document:
672
            item = tree.find_item(args.label)
673
674
    # Yield items from the requested object
675
    if item:
676
        yield item
677
    elif document:
678
        for item in document:
679
            yield item
680
    else:
681
        for document in tree:
682
            for item in document:
683
                yield item
684
685
686
def _export_import(args, cwd, error, document, ext):
687
    """Edit a document by calling export followed by import.
688
689
    :param args: Namespace of CLI arguments
690
    :param cwd: current working directory
691
    :param error: function to call for CLI errors
692
    :param document: :class:`~doorstop.core.document.Document` to edit
693
    :param ext: extension for export format
694
695
    """
696
    # Export the document to file
697
    args.prefix = document.prefix
698
    path = "{}-{}{}".format(args.prefix, int(time.time()), ext)
699
    args.path = path
700
    get('export')(args, cwd, error, catch=False, auto=True, _tree=document.tree)
701
702
    # Open the exported file
703
    editor.edit(path, tool=args.tool)
704
705
    # Import the file to the same document
706
    if utilities.ask("import from '{}'?".format(path)):
707
        args.attrs = {}
708
        args.map = {}
709
        get('import')(args, cwd, error, catch=False, _tree=document.tree)
710
        common.delete(path)
711
    else:
712
        utilities.show("import canceled")
713
        if utilities.ask("delete '{}'?".format(path)):
714
            common.delete(path)
715
        else:
716
            msg = "to manually import: doorstop import {0}".format(path)
717
            utilities.show(msg)
718