Passed
Pull Request — develop (#403)
by
unknown
01:43
created

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

Complexity

Conditions 9

Size

Total Lines 42
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 9

Importance

Changes 0
Metric Value
eloc 17
dl 0
loc 42
ccs 22
cts 22
cp 1
rs 6.6666
c 0
b 0
f 0
cc 9
nop 8
crap 9

How to fix   Many Parameters   

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