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

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