Passed
Push — develop ( 610d34...d32a89 )
by Jace
01:29 queued 10s
created

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

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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