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