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