Passed
Pull Request — develop (#359)
by
unknown
02:07
created

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

Complexity

Conditions 9

Size

Total Lines 40
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 9

Importance

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