Item.find_child_items_and_documents()   F
last analyzed

Complexity

Conditions 14

Size

Total Lines 44
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 30
dl 0
loc 44
rs 3.6
c 0
b 0
f 0
cc 14
nop 4

How to fix   Complexity   

Complexity

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

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

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