Example #1
0
 def load(self, reload=False):
     """Load the item's properties from its file."""
     if self._loaded and not reload:
         return
     log.debug("loading {}...".format(repr(self)))
     # Read text from file
     text = self._read(self.path)
     # Parse YAML data from text
     data = self._load(text, self.path)
     # Store parsed data
     for key, value in data.items():
         if key == 'level':
             value = Level(value)
         elif key == 'active':
             value = to_bool(value)
         elif key == 'normative':
             value = to_bool(value)
         elif key == 'derived':
             value = to_bool(value)
         elif key == 'reviewed':
             value = Stamp(value)
         elif key == 'text':
             value = Text(value)
         elif key == 'ref':
             value = value.strip()
         elif key == 'links':
             value = set(UID(part) for part in value)
         else:
             if isinstance(value, str):
                 value = Text(value)
         self._data[key] = value
     # Set meta attributes
     self._loaded = True
Example #2
0
 def test_add_item_after_header(self, mock_new):
     """Verify the next item after a header is indented."""
     mock_item = Mock()
     mock_item.number = 1
     mock_item.level = Level('1.0')
     self.document._iter = Mock(return_value=[mock_item])
     self.document.add_item()
     mock_new.assert_called_once_with(
         None, self.document, FILES, ROOT, 'REQ002', level=Level('1.1')
     )
Example #3
0
    def test_reorder_from_index_add(self):
        """Verify items can be added when reordering from an index."""
        data = {
            'initial': 2.0,
            'outline': [
                {'a': None},
                {
                    'b': [
                        {'ba': [{'baa': None}, {'new': None}, {'bac': None}]},
                        {'bb': [{'new': [{'text': 'item_text'}]}]},
                    ]
                },
                {'c': None},
            ],
        }
        expected = [
            Level('2'),
            Level('3.0'),
            Level('3.1.0'),
            Level('3.1.1'),
            Level('3.1.2'),
            Level('3.1.3'),
            Level('3.2.0'),
            Level('3.2.1'),
            Level('4'),
        ]
        # Act
        self.document._read_index = MagicMock(return_value=data)
        Document._reorder_from_index(self.document, 'mock_path')

        # Assert
        self.document._read_index.assert_called_once_with('mock_path')
        actual = [item.level for item in self.document.items]
        self.assertListEqual(expected, actual)
        self.assertEqual(self.document.items[-2].text, 'item_text')
Example #4
0
    def _reorder_automatic(items, start=None, keep=None):
        """Reorder a document's items automatically.

        :param items: items to reorder
        :param start: level to start numbering (None = use current start)
        :param keep: item to keep over duplicates

        """
        nlevel = plevel = None
        for clevel, item in Document._items_by_level(items, keep=keep):
            log.debug("current level: {}".format(clevel))
            # Determine the next level
            if not nlevel:
                # Use the specified or current starting level
                nlevel = Level(start) if start else clevel
                nlevel.heading = clevel.heading
                log.debug("next level (start): {}".format(nlevel))
            else:
                # Adjust the next level to be the same depth
                if len(clevel) > len(nlevel):
                    nlevel >>= len(clevel) - len(nlevel)
                    log.debug("matched current indent: {}".format(nlevel))
                elif len(clevel) < len(nlevel):
                    nlevel <<= len(nlevel) - len(clevel)
                    # nlevel += 1
                    log.debug("matched current dedent: {}".format(nlevel))
                nlevel.heading = clevel.heading
                # Check for a level jump
                _size = min(len(clevel.value), len(plevel.value))
                for index in range(max(_size - 1, 1)):
                    if clevel.value[index] > plevel.value[index]:
                        nlevel <<= len(nlevel) - 1 - index
                        nlevel += 1
                        nlevel >>= len(clevel) - len(nlevel)
                        msg = "next level (jump): {}".format(nlevel)
                        log.debug(msg)
                        break
                # Check for a normal increment
                else:
                    if len(nlevel) <= len(plevel):
                        nlevel += 1
                        msg = "next level (increment): {}".format(nlevel)
                        log.debug(msg)
                    else:
                        msg = "next level (indent/dedent): {}".format(nlevel)
                        log.debug(msg)
            # Apply the next level
            if clevel == nlevel:
                log.info("{}: {}".format(item, clevel))
            else:
                log.info("{}: {} to {}".format(item, clevel, nlevel))
            item.level = nlevel.copy()
            # Save the current level as the previous level
            plevel = clevel.copy()
Example #5
0
    def _reorder_automatic(items, start=None, keep=None):
        """Reorder a document's items automatically.

        :param items: items to reorder
        :param start: level to start numbering (None = use current start)
        :param keep: item to keep over duplicates

        """
        nlevel = plevel = None
        for clevel, item in Document._items_by_level(items, keep=keep):
            log.debug("current level: {}".format(clevel))
            # Determine the next level
            if not nlevel:
                # Use the specified or current starting level
                nlevel = Level(start) if start else clevel
                nlevel.heading = clevel.heading
                log.debug("next level (start): {}".format(nlevel))
            else:
                # Adjust the next level to be the same depth
                if len(clevel) > len(nlevel):
                    nlevel >>= len(clevel) - len(nlevel)
                    log.debug("matched current indent: {}".format(nlevel))
                elif len(clevel) < len(nlevel):
                    nlevel <<= len(nlevel) - len(clevel)
                    # nlevel += 1
                    log.debug("matched current dedent: {}".format(nlevel))
                nlevel.heading = clevel.heading
                # Check for a level jump
                _size = min(len(clevel.value), len(plevel.value))
                for index in range(max(_size - 1, 1)):
                    if clevel.value[index] > plevel.value[index]:
                        nlevel <<= len(nlevel) - 1 - index
                        nlevel += 1
                        nlevel >>= len(clevel) - len(nlevel)
                        msg = "next level (jump): {}".format(nlevel)
                        log.debug(msg)
                        break
                # Check for a normal increment
                else:
                    if len(nlevel) <= len(plevel):
                        nlevel += 1
                        msg = "next level (increment): {}".format(nlevel)
                        log.debug(msg)
                    else:
                        msg = "next level (indent/dedent): {}".format(nlevel)
                        log.debug(msg)
            # Apply the next level
            if clevel == nlevel:
                log.info("{}: {}".format(item, clevel))
            else:
                log.info("{}: {} to {}".format(item, clevel, nlevel))
            item.level = nlevel.copy()
            # Save the current level as the previous level
            plevel = clevel.copy()
Example #6
0
 def _getSubsequentLevel(self, level):
     new_level = Level(
         level)  # create a new object, the '=' operator does a reference
     if new_level.heading:
         parts = str(new_level).split(
             '.'
         )  # adding +1 to level doesn't work as expected when the level is a heading
         parts[-1] = 1  # x.x.x.0 --> x.x.x.1
         new_level = Level(parts)
     else:
         new_level += 1
     return new_level
Example #7
0
 def test_issues_duplicate_level(self):
     """Verify duplicate item levels are detected."""
     mock_item1 = Mock()
     mock_item1.uid = 'HLT001'
     mock_item1.level = Level('4.2')
     mock_item2 = Mock()
     mock_item2.uid = 'HLT002'
     mock_item2.level = Level('4.2')
     mock_items = [mock_item1, mock_item2]
     expected = DoorstopWarning("duplicate level: 4.2 (HLT001, HLT002)")
     issue = list(self.document._get_issues_level(mock_items))[0]
     self.assertIsInstance(issue, type(expected))
     self.assertEqual(expected.args, issue.args)
Example #8
0
 def test_issues_skipped_level_out_over(self):
     """Verify skipped (out and over) item levels are detected."""
     mock_item1 = Mock()
     mock_item1.uid = 'HLT001'
     mock_item1.level = Level('1.1')
     mock_item2 = Mock()
     mock_item2.uid = 'HLT002'
     mock_item2.level = Level('2.2')
     mock_items = [mock_item1, mock_item2]
     expected = DoorstopWarning("skipped level: 1.1 (HLT001), 2.2 (HLT002)")
     issues = list(self.document._get_issues_level(mock_items))
     self.assertEqual(1, len(issues))
     self.assertIsInstance(issues[0], type(expected))
     self.assertEqual(expected.args, issues[0].args)
Example #9
0
 def test_reorder_automatic_no_change(self):
     """Verify already ordered items can be reordered."""
     mock_items = [Mock(level=Level('1.1')),
                   Mock(level=Level('1.1.1.1')),
                   Mock(level=Level('2')),
                   Mock(level=Level('3')),
                   Mock(level=Level('4.1.1'))]
     expected = [Level('1.1'),
                 Level('1.1.1.1'),
                 Level('2'),
                 Level('3'),
                 Level('4.1.1')]
     Document._reorder_automatic(mock_items)
     actual = [item.level for item in mock_items]
     self.assertListEqual(expected, actual)
Example #10
0
 def test_add_item_with_number_name(self, mock_new):
     """Verify an item can be added to a document with a number as name."""
     self.document.sep = '-'
     self.document.add_item(name='99')
     mock_new.assert_called_once_with(
         None, self.document, FILES, ROOT, 'REQ-099', level=Level('2.2')
     )
Example #11
0
 def insertRowBefore(self, qidx):
     row = qidx.row()
     if row < len(self._data):  # clicked requirement actually exists
         item = self._data[row][len(self._headerData)]
         new_level = Level(
             item.get('level'))  # just use the level of the clicked req
         self.newReq(new_level)
Example #12
0
 def test_add_item(self, mock_new, mock_reorder):
     """Verify an item can be added to a document."""
     with patch('doorstop.settings.REORDER', True):
         self.document.add_item()
     mock_new.assert_called_once_with(
         None, self.document, FILES, ROOT, 'REQ006', level=Level('2.2')
     )
     self.assertEqual(0, mock_reorder.call_count)
Example #13
0
    def test_reorder_from_index_delete(self):
        """Verify items can be deleted when reordering from an index."""
        data = {
            'initial':
            2.0,
            'outline': [{
                'a': None
            }, {
                'b': [{
                    'ba': [{
                        'baa': None
                    }, {
                        'bab': None
                    }, {
                        'bac': None
                    }]
                }, {
                    'bb': [{
                        'bba': None
                    }]
                }]
            }, {
                'c': None
            }]
        }
        expected = [
            Level('2'),
            Level('3.0'),
            Level('3.1.0'),
            Level('3.1.1'),
            Level('3.1.2'),
            Level('3.1.3'),
            Level('3.2.0'),
            Level('3.2.1'),
            Level('4')
        ]

        mock_item = self.document.add_item()
        # Act
        self.document._read_index = MagicMock(return_value=data)
        Document._reorder_from_index(self.document, 'mock_path')

        # Assert
        self.document._read_index.assert_called_once_with('mock_path')
        items = []
        for item in self.document.items:
            if item is not mock_item:
                items.append(item)
        actual = [item.level for item in items]
        self.assertListEqual(expected, actual)
        self.assertEqual(mock_item.method_calls, [call.delete()])
Example #14
0
 def load(self, reload=False):
     """Load the item's properties from its file."""
     if self._loaded and not reload:
         return
     log.debug("loading {}...".format(repr(self)))
     # Read text from file
     text = self._read(self.path)
     # Parse YAML data from text
     data = self._load(text, self.path)
     # Store parsed data
     for key, value in data.items():
         if key == 'level':
             value = Level(value)
         elif key == 'active':
             value = to_bool(value)
         elif key == 'normative':
             value = to_bool(value)
         elif key == 'derived':
             value = to_bool(value)
         elif key == 'reviewed':
             value = Stamp(value)
         elif key == 'text':
             value = Text(value)
         elif key == 'ref':
             value = value.strip()
         elif key == 'links':
             value = set(UID(part) for part in value)
         elif key == 'header':
             value = Text(value)
         else:
             if isinstance(value, str):
                 value = Text(value)
         self._data[key] = value
     # Set meta attributes
     self._loaded = True
Example #15
0
 def mock_find_item(uid):
     """Return a mock item and store it."""
     if uid == 'bb':
         mock_item = self.document.add_item(level=Level('3.2'), uid=uid)
     elif uid == 'new':
         raise DoorstopError("unknown UID: bab")
     else:
         mock_item = self.document.add_item(uid=uid)
     return mock_item
Example #16
0
 def test_add_item_with_number(self, mock_new):
     """Verify an item can be added to a document with a number."""
     self.document.add_item(number=999)
     mock_new.assert_called_once_with(None,
                                      self.document,
                                      FILES,
                                      ROOT,
                                      'REQ999',
                                      level=Level('2.2'))
Example #17
0
 def test_init(self):
     """Verify levels can be parsed."""
     self.assertEqual((1, 0), Level((1, 0)).value)
     self.assertEqual((1, ), Level((1)).value)
     self.assertEqual((1, ), Level(()).value)
     self.assertEqual((1, 0), Level(Level('1.0')).value)
     self.assertEqual((1, 0), Level(1, heading=True).value)
     self.assertEqual((1, ), Level((1, 0), heading=False).value)
Example #18
0
    def test_reorder_from_index(self):
        """Verify items can be reordered from an index."""
        mock_items = []

        def mock_find_item(uid):
            """Return a mock item and store it."""
            mock_item = MagicMock()
            if uid == 'bb':
                mock_item.level = Level('3.2')
            elif uid == 'bab':
                raise DoorstopError("unknown UID: bab")
            mock_item.uid = uid
            mock_items.append(mock_item)
            return mock_item

        mock_document = Mock()
        mock_document.find_item = mock_find_item

        data = {
            'initial':
            2.0,
            'outline': [{
                'a': None
            }, {
                'b': [{
                    'ba': [{
                        'baa': None
                    }, {
                        'bab': None
                    }, {
                        'bac': None
                    }]
                }, {
                    'bb': None
                }]
            }, {
                'c': None
            }]
        }
        expected = [
            Level('2'),
            Level('3.0'),
            Level('3.1.0'),
            Level('3.1.1'),
            Level('3.1.3'),
            Level('3.2'),
            Level('4')
        ]

        # Act
        with patch('doorstop.common.read_text') as mock_read_text:
            with patch('doorstop.common.load_yaml', Mock(return_value=data)):
                Document._reorder_from_index(mock_document, 'mock_path')

        # Assert
        mock_read_text.assert_called_once_with('mock_path')
        actual = [item.level for item in mock_items]
        self.assertListEqual(expected, actual)
Example #19
0
 def mock_find_item(uid):
     """Return a mock item and store it."""
     mock_item = MagicMock()
     if uid == 'bb':
         mock_item.level = Level('3.2')
     elif uid == 'bab':
         raise DoorstopError("unknown UID: bab")
     mock_item.uid = uid
     mock_items.append(mock_item)
     return mock_item
Example #20
0
 def _reorder_from_index(document, path):
     """Reorder a document's item from the index."""
     # Load and parse index
     text = common.read_text(path)
     data = common.load_yaml(text, path)
     # Read updated values
     initial = data.get('initial', 1.0)
     outline = data.get('outline', [])
     # Update levels
     level = Level(initial)
     Document._reorder_section(outline, level, document)
Example #21
0
 def _reorder_from_index(document, path):
     """Reorder a document's item from the index."""
     data = document._read_index(path)  # pylint: disable=protected-access
     # Read updated values
     initial = data.get('initial', 1.0)
     outline = data.get('outline', [])
     # Update levels
     level = Level(initial)
     ids_after_reorder = []
     Document._reorder_section(outline, level, document, ids_after_reorder)
     for item in document.items:
         if item.uid not in ids_after_reorder:
             log.info('Deleting %s', item.uid)
             item.delete()
Example #22
0
    def _set_attributes(self, attributes):
        """Set the item's attributes."""
        self.yaml_validator.validate_item_yaml(attributes)
        for key, value in attributes.items():
            if key == 'level':
                value = Level(value)
            elif key == 'active':
                value = to_bool(value)
            elif key == 'normative':
                value = to_bool(value)
            elif key == 'derived':
                value = to_bool(value)
            elif key == 'reviewed':
                value = Stamp(value)
            elif key == 'text':
                value = Text(value)
            elif key == 'ref':
                value = value.strip()
            elif key == 'references':
                stripped_value = []
                for ref_dict in value:
                    ref_type = ref_dict['type']
                    ref_path = ref_dict['path']

                    stripped_ref_dict = {
                        "type": ref_type,
                        "path": ref_path.strip()
                    }
                    if 'keyword' in ref_dict:
                        ref_keyword = ref_dict['keyword']
                        stripped_ref_dict['keyword'] = ref_keyword

                    stripped_value.append(stripped_ref_dict)

                value = stripped_value
            elif key == 'links':
                value = set(UID(part) for part in value)
            elif key == 'header':
                value = Text(value)
            self._data[key] = value
Example #23
0
 def _set_attributes(self, attributes):
     """Set the item's attributes."""
     for key, value in attributes.items():
         if key == 'level':
             value = Level(value)
         elif key == 'active':
             value = to_bool(value)
         elif key == 'normative':
             value = to_bool(value)
         elif key == 'derived':
             value = to_bool(value)
         elif key == 'reviewed':
             value = Stamp(value)
         elif key == 'text':
             value = Text(value)
         elif key == 'ref':
             value = value.strip()
         elif key == 'links':
             value = set(UID(part) for part in value)
         elif key == 'header':
             value = Text(value)
         self._data[key] = value
Example #24
0
 def test_lshift_zero(self):
     """Verify detenting levels by zero has no effect.."""
     level = self.level_1_2_3
     level <<= 0
     self.assertEqual(Level('1.2.3'), level)
Example #25
0
 def test_lshift_heading(self):
     """Verify (heading) levels can be dedented."""
     level = self.level_1_2_heading
     level <<= 1
     self.assertEqual(Level('1.0'), level)
Example #26
0
 def level(self, value):
     """Set the item's level."""
     self._data['level'] = Level(value)
Example #27
0
class Item(BaseValidatable, BaseFileObject):  # pylint: disable=R0902
    """Represents an item file with linkable text."""

    EXTENSIONS = '.yml', '.yaml'

    DEFAULT_LEVEL = Level('1.0')
    DEFAULT_ACTIVE = True
    DEFAULT_NORMATIVE = True
    DEFAULT_DERIVED = False
    DEFAULT_REVIEWED = Stamp()
    DEFAULT_TEXT = Text()
    DEFAULT_REF = ""
    DEFAULT_HEADER = Text()

    def __init__(self, path, root=os.getcwd(), **kwargs):
        """Initialize an item from an existing file.

        :param path: path to Item file
        :param root: path to root of project

        """
        super().__init__()
        # Ensure the path is valid
        if not os.path.isfile(path):
            raise DoorstopError("item does not exist: {}".format(path))
        # Ensure the filename is valid
        filename = os.path.basename(path)
        name, ext = os.path.splitext(filename)
        try:
            UID(name).check()
        except DoorstopError:
            msg = "invalid item filename: {}".format(filename)
            raise DoorstopError(msg) from None
        # Ensure the file extension is valid
        if ext.lower() not in self.EXTENSIONS:
            msg = "'{0}' extension not in {1}".format(path, self.EXTENSIONS)
            raise DoorstopError(msg)
        # Initialize the item
        self.path = path
        self.root = root
        self.document = kwargs.get('document')
        self.tree = kwargs.get('tree')
        self.auto = kwargs.get('auto', Item.auto)
        # Set default values
        self._data['level'] = Item.DEFAULT_LEVEL
        self._data['active'] = Item.DEFAULT_ACTIVE
        self._data['normative'] = Item.DEFAULT_NORMATIVE
        self._data['derived'] = Item.DEFAULT_DERIVED
        self._data['reviewed'] = Item.DEFAULT_REVIEWED
        self._data['text'] = Item.DEFAULT_TEXT
        self._data['ref'] = Item.DEFAULT_REF
        self._data['links'] = set()
        if settings.ENABLE_HEADERS:
            self._data['header'] = Item.DEFAULT_HEADER

    def __repr__(self):
        return "Item('{}')".format(self.path)

    def __str__(self):
        if common.verbosity < common.STR_VERBOSITY:
            return str(self.uid)
        else:
            return "{} ({})".format(self.uid, self.relpath)

    def __lt__(self, other):
        if self.level == other.level:
            return self.uid < other.uid
        else:
            return self.level < other.level

    @staticmethod
    @add_item
    def new(tree, document, path, root, uid, level=None, auto=None):  # pylint: disable=R0913
        """Create a new item.

        :param tree: reference to the tree that contains this item
        :param document: reference to document that contains this item

        :param path: path to directory for the new item
        :param root: path to root of the project
        :param uid: UID for the new item

        :param level: level for the new item
        :param auto: automatically save the item

        :raises: :class:`~doorstop.common.DoorstopError` if the item
            already exists

        :return: new :class:`~doorstop.core.item.Item`

        """
        UID(uid).check()
        filename = str(uid) + Item.EXTENSIONS[0]
        path2 = os.path.join(path, filename)
        # Create the initial item file
        log.debug("creating item file at {}...".format(path2))
        Item._create(path2, name='item')
        # Initialize the item
        item = Item(path2, root=root, document=document, tree=tree, auto=False)
        item.level = level if level is not None else item.level
        if auto or (auto is None and Item.auto):
            item.save()
        # Return the item
        return item

    def load(self, reload=False):
        """Load the item's properties from its file."""
        if self._loaded and not reload:
            return
        log.debug("loading {}...".format(repr(self)))
        # Read text from file
        text = self._read(self.path)
        # Parse YAML data from text
        data = self._load(text, self.path)
        # Store parsed data
        for key, value in data.items():
            if key == 'level':
                value = Level(value)
            elif key == 'active':
                value = to_bool(value)
            elif key == 'normative':
                value = to_bool(value)
            elif key == 'derived':
                value = to_bool(value)
            elif key == 'reviewed':
                value = Stamp(value)
            elif key == 'text':
                value = Text(value)
            elif key == 'ref':
                value = value.strip()
            elif key == 'links':
                value = set(UID(part) for part in value)
            elif key == 'header':
                value = Text(value)
            else:
                if isinstance(value, str):
                    value = Text(value)
            self._data[key] = value
        # Set meta attributes
        self._loaded = True

    @edit_item
    def save(self):
        """Format and save the item's properties to its file."""
        log.debug("saving {}...".format(repr(self)))
        # Format the data items
        data = self.data
        # Dump the data to YAML
        text = self._dump(data)
        # Save the YAML to file
        self._write(text, self.path)
        # Set meta attributes
        self._loaded = False
        self.auto = True

    # properties #############################################################

    @property
    @auto_load
    def data(self):
        """Get all the item's data formatted for YAML dumping."""
        data = {}
        for key, value in self._data.items():
            if key == 'level':
                value = value.yaml
            elif key == 'text':
                value = value.yaml
            elif key == 'header':
                # Handle for case if the header is undefined in YAML
                if hasattr(value, 'yaml'):
                    value = value.yaml
                else:
                    value = ''
            elif key == 'ref':
                value = value.strip()
            elif key == 'links':
                value = [{str(i): i.stamp.yaml} for i in sorted(value)]
            elif key == 'reviewed':
                value = value.yaml
            else:
                if isinstance(value, str):
                    # length of "key_text: value_text"
                    length = len(key) + 2 + len(value)
                    if length > settings.MAX_LINE_LENGTH or '\n' in value:
                        value = Text.save_text(value)
                    else:
                        value = str(value)  # line is short enough as a string
            data[key] = value
        return data

    @property
    def uid(self):
        """Get the item's UID."""
        filename = os.path.basename(self.path)
        return UID(os.path.splitext(filename)[0])

    @property
    def prefix(self):
        """Get the item UID's prefix."""
        return self.uid.prefix

    @property
    def number(self):
        """Get the item UID's number."""
        return self.uid.number

    @property
    @auto_load
    def level(self):
        """Get the item's level."""
        return self._data['level']

    @level.setter
    @auto_save
    @auto_load
    def level(self, value):
        """Set the item's level."""
        self._data['level'] = Level(value)

    @property
    def depth(self):
        """Get the item's heading order based on it's level."""
        return len(self.level)

    @property
    @auto_load
    def active(self):
        """Get the item's active status.

        An inactive item will not be validated. Inactive items are
        intended to be used for:

        - future requirements
        - temporarily disabled requirements or tests
        - externally implemented requirements
        - etc.

        """
        return self._data['active']

    @active.setter
    @auto_save
    @auto_load
    def active(self, value):
        """Set the item's active status."""
        self._data['active'] = to_bool(value)

    @property
    @auto_load
    def derived(self):
        """Get the item's derived status.

        A derived item does not have links to items in its parent
        document, but should still be linked to by items in its child
        documents.

        """
        return self._data['derived']

    @derived.setter
    @auto_save
    @auto_load
    def derived(self, value):
        """Set the item's derived status."""
        self._data['derived'] = to_bool(value)

    @property
    @auto_load
    def normative(self):
        """Get the item's normative status.

        A non-normative item should not have or be linked to.
        Non-normative items are intended to be used for:

        - headings
        - comments
        - etc.

        """
        return self._data['normative']

    @normative.setter
    @auto_save
    @auto_load
    def normative(self, value):
        """Set the item's normative status."""
        self._data['normative'] = to_bool(value)

    @property
    def heading(self):
        """Indicate if the item is a heading.

        Headings have a level that ends in zero and are non-normative.

        """
        return self.level.heading and not self.normative

    @heading.setter
    @auto_save
    @auto_load
    def heading(self, value):
        """Set the item's heading status."""
        heading = to_bool(value)
        if heading and not self.heading:
            self.level.heading = True
            self.normative = False
        elif not heading and self.heading:
            self.level.heading = False
            self.normative = True

    @property
    @auto_load
    def cleared(self):
        """Indicate if no links are suspect."""
        items = self.parent_items
        for uid in self.links:
            for item in items:
                if uid == item.uid:
                    if uid.stamp != item.stamp():
                        return False
        return True

    @cleared.setter
    @auto_save
    @auto_load
    def cleared(self, value):
        """Set the item's suspect link status."""
        self.clear(_inverse=not to_bool(value))

    @property
    @auto_load
    def reviewed(self):
        """Indicate if the item has been reviewed."""
        stamp = self.stamp(links=True)
        if self._data['reviewed'] == Stamp(True):
            self._data['reviewed'] = stamp
        return self._data['reviewed'] == stamp

    @reviewed.setter
    @auto_save
    @auto_load
    def reviewed(self, value):
        """Set the item's review status."""
        self._data['reviewed'] = Stamp(value)

    @property
    @auto_load
    def text(self):
        """Get the item's text."""
        return self._data['text']

    @text.setter
    @auto_save
    @auto_load
    def text(self, value):
        """Set the item's text."""
        self._data['text'] = Text(value)

    @property
    @auto_load
    def header(self):
        """Get the item's header."""
        if settings.ENABLE_HEADERS:
            return self._data['header']
        return None

    @header.setter
    @auto_save
    @auto_load
    def header(self, value):
        """Set the item's header."""
        if settings.ENABLE_HEADERS:
            self._data['header'] = Text(value)

    @property
    @auto_load
    def ref(self):
        """Get the item's external file reference.

        An external reference can be part of a line in a text file or
        the filename of any type of file.

        """
        return self._data['ref']

    @ref.setter
    @auto_save
    @auto_load
    def ref(self, value):
        """Set the item's external file reference."""
        self._data['ref'] = str(value) if value else ""

    @property
    @auto_load
    def links(self):
        """Get a list of the item UIDs this item links to."""
        return sorted(self._data['links'])

    @links.setter
    @auto_save
    @auto_load
    def links(self, value):
        """Set the list of item UIDs this item links to."""
        self._data['links'] = set(UID(v) for v in value)

    @property
    def parent_links(self):
        """Get a list of the item UIDs this item links to."""
        return self.links  # alias

    @parent_links.setter
    def parent_links(self, value):
        """Set the list of item UIDs this item links to."""
        self.links = value  # alias

    @property
    @requires_tree
    def parent_items(self):
        """Get a list of items that this item links to."""
        items = []
        for uid in self.links:
            try:
                item = self.tree.find_item(uid)
            except DoorstopError:
                item = UnknownItem(uid)
                log.warning(item.exception)
            items.append(item)
        return items

    @property
    @requires_tree
    @requires_document
    def parent_documents(self):
        """Get a list of documents that this item's document should link to.

        .. note::

           A document only has one parent.

        """
        try:
            return [self.tree.find_document(self.document.prefix)]
        except DoorstopError:
            log.warning(Prefix.UNKNOWN_MESSGE.format(self.document.prefix))
            return []

    # actions ################################################################

    @auto_save
    def edit(self, tool=None):
        """Open the item for editing.

        :param tool: path of alternate editor

        """
        # Lock the item
        if self.tree:
            self.tree.vcs.lock(self.path)
        # Open in an editor
        editor.edit(self.path, tool=tool)
        # Force reloaded
        self._loaded = False

    @auto_save
    @auto_load
    def link(self, value):
        """Add a new link to another item UID.

        :param value: item or UID

        """
        uid = UID(value)
        log.info("linking to '{}'...".format(uid))
        self._data['links'].add(uid)

    @auto_save
    @auto_load
    def unlink(self, value):
        """Remove an existing link by item UID.

        :param value: item or UID

        """
        uid = UID(value)
        try:
            self._data['links'].remove(uid)
        except KeyError:
            log.warning("link to {0} does not exist".format(uid))

    def get_issues(self, skip=None, document_hook=None, item_hook=None):  # pylint: disable=unused-argument
        """Yield all the item's issues.

        :param skip: list of document prefixes to skip

        :return: generator of :class:`~doorstop.common.DoorstopError`,
                              :class:`~doorstop.common.DoorstopWarning`,
                              :class:`~doorstop.common.DoorstopInfo`

        """
        assert document_hook is None
        assert item_hook is None
        skip = [] if skip is None else skip

        log.info("checking item %s...", self)

        # Verify the file can be parsed
        self.load()

        # Skip inactive items
        if not self.active:
            log.info("skipped inactive item: %s", self)
            return

        # Delay item save if reformatting
        if settings.REFORMAT:
            self.auto = False

        # Check text
        if not self.text:
            yield DoorstopWarning("no text")

        # Check external references
        if settings.CHECK_REF:
            try:
                self.find_ref()
            except DoorstopError as exc:
                yield exc

        # Check links
        if not self.normative and self.links:
            yield DoorstopWarning("non-normative, but has links")

        # Check links against the document
        if self.document:
            yield from self._get_issues_document(self.document, skip)

        # Check links against the tree
        if self.tree:
            yield from self._get_issues_tree(self.tree)

        # Check links against both document and tree
        if self.document and self.tree:
            yield from self._get_issues_both(self.document, self.tree, skip)

        # Check review status
        if not self.reviewed:
            if settings.CHECK_REVIEW_STATUS:
                if not self._data['reviewed']:
                    if settings.REVIEW_NEW_ITEMS:
                        self.review()
                    else:
                        yield DoorstopInfo("needs initial review")
                else:
                    yield DoorstopWarning("unreviewed changes")

        # Reformat the file
        if settings.REFORMAT:
            log.debug("reformatting item %s...", self)
            self.save()

    def _get_issues_document(self, document, skip):
        """Yield all the item's issues against its document."""
        log.debug("getting issues against document...")

        if document in skip:
            log.debug("skipping issues against document %s...", document)
            return

        # Verify an item's UID matches its document's prefix
        if self.prefix != document.prefix:
            msg = "prefix differs from document ({})".format(document.prefix)
            yield DoorstopInfo(msg)

        # Verify an item has upward links
        if all((document.parent, self.normative,
                not self.derived)) and not self.links:
            msg = "no links to parent document: {}".format(document.parent)
            yield DoorstopWarning(msg)

        # Verify an item's links are to the correct parent
        for uid in self.links:
            try:
                prefix = uid.prefix
            except DoorstopError:
                msg = "invalid UID in links: {}".format(uid)
                yield DoorstopError(msg)
            else:
                if document.parent and prefix != document.parent:
                    # this is only 'info' because a document is allowed
                    # to contain items with a different prefix, but
                    # Doorstop will not create items like this
                    msg = "parent is '{}', but linked to: {}".format(
                        document.parent, uid)
                    yield DoorstopInfo(msg)

    def _get_issues_tree(self, tree):
        """Yield all the item's issues against its tree."""
        log.debug("getting issues against tree...")

        # Verify an item's links are valid
        identifiers = set()
        for uid in self.links:
            try:
                item = tree.find_item(uid)
            except DoorstopError:
                identifiers.add(uid)  # keep the invalid UID
                msg = "linked to unknown item: {}".format(uid)
                yield DoorstopError(msg)
            else:
                # check the linked item
                if not item.active:
                    msg = "linked to inactive item: {}".format(item)
                    yield DoorstopInfo(msg)
                if not item.normative:
                    msg = "linked to non-normative item: {}".format(item)
                    yield DoorstopWarning(msg)
                # check the link status
                if uid.stamp == Stamp(True):
                    uid.stamp = item.stamp()
                elif not str(uid.stamp) and settings.STAMP_NEW_LINKS:
                    uid.stamp = item.stamp()
                elif uid.stamp != item.stamp():
                    if settings.CHECK_SUSPECT_LINKS:
                        msg = "suspect link: {}".format(item)
                        yield DoorstopWarning(msg)
                # reformat the item's UID
                identifier2 = UID(item.uid, stamp=uid.stamp)
                identifiers.add(identifier2)

        # Apply the reformatted item UIDs
        if settings.REFORMAT:
            self._data['links'] = identifiers

    def _get_issues_both(self, document, tree, skip):
        """Yield all the item's issues against its document and tree."""
        log.debug("getting issues against document and tree...")

        if document.prefix in skip:
            log.debug("skipping issues against document %s...", document)
            return

        # Verify an item is being linked to (child links)
        if settings.CHECK_CHILD_LINKS and self.normative:
            find_all = settings.CHECK_CHILD_LINKS_STRICT or False
            items, documents = self._find_child_objects(document=document,
                                                        tree=tree,
                                                        find_all=find_all)

            if not items:
                for child_document in documents:
                    if document.prefix in skip:
                        msg = "skipping issues against document %s..."
                        log.debug(msg, child_document)
                        continue
                    msg = ("no links from child document: {}".format(
                        child_document))
                    yield DoorstopWarning(msg)
            elif settings.CHECK_CHILD_LINKS_STRICT:
                prefix = [item.prefix for item in items]
                for child in document.children:
                    if child in skip:
                        continue
                    if child not in prefix:
                        msg = 'no links from document: {}'.format(child)
                        yield DoorstopWarning(msg)

    @requires_tree
    def find_ref(self):
        """Get the external file reference and line number.

        :raises: :class:`~doorstop.common.DoorstopError` when no
            reference is found

        :return: relative path to file or None (when no reference
            set),
            line number (when found in file) or None (when found as
            filename) or None (when no reference set)

        """
        # Return immediately if no external reference
        if not self.ref:
            log.debug("no external reference to search for")
            return None, None
        # Update the cache
        if not settings.CACHE_PATHS:
            pyficache.clear_file_cache()
        # Search for the external reference
        log.debug("seraching for ref '{}'...".format(self.ref))
        pattern = r"(\b|\W){}(\b|\W)".format(re.escape(self.ref))
        log.trace("regex: {}".format(pattern))
        regex = re.compile(pattern)
        for path, filename, relpath in self.tree.vcs.paths:
            # Skip the item's file while searching
            if path == self.path:
                continue
            # Check for a matching filename
            if filename == self.ref:
                return relpath, None
            # Skip extensions that should not be considered text
            if os.path.splitext(filename)[-1] in settings.SKIP_EXTS:
                continue
            # Search for the reference in the file
            lines = pyficache.getlines(path)
            if lines is None:
                log.trace("unable to read lines from: {}".format(path))
                continue
            for lineno, line in enumerate(lines, start=1):
                if regex.search(line):
                    log.debug("found ref: {}".format(relpath))
                    return relpath, lineno

        msg = "external reference not found: {}".format(self.ref)
        raise DoorstopError(msg)

    def find_child_links(self, find_all=True):
        """Get a list of item UIDs that link to this item (reverse links).

        :param find_all: find all items (not just the first) before returning

        :return: list of found item UIDs

        """
        items, _ = self._find_child_objects(find_all=find_all)
        identifiers = [item.uid for item in items]
        return identifiers

    child_links = property(find_child_links)

    def find_child_items(self, find_all=True):
        """Get a list of items that link to this item.

        :param find_all: find all items (not just the first) before returning

        :return: list of found items

        """
        items, _ = self._find_child_objects(find_all=find_all)
        return items

    child_items = property(find_child_items)

    def find_child_documents(self):
        """Get a list of documents that should link to this item's document.

        :return: list of found documents

        """
        _, documents = self._find_child_objects(find_all=False)
        return documents

    child_documents = property(find_child_documents)

    def _find_child_objects(self, document=None, tree=None, find_all=True):
        """Get lists of child items and child documents.

        :param document: document containing the current item
        :param tree: tree containing the current item
        :param find_all: find all items (not just the first) before returning

        :return: list of found items, list of all child documents

        """
        child_items = []
        child_documents = []
        document = document or self.document
        tree = tree or self.tree
        if not document or not tree:
            return child_items, child_documents
        # Find child objects
        log.debug("finding item {}'s child objects...".format(self))
        for document2 in tree:
            if document2.parent == document.prefix:
                child_documents.append(document2)
                # Search for child items unless we only need to find one
                if not child_items or find_all:
                    for item2 in document2:
                        if self.uid in item2.links:
                            if not item2.active:
                                item2 = UnknownItem(item2.uid)
                                log.warning(item2.exception)
                                child_items.append(item2)
                            else:
                                child_items.append(item2)
                                if not find_all and item2.active:
                                    break
        # Display found links
        if child_items:
            if find_all:
                joined = ', '.join(str(i) for i in child_items)
                msg = "child items: {}".format(joined)
            else:
                msg = "first child item: {}".format(child_items[0])
            log.debug(msg)
            joined = ', '.join(str(d) for d in child_documents)
            log.debug("child documents: {}".format(joined))
        return sorted(child_items), child_documents

    @auto_load
    def stamp(self, links=False):
        """Hash the item's key content for later comparison."""
        values = [self.uid, self.text, self.ref]
        if links:
            values.extend(self.links)
        return Stamp(*values)

    @auto_save
    @auto_load
    def clear(self, _inverse=False):
        """Clear suspect links."""
        log.info("clearing suspect links...")
        items = self.parent_items
        for uid in self.links:
            for item in items:
                if uid == item.uid:
                    if _inverse:
                        uid.stamp = Stamp()
                    else:
                        uid.stamp = item.stamp()

    @auto_save
    @auto_load
    def review(self):
        """Mark the item as reviewed."""
        log.info("marking item as reviewed...")
        self._data['reviewed'] = self.stamp(links=True)

    @delete_item
    def delete(self, path=None):
        """Delete the item."""
        pass  # the item is deleted in the decorated method
Example #28
0
 def test_reorder_automatic(self):
     """Verify items can be reordered automatically."""
     mock_items = [
         Mock(level=Level('2.3')),
         Mock(level=Level('2.3')),
         Mock(level=Level('2.7')),
         Mock(level=Level('3.2.2')),
         Mock(level=Level('3.4.2')),
         Mock(level=Level('3.5.0')),
         Mock(level=Level('3.5.0')),
         Mock(level=Level('3.6')),
         Mock(level=Level('5.0')),
         Mock(level=Level('5.9'))
     ]
     expected = [
         Level('2.3'),
         Level('2.4'),
         Level('2.5'),
         Level('3.1.1'),
         Level('3.2.1'),
         Level('3.3.0'),
         Level('3.4.0'),
         Level('3.5'),
         Level('4.0'),
         Level('4.1')
     ]
     Document._reorder_automatic(mock_items)
     actual = [item.level for item in mock_items]
     self.assertListEqual(expected, actual)
Example #29
0
 def setUp(self):
     self.level_1 = Level('1')
     self.level_1_2 = Level('1.2')
     self.level_1_2_heading = Level('1.2.0')
     self.level_1_2_3 = Level('1.2.3')
Example #30
0
class TestLevel(unittest.TestCase):

    """Unit tests for the Level class."""  # pylint: disable=W0212

    def setUp(self):
        self.level_1 = Level('1')
        self.level_1_2 = Level('1.2')
        self.level_1_2_heading = Level('1.2.0')
        self.level_1_2_3 = Level('1.2.3')

    def test_init(self):
        """Verify levels can be parsed."""
        self.assertEqual((1, 0), Level((1, 0)).value)
        self.assertEqual((1,), Level((1)).value)
        self.assertEqual((1,), Level(()).value)
        self.assertEqual((1, 0), Level(Level('1.0')).value)
        self.assertEqual((1, 0), Level(1, heading=True).value)
        self.assertEqual((1,), Level((1, 0), heading=False).value)

    def test_repr(self):
        """Verify levels can be represented."""
        self.assertEqual("Level('1')", repr(self.level_1))
        self.assertEqual("Level('1.2')", repr(self.level_1_2))
        self.assertEqual("Level('1.2', heading=True)",
                         repr(self.level_1_2_heading))
        self.assertEqual("Level('1.2.3')", repr(self.level_1_2_3))

    def test_str(self):
        """Verify levels can be converted to strings."""
        self.assertEqual('1', str(self.level_1))
        self.assertEqual('1.2', str(self.level_1_2))
        self.assertEqual('1.2.0', str(self.level_1_2_heading))
        self.assertEqual('1.2.3', str(self.level_1_2_3))

    def test_len(self):
        """Verify a level length is equal to number of non-heading parts."""
        self.assertEqual(1, len(self.level_1))
        self.assertEqual(2, len(self.level_1_2))
        self.assertEqual(2, len(self.level_1_2_heading))
        self.assertEqual(3, len(self.level_1_2_3))

    def test_eq(self):
        """Verify levels can be equated."""
        self.assertNotEqual(self.level_1, self.level_1_2)
        self.assertEqual(self.level_1_2, Level([1, 2]))
        self.assertEqual(self.level_1_2, (1, 2))
        self.assertEqual(self.level_1_2, self.level_1_2_heading)

    def test_eq_other(self):
        """Verify levels can be equated with non-levels."""
        self.assertNotEqual(self.level_1, None)
        self.assertEqual((1, 2, 0), self.level_1_2_heading)
        self.assertEqual((1, 2), self.level_1_2_heading)

    def test_compare(self):
        """Verify levels can be compared."""
        self.assertLess(self.level_1, self.level_1_2)
        self.assertLessEqual(self.level_1, self.level_1)
        self.assertLessEqual(self.level_1, self.level_1_2)
        self.assertLess(self.level_1_2, [1, 3])
        self.assertGreater(self.level_1_2_3, self.level_1_2)
        self.assertGreaterEqual(self.level_1_2_3, self.level_1_2)
        self.assertGreaterEqual(self.level_1_2_3, self.level_1_2_3)

    def test_hash(self):
        """Verify level's can be hashed."""
        levels = {Level('1.2'): 1, Level('1.2.3'): 2}
        self.assertIn(self.level_1_2, levels)
        self.assertNotIn(self.level_1_2_heading, levels)

    def test_add(self):
        """Verify levels can be incremented."""
        level = self.level_1_2
        level += 1
        self.assertEqual(Level('1.3'), level)
        self.assertEqual(Level('1.5'), level + 2)

    def test_add_heading(self):
        """Verify (heading) levels can be incremented."""
        level = self.level_1_2_heading
        level += 2
        self.assertEqual(Level('1.4.0'), level)

    def test_sub(self):
        """Verify levels can be decremented."""
        level = self.level_1_2_3
        level -= 1
        self.assertEqual(Level('1.2.2'), level)
        self.assertEqual(Level('1.2.1'), level - 1)

    def test_sub_heading(self):
        """Verify (heading) levels can be decremented."""
        level = self.level_1_2_heading
        level -= 1
        self.assertEqual(Level('1.1.0'), level)

    def test_sub_zero(self):
        """Verify levels cannot be decremented to zero."""
        level = self.level_1_2
        level -= 2
        self.assertEqual(Level('1.1'), level)

    def test_rshift(self):
        """Verify levels can be indented."""
        level = self.level_1_2
        level >>= 1
        self.assertEqual(Level('1.2.1'), level)
        self.assertEqual(Level('1.2.1.1'), level >> 1)

    def test_rshift_heading(self):
        """Verify (heading) levels can be indented."""
        level = self.level_1_2_heading
        level >>= 2
        self.assertEqual(Level('1.2.1.1.0'), level)

    def test_rshift_negative(self):
        """Verify levels can be indented negatively."""
        level = self.level_1_2_3
        level >>= -1
        self.assertEqual(Level('1.2'), level)
        self.assertEqual(Level('1'), level >> -1)

    def test_lshift(self):
        """Verify levels can be dedented."""
        level = self.level_1_2_3
        level <<= 1
        self.assertEqual(Level('1.2'), level)
        self.assertEqual(Level('1'), level << 1)

    def test_lshift_heading(self):
        """Verify (heading) levels can be dedented."""
        level = self.level_1_2_heading
        level <<= 1
        self.assertEqual(Level('1.0'), level)

    def test_lshift_negative(self):
        """Verify levels can be dedented negatively."""
        level = self.level_1_2_3
        level <<= -1
        self.assertEqual(Level('1.2.3.1'), level)
        self.assertEqual(Level('1.2.3.1.1'), level << -1)

    def test_lshift_empty(self):
        """Verify levels can be dedented."""
        level = self.level_1_2_3
        level <<= 4
        self.assertEqual(Level('1'), level)

    def test_lshift_zero(self):
        """Verify detenting levels by zero has no effect.."""
        level = self.level_1_2_3
        level <<= 0
        self.assertEqual(Level('1.2.3'), level)

    def test_value(self):
        """Verify levels can be converted to their values."""
        self.assertEqual((1,), self.level_1.value)
        self.assertEqual((1, 2), self.level_1_2.value)
        self.assertEqual((1, 2, 0), self.level_1_2_heading.value)
        self.assertEqual((1, 2, 3), self.level_1_2_3.value)

    def test_yaml(self):
        """Verify levels can be converted to their YAML representation."""
        self.assertEqual(1, self.level_1.yaml)
        self.assertEqual(1.2, self.level_1_2.yaml)
        self.assertEqual('1.2.0', self.level_1_2_heading.yaml)
        self.assertEqual('1.2.3', self.level_1_2_3.yaml)

    def test_copy(self):
        """Verify levels can be copied."""
        level = self.level_1_2.copy()
        self.assertEqual(level, self.level_1_2)
        level += 1
        self.assertNotEqual(level, self.level_1_2)
Example #31
0
 def test_reorder_automatic_with_start(self):
     """Verify items can be reordered with a given start."""
     mock_item = Mock(level=Level('2.3'))
     mock_items = [
         Mock(level=Level('2.2')), mock_item,
         Mock(level=Level('2.3')),
         Mock(level=Level('2.7')),
         Mock(level=Level('3.2.2')),
         Mock(level=Level('3.4.2')),
         Mock(level=Level('3.5.0')),
         Mock(level=Level('3.5.0')),
         Mock(level=Level('3.6')),
         Mock(level=Level('5.0')),
         Mock(level=Level('5.9'))
     ]
     expected = [
         Level('1.2'),
         Level('1.3'),
         Level('1.4'),
         Level('1.5'),
         Level('2.1.1'),
         Level('2.2.1'),
         Level('2.3.0'),
         Level('2.4.0'),
         Level('2.5'),
         Level('3.0'),
         Level('3.1')
     ]
     Document._reorder_automatic(mock_items, start=(1, 2), keep=mock_item)
     actual = [item.level for item in mock_items]
     self.assertListEqual(expected, actual)
Example #32
0
 def test_lshift_empty(self):
     """Verify levels can be dedented."""
     level = self.level_1_2_3
     level <<= 4
     self.assertEqual(Level('1'), level)
Example #33
0
class Item(BaseFileObject):  # pylint: disable=R0902
    """Represents an item file with linkable text."""

    EXTENSIONS = '.yml', '.yaml'

    DEFAULT_LEVEL = Level('1.0')
    DEFAULT_ACTIVE = True
    DEFAULT_NORMATIVE = True
    DEFAULT_DERIVED = False
    DEFAULT_REVIEWED = Stamp()
    DEFAULT_TEXT = Text()
    DEFAULT_REF = ""
    DEFAULT_HEADER = Text()

    def __init__(self, document, path, root=os.getcwd(), **kwargs):
        """Initialize an item from an existing file.

        :param path: path to Item file
        :param root: path to root of project

        """
        super().__init__()
        # Ensure the path is valid
        if not os.path.isfile(path):
            raise DoorstopError("item does not exist: {}".format(path))
        # Ensure the filename is valid
        filename = os.path.basename(path)
        name, ext = os.path.splitext(filename)
        try:
            UID(name).check()
        except DoorstopError:
            msg = "invalid item filename: {}".format(filename)
            raise DoorstopError(msg) from None
        # Ensure the file extension is valid
        if ext.lower() not in self.EXTENSIONS:
            msg = "'{0}' extension not in {1}".format(path, self.EXTENSIONS)
            raise DoorstopError(msg)
        # Initialize the item
        self.path = path
        self.root: str = root
        self.document = document
        self.tree = kwargs.get('tree')
        self.auto = kwargs.get('auto', Item.auto)
        self.reference_finder = ReferenceFinder()
        self.yaml_validator = YamlValidator()
        # Set default values
        self._data['level'] = Item.DEFAULT_LEVEL
        self._data['active'] = Item.DEFAULT_ACTIVE
        self._data['normative'] = Item.DEFAULT_NORMATIVE
        self._data['derived'] = Item.DEFAULT_DERIVED
        self._data['reviewed'] = Item.DEFAULT_REVIEWED
        self._data['text'] = Item.DEFAULT_TEXT
        self._data['ref'] = Item.DEFAULT_REF
        self._data['references'] = None
        self._data['links'] = set()
        if settings.ENABLE_HEADERS:
            self._data['header'] = Item.DEFAULT_HEADER

    def __repr__(self):
        return "Item('{}')".format(self.path)

    def __str__(self):
        if common.verbosity < common.STR_VERBOSITY:
            return str(self.uid)
        else:
            return "{} ({})".format(self.uid, self.relpath)

    def __lt__(self, other):
        if self.level == other.level:
            return self.uid < other.uid
        else:
            return self.level < other.level

    @staticmethod
    @add_item
    def new(tree, document, path, root, uid, level=None, auto=None):  # pylint: disable=R0913
        """Create a new item.

        :param tree: reference to the tree that contains this item
        :param document: reference to document that contains this item

        :param path: path to directory for the new item
        :param root: path to root of the project
        :param uid: UID for the new item

        :param level: level for the new item
        :param auto: automatically save the item

        :raises: :class:`~doorstop.common.DoorstopError` if the item
            already exists

        :return: new :class:`~doorstop.core.item.Item`

        """
        UID(uid).check()
        filename = str(uid) + Item.EXTENSIONS[0]
        path2 = os.path.join(path, filename)
        # Create the initial item file
        log.debug("creating item file at {}...".format(path2))
        Item._create(path2, name='item')
        # Initialize the item
        item = Item(document, path2, root=root, tree=tree, auto=False)
        item.level = level if level is not None else item.level  # type: ignore
        if auto or (auto is None and Item.auto):
            item.save()
        # Return the item
        return item

    def _set_attributes(self, attributes):
        """Set the item's attributes."""
        self.yaml_validator.validate_item_yaml(attributes)
        for key, value in attributes.items():
            if key == 'level':
                value = Level(value)
            elif key == 'active':
                value = to_bool(value)
            elif key == 'normative':
                value = to_bool(value)
            elif key == 'derived':
                value = to_bool(value)
            elif key == 'reviewed':
                value = Stamp(value)
            elif key == 'text':
                value = Text(value)
            elif key == 'ref':
                value = value.strip()
            elif key == 'references':
                stripped_value = []
                for ref_dict in value:
                    ref_type = ref_dict['type']
                    ref_path = ref_dict['path']

                    stripped_ref_dict = {
                        "type": ref_type,
                        "path": ref_path.strip()
                    }
                    if 'keyword' in ref_dict:
                        ref_keyword = ref_dict['keyword']
                        stripped_ref_dict['keyword'] = ref_keyword

                    stripped_value.append(stripped_ref_dict)

                value = stripped_value
            elif key == 'links':
                value = set(UID(part) for part in value)
            elif key == 'header':
                value = Text(value)
            self._data[key] = value

    def load(self, reload=False):
        """Load the item's properties from its file."""
        if self._loaded and not reload:
            return
        log.debug("loading {}...".format(repr(self)))
        # Read text from file
        text = self._read(self.path)
        # Parse YAML data from text
        data = self._load(text, self.path)
        # Store parsed data
        self._set_attributes(data)
        # Set meta attributes
        self._loaded = True

    @edit_item
    def save(self):
        """Format and save the item's properties to its file."""
        log.debug("saving {}...".format(repr(self)))
        # Format the data items
        data = self._yaml_data()
        # Dump the data to YAML
        text = self._dump(data)
        # Save the YAML to file
        self._write(text, self.path)
        # Set meta attributes
        self._loaded = True
        self.auto = True

    # properties #############################################################

    def _yaml_data(self):
        """Get all the item's data formatted for YAML dumping."""
        data = {}
        for key, value in self._data.items():
            if key == 'level':
                value = value.yaml
            elif key == 'text':
                value = value.yaml
            elif key == 'header':
                # Handle for case if the header is undefined in YAML
                if hasattr(value, 'yaml'):
                    value = value.yaml
                else:
                    value = ''
            elif key == 'ref':
                value = value.strip()
            elif key == 'references':
                if value is None:
                    continue
                stripped_value = []
                for el in value:
                    ref_dict = {"path": el["path"].strip(), "type": "file"}

                    if 'keyword' in el:
                        ref_dict['keyword'] = el['keyword']

                    stripped_value.append(ref_dict)

                value = stripped_value
            elif key == 'links':
                value = [{str(i): i.stamp.yaml} for i in sorted(value)]
            elif key == 'reviewed':
                value = value.yaml
            else:
                value = _convert_to_yaml(0, len(key) + 2, value)
            data[key] = value
        return data

    @property  # type: ignore
    @auto_load
    def data(self):
        """Load and get all the item's data formatted for YAML dumping."""
        return self._yaml_data()

    @property
    def uid(self):
        """Get the item's UID."""
        filename = os.path.basename(self.path)
        return UID(os.path.splitext(filename)[0])

    @property  # type: ignore
    @auto_load
    def level(self):
        """Get the item's level."""
        return self._data['level']

    @level.setter  # type: ignore
    @auto_save
    def level(self, value):
        """Set the item's level."""
        self._data['level'] = Level(value)

    @property
    def depth(self):
        """Get the item's heading order based on it's level."""
        return len(self.level)

    @property  # type: ignore
    @auto_load
    def active(self):
        """Get the item's active status.

        An inactive item will not be validated. Inactive items are
        intended to be used for:

        - future requirements
        - temporarily disabled requirements or tests
        - externally implemented requirements
        - etc.

        """
        return self._data['active']

    @active.setter  # type: ignore
    @auto_save
    def active(self, value):
        """Set the item's active status."""
        self._data['active'] = to_bool(value)

    @property  # type: ignore
    @auto_load
    def derived(self):
        """Get the item's derived status.

        A derived item does not have links to items in its parent
        document, but should still be linked to by items in its child
        documents.

        """
        return self._data['derived']

    @derived.setter  # type: ignore
    @auto_save
    def derived(self, value):
        """Set the item's derived status."""
        self._data['derived'] = to_bool(value)

    @property  # type: ignore
    @auto_load
    def normative(self):
        """Get the item's normative status.

        A non-normative item should not have or be linked to.
        Non-normative items are intended to be used for:

        - headings
        - comments
        - etc.

        """
        return self._data['normative']

    @normative.setter  # type: ignore
    @auto_save
    def normative(self, value):
        """Set the item's normative status."""
        self._data['normative'] = to_bool(value)

    @property
    def heading(self):
        """Indicate if the item is a heading.

        Headings have a level that ends in zero and are non-normative.

        """
        return self.level.heading and not self.normative

    @heading.setter  # type: ignore
    @auto_save
    def heading(self, value):
        """Set the item's heading status."""
        heading = to_bool(value)
        if heading and not self.heading:
            self.level.heading = True
            self.normative = False
        elif not heading and self.heading:
            self.level.heading = False
            self.normative = True

    @property  # type: ignore
    @auto_load
    def cleared(self):
        """Indicate if no links are suspect."""
        for uid, item in self._get_parent_uid_and_item():
            if uid.stamp != item.stamp():
                return False
        return True

    @property  # type: ignore
    @auto_load
    def reviewed(self):
        """Indicate if the item has been reviewed."""
        stamp = self.stamp(links=True)
        if self._data['reviewed'] == Stamp(True):
            self._data['reviewed'] = stamp
        return self._data['reviewed'] == stamp

    @reviewed.setter  # type: ignore
    @auto_save
    def reviewed(self, value):
        """Set the item's review status."""
        self._data['reviewed'] = Stamp(value)

    @property  # type: ignore
    @auto_load
    def text(self):
        """Get the item's text."""
        return self._data['text']

    @text.setter  # type: ignore
    @auto_save
    def text(self, value):
        """Set the item's text."""
        self._data['text'] = Text(value)

    @property  # type: ignore
    @auto_load
    def header(self):
        """Get the item's header."""
        if settings.ENABLE_HEADERS:
            return self._data['header']
        return None

    @header.setter  # type: ignore
    @auto_save
    def header(self, value):
        """Set the item's header."""
        if settings.ENABLE_HEADERS:
            self._data['header'] = Text(value)

    @property  # type: ignore
    @auto_load
    def ref(self):
        """Get the item's external file reference.

        An external reference can be part of a line in a text file or
        the filename of any type of file.

        """
        return self._data['ref']

    @ref.setter  # type: ignore
    @auto_save
    def ref(self, value):
        """Set the item's external file reference."""
        self._data['ref'] = str(value) if value else ""

    @property  # type: ignore
    @auto_load
    def references(self):
        """Get the item's external file references."""
        return self._data['references']

    @references.setter  # type: ignore
    @auto_save
    def references(self, value):
        """Set the item's external file references."""
        if value is not None:
            assert isinstance(value, list)
        self._data['references'] = value

    @property  # type: ignore
    @auto_load
    def links(self):
        """Get a list of the item UIDs this item links to."""
        return sorted(self._data['links'])

    @links.setter  # type: ignore
    @auto_save
    def links(self, value):
        """Set the list of item UIDs this item links to."""
        self._data['links'] = set(UID(v) for v in value)

    @property
    def parent_links(self):
        """Get a list of the item UIDs this item links to."""
        return self.links  # alias

    @parent_links.setter
    def parent_links(self, value):
        """Set the list of item UIDs this item links to."""
        self.links = value  # alias

    @requires_tree
    def _get_parent_uid_and_item(self):
        """Yield UID and item of all links of this item."""
        for uid in self.links:
            try:
                item = self.tree.find_item(uid)
            except DoorstopError:
                item = UnknownItem(uid)
                log.warning(item.exception)
            yield uid, item

    @property
    def parent_items(self):
        """Get a list of items that this item links to."""
        return [item for uid, item in self._get_parent_uid_and_item()]

    @property  # type: ignore
    @requires_tree
    def parent_documents(self):
        """Get a list of documents that this item's document should link to.

        .. note::

           A document only has one parent.

        """
        try:
            return [self.tree.find_document(self.document.prefix)]
        except DoorstopError:
            log.warning(Prefix.UNKNOWN_MESSAGE.format(self.document.prefix))
            return []

    # actions ################################################################

    @auto_save
    def set_attributes(self, attributes):
        """Set the item's attributes and save them."""
        self._set_attributes(attributes)

    def edit(self, tool=None, edit_all=True):
        """Open the item for editing.

        :param tool: path of alternate editor
        :param edit_all: True to edit the whole item,
            False to only edit the text.

        """
        # Lock the item
        if self.tree:
            self.tree.vcs.lock(self.path)
        # Edit the whole file in an editor
        if edit_all:
            self.save()
            editor.edit(self.path, tool=tool)
            self.load(True)
        # Edit only the text part in an editor
        else:
            # Edit the text in a temporary file
            edited_text = editor.edit_tmp_content(title=str(self.uid),
                                                  original_content=str(
                                                      self.text),
                                                  tool=tool)
            # Save the text in the actual item file
            self.text = edited_text

    @auto_save
    def link(self, value):
        """Add a new link to another item UID.

        :param value: item or UID

        """
        uid = UID(value)
        log.info("linking to '{}'...".format(uid))
        self._data['links'].add(uid)

    @auto_save
    def unlink(self, value):
        """Remove an existing link by item UID.

        :param value: item or UID

        """
        uid = UID(value)
        try:
            self._data['links'].remove(uid)
        except KeyError:
            log.warning("link to {0} does not exist".format(uid))

    def is_reviewed(self):
        return self._data['reviewed']

    @requires_tree
    def find_ref(self):
        """Get the external file reference and line number.

        :raises: :class:`~doorstop.common.DoorstopError` when no
            reference is found

        :return: relative path to file or None (when no reference
            set),
            line number (when found in file) or None (when found as
            filename) or None (when no reference set)

        """
        # Return immediately if no external reference
        if not self.ref:
            log.debug("no external reference to search for")
            return None, None
        # Update the cache
        if not settings.CACHE_PATHS:
            pyficache.clear_file_cache()
        # Search for the external reference
        return self.reference_finder.find_ref(self.ref, self.tree, self.path)

    @requires_tree
    def find_references(self):
        """Get the array of references. Check each references before returning.

        :raises: :class:`~doorstop.common.DoorstopError` when no
            reference is found

        :return: Array of tuples:
            (
              relative path to file or None (when no reference set),
              line number (when found in file) or None (when found as
              filename) or None (when no reference set)
            )

        """

        if not self.references:
            log.debug("no external reference to search for")
            return []
        if not settings.CACHE_PATHS:
            pyficache.clear_file_cache()

        references = []
        for ref_item in self.references:
            path = ref_item["path"]
            keyword = ref_item["keyword"] if "keyword" in ref_item else None

            reference = self.reference_finder.find_file_reference(
                path, self.root, self.tree, path, keyword)
            references.append(reference)
        return references

    def find_child_links(self, find_all=True):
        """Get a list of item UIDs that link to this item (reverse links).

        :param find_all: find all items (not just the first) before returning

        :return: list of found item UIDs

        """
        items, _ = self.find_child_items_and_documents(find_all=find_all)
        identifiers = [item.uid for item in items]
        return identifiers

    child_links = property(find_child_links)

    def find_child_items(self, find_all=True):
        """Get a list of items that link to this item.

        :param find_all: find all items (not just the first) before returning

        :return: list of found items

        """
        items, _ = self.find_child_items_and_documents(find_all=find_all)
        return items

    child_items = property(find_child_items)

    def find_child_documents(self):
        """Get a list of documents that should link to this item's document.

        :return: list of found documents

        """
        _, documents = self.find_child_items_and_documents(find_all=False)
        return documents

    child_documents = property(find_child_documents)

    def find_child_items_and_documents(self,
                                       document=None,
                                       tree=None,
                                       find_all=True):
        """Get lists of child items and child documents.

        :param document: document containing the current item
        :param tree: tree containing the current item
        :param find_all: find all items (not just the first) before returning

        :return: list of found items, list of all child documents

        """
        child_items: List[Item] = []
        child_documents: List[Any] = [
        ]  # `List[Document]`` creats an import cycle
        document = document or self.document
        tree = tree or self.tree
        if not document or not tree:
            return child_items, child_documents
        # Find child objects
        log.debug("finding item {}'s child objects...".format(self))
        for document2 in tree:
            if document2.parent == document.prefix:
                child_documents.append(document2)
                # Search for child items unless we only need to find one
                if not child_items or find_all:
                    for item2 in document2:
                        if self.uid in item2.links:
                            if not item2.active:
                                item2 = UnknownItem(item2.uid)
                                log.warning(item2.exception)
                                child_items.append(item2)
                            else:
                                child_items.append(item2)
                                if not find_all and item2.active:
                                    break
        # Display found links
        if child_items:
            if find_all:
                joined = ', '.join(str(i) for i in child_items)
                msg = "child items: {}".format(joined)
            else:
                msg = "first child item: {}".format(child_items[0])
            log.debug(msg)
            joined = ', '.join(str(d) for d in child_documents)
            log.debug("child documents: {}".format(joined))
        return sorted(child_items), child_documents

    @auto_load
    def stamp(self, links=False):
        """Hash the item's key content for later comparison."""
        values = [self.uid, self.text, self.ref]

        if self.references:
            values.append(self.references)

        if links:
            values.extend(self.links)
        for key in self.document.extended_reviewed:
            if key in self._data:
                values.append(_convert_to_str(self._data[key], ""))
        return Stamp(*values)

    @auto_save
    def clear(self, parents=None):
        """Clear suspect links."""
        log.info("clearing suspect links...")
        for uid, item in self._get_parent_uid_and_item():
            if not parents or uid in parents:
                uid.stamp = item.stamp()

    @auto_save
    def review(self):
        """Mark the item as reviewed."""
        log.info("marking item as reviewed...")
        self._data['reviewed'] = self.stamp(links=True)

    @delete_item
    def delete(self, path=None):
        """Delete the item."""
Example #34
0
 def test_lshift_negative(self):
     """Verify levels can be dedented negatively."""
     level = self.level_1_2_3
     level <<= -1
     self.assertEqual(Level('1.2.3.1'), level)
     self.assertEqual(Level('1.2.3.1.1'), level << -1)