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

Complexity

Conditions 9

Size

Total Lines 52
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 32
CRAP Score 9

Importance

Changes 0
Metric Value
eloc 20
dl 0
loc 52
ccs 32
cts 32
cp 1
rs 6.6666
c 0
b 0
f 0
cc 9
nop 8
crap 9

How to fix   Long Method    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:

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