Passed
Pull Request — develop (#573)
by
unknown
02:06
created

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

Complexity

Conditions 11

Size

Total Lines 66
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 30
dl 0
loc 66
rs 5.4
c 0
b 0
f 0
cc 11
nop 9

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
    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