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

doorstop.core.document.Document.new()   C

Complexity

Conditions 11

Size

Total Lines 54
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
dl 0
loc 54
rs 5.4
c 0
b 0
f 0
cc 11
nop 8

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like doorstop.core.document.Document.new() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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