Passed
Pull Request — develop (#395)
by
unknown
01:50
created

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

Complexity

Conditions 5

Size

Total Lines 32
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 5

Importance

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