From 4cecf4903372fde0d76c7fa83b4a3b71e8c1363b Mon Sep 17 00:00:00 2001 From: Chris Drexler Date: Mon, 21 Mar 2022 21:39:20 +0100 Subject: [PATCH] feat: :sparkles: implement mapped documents mapped documents address the problem of "multi parent" docs by "mapping" requirements from one document to another. The target document must pick them up so that at least on back link exist, but no mandatory back link from each child requirement is needed (as in a normal parent-child doc relationship) --- doorstop/core/document.py | 13 ++++- doorstop/core/item.py | 10 +++- doorstop/core/tests/__init__.py | 9 ++- doorstop/core/tests/test_document.py | 4 +- doorstop/core/tree.py | 11 +++- doorstop/core/validators/item_validator.py | 67 ++++++++++++++++++++-- 6 files changed, 100 insertions(+), 14 deletions(-) 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)