Passed
Pull Request — develop (#399)
by
unknown
01:52
created

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

Complexity

Conditions 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 10
rs 10
c 0
b 0
f 0
cc 2
nop 2
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
                if value is None:
199
                    continue
200
201
                stripped_value = []
202
                for ref_dict in value:
203
                    ref_type = ref_dict['type']
204
                    ref_path = ref_dict['path']
205
                    stripped_value.append({"type": ref_type, "path": ref_path.strip()})
206
                value = stripped_value
207
            elif key == 'links':
208
                value = set(UID(part) for part in value)
209
            elif key == 'header':
210
                value = Text(value)
211
            self._data[key] = value
212
213
    def load(self, reload=False):
214
        """Load the item's properties from its file."""
215
        if self._loaded and not reload:
216
            return
217
        log.debug("loading {}...".format(repr(self)))
218
        # Read text from file
219
        text = self._read(self.path)
220
        # Parse YAML data from text
221
        data = self._load(text, self.path)
222
        # Store parsed data
223
        self._set_attributes(data)
224
        # Set meta attributes
225
        self._loaded = True
226
227
    @edit_item
228
    def save(self):
229
        """Format and save the item's properties to its file."""
230
        log.debug("saving {}...".format(repr(self)))
231
        # Format the data items
232
        data = self._yaml_data()
233
        # Dump the data to YAML
234
        text = self._dump(data)
235
        # Save the YAML to file
236
        self._write(text, self.path)
237
        # Set meta attributes
238
        self._loaded = True
239
        self.auto = True
240
241
    # properties #############################################################
242
243
    def _yaml_data(self):
244
        """Get all the item's data formatted for YAML dumping."""
245
        data = {}
246
        for key, value in self._data.items():
247
            if key == 'level':
248
                value = value.yaml
249
            elif key == 'text':
250
                value = value.yaml
251
            elif key == 'header':
252
                # Handle for case if the header is undefined in YAML
253
                if hasattr(value, 'yaml'):
254
                    value = value.yaml
255
                else:
256
                    value = ''
257
            elif key == 'ref':
258
                value = value.strip()
259
            elif key == 'references':
260
                if value is None:
261
                    continue
262
                stripped_value = []
263
                for el in value:
264
                    stripped_value.append({"type": "file", "path": el["path"].strip()})
265
                value = stripped_value
266
            elif key == 'links':
267
                value = [{str(i): i.stamp.yaml} for i in sorted(value)]
268
            elif key == 'reviewed':
269
                value = value.yaml
270
            else:
271
                value = _convert_to_yaml(0, len(key) + 2, value)
272
            data[key] = value
273
        return data
274
275
    @property  # type: ignore
276
    @auto_load
277
    def data(self):
278
        """Load and get all the item's data formatted for YAML dumping."""
279
        return self._yaml_data()
280
281
    @property
282
    def uid(self):
283
        """Get the item's UID."""
284
        filename = os.path.basename(self.path)
285
        return UID(os.path.splitext(filename)[0])
286
287
    @property
288
    def prefix(self):
289
        """Get the item UID's prefix."""
290
        return self.uid.prefix
291
292
    @property
293
    def number(self):
294
        """Get the item UID's number."""
295
        return self.uid.number
296
297
    @property  # type: ignore
298
    @auto_load
299
    def level(self):
300
        """Get the item's level."""
301
        return self._data['level']
302
303
    @level.setter  # type: ignore
304
    @auto_save
305
    def level(self, value):
306
        """Set the item's level."""
307
        self._data['level'] = Level(value)
308
309
    @property
310
    def depth(self):
311
        """Get the item's heading order based on it's level."""
312
        return len(self.level)
313
314
    @property  # type: ignore
315
    @auto_load
316
    def active(self):
317
        """Get the item's active status.
318
319
        An inactive item will not be validated. Inactive items are
320
        intended to be used for:
321
322
        - future requirements
323
        - temporarily disabled requirements or tests
324
        - externally implemented requirements
325
        - etc.
326
327
        """
328
        return self._data['active']
329
330
    @active.setter  # type: ignore
331
    @auto_save
332
    def active(self, value):
333
        """Set the item's active status."""
334
        self._data['active'] = to_bool(value)
335
336
    @property  # type: ignore
337
    @auto_load
338
    def derived(self):
339
        """Get the item's derived status.
340
341
        A derived item does not have links to items in its parent
342
        document, but should still be linked to by items in its child
343
        documents.
344
345
        """
346
        return self._data['derived']
347
348
    @derived.setter  # type: ignore
349
    @auto_save
350
    def derived(self, value):
351
        """Set the item's derived status."""
352
        self._data['derived'] = to_bool(value)
353
354
    @property  # type: ignore
355
    @auto_load
356
    def normative(self):
357
        """Get the item's normative status.
358
359
        A non-normative item should not have or be linked to.
360
        Non-normative items are intended to be used for:
361
362
        - headings
363
        - comments
364
        - etc.
365
366
        """
367
        return self._data['normative']
368
369
    @normative.setter  # type: ignore
370
    @auto_save
371
    def normative(self, value):
372
        """Set the item's normative status."""
373
        self._data['normative'] = to_bool(value)
374
375
    @property
376
    def heading(self):
377
        """Indicate if the item is a heading.
378
379
        Headings have a level that ends in zero and are non-normative.
380
381
        """
382
        return self.level.heading and not self.normative
383
384
    @heading.setter  # type: ignore
385
    @auto_save
386
    def heading(self, value):
387
        """Set the item's heading status."""
388
        heading = to_bool(value)
389
        if heading and not self.heading:
390
            self.level.heading = True
391
            self.normative = False
392
        elif not heading and self.heading:
393
            self.level.heading = False
394
            self.normative = True
395
396
    @property  # type: ignore
397
    @auto_load
398
    def cleared(self):
399
        """Indicate if no links are suspect."""
400
        for uid, item in self._get_parent_uid_and_item():
401
            if uid.stamp != item.stamp():
402
                return False
403
        return True
404
405
    @property  # type: ignore
406
    @auto_load
407
    def reviewed(self):
408
        """Indicate if the item has been reviewed."""
409
        stamp = self.stamp(links=True)
410
        if self._data['reviewed'] == Stamp(True):
411
            self._data['reviewed'] = stamp
412
        return self._data['reviewed'] == stamp
413
414
    @reviewed.setter  # type: ignore
415
    @auto_save
416
    def reviewed(self, value):
417
        """Set the item's review status."""
418
        self._data['reviewed'] = Stamp(value)
419
420
    @property  # type: ignore
421
    @auto_load
422
    def text(self):
423
        """Get the item's text."""
424
        return self._data['text']
425
426
    @text.setter  # type: ignore
427
    @auto_save
428
    def text(self, value):
429
        """Set the item's text."""
430
        self._data['text'] = Text(value)
431
432
    @property  # type: ignore
433
    @auto_load
434
    def header(self):
435
        """Get the item's header."""
436
        if settings.ENABLE_HEADERS:
437
            return self._data['header']
438
        return None
439
440
    @header.setter  # type: ignore
441
    @auto_save
442
    def header(self, value):
443
        """Set the item's header."""
444
        if settings.ENABLE_HEADERS:
445
            self._data['header'] = Text(value)
446
447
    @property  # type: ignore
448
    @auto_load
449
    def ref(self):
450
        """Get the item's external file reference.
451
452
        An external reference can be part of a line in a text file or
453
        the filename of any type of file.
454
455
        """
456
        return self._data['ref']
457
458
    @ref.setter  # type: ignore
459
    @auto_save
460
    def ref(self, value):
461
        """Set the item's external file reference."""
462
        self._data['ref'] = str(value) if value else ""
463
464
    @property  # type: ignore
465
    @auto_load
466
    def references(self):
467
        """Get the item's external file references.
468
469
        An external reference can be part of a line in a text file or
470
        the filename of any type of file.
471
472
        """
473
        return self._data['references']
474
475
    @references.setter  # type: ignore
476
    @auto_save
477
    def references(self, value):
478
        """Set the item's external file reference."""
479
        if value is not None:
480
            assert isinstance(value, list)
481
        self._data['references'] = value
482
483
    @property  # type: ignore
484
    @auto_load
485
    def links(self):
486
        """Get a list of the item UIDs this item links to."""
487
        return sorted(self._data['links'])
488
489
    @links.setter  # type: ignore
490
    @auto_save
491
    def links(self, value):
492
        """Set the list of item UIDs this item links to."""
493
        self._data['links'] = set(UID(v) for v in value)
494
495
    @property
496
    def parent_links(self):
497
        """Get a list of the item UIDs this item links to."""
498
        return self.links  # alias
499
500
    @parent_links.setter
501
    def parent_links(self, value):
502
        """Set the list of item UIDs this item links to."""
503
        self.links = value  # alias
504
505
    @requires_tree
506
    def _get_parent_uid_and_item(self):
507
        """Yield UID and item of all links of this item."""
508
        for uid in self.links:
509
            try:
510
                item = self.tree.find_item(uid)
511
            except DoorstopError:
512
                item = UnknownItem(uid)
513
                log.warning(item.exception)
514
            yield uid, item
515
516
    @property
517
    def parent_items(self):
518
        """Get a list of items that this item links to."""
519
        return [item for uid, item in self._get_parent_uid_and_item()]
520
521
    @property  # type: ignore
522
    @requires_tree
523
    def parent_documents(self):
524
        """Get a list of documents that this item's document should link to.
525
526
        .. note::
527
528
           A document only has one parent.
529
530
        """
531
        try:
532
            return [self.tree.find_document(self.document.prefix)]
533
        except DoorstopError:
534
            log.warning(Prefix.UNKNOWN_MESSAGE.format(self.document.prefix))
535
            return []
536
537
    # actions ################################################################
538
539
    @auto_save
540
    def set_attributes(self, attributes):
541
        """Set the item's attributes and save them."""
542
        self._set_attributes(attributes)
543
544
    def edit(self, tool=None, edit_all=True):
545
        """Open the item for editing.
546
547
        :param tool: path of alternate editor
548
        :param edit_all: True to edit the whole item,
549
            False to only edit the text.
550
551
        """
552
        # Lock the item
553
        if self.tree:
554
            self.tree.vcs.lock(self.path)
555
        # Edit the whole file in an editor
556
        if edit_all:
557
            self.save()
558
            editor.edit(self.path, tool=tool)
559
            self.load(True)
560
        # Edit only the text part in an editor
561
        else:
562
            # Edit the text in a temporary file
563
            edited_text = editor.edit_tmp_content(
564
                title=str(self.uid), original_content=str(self.text), tool=tool
565
            )
566
            # Save the text in the actual item file
567
            self.text = edited_text
568
569
    @auto_save
570
    def link(self, value):
571
        """Add a new link to another item UID.
572
573
        :param value: item or UID
574
575
        """
576
        uid = UID(value)
577
        log.info("linking to '{}'...".format(uid))
578
        self._data['links'].add(uid)
579
580
    @auto_save
581
    def unlink(self, value):
582
        """Remove an existing link by item UID.
583
584
        :param value: item or UID
585
586
        """
587
        uid = UID(value)
588
        try:
589
            self._data['links'].remove(uid)
590
        except KeyError:
591
            log.warning("link to {0} does not exist".format(uid))
592
593
    def is_reviewed(self):
594
        return self._data['reviewed']
595
596
    def set_links(self, links):
597
        self._data['links'] = links
598
599
    @requires_tree
600
    def find_ref(self):
601
        """Get the external file reference and line number.
602
603
        :raises: :class:`~doorstop.common.DoorstopError` when no
604
            reference is found
605
606
        :return: relative path to file or None (when no reference
607
            set),
608
            line number (when found in file) or None (when found as
609
            filename) or None (when no reference set)
610
611
        """
612
        # Return immediately if no external reference
613
        if not self.ref:
614
            log.debug("no external reference to search for")
615
            return None, None
616
        # Update the cache
617
        if not settings.CACHE_PATHS:
618
            pyficache.clear_file_cache()
619
        # Search for the external reference
620
        return self.reference_finder.find_ref(self.ref, self.tree, self.path)
621
622
    @requires_tree
623
    def find_references(self):
624
        """Get the array of references. Check each references before returning.
625
626
        :raises: :class:`~doorstop.common.DoorstopError` when no
627
            reference is found
628
629
        :return: relative path to file or None (when no reference
630
            set),
631
            line number (when found in file) or None (when found as
632
            filename) or None (when no reference set)
633
634
        """
635
        if not self.references:
636
            log.debug("no external reference to search for")
637
            return []
638
        if not settings.CACHE_PATHS:
639
            pyficache.clear_file_cache()
640
        for ref_item in self.references:
641
            path = ref_item["path"]
642
            keyword = ref_item["keyword"] if "keyword" in ref_item else None
643
644
            self.reference_finder.check_file_reference(
645
                path, self.root, self.tree, path, keyword
646
            )
647
        return self.references
648
649
    def _find_external_file_ref(self, ref_path):
650
        log.debug("searching for ref '{}'...".format(ref_path))
651
        ref_full_path = os.path.join(self.root, ref_path)
652
653
        for path, filename, relpath in self.tree.vcs.paths:
0 ignored issues
show
Unused Code introduced by
The variable filename seems to be unused.
Loading history...
Unused Code introduced by
The variable relpath seems to be unused.
Loading history...
654
            # Skip the item's file while searching
655
            if path == self.path:
656
                continue
657
            if path == ref_full_path:
658
                return True
659
660
        msg = "external reference not found: {}".format(ref_path)
661
        raise DoorstopError(msg)
662
663
    def find_child_links(self, find_all=True):
664
        """Get a list of item UIDs that link to this item (reverse links).
665
666
        :param find_all: find all items (not just the first) before returning
667
668
        :return: list of found item UIDs
669
670
        """
671
        items, _ = self.find_child_items_and_documents(find_all=find_all)
672
        identifiers = [item.uid for item in items]
673
        return identifiers
674
675
    child_links = property(find_child_links)
676
677
    def find_child_items(self, find_all=True):
678
        """Get a list of items that link to this item.
679
680
        :param find_all: find all items (not just the first) before returning
681
682
        :return: list of found items
683
684
        """
685
        items, _ = self.find_child_items_and_documents(find_all=find_all)
686
        return items
687
688
    child_items = property(find_child_items)
689
690
    def find_child_documents(self):
691
        """Get a list of documents that should link to this item's document.
692
693
        :return: list of found documents
694
695
        """
696
        _, documents = self.find_child_items_and_documents(find_all=False)
697
        return documents
698
699
    child_documents = property(find_child_documents)
700
701
    def find_child_items_and_documents(self, document=None, tree=None, find_all=True):
702
        """Get lists of child items and child documents.
703
704
        :param document: document containing the current item
705
        :param tree: tree containing the current item
706
        :param find_all: find all items (not just the first) before returning
707
708
        :return: list of found items, list of all child documents
709
710
        """
711
        child_items: List[Item] = []
712
        child_documents: List[Any] = []  # `List[Document]`` creats an import cycle
713
        document = document or self.document
714
        tree = tree or self.tree
715
        if not document or not tree:
716
            return child_items, child_documents
717
        # Find child objects
718
        log.debug("finding item {}'s child objects...".format(self))
719
        for document2 in tree:
720
            if document2.parent == document.prefix:
721
                child_documents.append(document2)
722
                # Search for child items unless we only need to find one
723
                if not child_items or find_all:
724
                    for item2 in document2:
725
                        if self.uid in item2.links:
726
                            if not item2.active:
727
                                item2 = UnknownItem(item2.uid)
728
                                log.warning(item2.exception)
729
                                child_items.append(item2)
730
                            else:
731
                                child_items.append(item2)
732
                                if not find_all and item2.active:
733
                                    break
734
        # Display found links
735
        if child_items:
736
            if find_all:
737
                joined = ', '.join(str(i) for i in child_items)
738
                msg = "child items: {}".format(joined)
739
            else:
740
                msg = "first child item: {}".format(child_items[0])
741
            log.debug(msg)
742
            joined = ', '.join(str(d) for d in child_documents)
743
            log.debug("child documents: {}".format(joined))
744
        return sorted(child_items), child_documents
745
746
    @auto_load
747
    def stamp(self, links=False):
748
        """Hash the item's key content for later comparison."""
749
        values = [self.uid, self.text, self.ref]
750
751
        if self.references:
752
            values.append(self.references)
753
754
        if links:
755
            values.extend(self.links)
756
        for key in self.document.extended_reviewed:
757
            if key in self._data:
758
                values.append(self._dump(self._data[key]))
759
            else:
760
                log.warning(
761
                    "{}: missing extended reviewed attribute: {}".format(self.uid, key)
762
                )
763
        return Stamp(*values)
764
765
    @auto_save
766
    def clear(self, parents=None):
767
        """Clear suspect links."""
768
        log.info("clearing suspect links...")
769
        for uid, item in self._get_parent_uid_and_item():
770
            if not parents or uid in parents:
771
                uid.stamp = item.stamp()
772
773
    @auto_save
774
    def review(self):
775
        """Mark the item as reviewed."""
776
        log.info("marking item as reviewed...")
777
        self._data['reviewed'] = self.stamp(links=True)
778
779
    @delete_item
780
    def delete(self, path=None):
781
        """Delete the item."""
782
783
784
class UnknownItem:
785
    """Represents an unknown item, which doesn't have a path."""
786
787
    UNKNOWN_PATH = '???'  # string to represent an unknown path
788
789
    normative = False  # do not include unknown items in traceability
790
    level = Item.DEFAULT_LEVEL
791
792
    def __init__(self, value, spec=Item):
793
        self._uid = UID(value)
794
        self._spec = dir(spec)  # list of attribute names for warnings
795
        msg = UID.UNKNOWN_MESSAGE.format(k='', u=self.uid)
796
        self.exception = DoorstopError(msg)
797
798
    def __str__(self):
799
        return Item.__str__(self)
800
801
    def __getattr__(self, name):
802
        if name in self._spec:
803
            log.debug(self.exception)
804
        return self.__getattribute__(name)
805
806
    def __lt__(self, other):
807
        return self.uid < other.uid
808
809
    @property
810
    def uid(self):
811
        """Get the item's UID."""
812
        return self._uid
813
814
    prefix = Item.prefix
815
    number = Item.number
816
817
    @property
818
    def relpath(self):
819
        """Get the unknown item's relative path string."""
820
        return "@{}{}".format(os.sep, self.UNKNOWN_PATH)
821
822
    def stamp(self):  # pylint: disable=R0201
823
        """Return an empty stamp."""
824
        return Stamp(None)
825