Passed
Pull Request — develop (#359)
by
unknown
02:07
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
# 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