Completed
Push — develop ( 0f6046...f40dba )
by Jace
15s queued 13s
created

doorstop.core.item.Item._yaml_data()   F

Complexity

Conditions 15

Size

Total Lines 47
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 36
dl 0
loc 47
rs 2.9998
c 0
b 0
f 0
cc 15
nop 2

How to fix   Complexity   

Complexity

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

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

1
# SPDX-License-Identifier: LGPL-3.0-only
2
3
"""Representation of an item in a document."""
4
5
import functools
6
import linecache
7
import os
8
from typing import Any, List
9
10
from doorstop import common, settings
11
from doorstop.common import DoorstopError
12
from doorstop.core import editor
13
from doorstop.core.base import (
14
    BaseFileObject,
15
    add_item,
16
    auto_load,
17
    auto_save,
18
    delete_item,
19
    edit_item,
20
)
21
from doorstop.core.reference_finder import ReferenceFinder
22
from doorstop.core.types import UID, Level, Prefix, Stamp, Text, to_bool
23
from doorstop.core.yaml_validator import YamlValidator
24
25
log = common.logger(__name__)
26
27
28
def _convert_to_yaml(indent, prefix, value):
29
    """Convert value to YAML output format.
30
31
    :param indent: the indentation level
32
    :param prefix: the length of the prefix before the value, e.g. '- ' for
33
    lists or 'key: ' for keys
34
    :param value: the value to convert
35
36
    :return: the value converted to YAML output format
37
38
    """
39
    if isinstance(value, str):
40
        length = indent + prefix + len(value)
41
        if length > settings.MAX_LINE_LENGTH or "\n" in value:
42
            value = Text.save_text(value.strip())
43
        else:
44
            value = str(value)  # line is short enough as a string
45
    elif isinstance(value, list):
46
        value = [_convert_to_yaml(indent, 2, v) for v in value]
47
    elif isinstance(value, dict):
48
        value = {
49
            k: _convert_to_yaml(indent + 2, len(k) + 2, v) for k, v in value.items()
50
        }
51
    return value
52
53
54
def _convert_to_str(value, result):
55
    """Convert value to a string serialization.
56
57
    This function is independent of the YAML format and may be used for data
58
    which should be independent of the actual item storage format.  It depends
59
    only on the Python sorting function, type information, and string
60
    representation.
61
62
    :param value: the value to convert
63
    :param result: the current result of the string serialization
64
65
    :return: the updated result of the string serialization
66
    """
67
    if isinstance(value, list):
68
        result += "\\L"
69
        for v in value:
70
            result = _convert_to_str(v, result)
71
        return result
72
    if isinstance(value, dict):
73
        result += "\\D"
74
        for k in sorted(value.keys()):
75
            result = _convert_to_str(value[k], result)
76
        return result
77
    return result + "\\T" + str(type(value)) + "\\V" + str(value).replace("\\", "\\\\")
78
79
80
def requires_tree(func):
81
    """Require a tree reference."""
82
83
    @functools.wraps(func)
84
    def wrapped(self, *args, **kwargs):
85
        if not self.tree:
86
            name = func.__name__
87
            log.critical("`{}` can only be called with a tree".format(name))
88
            return None
89
        return func(self, *args, **kwargs)
90
91
    return wrapped
92
93
94
class Item(BaseFileObject):  # pylint: disable=R0902
95
    """Represents an item file with linkable text."""
96
97
    EXTENSIONS = {
98
        "yaml": [".yml", ".yaml"],
99
        "markdown": [".md"],
100
    }
101
    MARKDOWN_TEXT_ATTRIBUTES = ["text", "header"]  # attributes parsed from content
102
103
    DEFAULT_LEVEL = Level("1.0")
104
    DEFAULT_ACTIVE = True
105
    DEFAULT_NORMATIVE = True
106
    DEFAULT_DERIVED = False
107
    DEFAULT_REVIEWED = Stamp()
108
    DEFAULT_TEXT = Text()
109
    DEFAULT_REF = ""
110
    DEFAULT_HEADER = Text()
111
    DEFAULT_ITEMFORMAT = "yaml"
112
113
    def __init__(self, document, path, root=os.getcwd(), **kwargs):
114
        """Initialize an item from an existing file.
115
116
        :param path: path to Item file
117
        :param root: path to root of project
118
119
        """
120
        super().__init__()
121
        # Ensure the path is valid
122
        if not os.path.isfile(path):
123
            raise DoorstopError("item does not exist: {}".format(path))
124
        # Ensure the filename is valid
125
        filename = os.path.basename(path)
126
        name, ext = os.path.splitext(filename)
127
        try:
128
            UID(name).check()
129
        except DoorstopError:
130
            msg = "invalid item filename: {}".format(filename)
131
            raise DoorstopError(msg) from None
132
        # Initialize the item
133
        self.path = path
134
        self.root: str = root
135
        self.document = document
136
        self.tree = kwargs.get("tree")
137
        self.auto = kwargs.get("auto", Item.auto)
138
        self.itemformat = kwargs.get("itemformat", Item.DEFAULT_ITEMFORMAT)
139
        self.reference_finder = ReferenceFinder()
140
        self.yaml_validator = YamlValidator()
141
        # Set default values
142
        self._data["level"] = Item.DEFAULT_LEVEL  # type: ignore
143
        self._data["active"] = Item.DEFAULT_ACTIVE  # type: ignore
144
        self._data["normative"] = Item.DEFAULT_NORMATIVE  # type: ignore
145
        self._data["derived"] = Item.DEFAULT_DERIVED  # type: ignore
146
        self._data["reviewed"] = Item.DEFAULT_REVIEWED  # type: ignore
147
        self._data["text"] = Item.DEFAULT_TEXT
148
        self._data["ref"] = Item.DEFAULT_REF
149
        self._data["references"] = None  # type: ignore
150
        self._data["links"] = set()  # type: ignore
151
        if settings.ENABLE_HEADERS:
152
            self._data["header"] = Item.DEFAULT_HEADER
153
154
        Item._check_itemformat(self.itemformat, path)
155
156
        # Ensure the file extension is valid
157
        if ext.lower() not in Item.EXTENSIONS[self.itemformat]:
158
            msg = "'{0}' extension for itemformat {1} not in {2}".format(
159
                path, self.itemformat, Item.EXTENSIONS[self.itemformat]
160
            )
161
            raise DoorstopError(msg)
162
163
    def __repr__(self):
164
        return "Item('{}')".format(self.path)
165
166
    def __str__(self):
167
        if common.verbosity < common.STR_VERBOSITY:
168
            return str(self.uid)
169
        else:
170
            return "{} ({})".format(self.uid, self.relpath)
171
172
    def __lt__(self, other):
173
        if self.level == other.level:
174
            return self.uid < other.uid
175
        else:
176
            return self.level < other.level
177
178
    @staticmethod
179
    @add_item
180
    def new(
181
        tree, document, path, root, uid, level=None, auto=None, itemformat_default=None
182
    ):  # pylint: disable=R0913
183
        """Create a new item.
184
185
        :param tree: reference to the tree that contains this item
186
        :param document: reference to document that contains this item
187
188
        :param path: path to directory for the new item
189
        :param root: path to root of the project
190
        :param uid: UID for the new item
191
192
        :param level: level for the new item
193
        :param auto: automatically save the item
194
195
        :param itemformat_default: file format for storing items, in case :param:`document` is not provided
196
197
        :raises: :class:`~doorstop.common.DoorstopError` if the item
198
            already exists
199
200
        :return: new :class:`~doorstop.core.item.Item`
201
202
        """
203
        UID(uid).check()
204
205
        if document:
206
            itemformat = document.itemformat
207
        elif itemformat_default:
208
            itemformat = itemformat_default
209
        else:
210
            itemformat = Item.DEFAULT_ITEMFORMAT
211
        Item._check_itemformat(itemformat, path)
212
213
        fileext = Item.EXTENSIONS[itemformat][0]
214
        filename = str(uid) + fileext
215
        path2 = os.path.join(path, filename)
216
        # Create the initial item file
217
        log.debug("creating item file at {}...".format(path2))
218
        Item._create(path2, name="item")
219
        # Initialize the item
220
        item = Item(
221
            document, path2, root=root, tree=tree, auto=False, itemformat=itemformat
222
        )
223
        item.level = level if level is not None else item.level  # type: ignore
224
        if auto or (auto is None and Item.auto):
225
            item.save()
226
        # Return the item
227
        return item
228
229
    @staticmethod
230
    def _check_itemformat(itemformat, path):
231
        # Ensure itemformat is valid
232
        if itemformat not in Item.EXTENSIONS:
233
            msg = "'{0}' itemformat {1} not in {2}".format(
234
                path, itemformat, Item.EXTENSIONS.keys()
235
            )
236
            raise DoorstopError(msg)
237
238
    def _set_attributes(self, attributes):
239
        """Set the item's attributes."""
240
        self.yaml_validator.validate_item_yaml(attributes)
241
        for key, value in attributes.items():
242
            if key == "level":
243
                value = Level(value)
244
            elif key == "active":
245
                value = to_bool(value)
246
            elif key == "normative":
247
                value = to_bool(value)
248
            elif key == "derived":
249
                value = to_bool(value)
250
            elif key == "reviewed":
251
                value = Stamp(value)
252
            elif key == "text":
253
                value = Text(value)
254
            elif key == "ref":
255
                value = value.strip()
256
            elif key == "references":
257
                stripped_value = []
258
                for ref_dict in value:
259
                    ref_type = ref_dict["type"]
260
                    ref_path = ref_dict["path"]
261
262
                    stripped_ref_dict = {"type": ref_type, "path": ref_path.strip()}
263
                    if "keyword" in ref_dict:
264
                        ref_keyword = ref_dict["keyword"]
265
                        stripped_ref_dict["keyword"] = ref_keyword
266
267
                    stripped_value.append(stripped_ref_dict)
268
269
                value = stripped_value
270
            elif key == "links":
271
                value = set(UID(part) for part in value)
272
            elif key == "header":
273
                value = Text(value)
274
            self._data[key] = value
275
276
    def load(self, reload=False):
277
        """Load the item's properties from its file."""
278
        if self._loaded and not reload:
279
            return
280
        log.debug("loading {}...".format(repr(self)))
281
        # Read text from file
282
        text = self._read(self.path)
283
284
        if self.itemformat == "markdown":
285
            # Parse YAML data from markdown with YAML frontmatter
286
            data = common.load_markdown(text, self.path, Item.MARKDOWN_TEXT_ATTRIBUTES)
287
        elif self.itemformat == "yaml":
288
            # Parse YAML data from text
289
            data = common.load_yaml(text, self.path)
290
        else:
291
            msg = "unknwon item format detected during load: {}({})".format(
292
                self.uid, self.itemformat
293
            )
294
            raise DoorstopError(msg) from None
295
        # Store parsed data
296
        self._set_attributes(data)
297
        # Set meta attributes
298
        self._loaded = True
299
300
    @edit_item
301
    def save(self):
302
        """Format and save the item's properties to its file."""
303
        log.debug("saving {}...".format(repr(self)))
304
        # Format the data items
305
        if self.itemformat == "markdown":
306
            # Dump the data to YAML-frontmatter
307
            data, textattr = self._yaml_data(
308
                textattributekeys=Item.MARKDOWN_TEXT_ATTRIBUTES
309
            )
310
            # Dump the data to markdown text
311
            text = common.dump_markdown(data, textattr)
312
        elif self.itemformat == "yaml":
313
            # Parse YAML data from text
314
            data, _ = self._yaml_data()
315
            # Dump the data to YAML
316
            text = self._dump(data)
317
        else:
318
            msg = "unknwon item format detected during save: {}({})".format(
319
                self.uid, self.itemformat
320
            )
321
            raise DoorstopError(msg) from None
322
        # Save the YAML to file
323
        self._write(text, self.path)
324
        # Set meta attributes
325
        self._loaded = True
326
        self.auto = True
327
328
    # properties #############################################################
329
330
    def _yaml_data(self, textattributekeys=None):
331
        """Get all the item's data formatted for YAML dumping."""
332
        data = {}
333
        textattributes = {}
334
        if not textattributekeys:
335
            textattributekeys = []
336
337
        for key, value in self._data.items():
338
            # if key in list of pure text attributes,
339
            # then store as-is in extra textattribute dict
340
            if key in textattributekeys:
341
                textattributes[key] = value
342
                continue
343
344
            if key == "level":
345
                value = value.yaml  # type: ignore
346
            elif key == "text":
347
                value = value.yaml  # type: ignore
348
            elif key == "header":
349
                # Handle for case if the header is undefined in YAML
350
                if hasattr(value, "yaml"):
351
                    value = value.yaml  # type: ignore
352
                else:
353
                    value = ""
354
            elif key == "ref":
355
                value = value.strip()
356
            elif key == "references":
357
                if value is None:
358
                    continue
359
                stripped_value = []
360
                for el in value:
361
                    ref_dict = {"path": el["path"].strip(), "type": "file"}  # type: ignore
362
363
                    if "keyword" in el:
364
                        ref_dict["keyword"] = el["keyword"]  # type: ignore
365
366
                    stripped_value.append(ref_dict)
367
368
                value = stripped_value  # type: ignore
369
            elif key == "links":
370
                value = [{str(i): i.stamp.yaml} for i in sorted(value)]  # type: ignore
371
            elif key == "reviewed":
372
                value = value.yaml  # type: ignore
373
            else:
374
                value = _convert_to_yaml(0, len(key) + 2, value)
375
            data[key] = value
376
        return data, textattributes
377
378
    @property  # type: ignore
379
    @auto_load
380
    def data(self):
381
        """Load and get all the item's data formatted for YAML dumping."""
382
        return self._yaml_data()[0]
383
384
    @property
385
    def uid(self):
386
        """Get the item's UID."""
387
        assert self.path
388
        filename = os.path.basename(self.path)
389
        return UID(os.path.splitext(filename)[0])
390
391
    @property  # type: ignore
392
    @auto_load
393
    def level(self):
394
        """Get the item's level."""
395
        return self._data["level"]
396
397
    @level.setter  # type: ignore
398
    @auto_save
399
    def level(self, value):
400
        """Set the item's level."""
401
        self._data["level"] = Level(value)  # type: ignore
402
403
    @property
404
    def depth(self):
405
        """Get the item's heading order based on it's level."""
406
        return len(self.level)
407
408
    @property  # type: ignore
409
    @auto_load
410
    def active(self):
411
        """Get the item's active status.
412
413
        An inactive item will not be validated. Inactive items are
414
        intended to be used for:
415
416
        - future requirements
417
        - temporarily disabled requirements or tests
418
        - externally implemented requirements
419
        - etc.
420
421
        """
422
        return self._data["active"]
423
424
    @active.setter  # type: ignore
425
    @auto_save
426
    def active(self, value):
427
        """Set the item's active status."""
428
        self._data["active"] = to_bool(value)
429
430
    @property  # type: ignore
431
    @auto_load
432
    def derived(self):
433
        """Get the item's derived status.
434
435
        A derived item does not have links to items in its parent
436
        document, but should still be linked to by items in its child
437
        documents.
438
439
        """
440
        return self._data["derived"]
441
442
    @derived.setter  # type: ignore
443
    @auto_save
444
    def derived(self, value):
445
        """Set the item's derived status."""
446
        self._data["derived"] = to_bool(value)
447
448
    @property  # type: ignore
449
    @auto_load
450
    def normative(self):
451
        """Get the item's normative status.
452
453
        A non-normative item should not have or be linked to.
454
        Non-normative items are intended to be used for:
455
456
        - headings
457
        - comments
458
        - etc.
459
460
        """
461
        return self._data["normative"]
462
463
    @normative.setter  # type: ignore
464
    @auto_save
465
    def normative(self, value):
466
        """Set the item's normative status."""
467
        self._data["normative"] = to_bool(value)
468
469
    @property
470
    def heading(self):
471
        """Indicate if the item is a heading.
472
473
        Headings have a level that ends in zero and are non-normative.
474
475
        """
476
        return self.level.heading and not self.normative
477
478
    @heading.setter  # type: ignore
479
    @auto_save
480
    def heading(self, value):
481
        """Set the item's heading status."""
482
        heading = to_bool(value)
483
        if heading and not self.heading:
484
            self.level.heading = True
485
            self.normative = False  # type: ignore
486
        elif not heading and self.heading:
487
            self.level.heading = False
488
            self.normative = True  # type: ignore
489
490
    @property  # type: ignore
491
    @auto_load
492
    def cleared(self):
493
        """Indicate if no links are suspect."""
494
        for uid, item in self._get_parent_uid_and_item():
495
            if uid.stamp != item.stamp():
496
                return False
497
        return True
498
499
    @property  # type: ignore
500
    @auto_load
501
    def reviewed(self):
502
        """Indicate if the item has been reviewed."""
503
        stamp = self.stamp(links=True)
504
        if self._data["reviewed"] == Stamp(True):
505
            self._data["reviewed"] = stamp
506
        return self._data["reviewed"] == stamp
507
508
    @reviewed.setter  # type: ignore
509
    @auto_save
510
    def reviewed(self, value):
511
        """Set the item's review status."""
512
        self._data["reviewed"] = Stamp(value)  # type: ignore
513
514
    @property  # type: ignore
515
    @auto_load
516
    def text(self):
517
        """Get the item's text."""
518
        return self._data["text"]
519
520
    @text.setter  # type: ignore
521
    @auto_save
522
    def text(self, value):
523
        """Set the item's text."""
524
        self._data["text"] = Text(value)
525
526
    @property  # type: ignore
527
    @auto_load
528
    def header(self):
529
        """Get the item's header."""
530
        if settings.ENABLE_HEADERS:
531
            return self._data["header"]
532
        return None
533
534
    @header.setter  # type: ignore
535
    @auto_save
536
    def header(self, value):
537
        """Set the item's header."""
538
        if settings.ENABLE_HEADERS:
539
            self._data["header"] = Text(value)
540
541
    @property  # type: ignore
542
    @auto_load
543
    def ref(self):
544
        """Get the item's external file reference.
545
546
        An external reference can be part of a line in a text file or
547
        the filename of any type of file.
548
549
        """
550
        return self._data["ref"]
551
552
    @ref.setter  # type: ignore
553
    @auto_save
554
    def ref(self, value):
555
        """Set the item's external file reference."""
556
        self._data["ref"] = str(value) if value else ""
557
558
    @property  # type: ignore
559
    @auto_load
560
    def references(self):
561
        """Get the item's external file references."""
562
        return self._data["references"]
563
564
    def attribute(self, attrib):
565
        """Get the item's custom attribute."""
566
        return self._data.get(attrib)
567
568
    @references.setter  # type: ignore
569
    @auto_save
570
    def references(self, value):
571
        """Set the item's external file references."""
572
        if value is not None:
573
            assert isinstance(value, list)
574
        self._data["references"] = value
575
576
    @property  # type: ignore
577
    @auto_load
578
    def links(self):
579
        """Get a list of the item UIDs this item links to."""
580
        return sorted(self._data["links"])
581
582
    @links.setter  # type: ignore
583
    @auto_save
584
    def links(self, value):
585
        """Set the list of item UIDs this item links to."""
586
        self._data["links"] = set(UID(v) for v in value)  # type: ignore
587
588
    @property
589
    def parent_links(self):
590
        """Get a list of the item UIDs this item links to."""
591
        return self.links
592
593
    @parent_links.setter
594
    def parent_links(self, value):
595
        """Set the list of item UIDs this item links to."""
596
        self.links = value  # type: ignore
597
598
    @requires_tree
599
    def _get_parent_uid_and_item(self):
600
        """Yield UID and item of all links of this item."""
601
        for uid in self.links:
602
            try:
603
                item = self.tree.find_item(uid)  # type: ignore
604
            except DoorstopError:
605
                item = UnknownItem(uid)
606
                log.warning(item.exception)
607
            yield uid, item
608
609
    @property
610
    def parent_items(self):
611
        """Get a list of items that this item links to."""
612
        return [item for uid, item in self._get_parent_uid_and_item()]
613
614
    @property  # type: ignore
615
    @requires_tree
616
    def parent_documents(self):
617
        """Get a list of documents that this item's document should link to.
618
619
        .. note::
620
621
           A document only has one parent.
622
623
        """
624
        try:
625
            return [self.tree.find_document(self.document.prefix)]  # type: ignore
626
        except DoorstopError:
627
            log.warning(Prefix.UNKNOWN_MESSAGE.format(self.document.prefix))
628
            return []
629
630
    # actions ################################################################
631
632
    @auto_save
633
    def set_attributes(self, attributes):
634
        """Set the item's attributes and save them."""
635
        self._set_attributes(attributes)
636
637
    def edit(self, tool=None, edit_all=True):
638
        """Open the item for editing.
639
640
        :param tool: path of alternate editor
641
        :param edit_all: True to edit the whole item,
642
            False to only edit the text.
643
644
        """
645
        # Lock the item
646
        if self.tree:
647
            self.tree.vcs.lock(self.path)
648
        # Edit the whole file in an editor
649
        if edit_all:
650
            self.save()
651
            editor.edit(self.path, tool=tool)
652
            self.load(True)
653
        # Edit only the text part in an editor
654
        else:
655
            # Edit the text in a temporary file
656
            edited_text = editor.edit_tmp_content(
657
                title=str(self.uid), original_content=str(self.text), tool=tool
658
            )
659
            # Save the text in the actual item file
660
            self.text = edited_text  # type: ignore
661
662
    @auto_save
663
    def link(self, value):
664
        """Add a new link to another item UID.
665
666
        :param value: item or UID
667
668
        """
669
        uid = UID(value)
670
        log.info("linking to '{}'...".format(uid))
671
        self._data["links"].add(uid)  # type: ignore
672
673
    @auto_save
674
    def unlink(self, value):
675
        """Remove an existing link by item UID.
676
677
        :param value: item or UID
678
679
        """
680
        uid = UID(value)
681
        try:
682
            self._data["links"].remove(uid)  # type: ignore
683
        except KeyError:
684
            log.warning("link to {0} does not exist".format(uid))
685
686
    def is_reviewed(self):
687
        return self._data["reviewed"]
688
689
    @requires_tree
690
    def find_ref(self):
691
        """Get the external file reference and line number.
692
693
        :raises: :class:`~doorstop.common.DoorstopError` when no
694
            reference is found
695
696
        :return: relative path to file or None (when no reference
697
            set),
698
            line number (when found in file) or None (when found as
699
            filename) or None (when no reference set)
700
701
        """
702
        # Return immediately if no external reference
703
        if not self.ref:
704
            log.debug("no external reference to search for")
705
            return None, None
706
        # Update the cache
707
        if not settings.CACHE_PATHS:
708
            linecache.clearcache()
709
        # Search for the external reference
710
        return self.reference_finder.find_ref(self.ref, self.tree, self.path)
711
712
    @requires_tree
713
    def find_references(self):
714
        """Get the array of references. Check each references before returning.
715
716
        :raises: :class:`~doorstop.common.DoorstopError` when no
717
            reference is found
718
719
        :return: Array of tuples:
720
            (
721
              relative path to file or None (when no reference set),
722
              line number (when found in file) or None (when found as
723
              filename) or None (when no reference set)
724
            )
725
726
        """
727
728
        if not self.references:
729
            log.debug("no external reference to search for")
730
            return []
731
        if not settings.CACHE_PATHS:
732
            linecache.clearcache()
733
734
        references = []
735
        for ref_item in self.references:
736
            path = ref_item["path"]
737
            keyword = ref_item["keyword"] if "keyword" in ref_item else None
738
739
            reference = self.reference_finder.find_file_reference(
740
                path, self.root, self.tree, self.path, keyword
741
            )
742
            references.append(reference)
743
        return references
744
745
    def find_child_links(self, find_all=True):
746
        """Get a list of item UIDs that link to this item (reverse links).
747
748
        :param find_all: find all items (not just the first) before returning
749
750
        :return: list of found item UIDs
751
752
        """
753
        items, _ = self.find_child_items_and_documents(find_all=find_all)
754
        identifiers = [item.uid for item in items]
755
        return identifiers
756
757
    child_links = property(find_child_links)
758
759
    def find_child_items(self, find_all=True):
760
        """Get a list of items that link to this item.
761
762
        :param find_all: find all items (not just the first) before returning
763
764
        :return: list of found items
765
766
        """
767
        items, _ = self.find_child_items_and_documents(find_all=find_all)
768
        return items
769
770
    child_items = property(find_child_items)
771
772
    def find_child_documents(self):
773
        """Get a list of documents that should link to this item's document.
774
775
        :return: list of found documents
776
777
        """
778
        _, documents = self.find_child_items_and_documents(find_all=False)
779
        return documents
780
781
    child_documents = property(find_child_documents)
782
783
    def find_child_items_and_documents(self, document=None, tree=None, find_all=True):
784
        """Get lists of child items and child documents.
785
786
        :param document: document containing the current item
787
        :param tree: tree containing the current item
788
        :param find_all: find all items (not just the first) before returning
789
790
        :return: list of found items, list of all child documents
791
792
        """
793
        child_items: List[Item] = []
794
        child_documents: List[Any] = []  # `List[Document]`` creats an import cycle
795
        document = document or self.document
796
        tree = tree or self.tree
797
        if not document or not tree:
798
            return child_items, child_documents
799
        # Find child objects
800
        log.debug("finding item {}'s child objects...".format(self))
801
        for document2 in tree:
802
            if document2.parent == document.prefix:
803
                child_documents.append(document2)
804
                # Search for child items unless we only need to find one
805
                if not child_items or find_all:
806
                    for item2 in document2:
807
                        if self.uid in item2.links:
808
                            if not item2.active:
809
                                item2 = UnknownItem(item2.uid)
810
                                log.warning(item2.exception)
811
                                child_items.append(item2)
812
                            else:
813
                                child_items.append(item2)
814
                                if not find_all and item2.active:
815
                                    break
816
        # Display found links
817
        if child_items:
818
            if find_all:
819
                joined = ", ".join(str(i) for i in child_items)
820
                msg = "child items: {}".format(joined)
821
            else:
822
                msg = "first child item: {}".format(child_items[0])
823
            log.debug(msg)
824
            joined = ", ".join(str(d) for d in child_documents)
825
            log.debug("child documents: {}".format(joined))
826
        return sorted(child_items), child_documents
827
828
    @auto_load
829
    def stamp(self, links=False):
830
        """Hash the item's key content for later comparison."""
831
        values = [self.uid, self.text, self.ref]
832
833
        if self.references:
834
            values.append(self.references)
835
836
        if links:
837
            values.extend(self.links)
838
        for key in self.document.extended_reviewed:
839
            if key in self._data:
840
                values.append(_convert_to_str(self._data[key], ""))
841
        return Stamp(*values)
842
843
    @auto_save
844
    def clear(self, parents=None):
845
        """Clear suspect links."""
846
        log.info("clearing suspect links...")
847
        for uid, item in self._get_parent_uid_and_item():
848
            if not parents or uid in parents:
849
                uid.stamp = item.stamp()
850
851
    @auto_save
852
    def review(self):
853
        """Mark the item as reviewed."""
854
        log.info("marking item as reviewed...")
855
        self._data["reviewed"] = self.stamp(links=True)
856
857
    @delete_item
858
    def delete(self, path=None):
859
        """Delete the item."""
860
861
862
class UnknownItem:
863
    """Represents an unknown item, which doesn't have a path."""
864
865
    UNKNOWN_PATH = "???"  # string to represent an unknown path
866
867
    normative = False  # do not include unknown items in traceability
868
    level = Item.DEFAULT_LEVEL
869
870
    def __init__(self, value, spec=Item):
871
        self._uid = UID(value)
872
        self._spec = dir(spec)  # list of attribute names for warnings
873
        msg = UID.UNKNOWN_MESSAGE.format(k="", u=self.uid)
874
        self.exception = DoorstopError(msg)
875
876
    def __str__(self):
877
        return Item.__str__(self)  # type: ignore
878
879
    def __getattr__(self, name):
880
        if name in self._spec:
881
            log.debug(self.exception)
882
        return self.__getattribute__(name)
883
884
    def __lt__(self, other):
885
        return self.uid < other.uid
886
887
    @property
888
    def uid(self):
889
        """Get the item's UID."""
890
        return self._uid
891
892
    @property
893
    def relpath(self):
894
        """Get the unknown item's relative path string."""
895
        return "@{}{}".format(os.sep, self.UNKNOWN_PATH)
896
897
    def stamp(self):  # pylint: disable=R0201
898
        """Return an empty stamp."""
899
        return Stamp(None)
900