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