doorstop.core.tree.Tree.add_item()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 18
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 18
rs 10
c 0
b 0
f 0
cc 1
nop 5
1
#!/usr/bin/env python
2
# -*- coding: utf-8 -*-
3
# SPDX-License-Identifier: LGPL-3.0-only
4
5
"""Representation of a hierarchy of documents."""
6
7
import sys
8
from itertools import chain
9
from typing import Dict, List, Optional, Union
10
11
from doorstop import common, settings
12
from doorstop.common import DoorstopError, DoorstopWarning
13
from doorstop.core import vcs
14
from doorstop.core.base import BaseValidatable
15
from doorstop.core.document import Document
16
from doorstop.core.item import Item
17
from doorstop.core.types import UID, Prefix
18
19
UTF8 = "utf-8"
20
CP437 = "cp437"
21
ASCII = "ascii"
22
23
BOX = {
24
    "end": {UTF8: "│   ", CP437: "┬   ", ASCII: "|   "},
25
    "tee": {UTF8: "├── ", CP437: "├── ", ASCII: "+-- "},
26
    "bend": {UTF8: "└── ", CP437: "└── ", ASCII: "+-- "},
27
    "pipe": {UTF8: "│   ", CP437: "│   ", ASCII: "|   "},
28
    "space": {UTF8: "    ", CP437: "    ", ASCII: "    "},
29
}
30
31
log = common.logger(__name__)
32
33
34
class Tree(BaseValidatable):  # pylint: disable=R0902
35
    """A bidirectional tree structure to store a hierarchy of documents.
36
37
    Although requirements link "upwards", bidirectionality simplifies
38
    document processing and validation.
39
40
    """
41
42
    @staticmethod
43
    def from_list(documents, root=None):
44
        """Initialize a new tree from a list of documents.
45
46
        :param documents: list of :class:`~doorstop.core.document.Document`
47
        :param root: path to root of the project
48
49
        :raises: :class:`~doorstop.common.DoorstopError` when the tree
50
            cannot be built
51
52
        :return: new :class:`~doorstop.core.tree.Tree`
53
54
        """
55
        if not documents:
56
            return Tree(document=None, root=root)
57
        unplaced = list(documents)
58
        for document in list(unplaced):
59
            if document.parent is None:
60
                log.info("root of the tree: {}".format(document))
61
                tree = Tree(document)
62
                document.tree = tree
63
                unplaced.remove(document)
64
                break
65
        else:
66
            raise DoorstopError("no root document")
67
68
        while unplaced:
69
            count = len(unplaced)
70
            for document in list(unplaced):
71
                if document.parent is None:
72
                    log.info("root of the tree: {}".format(document))
73
                    message = "multiple root documents:\n- {}: {}\n- {}: {}".format(
74
                        tree.document.prefix,
75
                        tree.document.path,
76
                        document.prefix,
77
                        document.path,
78
                    )
79
                    raise DoorstopError(message)
80
                try:
81
                    tree._place(document)  # pylint: disable=W0212
82
                except DoorstopError as error:
83
                    log.debug(error)
84
                else:
85
                    log.info("added to tree: {}".format(document))
86
                    document.tree = tree
87
                    unplaced.remove(document)
88
89
            if len(unplaced) == count:  # no more documents could be placed
90
                log.debug("unplaced documents: {}".format(unplaced))
91
                msg = "unplaced document: {}".format(unplaced[0])
92
                raise DoorstopError(msg)
93
94
        return tree
95
96
    def __init__(self, document, parent=None, root=None):
97
        self.document = document
98
        self.root = root or document.root  # enables mock testing
99
        self.parent = parent
100
        self.children: List[Tree] = []
101
        self._vcs = None  # working copy reference loaded in a property
102
        self.request_next_number = None  # server method injected by clients
103
        self._loaded = False
104
        self._item_cache: Dict[Union[str, UID], Item] = {}
105
        self._document_cache: Dict[str, Optional[Document]] = {}
106
107
    def __repr__(self):
108
        return "<Tree {}>".format(self._draw_line())
109
110
    def __str__(self):
111
        return self._draw_line()
112
113
    def __len__(self):
114
        if self.document:
115
            return 1 + sum(len(child) for child in self.children)
116
        else:
117
            return 0
118
119
    def __bool__(self):
120
        """Even empty trees should be considered truthy."""
121
        return True
122
123
    def __getitem__(self, key):
124
        raise IndexError("{} cannot be indexed by key".format(self.__class__))
125
126
    def __iter__(self):
127
        if self.document:
128
            yield self.document
129
        yield from chain(*(iter(c) for c in self.children))
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable c does not seem to be defined.
Loading history...
130
131
    def _place(self, document):
132
        """Attempt to place the document in the current tree.
133
134
        :param document: :class:`doorstop.core.document.Document` to add
135
136
        :raises: :class:`~doorstop.common.DoorstopError` if the document
137
            cannot yet be placed
138
139
        """
140
        log.debug("trying to add {}...".format(document))
141
        if not self.document:  # tree is empty
142
            if document.parent:
143
                msg = "unknown parent for {}: {}".format(document, document.parent)
144
                raise DoorstopError(msg)
145
            self.document = document
146
147
        elif document.parent:  # tree has documents, document has parent
148
            if document.parent.lower() == self.document.prefix.lower():
149
                # Current document is the parent
150
                node = Tree(document, self)
151
                self.children.append(node)
152
153
            else:
154
                # Search for the parent
155
                for child in self.children:
156
                    try:
157
                        child._place(document)  # pylint: disable=W0212
158
                    except DoorstopError:
159
                        pass  # the error is raised later
160
                    else:
161
                        break
162
                else:
163
                    msg = "unknown parent for {}: {}".format(document, document.parent)
164
                    raise DoorstopError(msg)
165
166
        else:  # tree has documents, but no parent specified for document
167
            msg = "no parent specified for {}".format(document)
168
            log.info(msg)
169
            prefixes = ", ".join(document.prefix for document in self)
170
            log.info("parent options: {}".format(prefixes))
171
            raise DoorstopError(msg)
172
173
        for document2 in self:
174
            children = self._get_prefix_of_children(document2)
175
            document2.children = children
176
177
    # attributes #############################################################
178
179
    @property
180
    def documents(self):
181
        """Get an list of documents in the tree."""
182
        return list(self)
183
184
    @property
185
    def vcs(self):
186
        """Get the working copy."""
187
        if self._vcs is None:
188
            self._vcs = vcs.load(self.root)
189
        return self._vcs
190
191
    # actions ################################################################
192
193
    # decorators are applied to methods in the associated classes
194
    def create_document(
195
        self, path, value, sep=None, digits=None, parent=None, itemformat=None
196
    ):  # pylint: disable=R0913
197
        """Create a new document and add it to the tree.
198
199
        :param path: directory path for the new document
200
        :param value: document or prefix
201
        :param sep: separator between prefix and numbers
202
        :param digits: number of digits for the document's numbers
203
        :param parent: parent document's prefix
204
        :param itemformat: file format for storing items
205
206
        :raises: :class:`~doorstop.common.DoorstopError` if the
207
            document cannot be created
208
209
        :return: newly created and placed document
210
            :class:`~doorstop.core.document.Document`
211
212
        """
213
        prefix = Prefix(value)
214
215
        # Check if a document with the same name already exists in the tree.
216
        for d in self.documents:
217
            if d.prefix == value:
218
                raise DoorstopError(
219
                    "The document name is already in use ({}).".format(d.path)
220
                )
221
222
        document = Document.new(
223
            self,
224
            path,
225
            self.root,
226
            prefix,
227
            sep=sep,
228
            digits=digits,
229
            parent=parent,
230
            itemformat=itemformat,
231
        )
232
        try:
233
            self._place(document)
234
        except DoorstopError:
235
            msg = "deleting unplaced directory {}...".format(document.path)
236
            log.debug(msg)
237
            document.delete()
238
            raise
239
        else:
240
            log.info("added to tree: {}".format(document))
241
        return document
242
243
    # decorators are applied to methods in the associated classes
244
    def add_item(self, value, number=None, level=None, reorder=True):
245
        """Add a new item to an existing document by prefix.
246
247
        :param value: document or prefix
248
        :param number: desired item number
249
        :param level: desired item level
250
        :param reorder: update levels of document items
251
252
        :raises: :class:`~doorstop.common.DoorstopError` if the item
253
            cannot be created
254
255
        :return: newly created :class:`~doorstop.core.item.Item`
256
257
        """
258
        prefix = Prefix(value)
259
        document = self.find_document(prefix)
260
        item = document.add_item(number=number, level=level, reorder=reorder)
261
        return item
262
263
    # decorators are applied to methods in the associated classes
264
    def remove_item(self, value, reorder=True):
265
        """Remove an item from a document by UID.
266
267
        :param value: item or UID
268
        :param reorder: update levels of document items
269
270
        :raises: :class:`~doorstop.common.DoorstopError` if the item
271
            cannot be removed
272
273
        :return: removed :class:`~doorstop.core.item.Item`
274
275
        """
276
        uid = UID(value)
277
        for document in self:
278
            try:
279
                document.find_item(uid)
280
            except DoorstopError:
281
                pass  # item not found in that document
282
            else:
283
                item = document.remove_item(uid, reorder=reorder)
284
                return item
285
286
        raise DoorstopError(UID.UNKNOWN_MESSAGE.format(k="", u=uid))
287
288
    def check_for_cycle(self, item, cid, path):
289
        """Check if a cyclic dependency would be created.
290
291
        :param item: an item on the dependency path
292
        :param cid: the child item's UID
293
        :param path: the path of UIDs from the child item to the item
294
295
        :raises: :class:`~doorstop.common.DoorstopError` if the link
296
            would create a cyclic dependency
297
        """
298
        for did in item.links:
299
            path2 = path + [did]
300
            if did in path:
301
                s = " -> ".join(list(map(str, path2)))
302
                msg = "link would create a cyclic dependency: {}".format(s)
303
                raise DoorstopError(msg)
304
            dep = self.find_item(did, _kind="dependency")
305
            self.check_for_cycle(dep, cid, path2)
306
307
    # decorators are applied to methods in the associated classes
308
    def link_items(self, cid, pid):
309
        """Add a new link between two items by UIDs.
310
311
        :param cid: child item's UID (or child item)
312
        :param pid: parent item's UID (or parent item)
313
314
        :raises: :class:`~doorstop.common.DoorstopError` if the link
315
            cannot be created
316
317
        :return: child :class:`~doorstop.core.item.Item`,
318
                 parent :class:`~doorstop.core.item.Item`
319
320
        """
321
        log.info("linking {} to {}...".format(cid, pid))
322
        # Find child item
323
        child = self.find_item(cid, _kind="child")
324
        # Find parent item
325
        parent = self.find_item(pid, _kind="parent")
326
        # Add link if it is not a self reference or cyclic dependency
327
        if child is parent:
328
            raise DoorstopError("link would be self reference")
329
        self.check_for_cycle(parent, child.uid, [child.uid, parent.uid])
330
        child.link(parent.uid)
331
        return child, parent
332
333
    # decorators are applied to methods in the associated classes`
334
    def unlink_items(self, cid, pid):
335
        """Remove a link between two items by UIDs.
336
337
        :param cid: child item's UID (or child item)
338
        :param pid: parent item's UID (or parent item)
339
340
        :raises: :class:`~doorstop.common.DoorstopError` if the link
341
            cannot be removed
342
343
        :return: child :class:`~doorstop.core.item.Item`,
344
                 parent :class:`~doorstop.core.item.Item`
345
346
        """
347
        log.info("unlinking '{}' from '{}'...".format(cid, pid))
348
        # Find child item
349
        child = self.find_item(cid, _kind="child")
350
        # Find parent item
351
        parent = self.find_item(pid, _kind="parent")
352
        # Remove link
353
        child.unlink(parent.uid)
354
        return child, parent
355
356
    # decorators are applied to methods in the associated classes
357
    def edit_item(self, uid, tool=None, launch=False):
358
        """Open an item for editing by UID.
359
360
        :param uid: item's UID (or item)
361
        :param tool: alternative text editor to open the item
362
        :param launch: open the text editor
363
364
        :raises: :class:`~doorstop.common.DoorstopError` if the item
365
            cannot be found
366
367
        :return: edited :class:`~doorstop.core.item.Item`
368
369
        """
370
        # Find the item
371
        item = self.find_item(uid)
372
        # Edit the item
373
        if launch:
374
            item.edit(tool=tool)
375
        # Return the item
376
        return item
377
378
    def find_document(self, value) -> Document:
379
        """Get a document by its prefix.
380
381
        :param value: document or prefix
382
383
        :raises: :class:`~doorstop.common.DoorstopError` if the document
384
            cannot be found
385
386
        :return: matching :class:`~doorstop.core.document.Document`
387
388
        """
389
        prefix = Prefix(value)
390
        log.debug("looking for document '{}'...".format(prefix))
391
        try:
392
            document = self._document_cache[prefix]
393
            if document:
394
                log.trace("found cached document: {}".format(document))  # type: ignore
395
                return document
396
            else:
397
                log.trace("found cached unknown: {}".format(prefix))  # type: ignore
398
        except KeyError:
399
            for document in self:
400
                if not document:
401
                    # TODO: mypy seems to think document can be None here, but that shouldn't be possible
402
                    continue
403
                if document.prefix == prefix:
404
                    log.trace("found document: {}".format(document))  # type: ignore
405
                    if settings.CACHE_DOCUMENTS:
406
                        self._document_cache[prefix] = document
407
                        log.trace(  # type: ignore
408
                            "cached document: {}".format(document)
409
                        )
410
                    return document
411
            log.debug("could not find document: {}".format(prefix))
412
            if settings.CACHE_DOCUMENTS:
413
                self._document_cache[prefix] = None
414
                log.trace("cached unknown: {}".format(prefix))  # type: ignore
415
416
        raise DoorstopError(Prefix.UNKNOWN_MESSAGE.format(prefix))
417
418
    def find_item(self, value, _kind=""):
419
        """Get an item by its UID.
420
421
        :param value: item or UID
422
423
        :raises: :class:`~doorstop.common.DoorstopError` if the item
424
            cannot be found
425
426
        :return: matching :class:`~doorstop.core.item.Item`
427
428
        """
429
        uid = UID(value)
430
        _kind = (" " + _kind) if _kind else _kind  # for logging messages
431
        log.debug("looking for{} item '{}'...".format(_kind, uid))
432
        try:
433
            item = self._item_cache[uid]
434
            if item:
435
                log.trace("found cached item: {}".format(item))  # type: ignore
436
                if item.active:
437
                    return item
438
                else:
439
                    log.trace("item is inactive: {}".format(item))  # type: ignore
440
            else:
441
                log.trace("found cached unknown: {}".format(uid))  # type: ignore
442
        except KeyError:
443
            for document in self:
444
                try:
445
                    item = document.find_item(uid, _kind=_kind)
446
                except DoorstopError:
447
                    pass  # item not found in that document
448
                else:
449
                    log.trace("found item: {}".format(item))  # type: ignore
450
                    if settings.CACHE_ITEMS:
451
                        self._item_cache[uid] = item
452
                        log.trace("cached item: {}".format(item))  # type: ignore
453
                    if item.active:
454
                        return item
455
                    else:
456
                        log.trace("item is inactive: {}".format(item))  # type: ignore
457
458
            log.debug("could not find item: {}".format(uid))
459
            if settings.CACHE_ITEMS:
460
                self._item_cache[uid] = None  # type: ignore
461
                log.trace("cached unknown: {}".format(uid))  # type: ignore
462
463
        raise DoorstopError(UID.UNKNOWN_MESSAGE.format(k=_kind, u=uid))
464
465
    def get_issues(self, skip=None, document_hook=None, item_hook=None):
466
        """Yield all the tree's issues.
467
468
        :param skip: list of document prefixes to skip
469
        :param document_hook: function to call for custom document validation
470
        :param item_hook: function to call for custom item validation
471
472
        :return: generator of :class:`~doorstop.common.DoorstopError`,
473
                              :class:`~doorstop.common.DoorstopWarning`,
474
                              :class:`~doorstop.common.DoorstopInfo`
475
476
        """
477
        hook = document_hook if document_hook else lambda **kwargs: []
478
        documents = list(self)
479
        # Check for documents
480
        if not documents:
481
            yield DoorstopWarning("no documents")
482
        # Check each document
483
        for document in documents:
484
            for issue in chain(
485
                hook(document=document, tree=self),
486
                document.get_issues(skip=skip, item_hook=item_hook),
487
            ):
488
                # Prepend the document's prefix to yielded exceptions
489
                if isinstance(issue, Exception):
490
                    yield type(issue)("{}: {}".format(document.prefix, issue))
491
492
    def get_traceability(self):
493
        """Return sorted rows of traceability slices.
494
495
        :return: list of list of :class:`~doorstop.core.item.Item` or `None`
496
497
        """
498
499
        def by_uid(row):
500
            row2 = []
501
            for item in row:
502
                if item:
503
                    row2.append("0" + str(item.uid))
504
                else:
505
                    row2.append("1")  # force `None` to sort after items
506
            return row2
507
508
        # Create mapping of document prefix to slice index
509
        mapping = {}
510
        for index, document in enumerate(self.documents):
511
            mapping[document.prefix] = index
512
513
        # Collect all rows
514
        rows = set()
515
        for index, document in enumerate(self.documents):
516
            for item in document:
517
                if item.active:
518
                    for row in self._iter_rows(item, mapping):
519
                        rows.add(row)
520
521
        # Sort rows
522
        return sorted(rows, key=by_uid)
523
524
    def _get_prefix_of_children(self, document):
525
        """Return the prefixes of the children of this document."""
526
        for child in self.children:
527
            if child.document == document:
528
                children = [c.document.prefix for c in child.children]
529
                return children
530
        children = [c.document.prefix for c in self.children]
531
        return children
532
533
    def _iter_rows(
534
        self, item, mapping, parent=True, child=True, row=None
535
    ):  # pylint: disable=R0913
536
        """Generate all traceability row slices.
537
538
        :param item: base :class:`~doorstop.core.item.Item` for slicing
539
        :param mapping: `dict` of document prefix to slice index
540
        :param parent: indicate recursion is in the parent direction
541
        :param child: indicates recursion is in the child direction
542
        :param row: currently generated row
543
544
        """
545
546
        class Row(list):
547
            """List type that tracks upper and lower boundaries."""
548
549
            def __init__(self, *args, parent=False, child=False, **kwargs):
550
                super().__init__(*args, **kwargs)
551
                # Flags to indicate upper and lower bounds have been hit
552
                self.parent = parent
553
                self.child = child
554
555
        if item.normative:
556
            # Start the next row or copy from recursion
557
            if row is None:
558
                row = Row([None] * len(mapping))
559
            else:
560
                row = Row(row, parent=row.parent, child=row.child)
561
562
            # Add the current item to the row
563
            row[mapping[item.document.prefix]] = item
564
565
            # Recurse to the next parent/child item
566
            if parent:
567
                items = item.parent_items
568
                for item2 in items:
569
                    yield from self._iter_rows(item2, mapping, child=False, row=row)
570
                if not items:
571
                    row.parent = True
572
            if child:
573
                items = item.child_items
574
                for item2 in items:
575
                    yield from self._iter_rows(item2, mapping, parent=False, row=row)
576
                if not items:
577
                    row.child = True
578
579
            # Yield the row if both boundaries have been hit
580
            if row.parent and row.child:
581
                yield tuple(row)
582
583
    def load(self, reload=False):
584
        """Load the tree's documents and items.
585
586
        Unlike the :class:`~doorstop.core.document.Document` and
587
        :class:`~doorstop.core.item.Item` class, this load method is not
588
        used internally. Its purpose is to force the loading of
589
        content in large trees where lazy loading may cause long delays
590
        late in processing.
591
592
        """
593
        if self._loaded and not reload:
594
            return
595
        log.info("loading the tree...")
596
        for document in self:
597
            document.load(reload=True)
598
        # Set meta attributes
599
        self._loaded = True
600
601
    def draw(self, encoding=None, html_links=False):
602
        """Get the tree structure as text.
603
604
        :param encoding: limit character set to:
605
606
            - `'utf-8'` - all characters
607
            - `'cp437'` - Code Page 437 characters
608
            - (other) - ASCII characters
609
610
        """
611
        encoding = encoding or getattr(sys.stdout, "encoding", None)
612
        encoding = encoding.lower() if encoding else None
613
        return "\n".join(self._draw_lines(encoding, html_links))
614
615
    def _draw_line(self):
616
        """Get the tree structure in one line."""
617
        # Build parent prefix string (`getattr` to enable mock testing)
618
        prefix = getattr(self.document, "prefix", "") or str(self.document)
619
        # Build children prefix strings
620
        children = ", ".join(
621
            c._draw_line() for c in self.children  # pylint: disable=protected-access
622
        )
623
        # Format the tree
624
        if children:
625
            return "{} <- [ {} ]".format(prefix, children)
626
        else:
627
            return "{}".format(prefix)
628
629
    def _draw_lines(self, encoding, html_links=False):
630
        """Generate lines of the tree structure."""
631
        # Build parent prefix string (`getattr` to enable mock testing)
632
        prefix = getattr(self.document, "prefix", "") or str(self.document)
633
        if html_links:
634
            prefix = '<a href="documents/{0}.html">{0}</a>'.format(prefix)
635
        yield prefix
636
        # Build child prefix strings
637
        for count, child in enumerate(self.children, start=1):
638
            if count == 1:
639
                yield self._symbol("end", encoding)
640
            else:
641
                yield self._symbol("pipe", encoding)
642
            if count < len(self.children):
643
                base = self._symbol("pipe", encoding)
644
                indent = self._symbol("tee", encoding)
645
            else:
646
                base = self._symbol("space", encoding)
647
                indent = self._symbol("bend", encoding)
648
            for index, line in enumerate(
649
                # pylint: disable=protected-access
650
                child._draw_lines(encoding, html_links)
651
            ):
652
                if index == 0:
653
                    yield indent + line
654
                else:
655
                    yield base + line
656
657
    @staticmethod
658
    def _symbol(name, encoding):
659
        """Get a drawing symbol based on encoding."""
660
        if encoding not in (UTF8, CP437):
661
            encoding = ASCII
662
        return BOX[name][encoding]
663
664
    # decorators are applied to methods in the associated classes
665
    def delete(self):
666
        """Delete the tree and its documents and items."""
667
        for document in self:
668
            document.delete()
669
        self.document = None
670
        self.children = []
671