Completed
Push — develop ( 9b02bc...c0484b )
by Jace
15s queued 10s
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 20
CRAP Score 9

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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