Passed
Pull Request — develop (#566)
by
unknown
01:26
created

doorstop.core.item.Item.new()   B

Complexity

Conditions 6

Size

Total Lines 39
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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