Passed
Pull Request — develop (#340)
by
unknown
03:26
created

doorstop.core.document.Document.config()   A

Complexity

Conditions 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 4
ccs 3
cts 3
cp 1
rs 10
c 0
b 0
f 0
cc 1
nop 1
crap 1
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