Skip to content

Commit

Permalink
add machine readable traceability matrix
Browse files Browse the repository at this point in the history
  • Loading branch information
straun committed Oct 9, 2020
1 parent bff36c8 commit 0968b3f
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 8 deletions.
21 changes: 21 additions & 0 deletions doorstop/common.py
Expand Up @@ -3,6 +3,7 @@
"""Common exceptions, classes, and functions for Doorstop."""

import argparse
import csv
import glob
import logging
import os
Expand Down Expand Up @@ -177,6 +178,26 @@ def write_text(text, path):
return path


def write_csv(table, path, delimiter=',', newline='', encoding='utf-8'):
"""Write table to a file.
:param table: iterator of rows
:param path: file to write lines
:param delimiter: string to end cells
:param newline: string to end lines
:param encoding: output file encoding
:return: path of new file
"""
log.trace("writing table to '{}'...".format(path)) # type: ignore
with open(path, 'w', newline=newline, encoding=encoding) as stream:
writer = csv.writer(stream, delimiter=delimiter)
for row in table:
writer.writerow(row)
return path


def touch(path):
"""Ensure a file exists."""
if not os.path.exists(path):
Expand Down
13 changes: 7 additions & 6 deletions doorstop/core/exporter.py
Expand Up @@ -2,7 +2,6 @@

"""Functions to export documents and items."""

import csv
import datetime
import os
from collections import defaultdict
Expand Down Expand Up @@ -216,11 +215,13 @@ def _file_csv(obj, path, delimiter=',', auto=False):
:return: path of created file
"""
with open(path, 'w', newline='', encoding='utf-8') as stream:
writer = csv.writer(stream, delimiter=delimiter)
for row in _tabulate(obj, auto=auto):
writer.writerow(row)
return path
return common.write_csv(
_tabulate(obj, auto=auto),
path,
delimiter=delimiter,
newline='',
encoding='utf-8',
)


def _file_tsv(obj, path, auto=False):
Expand Down
61 changes: 60 additions & 1 deletion doorstop/core/publisher.py
Expand Up @@ -33,12 +33,21 @@
CSS = os.path.join(os.path.dirname(__file__), 'files', 'doorstop.css')
HTMLTEMPLATE = 'sidebar'
INDEX = 'index.html'
MATRIX = 'traceability.csv'

log = common.logger(__name__)


def publish(
obj, path, ext=None, linkify=None, index=None, template=None, toc=True, **kwargs
obj,
path,
ext=None,
linkify=None,
index=None,
matrix=None,
template=None,
toc=True,
**kwargs,
):
"""Publish an object to a given format.
Expand All @@ -52,6 +61,7 @@ def publish(
:param ext: file extension to override output extension
:param linkify: turn links into hyperlinks (for Markdown or HTML)
:param index: create an index.html (for HTML)
:param matrix: create a traceability matrix, traceability.csv
:raises: :class:`doorstop.common.DoorstopError` for unknown file formats
Expand All @@ -65,6 +75,8 @@ def publish(
linkify = is_tree(obj) and ext in ['.html', '.md']
if index is None:
index = is_tree(obj) and ext == '.html'
if matrix is None:
matrix = is_tree(obj)

if is_tree(obj):
assets_dir = os.path.join(path, Document.ASSETS) # path is a directory name
Expand Down Expand Up @@ -105,6 +117,10 @@ def publish(
if index and count:
_index(path, tree=obj if is_tree(obj) else None)

# Create traceability matrix
if index and matrix and count:
_matrix(path, tree=obj if is_tree(obj) else None)

# Return the published path
if count:
msg = "published to {} file{}".format(count, 's' if count > 1 else '')
Expand Down Expand Up @@ -226,6 +242,49 @@ def _lines_css():
yield ''


def _matrix(directory, tree, filename=MATRIX, ext=None):
"""Create a traceability matrix for all the items.
:param directory: directory for matrix
:param tree: tree to access the traceability data
:param filename: filename for matrix
:param ext: file extensionto use for the matrix
"""
# Get path and format extension
path = os.path.join(directory, filename)
ext = ext or os.path.splitext(path)[-1] or '.csv'

# Create the matrix
if tree:
log.info("creating an {}...".format(filename))
content = _matrix_content(tree)
common.write_csv(content, path)
else:
log.warning("no data for {}".format(filename))


def _extract_prefix(document):
if document:
return document.prefix
else:
return None


def _extract_uid(item):
if item:
return item.uid
else:
return None


def _matrix_content(tree):
"""Yield rows of content for the traceability matrix."""
yield tuple(map(_extract_prefix, tree.documents))
for row in tree.get_traceability():
yield tuple(map(_extract_uid, row))


def publish_lines(obj, ext='.txt', **kwargs):
"""Yield lines for a report in the specified format.
Expand Down
39 changes: 38 additions & 1 deletion doorstop/core/tests/test_publisher.py
Expand Up @@ -132,7 +132,15 @@ def test_publish_tree(self, mock_open, mock_index, mock_makedirs):
mock_open.side_effect = lambda *args, **kw: mock.mock_open(
read_data="$body"
).return_value
expected_calls = [call(os.path.join('mock', 'directory', 'MOCK.html'), 'wb')]
expected_calls = [
call(os.path.join('mock', 'directory', 'MOCK.html'), 'wb'),
call(
os.path.join('mock', 'directory', 'traceability.csv'),
'w',
encoding='utf-8',
newline='',
),
]
# Act
dirpath2 = publisher.publish(self.mock_tree, dirpath)
# Assert
Expand Down Expand Up @@ -204,6 +212,35 @@ def test_index_tree(self):
# Assert
self.assertTrue(os.path.isfile(path))

def test_matrix_tree(self):
"""Verify a traceability matrix can be created with a tree."""
path = os.path.join(FILES, 'testmatrix.csv')
mock_tree = MagicMock()
mock_tree.documents = []
for prefix in ('SYS', 'HLR', 'LLR', 'HLT', 'LLT'):
mock_document = MagicMock()
mock_document.prefix = prefix
mock_tree.documents.append(mock_document)
mock_tree.draw = lambda: "(mock tree structure)"
mock_item = Mock()
mock_item.uid = 'KNOWN-001'
mock_item.document = Mock()
mock_item.document.prefix = 'KNOWN'
mock_item.header = None
mock_item_unknown = Mock(spec=['uid'])
mock_item_unknown.uid = 'UNKNOWN-002'
mock_trace = [
(None, mock_item, None, None, None),
(None, None, None, mock_item_unknown, None),
(None, None, None, None, None),
]
mock_tree.get_traceability = lambda: mock_trace
# Act
publisher._matrix(FILES, tree=mock_tree, filename="testmatrix.csv")
# Assert
self.assertTrue(os.path.isfile(path))
# TODO assert contents

def test_lines_text_item(self):
"""Verify text can be published from an item."""
with patch.object(
Expand Down

0 comments on commit 0968b3f

Please sign in to comment.