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

Complexity

Conditions 10

Size

Total Lines 39
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 33
CRAP Score 10

Importance

Changes 0
Metric Value
eloc 24
dl 0
loc 39
ccs 33
cts 33
cp 1
rs 5.9999
c 0
b 0
f 0
cc 10
nop 5
crap 10

How to fix   Complexity   

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