Passed
Pull Request — develop (#399)
by
unknown
01:37
created

doorstop.core.item.Item.cleared()   A

Complexity

Conditions 3

Size

Total Lines 8
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

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