Passed
Push — develop ( 38b266...14345b )
by Jace
01:56 queued 10s
created

doorstop.core.item._convert_to_yaml()   B

Complexity

Conditions 6

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 6

Importance

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