diff --git a/doorstop/core/document.py b/doorstop/core/document.py index 939d90cbb..773a90ed0 100644 --- a/doorstop/core/document.py +++ b/doorstop/core/document.py @@ -186,11 +186,14 @@ def load(self, reload=False): self._data[key] = value.strip() elif key == "digits": self._data[key] = int(value) # type: ignore + elif key == "mapped_to": + self._data[key] = value.strip() else: - msg = "unexpected document setting '{}' in: {}".format( - key, self.config + log.debug( + "custom document attribute found: {} = {}".format(key, value) ) - raise DoorstopError(msg) + # custom attribute + self._data[key] = value except (AttributeError, TypeError, ValueError): msg = "invalid value for '{}' in: {}".format(key, self.config) raise DoorstopError(msg) @@ -437,6 +440,10 @@ def index(self): log.info("deleting {} index...".format(self)) common.delete(self.index) + def attribute(self, attrib): + """Get the item's custom attribute.""" + return self._data.get(attrib) + # actions ################################################################ # decorators are applied to methods in the associated classes diff --git a/doorstop/core/item.py b/doorstop/core/item.py index 9797b8e9e..18df58ed3 100644 --- a/doorstop/core/item.py +++ b/doorstop/core/item.py @@ -728,10 +728,18 @@ def find_child_items_and_documents(self, document=None, tree=None, find_all=True tree = tree or self.tree if not document or not tree: return child_items, child_documents + + # get list of mapped documents + mapped_document_prefixes = document.attribute("mapped_to") if document else [] + if not mapped_document_prefixes: + mapped_document_prefixes = [] + # Find child objects log.debug("finding item {}'s child objects...".format(self)) for document2 in tree: - if document2.parent == document.prefix: + if (document2.parent == document.prefix) or ( + document2.prefix in mapped_document_prefixes + ): child_documents.append(document2) # Search for child items unless we only need to find one if not child_items or find_all: diff --git a/doorstop/core/tests/__init__.py b/doorstop/core/tests/__init__.py index c51863b3f..ad7005669 100644 --- a/doorstop/core/tests/__init__.py +++ b/doorstop/core/tests/__init__.py @@ -4,7 +4,7 @@ import logging import os -from typing import List +from typing import Dict, List from unittest.mock import MagicMock, Mock, patch from doorstop.core.base import BaseFileObject @@ -95,6 +95,7 @@ def __init__(self): self.prefix = "RQ" self._items: List[Item] = [] self.extended_reviewed: List[str] = [] + self._data: Dict[str, str] = {} def __iter__(self): yield from self._items @@ -102,6 +103,12 @@ def __iter__(self): def set_items(self, items): self._items = items + def set_data(self, data): + self._data = data + + def attribute(self, name): + return self._data.get(name) + class MockDocumentSkip(MockDocument): # pylint: disable=W0223,R0902 """Mock Document class that is always skipped in tree placement.""" diff --git a/doorstop/core/tests/test_document.py b/doorstop/core/tests/test_document.py index ebc3883d8..9725eaad6 100644 --- a/doorstop/core/tests/test_document.py +++ b/doorstop/core/tests/test_document.py @@ -159,8 +159,8 @@ def test_load_invalid(self): def test_load_unknown(self): """Verify loading a document config with an unknown key fails.""" self.document._file = YAML_UNKNOWN - msg = "^unexpected document setting 'John' in: .*\\.doorstop.yml$" - self.assertRaisesRegex(DoorstopError, msg, self.document.load) + self.document.load() + self.assertEqual("Doe", self.document.attribute("John")) def test_load_unknown_attributes(self): """Verify loading a document config with unknown attributes fails.""" diff --git a/doorstop/core/tree.py b/doorstop/core/tree.py index 156f75b1b..7112a3872 100644 --- a/doorstop/core/tree.py +++ b/doorstop/core/tree.py @@ -627,9 +627,16 @@ def _draw_line(self): def _draw_lines(self, encoding, html_links=False): """Generate lines of the tree structure.""" # Build parent prefix string (`getattr` to enable mock testing) - prefix = getattr(self.document, "prefix", "") or str(self.document) + prefix_link = prefix = getattr(self.document, "prefix", "") or str( + self.document + ) + + attribute_fn = getattr(self.document, "attribute", None) + mapped = attribute_fn("mapped_to") if callable(attribute_fn) else None + + prefix += " (" + ",".join(mapped) + ")" if mapped else "" if html_links: - prefix = '{0}'.format(prefix) + prefix = '{1}'.format(prefix_link, prefix) yield prefix # Build child prefix strings for count, child in enumerate(self.children, start=1): diff --git a/doorstop/core/validators/item_validator.py b/doorstop/core/validators/item_validator.py index 94abfbc48..c240a88a1 100644 --- a/doorstop/core/validators/item_validator.py +++ b/doorstop/core/validators/item_validator.py @@ -198,7 +198,13 @@ def _get_issues_both(self, item, document, tree, skip): # Verify an item is being linked to (child links) if settings.CHECK_CHILD_LINKS and item.normative: - find_all = settings.CHECK_CHILD_LINKS_STRICT or False + + mapped_document_prefixes = document.attribute("mapped_to") + if not mapped_document_prefixes: + mapped_document_prefixes = [] + + find_all = settings.CHECK_CHILD_LINKS_STRICT or mapped_document_prefixes + items, documents = item.find_child_items_and_documents( document=document, tree=tree, find_all=find_all ) @@ -209,13 +215,64 @@ def _get_issues_both(self, item, document, tree, skip): msg = "skipping issues against document %s..." log.debug(msg, child_document) continue - msg = "no links from child document: {}".format(child_document) + + if child_document.prefix in mapped_document_prefixes: + msg = "no links at all, missing mapped document: {}".format( + child_document + ) + else: + msg = "no links at all, missing child document: {}".format( + child_document + ) yield DoorstopWarning(msg) - elif settings.CHECK_CHILD_LINKS_STRICT: + + # here items are found but no strict checking is enabled + # only check "mapped_to" as mandatory links + else: prefix = [item.document.prefix for item in items] - for child in document.children: + + found = False + not_found_list = [] + + # check if at least on of the normal children exist + for child_document in documents: + if child_document.prefix in skip: + msg = "skipping issues against document %s..." + log.debug(msg, child_document) + continue + + # handle mapped documents later + if child_document.prefix in mapped_document_prefixes: + continue + + if child_document.prefix in prefix: + # found at least one link from child document + found = True + else: + not_found_list.append(child_document.prefix) + + # not found anything but not strict: accept a link from any child document + if not found and not settings.CHECK_CHILD_LINKS_STRICT: + for d in not_found_list: + msg = "links found, missing at lest one document: {}".format(d) + yield DoorstopWarning(msg) + + if settings.CHECK_CHILD_LINKS_STRICT: + # if strict check: report any document with no child links + for d in not_found_list: + msg = "no links from document: {}".format(d) + yield DoorstopWarning(msg) + + # handle mapped documents: they are treated like "strict" + for child in mapped_document_prefixes: + if child in skip: + msg = "skipping issues against mapped document %s..." + log.debug(msg, child) + continue + if child in skip: continue + if child not in prefix: - msg = "no links from document: {}".format(child) + msg = "no links from mapped document: {}".format(child) yield DoorstopWarning(msg)