Passed
Pull Request — develop (#428)
by
unknown
02:23
created

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

Complexity

Conditions 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
nop 1
1
# SPDX-License-Identifier: LGPL-3.0-only
2
3
"""Representation of an item in a document."""
4
5
import functools
6
import os
7
import re
8
from typing import Any, List
9
10
import pyficache
11
12
from doorstop import common, settings
13
from doorstop.common import DoorstopError
14
from doorstop.core import editor
15
from doorstop.core.base import (
16
    BaseFileObject,
17
    add_item,
18
    auto_load,
19
    auto_save,
20
    delete_item,
21
    edit_item,
22
)
23
from doorstop.core.types import UID, Level, Prefix, Stamp, Text, to_bool
24
25
log = common.logger(__name__)
26
27
28
def _convert_to_yaml(indent, prefix, value):
29
    """Convert value to YAML output format.
30
31
    :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
    :param value: the value to convert
35
36
    :return: the value converted to YAML output format
37
38
    """
39
    if isinstance(value, str):
40
        length = indent + prefix + len(value)
41
        if length > settings.MAX_LINE_LENGTH or '\n' in value:
42
            value = Text.save_text(value.strip())
43
        else:
44
            value = str(value)  # line is short enough as a string
45
    elif isinstance(value, list):
46
        value = [_convert_to_yaml(indent, 2, v) for v in value]
47
    elif isinstance(value, dict):
48
        value = {
49
            k: _convert_to_yaml(indent + 2, len(k) + 2, v) for k, v in value.items()
50
        }
51
    return value
52
53
54
def requires_tree(func):
55
    """Require a tree reference."""
56
57
    @functools.wraps(func)
58
    def wrapped(self, *args, **kwargs):
59
        if not self.tree:
60
            name = func.__name__
61
            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
class Item(BaseFileObject):  # pylint: disable=R0902
69
    """Represents an item file with linkable text."""
70
71
    EXTENSIONS = '.yml', '.yaml'
72
73
    DEFAULT_LEVEL = Level('1.0')
74
    DEFAULT_ACTIVE = True
75
    DEFAULT_NORMATIVE = True
76
    DEFAULT_DERIVED = False
77
    DEFAULT_REVIEWED = Stamp()
78
    DEFAULT_TEXT = Text()
79
    DEFAULT_REF = ""
80
    DEFAULT_HEADER = Text()
81
82
    def __init__(self, document, path, root=os.getcwd(), **kwargs):
83
        """Initialize an item from an existing file.
84
85
        :param path: path to Item file
86
        :param root: path to root of project
87
88
        """
89
        super().__init__()
90
        # Ensure the path is valid
91
        if not os.path.isfile(path):
92
            raise DoorstopError("item does not exist: {}".format(path))
93
        # Ensure the filename is valid
94
        filename = os.path.basename(path)
95
        name, ext = os.path.splitext(filename)
96
        try:
97
            UID(name).check()
98
        except DoorstopError:
99
            msg = "invalid item filename: {}".format(filename)
100
            raise DoorstopError(msg) from None
101
        # Ensure the file extension is valid
102
        if ext.lower() not in self.EXTENSIONS:
103
            msg = "'{0}' extension not in {1}".format(path, self.EXTENSIONS)
104
            raise DoorstopError(msg)
105
        # Initialize the item
106
        self.path = path
107
        self.root = root
108
        self.document = document
109
        self.tree = kwargs.get('tree')
110
        self.auto = kwargs.get('auto', Item.auto)
111
        # Set default values
112
        self._data['level'] = Item.DEFAULT_LEVEL
113
        self._data['active'] = Item.DEFAULT_ACTIVE
114
        self._data['normative'] = Item.DEFAULT_NORMATIVE
115
        self._data['derived'] = Item.DEFAULT_DERIVED
116
        self._data['reviewed'] = Item.DEFAULT_REVIEWED
117
        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
            return self.level < other.level
137
138
    @staticmethod
139
    @add_item
140
    def new(
141
        tree, document, path, root, uid, level=None, auto=None
142
    ):  # pylint: disable=R0913
143
        """Create a new item.
144
145
        :param tree: reference to the tree that contains this item
146
        :param document: reference to document that contains this item
147
148
        :param path: path to directory for the new item
149
        :param root: path to root of the project
150
        :param uid: UID for the new item
151
152
        :param level: level for the new item
153
        :param auto: automatically save the item
154
155
        :raises: :class:`~doorstop.common.DoorstopError` if the item
156
            already exists
157
158
        :return: new :class:`~doorstop.core.item.Item`
159
160
        """
161
        UID(uid).check()
162
        filename = str(uid) + Item.EXTENSIONS[0]
163
        path2 = os.path.join(path, filename)
164
        # Create the initial item file
165
        log.debug("creating item file at {}...".format(path2))
166
        Item._create(path2, name='item')
167
        # Initialize the item
168
        item = Item(document, path2, root=root, tree=tree, auto=False)
169
        item.level = level if level is not None else item.level  # type: ignore
170
        if auto or (auto is None and Item.auto):
171
            item.save()
172
        # Return the item
173
        return item
174
175
    def _set_attributes(self, attributes):
176
        """Set the item's attributes."""
177
        for key, value in attributes.items():
178
            if key == 'level':
179
                value = Level(value)
180
            elif key == 'active':
181
                value = to_bool(value)
182
            elif key == 'normative':
183
                value = to_bool(value)
184
            elif key == 'derived':
185
                value = to_bool(value)
186
            elif key == 'reviewed':
187
                value = Stamp(value)
188
            elif key == 'text':
189
                value = Text(value)
190
            elif key == 'ref':
191
                value = value.strip()
192
            elif key == 'links':
193
                value = set(UID(part) for part in value)
194
            elif key == 'header':
195
                value = Text(value)
196
            self._data[key] = value
197
198
    def load(self, reload=False):
199
        """Load the item's properties from its file."""
200
        if self._loaded and not reload:
201
            return
202
        log.debug("loading {}...".format(repr(self)))
203
        # Read text from file
204
        text = self._read(self.path)
205
        # Parse YAML data from text
206
        data = self._load(text, self.path)
207
        # Store parsed data
208
        self._set_attributes(data)
209
        # Set meta attributes
210
        self._loaded = True
211
212
    @edit_item
213
    def save(self):
214
        """Format and save the item's properties to its file."""
215
        log.debug("saving {}...".format(repr(self)))
216
        # Format the data items
217
        data = self._yaml_data()
218
        # Dump the data to YAML
219
        text = self._dump(data)
220
        # Save the YAML to file
221
        self._write(text, self.path)
222
        # Set meta attributes
223
        self._loaded = True
224
        self.auto = True
225
226
    # properties #############################################################
227
228
    def _yaml_data(self):
229
        """Get all the item's data formatted for YAML dumping."""
230
        data = {}
231
        for key, value in self._data.items():
232
            if key == 'level':
233
                value = value.yaml
234
            elif key == 'text':
235
                value = value.yaml
236
            elif key == 'header':
237
                # Handle for case if the header is undefined in YAML
238
                if hasattr(value, 'yaml'):
239
                    value = value.yaml
240
                else:
241
                    value = ''
242
            elif key == 'ref':
243
                value = value.strip()
244
            elif key == 'links':
245
                value = [{str(i): i.stamp.yaml} for i in sorted(value)]
246
            elif key == 'reviewed':
247
                value = value.yaml
248
            else:
249
                value = _convert_to_yaml(0, len(key) + 2, value)
250
            data[key] = value
251
        return data
252
253
    @property  # type: ignore
254
    @auto_load
255
    def data(self):
256
        """Load and get all the item's data formatted for YAML dumping."""
257
        return self._yaml_data()
258
259
    @property
260
    def uid(self):
261
        """Get the item's UID."""
262
        filename = os.path.basename(self.path)
263
        return UID(os.path.splitext(filename)[0])
264
265
    @property  # type: ignore
266
    @auto_load
267
    def level(self):
268
        """Get the item's level."""
269
        return self._data['level']
270
271
    @level.setter  # type: ignore
272
    @auto_save
273
    def level(self, value):
274
        """Set the item's level."""
275
        self._data['level'] = Level(value)
276
277
    @property
278
    def depth(self):
279
        """Get the item's heading order based on it's level."""
280
        return len(self.level)
281
282
    @property  # type: ignore
283
    @auto_load
284
    def active(self):
285
        """Get the item's active status.
286
287
        An inactive item will not be validated. Inactive items are
288
        intended to be used for:
289
290
        - future requirements
291
        - temporarily disabled requirements or tests
292
        - externally implemented requirements
293
        - etc.
294
295
        """
296
        return self._data['active']
297
298
    @active.setter  # type: ignore
299
    @auto_save
300
    def active(self, value):
301
        """Set the item's active status."""
302
        self._data['active'] = to_bool(value)
303
304
    @property  # type: ignore
305
    @auto_load
306
    def derived(self):
307
        """Get the item's derived status.
308
309
        A derived item does not have links to items in its parent
310
        document, but should still be linked to by items in its child
311
        documents.
312
313
        """
314
        return self._data['derived']
315
316
    @derived.setter  # type: ignore
317
    @auto_save
318
    def derived(self, value):
319
        """Set the item's derived status."""
320
        self._data['derived'] = to_bool(value)
321
322
    @property  # type: ignore
323
    @auto_load
324
    def normative(self):
325
        """Get the item's normative status.
326
327
        A non-normative item should not have or be linked to.
328
        Non-normative items are intended to be used for:
329
330
        - headings
331
        - comments
332
        - etc.
333
334
        """
335
        return self._data['normative']
336
337
    @normative.setter  # type: ignore
338
    @auto_save
339
    def normative(self, value):
340
        """Set the item's normative status."""
341
        self._data['normative'] = to_bool(value)
342
343
    @property
344
    def heading(self):
345
        """Indicate if the item is a heading.
346
347
        Headings have a level that ends in zero and are non-normative.
348
349
        """
350
        return self.level.heading and not self.normative
351
352
    @heading.setter  # type: ignore
353
    @auto_save
354
    def heading(self, value):
355
        """Set the item's heading status."""
356
        heading = to_bool(value)
357
        if heading and not self.heading:
358
            self.level.heading = True
359
            self.normative = False
360
        elif not heading and self.heading:
361
            self.level.heading = False
362
            self.normative = True
363
364
    @property  # type: ignore
365
    @auto_load
366
    def cleared(self):
367
        """Indicate if no links are suspect."""
368
        for uid, item in self._get_parent_uid_and_item():
369
            if uid.stamp != item.stamp():
370
                return False
371
        return True
372
373
    @property  # type: ignore
374
    @auto_load
375
    def reviewed(self):
376
        """Indicate if the item has been reviewed."""
377
        stamp = self.stamp(links=True)
378
        if self._data['reviewed'] == Stamp(True):
379
            self._data['reviewed'] = stamp
380
        return self._data['reviewed'] == stamp
381
382
    @reviewed.setter  # type: ignore
383
    @auto_save
384
    def reviewed(self, value):
385
        """Set the item's review status."""
386
        self._data['reviewed'] = Stamp(value)
387
388
    @property  # type: ignore
389
    @auto_load
390
    def text(self):
391
        """Get the item's text."""
392
        return self._data['text']
393
394
    @text.setter  # type: ignore
395
    @auto_save
396
    def text(self, value):
397
        """Set the item's text."""
398
        self._data['text'] = Text(value)
399
400
    @property  # type: ignore
401
    @auto_load
402
    def header(self):
403
        """Get the item's header."""
404
        if settings.ENABLE_HEADERS:
405
            return self._data['header']
406
        return None
407
408
    @header.setter  # type: ignore
409
    @auto_save
410
    def header(self, value):
411
        """Set the item's header."""
412
        if settings.ENABLE_HEADERS:
413
            self._data['header'] = Text(value)
414
415
    @property  # type: ignore
416
    @auto_load
417
    def ref(self):
418
        """Get the item's external file reference.
419
420
        An external reference can be part of a line in a text file or
421
        the filename of any type of file.
422
423
        """
424
        return self._data['ref']
425
426
    @ref.setter  # type: ignore
427
    @auto_save
428
    def ref(self, value):
429
        """Set the item's external file reference."""
430
        self._data['ref'] = str(value) if value else ""
431
432
    @property  # type: ignore
433
    @auto_load
434
    def links(self):
435
        """Get a list of the item UIDs this item links to."""
436
        return sorted(self._data['links'])
437
438
    @links.setter  # type: ignore
439
    @auto_save
440
    def links(self, value):
441
        """Set the list of item UIDs this item links to."""
442
        self._data['links'] = set(UID(v) for v in value)
443
444
    @property
445
    def parent_links(self):
446
        """Get a list of the item UIDs this item links to."""
447
        return self.links  # alias
448
449
    @parent_links.setter
450
    def parent_links(self, value):
451
        """Set the list of item UIDs this item links to."""
452
        self.links = value  # alias
453
454
    @requires_tree
455
    def _get_parent_uid_and_item(self):
456
        """Yield UID and item of all links of this item."""
457
        for uid in self.links:
458
            try:
459
                item = self.tree.find_item(uid)
460
            except DoorstopError:
461
                item = UnknownItem(uid)
462
                log.warning(item.exception)
463
            yield uid, item
464
465
    @property
466
    def parent_items(self):
467
        """Get a list of items that this item links to."""
468
        return [item for uid, item in self._get_parent_uid_and_item()]
469
470
    @property  # type: ignore
471
    @requires_tree
472
    def parent_documents(self):
473
        """Get a list of documents that this item's document should link to.
474
475
        .. note::
476
477
           A document only has one parent.
478
479
        """
480
        try:
481
            return [self.tree.find_document(self.document.prefix)]
482
        except DoorstopError:
483
            log.warning(Prefix.UNKNOWN_MESSAGE.format(self.document.prefix))
484
            return []
485
486
    # actions ################################################################
487
488
    @auto_save
489
    def set_attributes(self, attributes):
490
        """Set the item's attributes and save them."""
491
        self._set_attributes(attributes)
492
493
    def edit(self, tool=None, edit_all=True):
494
        """Open the item for editing.
495
496
        :param tool: path of alternate editor
497
        :param edit_all: True to edit the whole item,
498
            False to only edit the text.
499
500
        """
501
        # Lock the item
502
        if self.tree:
503
            self.tree.vcs.lock(self.path)
504
        # Edit the whole file in an editor
505
        if edit_all:
506
            self.save()
507
            editor.edit(self.path, tool=tool)
508
            self.load(True)
509
        # Edit only the text part in an editor
510
        else:
511
            # Edit the text in a temporary file
512
            edited_text = editor.edit_tmp_content(
513
                title=str(self.uid), original_content=str(self.text), tool=tool
514
            )
515
            # Save the text in the actual item file
516
            self.text = edited_text
517
518
    @auto_save
519
    def link(self, value):
520
        """Add a new link to another item UID.
521
522
        :param value: item or UID
523
524
        """
525
        uid = UID(value)
526
        log.info("linking to '{}'...".format(uid))
527
        self._data['links'].add(uid)
528
529
    @auto_save
530
    def unlink(self, value):
531
        """Remove an existing link by item UID.
532
533
        :param value: item or UID
534
535
        """
536
        uid = UID(value)
537
        try:
538
            self._data['links'].remove(uid)
539
        except KeyError:
540
            log.warning("link to {0} does not exist".format(uid))
541
542
    def is_reviewed(self):
543
        return self._data['reviewed']
544
545
    @requires_tree
546
    def find_ref(self):
547
        """Get the external file reference and line number.
548
549
        :raises: :class:`~doorstop.common.DoorstopError` when no
550
            reference is found
551
552
        :return: relative path to file or None (when no reference
553
            set),
554
            line number (when found in file) or None (when found as
555
            filename) or None (when no reference set)
556
557
        """
558
        # Return immediately if no external reference
559
        if not self.ref:
560
            log.debug("no external reference to search for")
561
            return None, None
562
        # Update the cache
563
        if not settings.CACHE_PATHS:
564
            pyficache.clear_file_cache()
565
        # Search for the external reference
566
        log.debug("seraching for ref '{}'...".format(self.ref))
567
        pattern = r"(\b|\W){}(\b|\W)".format(re.escape(self.ref))
568
        log.trace("regex: {}".format(pattern))  # type: ignore
569
        regex = re.compile(pattern)
570
        for path, filename, relpath in self.tree.vcs.paths:
571
            # Skip the item's file while searching
572
            if path == self.path:
573
                continue
574
            # Check for a matching filename
575
            if filename == self.ref:
576
                return relpath, None
577
            # Skip extensions that should not be considered text
578
            if os.path.splitext(filename)[-1] in settings.SKIP_EXTS:
579
                continue
580
            # Search for the reference in the file
581
            lines = pyficache.getlines(path)
582
            if lines is None:
583
                log.trace("unable to read lines from: {}".format(path))  # type: ignore
584
                continue
585
            for lineno, line in enumerate(lines, start=1):
586
                if regex.search(line):
587
                    log.debug("found ref: {}".format(relpath))
588
                    return relpath, lineno
589
590
        msg = "external reference not found: {}".format(self.ref)
591
        raise DoorstopError(msg)
592
593
    def find_child_links(self, find_all=True):
594
        """Get a list of item UIDs that link to this item (reverse links).
595
596
        :param find_all: find all items (not just the first) before returning
597
598
        :return: list of found item UIDs
599
600
        """
601
        items, _ = self.find_child_items_and_documents(find_all=find_all)
602
        identifiers = [item.uid for item in items]
603
        return identifiers
604
605
    child_links = property(find_child_links)
606
607
    def find_child_items(self, find_all=True):
608
        """Get a list of items that link to this item.
609
610
        :param find_all: find all items (not just the first) before returning
611
612
        :return: list of found items
613
614
        """
615
        items, _ = self.find_child_items_and_documents(find_all=find_all)
616
        return items
617
618
    child_items = property(find_child_items)
619
620
    def find_child_documents(self):
621
        """Get a list of documents that should link to this item's document.
622
623
        :return: list of found documents
624
625
        """
626
        _, documents = self.find_child_items_and_documents(find_all=False)
627
        return documents
628
629
    child_documents = property(find_child_documents)
630
631
    def find_child_items_and_documents(self, document=None, tree=None, find_all=True):
632
        """Get lists of child items and child documents.
633
634
        :param document: document containing the current item
635
        :param tree: tree containing the current item
636
        :param find_all: find all items (not just the first) before returning
637
638
        :return: list of found items, list of all child documents
639
640
        """
641
        child_items: List[Item] = []
642
        child_documents: List[Any] = []  # `List[Document]`` creats an import cycle
643
        document = document or self.document
644
        tree = tree or self.tree
645
        if not document or not tree:
646
            return child_items, child_documents
647
        # Find child objects
648
        log.debug("finding item {}'s child objects...".format(self))
649
        for document2 in tree:
650
            if document2.parent == document.prefix:
651
                child_documents.append(document2)
652
                # Search for child items unless we only need to find one
653
                if not child_items or find_all:
654
                    for item2 in document2:
655
                        if self.uid in item2.links:
656
                            if not item2.active:
657
                                item2 = UnknownItem(item2.uid)
658
                                log.warning(item2.exception)
659
                                child_items.append(item2)
660
                            else:
661
                                child_items.append(item2)
662
                                if not find_all and item2.active:
663
                                    break
664
        # Display found links
665
        if child_items:
666
            if find_all:
667
                joined = ', '.join(str(i) for i in child_items)
668
                msg = "child items: {}".format(joined)
669
            else:
670
                msg = "first child item: {}".format(child_items[0])
671
            log.debug(msg)
672
            joined = ', '.join(str(d) for d in child_documents)
673
            log.debug("child documents: {}".format(joined))
674
        return sorted(child_items), child_documents
675
676
    @auto_load
677
    def stamp(self, links=False):
678
        """Hash the item's key content for later comparison."""
679
        values = [self.uid, self.text, self.ref]
680
        if links:
681
            values.extend(self.links)
682
        for key in self.document.extended_reviewed:
683
            if key in self._data:
684
                values.append(self._dump(self._data[key]))
685
            else:
686
                log.warning(
687
                    "{}: missing extended reviewed attribute: {}".format(self.uid, key)
688
                )
689
        return Stamp(*values)
690
691
    @auto_save
692
    def clear(self, parents=None):
693
        """Clear suspect links."""
694
        log.info("clearing suspect links...")
695
        for uid, item in self._get_parent_uid_and_item():
696
            if not parents or uid in parents:
697
                uid.stamp = item.stamp()
698
699
    @auto_save
700
    def review(self):
701
        """Mark the item as reviewed."""
702
        log.info("marking item as reviewed...")
703
        self._data['reviewed'] = self.stamp(links=True)
704
705
    @delete_item
706
    def delete(self, path=None):
707
        """Delete the item."""
708
709
710
class UnknownItem:
711
    """Represents an unknown item, which doesn't have a path."""
712
713
    UNKNOWN_PATH = '???'  # string to represent an unknown path
714
715
    normative = False  # do not include unknown items in traceability
716
    level = Item.DEFAULT_LEVEL
717
718
    def __init__(self, value, spec=Item):
719
        self._uid = UID(value)
720
        self._spec = dir(spec)  # list of attribute names for warnings
721
        msg = UID.UNKNOWN_MESSAGE.format(k='', u=self.uid)
722
        self.exception = DoorstopError(msg)
723
724
    def __str__(self):
725
        return Item.__str__(self)
726
727
    def __getattr__(self, name):
728
        if name in self._spec:
729
            log.debug(self.exception)
730
        return self.__getattribute__(name)
731
732
    def __lt__(self, other):
733
        return self.uid < other.uid
734
735
    @property
736
    def uid(self):
737
        """Get the item's UID."""
738
        return self._uid
739
740
    @property
741
    def relpath(self):
742
        """Get the unknown item's relative path string."""
743
        return "@{}{}".format(os.sep, self.UNKNOWN_PATH)
744
745
    def stamp(self):  # pylint: disable=R0201
746
        """Return an empty stamp."""
747
        return Stamp(None)
748