doorstop.core.document.Document.add_item()   F
last analyzed

Complexity

Conditions 16

Size

Total Lines 64
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 41
dl 0
loc 64
rs 2.4
c 0
b 0
f 0
cc 16
nop 6

How to fix   Long Method    Complexity   

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.add_item() 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.

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