Passed
Pull Request — develop (#399)
by
unknown
02:27
created

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

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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