Completed
Push — develop ( 0f6046...f40dba )
by Jace
15s queued 13s
created

doorstop.core.document.Document.template()   A

Complexity

Conditions 2

Size

Total Lines 6
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 5
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 2
nop 1
1
# SPDX-License-Identifier: LGPL-3.0-only
2
3
"""Representation of a collection of items."""
4
5
import os
6
import re
7
from collections import OrderedDict
8
from itertools import chain
9
from typing import Dict, List
10
11
import yaml
12
13
from doorstop import common, settings
14
from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning
15
from doorstop.core.base import (
16
    BaseFileObject,
17
    BaseValidatable,
18
    add_document,
19
    auto_load,
20
    auto_save,
21
    delete_document,
22
    edit_document,
23
)
24
from doorstop.core.item import Item
25
from doorstop.core.types import UID, Level, Prefix
26
from doorstop.core.validators.item_validator import ItemValidator
27
28
log = common.logger(__name__)
29
30
31
class Document(BaseValidatable, BaseFileObject):  # pylint: disable=R0902
32
    """Represents a document directory containing an outline of items."""
33
34
    CONFIG = ".doorstop.yml"
35
    SKIP = ".doorstop.skip"  # indicates this document should be skipped
36
    ASSETS = "assets"
37
    INDEX = "index.yml"
38
    TEMPLATE = "template"
39
40
    DEFAULT_PREFIX = Prefix("REQ")
41
    DEFAULT_SEP = ""
42
    DEFAULT_DIGITS = 3
43
44
    def __init__(self, path, root=os.getcwd(), **kwargs):
45
        """Initialize a document from an exiting directory.
46
47
        :param path: path to document directory
48
        :param root: path to root of project
49
50
        """
51
        super().__init__()
52
        # Ensure the directory is valid
53
        if not os.path.isfile(os.path.join(path, Document.CONFIG)):
54
            relpath = os.path.relpath(path, root)
55
            msg = "no {} in {}".format(Document.CONFIG, relpath)
56
            raise DoorstopError(msg)
57
        # Initialize the document
58
        self.path = path
59
        self.root = root
60
        self.tree = kwargs.get("tree")
61
        self.auto = kwargs.get("auto", Document.auto)
62
        # Set default values
63
        self._attribute_defaults = None
64
        self._attribute_publish = None
65
        self._data["prefix"] = Document.DEFAULT_PREFIX
66
        self._data["sep"] = Document.DEFAULT_SEP
67
        self._data["digits"] = Document.DEFAULT_DIGITS  # type: ignore
68
        self._data["parent"] = None  # type: ignore
69
        self._data["itemformat"] = kwargs.get("itemformat")  # type: ignore
70
        self._extended_reviewed: List[str] = []
71
        self._items: List[Item] = []
72
        self._itered = False
73
        self.children: List[Document] = []
74
75
        if not self._data["itemformat"]:
76
            self._data["itemformat"] = Item.DEFAULT_ITEMFORMAT
77
78
    def __repr__(self):
79
        return "Document('{}')".format(self.path)
80
81
    def __str__(self):
82
        if common.verbosity < common.STR_VERBOSITY:
83
            return self.prefix
84
        else:
85
            return "{} ({})".format(self.prefix, self.relpath)
86
87
    def __iter__(self):
88
        yield from self._iter()
89
90
    def __len__(self):
91
        return len(list(i for i in self._iter() if i.active))
92
93
    def __bool__(self):
94
        """Even empty documents should be considered truthy."""
95
        return True
96
97
    @staticmethod
98
    @add_document
99
    def new(
100
        tree,
101
        path,
102
        root,
103
        prefix,
104
        sep=None,
105
        digits=None,
106
        parent=None,
107
        auto=None,
108
        itemformat=None,
109
    ):  # pylint: disable=R0913,C0301
110
        """Create a new document.
111
112
        :param tree: reference to tree that contains this document
113
114
        :param path: path to directory for the new document
115
        :param root: path to root of the project
116
        :param prefix: prefix for the new document
117
118
        :param sep: separator between prefix and numbers
119
        :param digits: number of digits for the new document
120
        :param parent: parent UID for the new document
121
        :param auto: automatically save the document
122
123
        :param itemformat: file format for storing items
124
125
        :raises: :class:`~doorstop.common.DoorstopError` if the document
126
            already exists
127
128
        :return: new :class:`~doorstop.core.document.Document`
129
130
        """
131
        # Check separator
132
        if sep and sep not in settings.SEP_CHARS:
133
            raise DoorstopError("invalid UID separator '{}'".format(sep))
134
135
        config = os.path.join(path, Document.CONFIG)
136
137
        # Check for an existing document
138
        if os.path.exists(config):
139
            raise DoorstopError("document already exists: {}".format(path))
140
141
        # Create the document directory
142
        Document._create(config, name="document")
143
144
        # Initialize the document
145
        document = Document(
146
            path, root=root, tree=tree, auto=False, itemformat=itemformat
147
        )
148
        document.prefix = (  # type: ignore
149
            prefix if prefix is not None else document.prefix
150
        )
151
        document.sep = sep if sep is not None else document.sep  # type: ignore
152
        document.digits = (  # type: ignore
153
            digits if digits is not None else document.digits
154
        )
155
        document.parent = (  # type: ignore
156
            parent if parent is not None else document.parent
157
        )
158
        if auto or (auto is None and Document.auto):
159
            document.save()
160
161
        # Return the document
162
        return document
163
164
    def _load_with_include(self, yamlfile):
165
        """Load the YAML file and process input tags."""
166
        # Read text from file
167
        text = self._read(yamlfile)
168
        # Parse YAML data from text
169
        class IncludeLoader(yaml.SafeLoader):
170
            def include(self, node):
171
                container = IncludeLoader.filenames[0]  # type: ignore
172
                dirname = os.path.dirname(container)
173
                filename = os.path.join(dirname, self.construct_scalar(node))
174
                IncludeLoader.filenames.insert(0, filename)  # type: ignore
175
                try:
176
                    with open(filename, "r") as f:
177
                        data = yaml.load(f, IncludeLoader)
178
                except Exception as ex:
179
                    msg = "include in '{}' failed: {}".format(container, ex)
180
                    raise DoorstopError(msg)
181
                IncludeLoader.filenames.pop()  # type: ignore
182
                return data
183
184
        IncludeLoader.add_constructor("!include", IncludeLoader.include)
185
        IncludeLoader.filenames = [yamlfile]  # type: ignore
186
        return self._load(text, yamlfile, loader=IncludeLoader)
187
188
    def load(self, reload=False):
189
        """Load the document's properties from its file."""
190
        if self._loaded and not reload:
191
            return
192
        log.debug("loading {}...".format(repr(self)))
193
        data = self._load_with_include(self.config)
194
        # Store parsed data
195
        sets = data.get("settings", {})
196
        for key, value in sets.items():
197
            try:
198
                if key == "prefix":
199
                    self._data[key] = Prefix(value)
200
                elif key == "sep":
201
                    self._data[key] = value.strip()
202
                elif key == "parent":
203
                    self._data[key] = value.strip()
204
                elif key == "digits":
205
                    self._data[key] = int(value)  # type: ignore
206
                elif key == "itemformat":
207
                    self._data[key] = value.strip()
208
                else:
209
                    msg = "unexpected document setting '{}' in: {}".format(
210
                        key, self.config
211
                    )
212
                    raise DoorstopError(msg)
213
            except (AttributeError, TypeError, ValueError):
214
                msg = "invalid value for '{}' in: {}".format(key, self.config)
215
                raise DoorstopError(msg)
216
        # Store parsed attributes
217
        attributes = data.get("attributes", {})
218
        for key, value in attributes.items():
219
            if key == "defaults":
220
                self._attribute_defaults = value
221
            elif key == "reviewed":
222
                self._extended_reviewed = sorted(set(v for v in value))
223
            elif key == "publish":
224
                self._attribute_publish = value
225
            else:
226
                msg = "unexpected attributes configuration '{}' in: {}".format(
227
                    key, self.config
228
                )
229
                raise DoorstopError(msg)
230
        # Set meta attributes
231
        self._loaded = True
232
        if reload:
233
            list(self._iter(reload=reload))
234
235
    @edit_document
236
    def save(self):
237
        """Save the document's properties to its file."""
238
        log.debug("saving {}...".format(repr(self)))
239
        # Format the data items
240
        data = {}
241
        sets = {}
242
        for key, value in self._data.items():
243
            if key == "prefix":
244
                sets[key] = str(value)
245
            elif key == "parent":
246
                if value:
247
                    sets[key] = value
248
            else:
249
                sets[key] = value
250
        data["settings"] = sets
251
        # Save the attributes
252
        attributes = {}
253
        if self._attribute_defaults:
254
            attributes["defaults"] = self._attribute_defaults
255
        if self._extended_reviewed:
256
            attributes["reviewed"] = self._extended_reviewed
257
        if attributes:
258
            data["attributes"] = attributes
259
        # Dump the data to YAML
260
        text = self._dump(data)
261
        # Save the YAML to file
262
        self._write(text, self.config)
263
        # Set meta attributes
264
        self._loaded = False
265
        self.auto = True
266
267
    def _iter(self, reload=False):
268
        """Yield the document's items."""
269
        if self._itered and not reload:
270
            msg = "iterating document {}'s loaded items...".format(self)
271
            log.debug(msg)
272
            yield from list(self._items)
273
            return
274
        log.info("loading document {}'s items...".format(self))
275
        # Reload the document's item
276
        self._items = []
277
        for dirpath, dirnames, filenames in os.walk(self.path):
278
            for dirname in list(dirnames):
279
                path = os.path.join(dirpath, dirname, Document.CONFIG)
280
                if os.path.exists(path):
281
                    path = os.path.dirname(path)
282
                    dirnames.remove(dirname)
283
                    log.trace(  # type: ignore
284
                        "skipped embedded document: {}".format(path)
285
                    )
286
            for filename in filenames:
287
                path = os.path.join(dirpath, filename)
288
                try:
289
                    item = Item(
290
                        self,
291
                        path,
292
                        root=self.root,
293
                        tree=self.tree,
294
                        itemformat=self.itemformat,
295
                    )
296
                except DoorstopError:
297
                    pass  # skip non-item files
298
                else:
299
                    self._items.append(item)
300
                    if reload:
301
                        try:
302
                            item.load(reload=reload)
303
                        except Exception:
304
                            log.error("Unable to load: %s", item)
305
                            raise
306
                    if settings.CACHE_ITEMS and self.tree:
307
                        self.tree._item_cache[  # pylint: disable=protected-access
308
                            item.uid
309
                        ] = item
310
                        log.trace("cached item: {}".format(item))  # type: ignore
311
        # Set meta attributes
312
        self._itered = True
313
        # Yield items
314
        yield from list(self._items)
315
316
    def copy_assets(self, dest):
317
        """Copy the contents of the assets directory."""
318
        if not self.assets:
319
            return
320
        # Create folder if it does not exist.
321
        if not os.path.isdir(dest):
322
            os.makedirs(dest)
323
        common.copy_dir_contents(self.assets, dest)
324
325
    # properties #############################################################
326
327
    @property
328
    def config(self):
329
        """Get the path to the document's file."""
330
        assert self.path
331
        return os.path.join(self.path, Document.CONFIG)
332
333
    @property
334
    def assets(self):
335
        """Get the path to the document's assets if they exist else `None`."""
336
        assert self.path
337
        path = os.path.join(self.path, Document.ASSETS)
338
        return path if os.path.isdir(path) else None
339
340
    @property
341
    def template(self):
342
        """Get the path to the document's template if they exist else `None`."""
343
        assert self.path
344
        path = os.path.join(self.path, Document.TEMPLATE)
345
        return path if os.path.isdir(path) else None
346
347
    @property  # type: ignore
348
    @auto_load
349
    def prefix(self):
350
        """Get the document's prefix."""
351
        return self._data["prefix"]
352
353
    @property  # type: ignore
354
    @auto_load
355
    def publish(self):
356
        """Get the document's prefix."""
357
        return self._attribute_publish
358
359
    @prefix.setter  # type: ignore
360
    @auto_save
361
    @auto_load
362
    def prefix(self, value):
363
        """Set the document's prefix."""
364
        self._data["prefix"] = Prefix(value)
365
        # TODO: should the new prefix be applied to all items?
366
367
    @property  # type: ignore
368
    @auto_load
369
    def extended_reviewed(self):
370
        """Get the document's extended reviewed attribute keys."""
371
        return self._extended_reviewed
372
373
    @property  # type: ignore
374
    @auto_load
375
    def sep(self):
376
        """Get the prefix-number separator to use for new item UIDs."""
377
        return self._data["sep"]
378
379
    @sep.setter  # type: ignore
380
    @auto_save
381
    @auto_load
382
    def sep(self, value):
383
        """Set the prefix-number separator to use for new item UIDs."""
384
        # TODO: raise a specific exception for invalid separator characters?
385
        assert not value or value in settings.SEP_CHARS
386
        self._data["sep"] = value.strip()
387
        # TODO: should the new separator be applied to all items?
388
389
    @property  # type: ignore
390
    @auto_load
391
    def digits(self):
392
        """Get the number of digits to use for new item UIDs."""
393
        return self._data["digits"]
394
395
    @digits.setter  # type: ignore
396
    @auto_save
397
    @auto_load
398
    def digits(self, value):
399
        """Set the number of digits to use for new item UIDs."""
400
        self._data["digits"] = value
401
        # TODO: should the new digits be applied to all items?
402
403
    @property  # type: ignore
404
    @auto_load
405
    def parent(self):
406
        """Get the document's parent document prefix."""
407
        return self._data["parent"]
408
409
    @parent.setter  # type: ignore
410
    @auto_save
411
    @auto_load
412
    def parent(self, value):
413
        """Set the document's parent document prefix."""
414
        self._data["parent"] = str(value) if value else ""
415
416
    @property
417
    def itemformat(self):
418
        """Get storage format for item files."""
419
        return self._data["itemformat"]
420
421
    @property
422
    def items(self):
423
        """Get an ordered list of active items in the document."""
424
        return sorted(i for i in self._iter() if i.active)
425
426
    @property
427
    def depth(self):
428
        """Return the maximum item level depth."""
429
        return max(item.depth for item in self)
430
431
    @property
432
    def next_number(self):
433
        """Get the next item number for the document."""
434
        try:
435
            number = max(item.uid.number for item in self) + 1
436
        except ValueError:
437
            number = 1
438
        log.debug("next number (local): {}".format(number))
439
440
        if self.tree and self.tree.request_next_number:
441
            remote_number = 0
442
            while remote_number is not None and remote_number < number:
443
                if remote_number:
444
                    log.warning("server is behind, requesting next number...")
445
                remote_number = self.tree.request_next_number(self.prefix)
446
                log.debug("next number (remote): {}".format(remote_number))
447
            if remote_number:
448
                number = remote_number
449
450
        return number
451
452
    @property
453
    def skip(self):
454
        """Indicate the document should be skipped."""
455
        assert self.path
456
        return os.path.isfile(os.path.join(self.path, Document.SKIP))
457
458
    @property
459
    def index(self):
460
        """Get the path to the document's index if it exists else `None`."""
461
        assert self.path
462
        path = os.path.join(self.path, Document.INDEX)
463
        return path if os.path.isfile(path) else None
464
465
    @index.setter
466
    def index(self, value):
467
        """Create or update the document's index."""
468
        if value:
469
            assert self.path
470
            path = os.path.join(self.path, Document.INDEX)
471
            log.info("creating {} index...".format(self))
472
            common.write_lines(
473
                self._lines_index(self.items), path, end=settings.WRITE_LINESEPERATOR
474
            )
475
476
    @index.deleter
477
    def index(self):
478
        """Delete the document's index if it exists."""
479
        log.info("deleting {} index...".format(self))
480
        common.delete(self.index)
481
482
    # actions ################################################################
483
484
    # decorators are applied to methods in the associated classes
485
    def add_item(self, number=None, level=None, reorder=True, defaults=None, name=None):
486
        """Create a new item for the document and return it.
487
488
        :param number: desired item number
489
        :param level: desired item level
490
        :param reorder: update levels of document items
491
492
        :return: added :class:`~doorstop.core.item.Item`
493
494
        """
495
        uid = None
496
        if name is None:
497
            number = max(number or 0, self.next_number)
498
            log.debug("next number: {}".format(number))
499
            uid = UID(self.prefix, self.sep, number, self.digits)
500
        else:
501
            try:
502
                uid = UID(self.prefix, self.sep, int(name), self.digits)
503
            except ValueError:
504
                if not self.sep:
505
                    msg = "cannot add item with name '{}' to document '{}' without a separator".format(
506
                        name, self.prefix
507
                    )
508
                    raise DoorstopError(msg)
509
                if self.sep not in settings.SEP_CHARS:
510
                    msg = "cannot add item with name '{}' to document '{}' with an invalid separator '{}'".format(
511
                        name, self.prefix, self.sep
512
                    )
513
                    raise DoorstopError(msg)
514
                uid = UID(self.prefix, self.sep, name)
515
                if uid.prefix != self.prefix or uid.name != name:
516
                    msg = "invalid item name '{}'".format(name)
517
                    raise DoorstopError(msg)
518
519
        try:
520
            last = self.items[-1]
521
        except IndexError:
522
            next_level = level
523
        else:
524
            if level:
525
                next_level = level
526
            elif last.level.heading:
527
                next_level = last.level >> 1
528
                next_level.heading = False
529
            else:
530
                next_level = last.level + 1
531
        log.debug("next level: {}".format(next_level))
532
533
        # Load more defaults before the item is created to avoid partially
534
        # constructed items in case the loading fails.
535
        more_defaults = self._load_with_include(defaults) if defaults else None
536
537
        item = Item.new(self.tree, self, self.path, self.root, uid, level=next_level)
538
539
        # exclusivity: apply defaults given by command line OR
540
        # document based defaults, but not both
541
        if more_defaults:
542
            item.set_attributes(more_defaults)
543
        elif self._attribute_defaults:
544
            item.set_attributes(self._attribute_defaults)
545
546
        if level and reorder:
547
            self.reorder(keep=item)
548
        return item
549
550
    # decorators are applied to methods in the associated classes
551
    def remove_item(self, value, reorder=True):
552
        """Remove an item by its UID.
553
554
        :param value: item or UID
555
        :param reorder: update levels of document items
556
557
        :raises: :class:`~doorstop.common.DoorstopError` if the item
558
            cannot be found
559
560
        :return: removed :class:`~doorstop.core.item.Item`
561
562
        """
563
        uid = UID(value)
564
        item = self.find_item(uid)
565
        item.delete()
566
        if reorder:
567
            self.reorder()
568
        return item
569
570
    # decorators are applied to methods in the associated classes
571
    def reorder(self, manual=True, automatic=True, start=None, keep=None, _items=None):
572
        """Reorder a document's items.
573
574
        Two methods are using to create the outline order:
575
576
        - manual: specify the order using an updated index file
577
        - automatic: shift duplicate levels and compress gaps
578
579
        :param manual: enable manual ordering using the index (if one exists)
580
581
        :param automatic: enable automatic ordering (after manual ordering)
582
        :param start: level to start numbering (None = use current start)
583
        :param keep: item or UID to keep over duplicates
584
585
        """
586
        # Reorder manually
587
        if manual and self.index:
588
            log.info("reordering {} from index...".format(self))
589
            self._reorder_from_index(self, self.index)
590
            del self.index
591
        # Reorder automatically
592
        if automatic:
593
            log.info("reordering {} automatically...".format(self))
594
            items = _items or self.items
595
            keep = self.find_item(keep) if keep else None
596
            self._reorder_automatic(items, start=start, keep=keep)
597
598
    @staticmethod
599
    def _lines_index(items):
600
        """Generate (pseudo) YAML lines for the document index."""
601
        yield "#" * settings.MAX_LINE_LENGTH
602
        yield "# THIS TEMPORARY FILE WILL BE DELETED AFTER DOCUMENT REORDERING"
603
        yield "# MANUALLY INDENT, DEDENT, & MOVE ITEMS TO THEIR DESIRED LEVEL"
604
        yield "# A NEW ITEM WILL BE ADDED FOR ANY UNKNOWN IDS, i.e. - new: "
605
        yield "# THE COMMENT WILL BE USED AS THE ITEM TEXT FOR NEW ITEMS"
606
        yield "# CHANGES WILL BE REFLECTED IN THE ITEM FILES AFTER CONFIRMATION"
607
        yield "#" * settings.MAX_LINE_LENGTH
608
        yield ""
609
        yield "initial: {}".format(items[0].level if items else 1.0)
610
        yield "outline:"
611
        for item in items:
612
            space = "    " * item.depth
613
            lines = item.text.strip().splitlines()
614
            comment = lines[0].replace("\\", "\\\\") if lines else ""
615
            line = space + "- {u}: # {c}".format(u=item.uid, c=comment)
616
            if len(line) > settings.MAX_LINE_LENGTH:
617
                line = line[: settings.MAX_LINE_LENGTH - 3] + "..."
618
            yield line
619
620
    @staticmethod
621
    def _read_index(path):
622
        """Load the index, converting comments to text entries for each item."""
623
        with open(path, "r", encoding="utf-8") as stream:
624
            text = stream.read()
625
        yaml_text = []
626
        for line in text.split("\n"):
627
            m = re.search(r"(\s+)(- [\w\d-]+\s*): # (.+)$", line)
628
            if m:
629
                prefix = m.group(1)
630
                uid = m.group(2)
631
                item_text = m.group(3).replace('"', '\\"')
632
                yaml_text.append("{p}{u}:".format(p=prefix, u=uid))
633
                yaml_text.append('    {p}- text: "{t}"'.format(p=prefix, t=item_text))
634
            else:
635
                yaml_text.append(line)
636
        return common.load_yaml("\n".join(yaml_text), path)
637
638
    @staticmethod
639
    def _reorder_from_index(document, path):
640
        """Reorder a document's item from the index."""
641
        data = document._read_index(path)  # pylint: disable=protected-access
642
        # Read updated values
643
        initial = data.get("initial", 1.0)
644
        outline = data.get("outline", [])
645
        # Update levels
646
        level = Level(initial)
647
        ids_after_reorder: List[str] = []
648
        Document._reorder_section(outline, level, document, ids_after_reorder)
649
        for item in document.items:
650
            if item.uid not in ids_after_reorder:
651
                log.info("Deleting %s", item.uid)
652
                item.delete()
653
654
    @staticmethod
655
    def _reorder_section(section, level, document, list_of_ids):
656
        """Recursive function to reorder a section of an outline.
657
658
        :param section: recursive `list` of `dict` loaded from document index
659
        :param level: current :class:`~doorstop.core.types.Level`
660
        :param document: :class:`~doorstop.core.document.Document` to order
661
662
        """
663
        if isinstance(section, dict):  # a section
664
665
            # Get the item and subsection
666
            uid = list(section.keys())[0]
667
            if uid == "text":
668
                return
669
            subsection = section[uid]
670
671
            # An item is a header if it has a subsection
672
            level.heading = False
673
            item_text = ""
674
            if isinstance(subsection, str):
675
                item_text = subsection
676
            elif isinstance(subsection, list):
677
                if "text" in subsection[0]:
678
                    item_text = subsection[0]["text"]
679
                    if len(subsection) > 1:
680
                        level.heading = True
681
682
            try:
683
                item = document.find_item(uid)
684
                item.level = level
685
                log.info("Found ({}): {}".format(uid, level))
686
                list_of_ids.append(uid)
687
            except DoorstopError:
688
                item = document.add_item(level=level, reorder=False)
689
                list_of_ids.append(item.uid)
690
                if level.heading:
691
                    item.normative = False
692
                item.text = item_text
693
                log.info("Created ({}): {}".format(item.uid, level))
694
695
            # Process the heading's subsection
696
            if subsection:
697
                Document._reorder_section(subsection, level >> 1, document, list_of_ids)
698
699
        elif isinstance(section, list):  # a list of sections
700
701
            # Process each subsection
702
            for index, subsection in enumerate(section):
703
                Document._reorder_section(
704
                    subsection, level + index, document, list_of_ids
705
                )
706
707
    @staticmethod
708
    def _reorder_automatic(items, start=None, keep=None):
709
        """Reorder a document's items automatically.
710
711
        :param items: items to reorder
712
        :param start: level to start numbering (None = use current start)
713
        :param keep: item to keep over duplicates
714
715
        """
716
        nlevel = plevel = None
717
        for clevel, item in Document._items_by_level(items, keep=keep):
718
            log.debug("current level: {}".format(clevel))
719
            # Determine the next level
720
            if not nlevel:
721
                # Use the specified or current starting level
722
                nlevel = Level(start) if start else clevel
723
                nlevel.heading = clevel.heading
724
                log.debug("next level (start): {}".format(nlevel))
725
            else:
726
                # Adjust the next level to be the same depth
727
                if len(clevel) > len(nlevel):
728
                    nlevel >>= len(clevel) - len(nlevel)
729
                    log.debug("matched current indent: {}".format(nlevel))
730
                elif len(clevel) < len(nlevel):
731
                    nlevel <<= len(nlevel) - len(clevel)
732
                    # nlevel += 1
733
                    log.debug("matched current dedent: {}".format(nlevel))
734
                nlevel.heading = clevel.heading
735
                # Check for a level jump
736
                _size = min(len(clevel.value), len(plevel.value))
737
                for index in range(max(_size - 1, 1)):
738
                    if clevel.value[index] > plevel.value[index]:
739
                        nlevel <<= len(nlevel) - 1 - index
740
                        nlevel += 1
741
                        nlevel >>= len(clevel) - len(nlevel)
742
                        msg = "next level (jump): {}".format(nlevel)
743
                        log.debug(msg)
744
                        break
745
                # Check for a normal increment
746
                else:
747
                    if len(nlevel) <= len(plevel):
748
                        nlevel += 1
749
                        msg = "next level (increment): {}".format(nlevel)
750
                        log.debug(msg)
751
                    else:
752
                        msg = "next level (indent/dedent): {}".format(nlevel)
753
                        log.debug(msg)
754
            # Apply the next level
755
            if clevel == nlevel:
756
                log.info("{}: {}".format(item, clevel))
757
            else:
758
                log.info("{}: {} to {}".format(item, clevel, nlevel))
759
            item.level = nlevel.copy()
760
            # Save the current level as the previous level
761
            plevel = clevel.copy()
762
763
    @staticmethod
764
    def _items_by_level(items, keep=None):
765
        """Iterate through items by level with the kept item first."""
766
        # Collect levels
767
        levels: Dict[Level, List[Item]] = OrderedDict()
768
        for item in items:
769
            if item.level in levels:
770
                levels[item.level].append(item)
771
            else:
772
                levels[item.level] = [item]
773
        # Reorder levels
774
        for level, items_at_level in levels.items():
775
            # Reorder items at this level
776
            if keep in items_at_level:
777
                # move the kept item to the front of the list
778
                log.debug("keeping {} level over duplicates".format(keep))
779
                items_at_level.remove(keep)
780
                items_at_level.insert(0, keep)
781
            for item in items_at_level:
782
                yield level, item
783
784
    def find_item(self, value, _kind=""):
785
        """Return an item by its UID.
786
787
        :param value: item or UID
788
789
        :raises: :class:`~doorstop.common.DoorstopError` if the item
790
            cannot be found
791
792
        :return: matching :class:`~doorstop.core.item.Item`
793
794
        """
795
        uid = UID(value)
796
        for item in self:
797
            if item.uid == uid:
798
                if item.active:
799
                    return item
800
                else:
801
                    log.trace("item is inactive: {}".format(item))  # type: ignore
802
803
        raise DoorstopError("no matching{} UID: {}".format(_kind, uid))
804
805
    def get_issues(
806
        self, skip=None, document_hook=None, item_hook=None
807
    ):  # pylint: disable=unused-argument
808
        """Yield all the document's issues.
809
810
        :param skip: list of document prefixes to skip
811
        :param item_hook: function to call for custom item validation
812
813
        :return: generator of :class:`~doorstop.common.DoorstopError`,
814
                              :class:`~doorstop.common.DoorstopWarning`,
815
                              :class:`~doorstop.common.DoorstopInfo`
816
817
        """
818
        assert document_hook is None
819
        skip = [] if skip is None else skip
820
        hook = item_hook if item_hook else lambda **kwargs: []
821
822
        if self.prefix in skip:
823
            log.info("skipping document %s...", self)
824
            return
825
        else:
826
            log.info("checking document %s...", self)
827
828
        # Check for items
829
        items = self.items
830
        if not items:
831
            yield DoorstopWarning("no items")
832
            return
833
834
        # Reorder or check item levels
835
        if settings.REORDER:
836
            self.reorder(_items=items)
837
        elif settings.CHECK_LEVELS:
838
            yield from self._get_issues_level(items)
839
840
        item_validator = ItemValidator()
841
842
        # Check each item
843
        for item in items:
844
845
            # Check item
846
            for issue in chain(
847
                hook(item=item, document=self, tree=self.tree),
848
                item_validator.get_issues(item, skip=skip),
849
            ):
850
851
                # Prepend the item's UID to yielded exceptions
852
                if isinstance(issue, Exception):
853
                    yield type(issue)("{}: {}".format(item.uid, issue))
854
855
    @staticmethod
856
    def _get_issues_level(items):
857
        """Yield all the document's issues related to item level."""
858
        prev = items[0] if items else None
859
        for item in items[1:]:
860
            puid = prev.uid
861
            plev = prev.level
862
            nuid = item.uid
863
            nlev = item.level
864
            log.debug("checking level {} to {}...".format(plev, nlev))
865
            # Duplicate level
866
            if plev == nlev:
867
                uids = sorted((puid, nuid))
868
                msg = "duplicate level: {} ({}, {})".format(plev, *uids)
869
                yield DoorstopWarning(msg)
870
            # Skipped level
871
            length = min(len(plev.value), len(nlev.value))
872
            for index in range(length):
873
                # Types of skipped levels:
874
                #         1. over: 1.0 --> 1.2
875
                #         2. out: 1.1 --> 3.0
876
                if (
877
                    nlev.value[index] - plev.value[index] > 1
878
                    or
879
                    # 3. over and out: 1.1 --> 2.2
880
                    (
881
                        plev.value[index] != nlev.value[index]
882
                        and index + 1 < length
883
                        and nlev.value[index + 1] not in (0, 1)
884
                    )
885
                ):
886
                    msg = "skipped level: {} ({}), {} ({})".format(
887
                        plev, puid, nlev, nuid
888
                    )
889
                    yield DoorstopInfo(msg)
890
                    break
891
            prev = item
892
893
    @delete_document
894
    def delete(self, path=None):
895
        """Delete the document and its items."""
896
        for item in self:
897
            item.delete()
898
        # the document is deleted in the decorated method
899