Passed
Pull Request — develop (#399)
by
unknown
02:06
created

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

Complexity

Conditions 4

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 4.074

Importance

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