Passed
Pull Request — develop (#359)
by
unknown
01:51
created

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

Complexity

Conditions 9

Size

Total Lines 42
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 9

Importance

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

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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