Completed
Push — develop ( 9b02bc...c0484b )
by Jace
15s queued 10s
created

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

Complexity

Conditions 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

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