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

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

Complexity

Conditions 7

Size

Total Lines 49
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
dl 0
loc 49
rs 7.9759
c 0
b 0
f 0
cc 7
nop 8

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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