Issues (16)

doorstop/cli/commands.py (1 issue)

check_variables.sometimes_not_defined

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