Passed
Pull Request — develop (#340)
by
unknown
03:26
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 14
CRAP Score 9

Importance

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