Passed
Pull Request — develop (#569)
by
unknown
01:28
created

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

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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