From 17cc00aa123c336f5069ba44378e20d9aab9f56f Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Thu, 24 Oct 2024 15:35:53 +0100 Subject: [PATCH 1/9] feat: add ability to export a tree to a single xlsx with multiple sheets --- doorstop/cli/tests/test_all.py | 10 +++- doorstop/core/exporter.py | 69 ++++++++++++++++++++++------ doorstop/core/importer.py | 1 + doorstop/core/tests/test_exporter.py | 22 +++++---- 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/doorstop/cli/tests/test_all.py b/doorstop/cli/tests/test_all.py index 7b6d046a6..6c292ef28 100644 --- a/doorstop/cli/tests/test_all.py +++ b/doorstop/cli/tests/test_all.py @@ -702,12 +702,18 @@ def test_export_document_xlsx_error(self): self.assertRaises(SystemExit, main, ["export", "tut", path]) self.assertFalse(os.path.isfile(path)) - def test_export_tree_xlsx(self): - """Verify 'doorstop export' can create an XLSX directory.""" + def test_export_tree_xlsx_dir(self): + """Verify 'doorstop export' can create an XLSX tree export.""" path = os.path.join(self.temp, "all") self.assertIs(None, main(["export", "all", path, "--xlsx"])) self.assertTrue(os.path.isdir(path)) + def test_export_tree_xlsx_file(self): + """Verify 'doorstop export' can create an XLSX tree export.""" + path = os.path.join(self.temp, "all.xlsx") + self.assertIs(None, main(["export", "all", path, "--xlsx"])) + self.assertTrue(os.path.isfile(path)) + def test_export_tree_no_path(self): """Verify 'doorstop export' returns an error with no path.""" self.assertRaises(SystemExit, main, ["export", "all"]) diff --git a/doorstop/core/exporter.py b/doorstop/core/exporter.py index c70eb5ea9..67859af48 100644 --- a/doorstop/core/exporter.py +++ b/doorstop/core/exporter.py @@ -3,7 +3,9 @@ """Functions to export documents and items.""" import datetime +import logging import os + from collections import defaultdict from typing import Any, Dict @@ -12,7 +14,7 @@ from doorstop import common, settings from doorstop.common import DoorstopError -from doorstop.core.types import iter_documents, iter_items +from doorstop.core.types import iter_documents, iter_items, is_tree LIST_SEP = "\n" # string separating list values when joined in a string @@ -25,13 +27,15 @@ def export(obj, path, ext=None, **kwargs): """Export an object to a given format. - The function can be called in two ways: + The function can be called in three ways: 1. document or item-like object + output file path 2. tree-like object + output directory path + 3. tree-like object + output file path + - :param obj: (1) Item, list of Items, Document or (2) Tree - :param path: (1) output file path or (2) output directory path + :param obj: (1) Item, list of Items, Document or (2) (3) Tree + :param path: (1) (3) output file path or (2) output directory path :param ext: file extension to override output extension :raises: :class:`doorstop.common.DoorstopError` for unknown file formats @@ -40,9 +44,15 @@ def export(obj, path, ext=None, **kwargs): """ # Determine the output format + is_dir = (os.path.splitext(path)[-1] == "") ext = ext or os.path.splitext(path)[-1] or ".csv" check(ext) + if can_export_tree(ext) and is_tree(obj) and not is_dir: + log.info("exporting to {}...".format(path)) + export_file(obj, path, ext, **kwargs) + return path + # Export documents count = 0 for obj2, path2 in iter_documents(obj, path, ext): @@ -97,7 +107,7 @@ def export_file(obj, path, ext=None, **kwargs): """ ext = ext or os.path.splitext(path)[-1] func = check(ext, get_file_func=True) - log.debug("converting %s to file format %s...", obj, ext) + log.debug("converting {obj} to file format {ext} using {func}...") try: return func(obj, path, **kwargs) except IOError: @@ -246,14 +256,39 @@ def _file_xlsx(obj, path, auto=False): :return: path of created file """ - workbook = _get_xlsx(obj, auto) + + log.debug(f"xlsx export: _file_xlsx called with {obj}, {path}, auto={auto}") + workbook = _get_xlsx(obj, path, auto) workbook.save(path) return path -def _get_xlsx(obj, auto): - """Create an XLSX workbook object. +def _get_xlsx(obj, path, auto): + # Create a new workbook + workbook = openpyxl.Workbook() + # We don't want the default sheet "Sheet" + workbook.remove(workbook.active) + + first_sheet = None + if is_tree(obj): + log.debug("xlsx export: exporting tree") + for obj2, path2 in iter_documents(obj, path, ".xlsx"): + sheet = _add_xlsx_sheet(workbook, obj2, auto) + first_sheet = sheet or first_sheet + else: + log.debug("xlsx export: exporting single Item/Document") + first_sheet = _add_xlsx_sheet(workbook, obj, auto) + + log.debug(f"xlsx export: First sheet is {first_sheet}") + assert first_sheet != None + workbook.active = first_sheet + + return workbook + + +def _add_xlsx_sheet(workbook, obj, auto): + """Add a sheet to XLSX workbook object. :param obj: Item, list of Items, or Document to export :param auto: include placeholders for new items on import @@ -264,13 +299,16 @@ def _get_xlsx(obj, auto): col_widths: Dict[Any, float] = defaultdict(float) col = "A" - # Create a new workbook - workbook = openpyxl.Workbook() - worksheet = workbook.active + worksheet = workbook.create_sheet(title=obj.prefix) + log.debug(f"xls export: Created worksheet {worksheet.title}") + + log.debug(f"xls export: populating cells") # Populate cells for row, data in enumerate(_tabulate(obj, auto=auto), start=1): + log.debug(f"xls export: row={row}, data={data}") for col_idx, value in enumerate(data, start=1): + log.debug(f"xls export: column={col_idx}, value={value}") cell = worksheet.cell(column=col_idx, row=row) # wrap text in every cell @@ -307,8 +345,7 @@ def _get_xlsx(obj, auto): # Freeze top row worksheet.freeze_panes = worksheet.cell(row=2, column=1) - - return workbook + return worksheet def _width(text): @@ -325,6 +362,8 @@ def _width(text): FORMAT_FILE = {".csv": _file_csv, ".tsv": _file_tsv, ".xlsx": _file_xlsx} # Union of format dictionaries FORMAT = dict(list(FORMAT_LINES.items()) + list(FORMAT_FILE.items())) # type: ignore +# Can a given format export a whole tree? +FORMAT_TREE = {".xlsx": True} def check(ext, get_lines_gen=False, get_file_func=False): @@ -368,3 +407,7 @@ def check(ext, get_lines_gen=False, get_file_func=False): raise exc return None + + +def can_export_tree(ext): + return ext in FORMAT_TREE diff --git a/doorstop/core/importer.py b/doorstop/core/importer.py index adbc470e8..29d483321 100644 --- a/doorstop/core/importer.py +++ b/doorstop/core/importer.py @@ -194,6 +194,7 @@ def _file_xlsx(path, document, mapping=None): workbook = openpyxl.load_workbook(path, data_only=True) worksheet = workbook.active + log.debug(f"xlsx import: importing sheet {worksheet.title} in workbook {workbook}") index = 0 # Extract header and data rows diff --git a/doorstop/core/tests/test_exporter.py b/doorstop/core/tests/test_exporter.py index 46127db76..ea7abf6dc 100644 --- a/doorstop/core/tests/test_exporter.py +++ b/doorstop/core/tests/test_exporter.py @@ -5,7 +5,9 @@ import os import tempfile import unittest -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import ANY, MagicMock, Mock, patch + +import openpyxl from doorstop.common import DoorstopError from doorstop.core import exporter @@ -132,23 +134,25 @@ def test_file_tsv(self, mock_file_csv): self.item, path, delimiter="\t", auto=False ) - @patch("doorstop.core.exporter._get_xlsx") - def test_file_xlsx(self, mock_get_xlsx): + @patch("doorstop.core.exporter._add_xlsx_sheet") + def test_file_xlsx(self, mock_add_xlsx_sheet): """Verify a (mock) XLSX file can be created.""" + mock_add_xlsx_sheet.side_effect = lambda *args: args[0].create_sheet() temp = tempfile.gettempdir() + path = os.path.join(temp, "exported.xlsx") # Act exporter._file_xlsx(self.item, path) # pylint:disable=W0212 # Assert - mock_get_xlsx.assert_called_once_with(self.item, False) + mock_add_xlsx_sheet.assert_called_once_with(ANY, self.item, False) def test_get_xlsx(self): - """Verify an XLSX object can be created.""" + """Verify an XLSX worksheet object can be created.""" # Act - workbook = exporter._get_xlsx(self.item4, auto=False) # pylint: disable=W0212 + workbook = openpyxl.Workbook() + worksheet = exporter._add_xlsx_sheet(workbook, self.item4, auto=False) # pylint: disable=W0212 # Assert rows = [] - worksheet = workbook.active for data in worksheet.rows: rows.append([cell.value for cell in data]) self.assertIn("long", rows[0]) @@ -157,10 +161,10 @@ def test_get_xlsx(self): def test_get_xlsx_auto(self): """Verify an XLSX object can be created with placeholder rows.""" # Act - workbook = exporter._get_xlsx(self.item4, auto=True) # pylint: disable=W0212 + workbook = openpyxl.Workbook() + worksheet = exporter._add_xlsx_sheet(workbook, self.item4, auto=True) # pylint: disable=W0212 # Assert rows = [] - worksheet = workbook.active for data in worksheet.rows: rows.append([cell.value for cell in data]) self.assertEqual("...", rows[-1][0]) From a387481396562dff22262694b00bb1b0f292223f Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Fri, 25 Oct 2024 09:47:33 +0100 Subject: [PATCH 2/9] feat: Enable saving/loading Document to a dict --- doorstop/core/document.py | 63 ++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/doorstop/core/document.py b/doorstop/core/document.py index 356842ab2..f4a1f5c96 100644 --- a/doorstop/core/document.py +++ b/doorstop/core/document.py @@ -192,34 +192,33 @@ def include(self, node): IncludeLoader.filenames = [yamlfile] # type: ignore return self._load(text, yamlfile, loader=IncludeLoader) - def load(self, reload=False): - """Load the document's properties from its file.""" - if self._loaded and not reload: - return - log.debug("loading {}...".format(repr(self))) - data = self._load_with_include(self.config) - # Store parsed data - sets = data.get("settings", {}) - for key, value in sets.items(): - try: - if key == "prefix": + @property + def set_properties(self, data): + def fill_key(key, value): + match key: + case "prefix": self._data[key] = Prefix(value) - elif key == "sep": + case "sep": self._data[key] = value.strip() - elif key == "parent": + case "parent": self._data[key] = value.strip() - elif key == "digits": + case "digits": self._data[key] = int(value) # type: ignore - elif key == "itemformat": + case "itemformat": self._data[key] = value.strip() - else: - msg = "unexpected document setting '{}' in: {}".format( - key, self.config - ) + case _: + msg = f"unexpected document setting '{key}' in: {self.config}" raise DoorstopError(msg) + + # Store parsed data + sets = data.get("settings", {}) + for key, value in sets.items(): + try: + fill_key(key, value) except (AttributeError, TypeError, ValueError): - msg = "invalid value for '{}' in: {}".format(key, self.config) + msg = f"invalid value for '{key}' in: {self.config}" raise DoorstopError(msg) + # Store parsed attributes attributes = data.get("attributes", {}) for key, value in attributes.items(): @@ -237,16 +236,20 @@ def load(self, reload=False): self.extensions = data.get("extensions", {}) + def load(self, reload=False): + """Load the document's properties from its file.""" + if self._loaded and not reload: + return + log.debug("loading {}...".format(repr(self))) + data = self._load_with_include(self.config) + self._load_from_dict(data, reload) # Set meta attributes self._loaded = True if reload: list(self._iter(reload=reload)) - @edit_document - def save(self): - """Save the document's properties to its file.""" - log.debug("saving {}...".format(repr(self))) - # Format the data items + @property + def properties(self): data = {} sets = {} for key, value in self._data.items(): @@ -266,8 +269,14 @@ def save(self): attributes["reviewed"] = self._extended_reviewed if attributes: data["attributes"] = attributes - # Dump the data to YAML - text = self._dump(data) + return data + + @edit_document + def save(self): + """Save the document's properties to its file.""" + log.debug("saving {}...".format(repr(self))) + # Dump the data to YAML + text = self._dump(self.properties) # Save the YAML to file self._write(text, self.config) # Set meta attributes From 8caa455064e8ffdfb9a7950991ea2a42c644da16 Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Fri, 25 Oct 2024 10:06:45 +0100 Subject: [PATCH 3/9] feat: Allow importing/exporting of whole tree into xlsx --- doorstop/cli/commands.py | 40 +++++++++++------ doorstop/cli/main.py | 2 +- doorstop/core/importer.py | 93 ++++++++++++++++++++++++++++++--------- 3 files changed, 98 insertions(+), 37 deletions(-) diff --git a/doorstop/cli/commands.py b/doorstop/cli/commands.py index 06c4f4d7f..d3743f0d8 100644 --- a/doorstop/cli/commands.py +++ b/doorstop/cli/commands.py @@ -439,49 +439,61 @@ def run_import(args, cwd, error, catch=True, _tree=None): document = item = None attrs = utilities.literal_eval(args.attrs, error) mapping = utilities.literal_eval(args.map, error) + ext = utilities.get_ext(args, error, None, None) + if args.path: - if not args.prefix: + if not args.prefix and not importer.can_import_tree(ext): error("when [path] specified, [prefix] is also required") elif args.document: error("'--document' cannot be used with [path] [prefix]") elif args.item: error("'--item' cannot be used with [path] [prefix]") - ext = utilities.get_ext(args, error, None, None) - elif not (args.document or args.item): + elif not (args.document or args.item or importer.can_import_tree(ext)): error("specify [path], '--document', or '--item' to import") + request_next_number = _request_next_number(args) + tree = _tree or _get_tree( + args, cwd, request_next_number=request_next_number + ) + + documents = [] with utilities.capture(catch=catch) as success: - if args.path: + # import supports importing a tree and file passed in with no --document/--item and no prefix + if args.path and os.path.isfile(args.path) and \ + importer.can_import_tree(ext) and \ + not (args.document or args.item) and \ + not args.prefix: + documents = importer.import_file(args.path, ext=ext, mapping=mapping, tree=tree) + + # passed a path and prefix + elif args.path and args.prefix: # get the document - request_next_number = _request_next_number(args) - tree = _tree or _get_tree( - args, cwd, request_next_number=request_next_number - ) document = tree.find_document(args.prefix) + log.debug(f"Found document: '{document}' for prefix {args.prefix}") # import items into it msg = "importing '{}' into document {}...".format(args.path, document) utilities.show(msg, flush=True) - importer.import_file(args.path, document, ext, mapping=mapping) + documents = importer.import_file(args.path, ext=ext, mapping=mapping, document=document) elif args.document: prefix, path = args.document - document = importer.create_document(prefix, path, parent=args.parent) + documents = [importer.create_document(prefix, path, parent=args.parent)] elif args.item: prefix, uid = args.item request_next_number = _request_next_number(args) item = importer.add_item( prefix, uid, attrs=attrs, request_next_number=request_next_number ) + documents = [item.document] if not success: return False - if document: + for doc in documents: utilities.show( - "imported document: {} ({})".format(document.prefix, document.relpath) + "imported into document: {} ({})".format(doc.prefix, doc.relpath) ) - else: - assert item + if item: utilities.show("imported item: {} ({})".format(item.uid, item.relpath)) return True diff --git a/doorstop/cli/main.py b/doorstop/cli/main.py index eb5756aa4..41a5c1ae0 100644 --- a/doorstop/cli/main.py +++ b/doorstop/cli/main.py @@ -184,7 +184,7 @@ def main(args=None): # pylint: disable=R0915 log.debug(f"command cancelled: {args}") success = False if success: - log.debug("command succeeded: {args}") + log.debug(f"command succeeded: {args}") else: log.debug(f"command failed: {args}") sys.exit(1) diff --git a/doorstop/core/importer.py b/doorstop/core/importer.py index 29d483321..d1d79e890 100644 --- a/doorstop/core/importer.py +++ b/doorstop/core/importer.py @@ -24,23 +24,24 @@ log = common.logger(__name__) -def import_file(path, document, ext=None, mapping=None, **kwargs): +def import_file(path, ext=None, document=None, tree=None, mapping=None, **kwargs): """Import items from an exported file. :param path: input file location - :param document: document to import items + :param document: document to import items into + :param tree: tree to import documents into :param ext: file extension to override input path's extension :param mapping: dictionary mapping custom to standard attribute names :raise DoorstopError: for unknown file formats - :return: document with imported items + :return: list of Documents created/modified """ - log.info("importing {} into {}...".format(path, document)) + log.info(f"importing {path} into {document if document else tree}...") ext = ext or os.path.splitext(path)[-1] func = check(ext) - func(path, document, mapping=mapping, **kwargs) + return func(path, document, tree=tree, mapping=mapping, **kwargs) def create_document(prefix, path, parent=None, tree=None): @@ -114,6 +115,7 @@ def _file_yml(path, document, **_): :param path: input file location :param document: document to import items + returns: list of Documents created/modified """ # Parse the file log.info("reading items in {}...".format(path)) @@ -129,9 +131,10 @@ def _file_yml(path, document, **_): else: item.delete() add_item(document.prefix, uid, attrs=attrs, document=document) + return [document] -def _file_csv(path, document, delimiter=",", mapping=None): +def _file_csv(path, document, delimiter=",", mapping=None, **_): """Import items from a CSV export to a document. :param path: input file location @@ -139,6 +142,8 @@ def _file_csv(path, document, delimiter=",", mapping=None): :param delimiter: CSV field delimiter :param mapping: dictionary mapping custom to standard attribute names + returns: list of Documents created/modified + """ rows = [] @@ -165,36 +170,40 @@ def _file_csv(path, document, delimiter=",", mapping=None): # Import items from the rows _itemize(header, data, document, mapping=mapping) + return [document] -def _file_tsv(path, document, mapping=None): +def _file_tsv(path, document, mapping=None, **_): """Import items from a TSV export to a document. :param path: input file location :param document: document to import items :param mapping: dictionary mapping custom to standard attribute names + :return: list of Documents created/modified """ _file_csv(path, document, delimiter="\t", mapping=mapping) + return [document] -def _file_xlsx(path, document, mapping=None): - """Import items from an XLSX export to a document. +def _check_doc(tree, worksheet, workbook): + log.debug(f"xlsx import: importing sheet {worksheet.title}") + prefix = worksheet.title + try: + document = tree.find_document(prefix) + except DoorstopError: + log.warn(f"no matching document found for sheet {worksheet.title}. " + f"If you wish to import this, first create a document " + f"with the prefix {prefix}") + return None + return document - :param path: input file location - :param document: document to import items - :param mapping: dictionary mapping custom to standard attribute names - """ +def _load_xlsx(document, worksheet, mapping): + log.info(f"Loading into document {document.path}, prefix {document.prefix}...") + header = [] data = [] - - # Parse the file - log.debug("reading rows in {}...".format(path)) - workbook = openpyxl.load_workbook(path, data_only=True) - worksheet = workbook.active - - log.debug(f"xlsx import: importing sheet {worksheet.title} in workbook {workbook}") index = 0 # Extract header and data rows @@ -213,16 +222,51 @@ def _file_xlsx(path, document, mapping=None): msg = "workbook contains the maximum number of rows" warnings.warn(msg, Warning) - # Import items from the rows _itemize(header, data, document, mapping=mapping) +def _file_xlsx(path, document, mapping=None, tree=None, **_): + """Import items from an XLSX export to a document. + + :param path: input file location + :param document: document to import items + :param mapping: dictionary mapping custom to standard attribute names + + :return: list of Documents created/modified + """ + + # Parse the file + log.info(f"reading sheets in {path}...") + workbook = openpyxl.load_workbook(path, data_only=True) + + if document: + if len(workbook.worksheets) == 1: + _load_xlsx(document, workbook.worksheets[0], mapping) + else: + sheet = workbook.get_sheet_by_name(document.prefix) + if sheet: + _load_xlsx(document, sheet, mapping) + else: + raise DoorstopError(f"No sheet matching {document.prefix}") + return [document] + else: + if not tree: + tree = _get_tree() + documents = [] + for worksheet in workbook.worksheets: + log.info(f"sheet {worksheet.title}...") + document = _check_doc(tree, worksheet, workbook) + log.debug(f"importing into {document.prefix} {document.path}") + _load_xlsx(document, sheet, mapping) + documents.append(document) + return documents + def _itemize(header, data, document, mapping=None): """Conversion function for multiple formats. :param header: list of columns names :param data: list of lists of row values - :param document: document to import items + :param document: document to import items into :param mapping: dictionary mapping custom to standard attribute names """ @@ -319,6 +363,7 @@ def _split_list(value): ".xlsx": _file_xlsx, } +FORMAT_TREE = {".xlsx": True} def check(ext): """Confirm an extension is supported for import. @@ -338,3 +383,7 @@ def check(ext): else: log.debug("found file reader for: {}".format(ext)) return func + + +def can_import_tree(ext): + return ext in FORMAT_TREE From a4eaf5f27d27f80ebee15cdb10e46a28dbcdeecf Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Fri, 25 Oct 2024 10:07:44 +0100 Subject: [PATCH 4/9] feat: Tests for importing/exporting of whole tree into xlsx --- doorstop/cli/tests/test_all.py | 8 +- doorstop/core/tests/files/exported-all.xlsx | Bin 0 -> 11700 bytes doorstop/core/tests/test_all.py | 8 +- doorstop/core/tests/test_importer.py | 96 ++++++++++++++++---- 4 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 doorstop/core/tests/files/exported-all.xlsx diff --git a/doorstop/cli/tests/test_all.py b/doorstop/cli/tests/test_all.py index 6c292ef28..40598f350 100644 --- a/doorstop/cli/tests/test_all.py +++ b/doorstop/cli/tests/test_all.py @@ -576,7 +576,7 @@ class TestImportFile(MockTestCase): def test_import_file_missing_prefix(self): """Verify 'doorstop import' returns an error with a missing prefix.""" - path = os.path.join(FILES, "exported.xlsx") + path = os.path.join(FILES, "exported.csv") self.assertRaises(SystemExit, main, ["import", path]) def test_import_file_extra_flags(self): @@ -619,7 +619,7 @@ def test_import_csv_to_document_existing(self): self.assertIs(None, main(["import", path, "PREFIX"])) # Assert path = os.path.join(dirpath, "REQ001.yml") - self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.path.isfile(path), f"{path} is not a file") def test_import_tsv_to_document_existing(self): """Verify 'doorstop import' can import TSV to an existing document.""" @@ -630,7 +630,7 @@ def test_import_tsv_to_document_existing(self): self.assertIs(None, main(["import", path, "PREFIX"])) # Assert path = os.path.join(dirpath, "REQ001.yml") - self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.path.isfile(path), f"{path} is not a file") def test_import_xlsx_to_document_existing(self): """Verify 'doorstop import' can import XLSX to an existing document.""" @@ -641,7 +641,7 @@ def test_import_xlsx_to_document_existing(self): self.assertIs(None, main(["import", path, "PREFIX"])) # Assert path = os.path.join(dirpath, "REQ001.yml") - self.assertTrue(os.path.isfile(path)) + self.assertTrue(os.path.isfile(path), f"{path} is not a file") @unittest.skipUnless(os.getenv(ENV), REASON) diff --git a/doorstop/core/tests/files/exported-all.xlsx b/doorstop/core/tests/files/exported-all.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..c2e7b0127343ad8e1aea165b92310145f29431e4 GIT binary patch literal 11700 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 literal 0 HcmV?d00001 diff --git a/doorstop/core/tests/test_all.py b/doorstop/core/tests/test_all.py index 243dc6cc0..6b2f58b9a 100644 --- a/doorstop/core/tests/test_all.py +++ b/doorstop/core/tests/test_all.py @@ -399,7 +399,7 @@ def test_import_yml(self): _tree = _get_tree() document = _tree.create_document(_path, "REQ") # Act - core.importer.import_file(path, document) + core.importer.import_file(path, document=document) # Assert expected = [item.data for item in self.document.items] actual = [item.data for item in document.items] @@ -414,7 +414,7 @@ def test_import_csv(self): _tree = _get_tree() document = _tree.create_document(_path, "REQ") # Act - core.importer.import_file(path, document) + core.importer.import_file(path, document=document) # Assert expected = [item.data for item in self.document.items] actual = [item.data for item in document.items] @@ -429,7 +429,7 @@ def test_import_tsv(self): _tree = _get_tree() document = _tree.create_document(_path, "REQ") # Act - core.importer.import_file(path, document) + core.importer.import_file(path, document=document) # Assert expected = [item.data for item in self.document.items] actual = [item.data for item in document.items] @@ -445,7 +445,7 @@ def test_import_xlsx(self): _tree = _get_tree() document = _tree.create_document(_path, "REQ") # Act - core.importer.import_file(path, document) + core.importer.import_file(path, document=document) # Assert expected = [item.data for item in self.document.items] actual = [item.data for item in document.items] diff --git a/doorstop/core/tests/test_importer.py b/doorstop/core/tests/test_importer.py index 1df09bf83..4e1a381ce 100644 --- a/doorstop/core/tests/test_importer.py +++ b/doorstop/core/tests/test_importer.py @@ -14,6 +14,7 @@ from doorstop.common import DoorstopError from doorstop.core import importer from doorstop.core.builder import _set_tree +from doorstop.core.tests import MockDataMixIn from doorstop.core.tests.test_document import FILES, MockItem from doorstop.core.tree import Tree @@ -51,17 +52,16 @@ ```""" -class TestModule(unittest.TestCase): +class TestModule(MockDataMixIn, unittest.TestCase): """Unit tests for the doorstop.core.importer module.""" maxDiff = None - def test_import_file_unknown(self): """Verify an exception is raised when importing unknown formats.""" mock_document = Mock() - self.assertRaises(DoorstopError, importer.import_file, "a.a", mock_document) + self.assertRaises(DoorstopError, importer.import_file, "a.a", document=mock_document) self.assertRaises( - DoorstopError, importer.import_file, "a.csv", mock_document, ".a" + DoorstopError, importer.import_file, "a.csv", ext=".a", document=mock_document ) @patch("doorstop.core.importer._file_csv") @@ -70,15 +70,15 @@ def test_import_file(self, mock_file_csv): mock_path = "path/to/file.csv" mock_document = Mock() importer.FORMAT_FILE[".csv"] = mock_file_csv - importer.import_file(mock_path, mock_document) - mock_file_csv.assert_called_once_with(mock_path, mock_document, mapping=None) + importer.import_file(mock_path, document=mock_document) + mock_file_csv.assert_called_once_with(mock_path, document=mock_document, mapping=None) @patch("doorstop.core.importer.check") def test_import_file_custom_ext(self, mock_check): """Verify a custom extension can be specified for import.""" mock_path = "path/to/file.ext" mock_document = Mock() - importer.import_file(mock_path, mock_document, ext=".custom") + importer.import_file(mock_path, document=mock_document, ext=".custom") mock_check.assert_called_once_with(".custom") @patch("doorstop.core.importer.add_item") @@ -274,19 +274,7 @@ def test_file_tsv(self, mock_file_csv): mock_path, mock_document, delimiter="\t", mapping=None ) - @patch("doorstop.core.importer._itemize") - def test_file_xlsx(self, mock_itemize): - """Verify a XLSX file can be imported.""" - path = os.path.join(FILES, "exported.xlsx") - mock_document = Mock() - # Act - with catch_warnings(): - importer._file_xlsx(path, mock_document) - # Assert - args, kwargs = mock_itemize.call_args - logging.debug("args: {}".format(args)) - logging.debug("kwargs: {}".format(kwargs)) - header, data, document = args + def _xlsx_check_header(self, header): expected_header = [ "uid", "level", @@ -301,6 +289,26 @@ def test_file_xlsx(self, mock_itemize): "reviewed", ] self.assertEqual(expected_header, header) + + def _xlsx_check_tst(self, data): + expected_data = [ + [ + "tst003", + "1", + "Tutorial", + None, + None, + "", + True, + False, + None, + True, + None, + ] + ] + self.assertEqual(expected_data, data) + + def _xlsx_check_req(self, data): expected_data = [ [ "REQ001", @@ -384,8 +392,56 @@ def test_file_xlsx(self, mock_itemize): ], ] self.assertEqual(expected_data, data) + + @patch("doorstop.core.importer._itemize") + def test_file_xlsx_single(self, mock_itemize): + """Verify a XLSX file can be imported.""" + path = os.path.join(FILES, "exported.xlsx") + mock_document = Mock() + # Act + with catch_warnings(): + importer._file_xlsx(path, mock_document) + # Assert + args, kwargs = mock_itemize.call_args + logging.debug("args: {}".format(args)) + logging.debug("kwargs: {}".format(kwargs)) + header, data, document = args + self._xlsx_check_header(header) + self._xlsx_check_req(data) + self.assertIs(mock_document, document) + @patch("doorstop.core.importer._itemize") + def test_file_xlsx_all(self, mock_itemize): + """Verify a XLSX file can be imported.""" + + def check_itemize(*args, **kwargs): + logging.debug("args: {}".format(args)) + logging.debug("kwargs: {}".format(kwargs)) + header, data, document = args + self._xlsx_check_header(header) + match document.prefix: + case "REQ": + self._xlsx_check_req(data) + case "tst": + self._xlsx_check_tst(data) + case _: + self.fail(f"unexpected sheet {document.prefix}") + + path = os.path.join(FILES, "exported-all.xlsx") + mock_itemize.side_effect = check_itemize + + # Act + with catch_warnings(): + importer._file_xlsx(path, None) + # Assert + args, kwargs = mock_itemize.call_args + logging.debug("args: {}".format(args)) + logging.debug("kwargs: {}".format(kwargs)) + header, data, document = args + self._xlsx_check_header(header) + self._xlsx_check_req(data) + @patch("doorstop.core.importer._itemize") def test_file_xlsx_formula(self, mock_itemize): """Verify a XLSX file with formula can be imported.""" From 229ac256b8a1901e147172cdbdfc6b2d0a418cd0 Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Fri, 25 Oct 2024 13:55:09 +0100 Subject: [PATCH 5/9] feat: wip: Export document settings & attributes to xlsx --- doorstop/core/document.py | 101 +++++++++++++++++++++----------------- doorstop/core/exporter.py | 23 +++++++++ 2 files changed, 79 insertions(+), 45 deletions(-) diff --git a/doorstop/core/document.py b/doorstop/core/document.py index f4a1f5c96..1f0c8874c 100644 --- a/doorstop/core/document.py +++ b/doorstop/core/document.py @@ -10,7 +10,7 @@ import yaml -from doorstop import common, settings +from doorstop import common, settings as dsettings from doorstop.common import ( DoorstopError, DoorstopInfo, @@ -135,7 +135,7 @@ def new( """ # Check separator - if sep and sep not in settings.SEP_CHARS: + if sep and sep not in dsettings.SEP_CHARS: raise DoorstopError("invalid UID separator '{}'".format(sep)) config = os.path.join(path, Document.CONFIG) @@ -193,7 +193,23 @@ def include(self, node): return self._load(text, yamlfile, loader=IncludeLoader) @property - def set_properties(self, data): + def settings(self): + """ + Document settings as a dict + """ + sets = {} + for key, value in self._data.items(): + if key == "prefix": + sets[key] = str(value) + elif key == "parent": + if value: + sets[key] = value + else: + sets[key] = value + return sets + + @settings.setter + def settings(self, settings): def fill_key(key, value): match key: case "prefix": @@ -210,17 +226,29 @@ def fill_key(key, value): msg = f"unexpected document setting '{key}' in: {self.config}" raise DoorstopError(msg) - # Store parsed data - sets = data.get("settings", {}) - for key, value in sets.items(): + for key, value in settings.items(): try: fill_key(key, value) except (AttributeError, TypeError, ValueError): msg = f"invalid value for '{key}' in: {self.config}" raise DoorstopError(msg) + @property + def attributes(self): + """ + Document attributes as a dict + """ + # Save the attributes + attributes = {} + if self._attribute_defaults: + attributes["defaults"] = self._attribute_defaults + if self._extended_reviewed: + attributes["reviewed"] = self._extended_reviewed + return attributes + + @attributes.setter + def attributes(self, attributes): # Store parsed attributes - attributes = data.get("attributes", {}) for key, value in attributes.items(): if key == "defaults": self._attribute_defaults = value @@ -234,49 +262,32 @@ def fill_key(key, value): ) raise DoorstopError(msg) - self.extensions = data.get("extensions", {}) - def load(self, reload=False): """Load the document's properties from its file.""" if self._loaded and not reload: return log.debug("loading {}...".format(repr(self))) data = self._load_with_include(self.config) - self._load_from_dict(data, reload) + self.settings = data["settings"] if "settings" in data else {} + self.attributes = data["attributes"] if "attributes" in data else {} + self.extensions = data.get("extensions", {}) # Set meta attributes self._loaded = True if reload: list(self._iter(reload=reload)) - @property - def properties(self): - data = {} - sets = {} - for key, value in self._data.items(): - if key == "prefix": - sets[key] = str(value) - elif key == "parent": - if value: - sets[key] = value - else: - sets[key] = value - data["settings"] = sets - # Save the attributes - attributes = {} - if self._attribute_defaults: - attributes["defaults"] = self._attribute_defaults - if self._extended_reviewed: - attributes["reviewed"] = self._extended_reviewed - if attributes: - data["attributes"] = attributes - return data - @edit_document def save(self): """Save the document's properties to its file.""" log.debug("saving {}...".format(repr(self))) - # Dump the data to YAML - text = self._dump(self.properties) + + data = {} + if self.settings: + data["settings"] = self.settings, + if self.attributes: + data["attributes"] = self.attributes + + text = self._dump(data) # Save the YAML to file self._write(text, self.config) # Set meta attributes @@ -322,7 +333,7 @@ def _iter(self, reload=False): except Exception: log.error("Unable to load: %s", item) raise - if settings.CACHE_ITEMS and self.tree: + if dsettings.CACHE_ITEMS and self.tree: self.tree._item_cache[ # pylint: disable=protected-access item.uid ] = item @@ -401,7 +412,7 @@ def sep(self): def sep(self, value): """Set the prefix-number separator to use for new item UIDs.""" # TODO: raise a specific exception for invalid separator characters? - assert not value or value in settings.SEP_CHARS + assert not value or value in dsettings.SEP_CHARS self._data["sep"] = value.strip() # TODO: should the new separator be applied to all items? @@ -489,7 +500,7 @@ def index(self, value): path = os.path.join(self.path, Document.INDEX) log.info("creating {} index...".format(self)) common.write_lines( - self._lines_index(self.items), path, end=settings.WRITE_LINESEPERATOR + self._lines_index(self.items), path, end=dsettings.WRITE_LINESEPERATOR ) @index.deleter @@ -525,7 +536,7 @@ def add_item(self, number=None, level=None, reorder=True, defaults=None, name=No name, self.prefix ) raise DoorstopError(msg) - if self.sep not in settings.SEP_CHARS: + if self.sep not in dsettings.SEP_CHARS: msg = "cannot add item with name '{}' to document '{}' with an invalid separator '{}'".format( name, self.prefix, self.sep ) @@ -617,13 +628,13 @@ def reorder(self, manual=True, automatic=True, start=None, keep=None, _items=Non @staticmethod def _lines_index(items): """Generate (pseudo) YAML lines for the document index.""" - yield "#" * settings.MAX_LINE_LENGTH + yield "#" * dsettings.MAX_LINE_LENGTH yield "# THIS TEMPORARY FILE WILL BE DELETED AFTER DOCUMENT REORDERING" yield "# MANUALLY INDENT, DEDENT, & MOVE ITEMS TO THEIR DESIRED LEVEL" yield "# A NEW ITEM WILL BE ADDED FOR ANY UNKNOWN IDS, i.e. - new: " yield "# THE COMMENT WILL BE USED AS THE ITEM TEXT FOR NEW ITEMS" yield "# CHANGES WILL BE REFLECTED IN THE ITEM FILES AFTER CONFIRMATION" - yield "#" * settings.MAX_LINE_LENGTH + yield "#" * dsettings.MAX_LINE_LENGTH yield "" yield "initial: {}".format(items[0].level if items else 1.0) yield "outline:" @@ -632,8 +643,8 @@ def _lines_index(items): lines = item.text.strip().splitlines() comment = lines[0].replace("\\", "\\\\") if lines else "" line = space + "- {u}: # {c}".format(u=item.uid, c=comment) - if len(line) > settings.MAX_LINE_LENGTH: - line = line[: settings.MAX_LINE_LENGTH - 3] + "..." + if len(line) > dsettings.MAX_LINE_LENGTH: + line = line[: dsettings.MAX_LINE_LENGTH - 3] + "..." yield line @staticmethod @@ -863,9 +874,9 @@ def get_issues( return # Reorder or check item levels - if settings.REORDER: + if dsettings.REORDER: self.reorder(_items=items) - elif settings.CHECK_LEVELS: + elif dsettings.CHECK_LEVELS: yield from self._get_issues_level(items) item_validator = ItemValidator() diff --git a/doorstop/core/exporter.py b/doorstop/core/exporter.py index 67859af48..d6a3cc21c 100644 --- a/doorstop/core/exporter.py +++ b/doorstop/core/exporter.py @@ -264,6 +264,22 @@ def _file_xlsx(obj, path, auto=False): return path +def _add_properties_sheet(wb, document_properties): + sheet = wb.create_sheet(title="Document Properties") + sheet.append([ + "prefix", + "settings key", + "settings value", + "attributes key", + "attributes value", + ]) + for prefix, data in document_properties.items(): + for set_k, set_v in data["settings"].items(): + sheet.append([prefix, repr(set_k), repr(set_v)]) + for attr_k, attr_v in data["attributes"].items(): + sheet.append([prefix, "", "", repr(attr_k), repr(attr_v)]) + + def _get_xlsx(obj, path, auto): # Create a new workbook workbook = openpyxl.Workbook() @@ -272,10 +288,17 @@ def _get_xlsx(obj, path, auto): first_sheet = None if is_tree(obj): + document_properties = {} log.debug("xlsx export: exporting tree") for obj2, path2 in iter_documents(obj, path, ".xlsx"): sheet = _add_xlsx_sheet(workbook, obj2, auto) first_sheet = sheet or first_sheet + document_properties[obj2.prefix] = { + "settings": obj2.settings, + "attributes": obj2.attributes + } + if document_properties: + _add_properties_sheet(workbook, document_properties) else: log.debug("xlsx export: exporting single Item/Document") first_sheet = _add_xlsx_sheet(workbook, obj, auto) From a56bf05277a07868fa1d53ce0f900eb9cbaa298f Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Fri, 25 Oct 2024 13:56:58 +0100 Subject: [PATCH 6/9] feat: Allow importing/exporting of whole tree into xlsx --- doorstop/core/importer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doorstop/core/importer.py b/doorstop/core/importer.py index d1d79e890..806219bad 100644 --- a/doorstop/core/importer.py +++ b/doorstop/core/importer.py @@ -257,7 +257,7 @@ def _file_xlsx(path, document, mapping=None, tree=None, **_): log.info(f"sheet {worksheet.title}...") document = _check_doc(tree, worksheet, workbook) log.debug(f"importing into {document.prefix} {document.path}") - _load_xlsx(document, sheet, mapping) + _load_xlsx(document, worksheet, mapping) documents.append(document) return documents From 38e5f6d1986e2f408f89cd010a809977c9c890ab Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Fri, 25 Oct 2024 13:59:45 +0100 Subject: [PATCH 7/9] wip: dummy out import of document properties for now --- doorstop/core/importer.py | 12 ++++++++---- reqs/ext/EXT001.yml | 1 - reqs/ext/EXT002.yml | 1 - reqs/tutorial/TUT001.yml | 1 + reqs/tutorial/TUT002.yml | 1 + reqs/tutorial/TUT003.yml | 1 + reqs/tutorial/TUT004.yml | 1 + reqs/tutorial/TUT005.yml | 1 + reqs/tutorial/TUT008.yml | 1 + reqs/tutorial/TUT009.yml | 1 + reqs/tutorial/TUT010.yml | 1 + reqs/tutorial/TUT011.yml | 1 + reqs/tutorial/TUT012.yml | 1 + reqs/tutorial/TUT013.yml | 1 + reqs/tutorial/TUT014.yml | 1 + reqs/tutorial/TUT015.yml | 1 + reqs/tutorial/TUT016.yml | 1 + reqs/tutorial/TUT017.yml | 1 + reqs/tutorial/TUT018.yml | 1 + reqs/tutorial/TUT019.yml | 1 + reqs/tutorial/TUT020.yml | 2 +- reqs/tutorial/TUT021.yml | 1 + reqs/tutorial/TUT022.yml | 1 + reqs/tutorial/TUT023.yml | 1 + reqs/tutorial/TUT024.yml | 1 + reqs/tutorial/TUT025.yml | 1 + 26 files changed, 31 insertions(+), 7 deletions(-) diff --git a/doorstop/core/importer.py b/doorstop/core/importer.py index 806219bad..693a5d379 100644 --- a/doorstop/core/importer.py +++ b/doorstop/core/importer.py @@ -254,11 +254,15 @@ def _file_xlsx(path, document, mapping=None, tree=None, **_): tree = _get_tree() documents = [] for worksheet in workbook.worksheets: - log.info(f"sheet {worksheet.title}...") + if worksheet.title == "Document Properties": + #TODO + continue + log.info(f"checking sheet {worksheet.title}...") document = _check_doc(tree, worksheet, workbook) - log.debug(f"importing into {document.prefix} {document.path}") - _load_xlsx(document, worksheet, mapping) - documents.append(document) + if document: + log.debug(f"importing into {document.prefix} {document.path}") + _load_xlsx(document, worksheet, mapping) + documents.append(document) return documents def _itemize(header, data, document, mapping=None): diff --git a/reqs/ext/EXT001.yml b/reqs/ext/EXT001.yml index b86847a72..49450e2d1 100644 --- a/reqs/ext/EXT001.yml +++ b/reqs/ext/EXT001.yml @@ -7,7 +7,6 @@ normative: true ref: '' references: - path: reqs/ext/test.file - sha: 24d9b35c727e6c78676c2ad378a8c3c47cfb539f3583d3cd9e1eafee51d5679d type: file reviewed: n0xxAj0z-SqNcebKEu8p9HDE8jAs5I8Vz4kX-5ZieA4= text: | diff --git a/reqs/ext/EXT002.yml b/reqs/ext/EXT002.yml index 80989fdd2..06af8f8bb 100644 --- a/reqs/ext/EXT002.yml +++ b/reqs/ext/EXT002.yml @@ -7,7 +7,6 @@ normative: true ref: '' references: - path: reqs/ext/test-modified.file - sha: 49ca5d81054fdd20572294b9350b605d05e0df91da09a46fb8bde7fd6c1c172d type: file reviewed: arLWz1tqET94t2j7FG0ncvKpwGe5twDi-jPbBnikxho= text: | diff --git a/reqs/tutorial/TUT001.yml b/reqs/tutorial/TUT001.yml index 88600c665..4a40fdb62 100644 --- a/reqs/tutorial/TUT001.yml +++ b/reqs/tutorial/TUT001.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 1.1 diff --git a/reqs/tutorial/TUT002.yml b/reqs/tutorial/TUT002.yml index 6043467b4..2d9da6e91 100644 --- a/reqs/tutorial/TUT002.yml +++ b/reqs/tutorial/TUT002.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 1.2 diff --git a/reqs/tutorial/TUT003.yml b/reqs/tutorial/TUT003.yml index cc4a78cfb..ef4d4c3d9 100644 --- a/reqs/tutorial/TUT003.yml +++ b/reqs/tutorial/TUT003.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 1 diff --git a/reqs/tutorial/TUT004.yml b/reqs/tutorial/TUT004.yml index a1e6fc7f8..f22e98a2d 100644 --- a/reqs/tutorial/TUT004.yml +++ b/reqs/tutorial/TUT004.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 1.3 diff --git a/reqs/tutorial/TUT005.yml b/reqs/tutorial/TUT005.yml index b705ab427..4632ab317 100644 --- a/reqs/tutorial/TUT005.yml +++ b/reqs/tutorial/TUT005.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 2.0 diff --git a/reqs/tutorial/TUT008.yml b/reqs/tutorial/TUT008.yml index a74a455a9..3222fec81 100644 --- a/reqs/tutorial/TUT008.yml +++ b/reqs/tutorial/TUT008.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 1.4 diff --git a/reqs/tutorial/TUT009.yml b/reqs/tutorial/TUT009.yml index 6e4990d75..12168cd74 100644 --- a/reqs/tutorial/TUT009.yml +++ b/reqs/tutorial/TUT009.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 2.1 diff --git a/reqs/tutorial/TUT010.yml b/reqs/tutorial/TUT010.yml index fda227a80..d9002c4cd 100644 --- a/reqs/tutorial/TUT010.yml +++ b/reqs/tutorial/TUT010.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 2.2 diff --git a/reqs/tutorial/TUT011.yml b/reqs/tutorial/TUT011.yml index 76feee35d..26934c9c8 100644 --- a/reqs/tutorial/TUT011.yml +++ b/reqs/tutorial/TUT011.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 3.0 diff --git a/reqs/tutorial/TUT012.yml b/reqs/tutorial/TUT012.yml index 61c21d5b6..6cb6e51d1 100644 --- a/reqs/tutorial/TUT012.yml +++ b/reqs/tutorial/TUT012.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 3.2 diff --git a/reqs/tutorial/TUT013.yml b/reqs/tutorial/TUT013.yml index 00dd5c01a..b3b2a5548 100644 --- a/reqs/tutorial/TUT013.yml +++ b/reqs/tutorial/TUT013.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 3.3 diff --git a/reqs/tutorial/TUT014.yml b/reqs/tutorial/TUT014.yml index 9ba0249bc..fbb27dfe4 100644 --- a/reqs/tutorial/TUT014.yml +++ b/reqs/tutorial/TUT014.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 4.0 diff --git a/reqs/tutorial/TUT015.yml b/reqs/tutorial/TUT015.yml index 83db715ef..4ee7c7cfd 100644 --- a/reqs/tutorial/TUT015.yml +++ b/reqs/tutorial/TUT015.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 4.1 diff --git a/reqs/tutorial/TUT016.yml b/reqs/tutorial/TUT016.yml index 2d97cee09..9145b9680 100644 --- a/reqs/tutorial/TUT016.yml +++ b/reqs/tutorial/TUT016.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 3.1 diff --git a/reqs/tutorial/TUT017.yml b/reqs/tutorial/TUT017.yml index d85e4d986..91343a7a6 100644 --- a/reqs/tutorial/TUT017.yml +++ b/reqs/tutorial/TUT017.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: | Lot's of different little examples in a single heading which is very long diff --git a/reqs/tutorial/TUT018.yml b/reqs/tutorial/TUT018.yml index 45bd019c5..f88301406 100644 --- a/reqs/tutorial/TUT018.yml +++ b/reqs/tutorial/TUT018.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 1.6.0 diff --git a/reqs/tutorial/TUT019.yml b/reqs/tutorial/TUT019.yml index 11961e004..8f2adf0fb 100644 --- a/reqs/tutorial/TUT019.yml +++ b/reqs/tutorial/TUT019.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 1.6.1 diff --git a/reqs/tutorial/TUT020.yml b/reqs/tutorial/TUT020.yml index 14dbfab1b..3f663cdc4 100644 --- a/reqs/tutorial/TUT020.yml +++ b/reqs/tutorial/TUT020.yml @@ -1,5 +1,5 @@ -CUSTOM-ATTRIB: true active: true +custom-attrib: true derived: false header: '' level: 2.3 diff --git a/reqs/tutorial/TUT021.yml b/reqs/tutorial/TUT021.yml index 88cbec19f..ea1566d0e 100644 --- a/reqs/tutorial/TUT021.yml +++ b/reqs/tutorial/TUT021.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: '' level: 5.0 diff --git a/reqs/tutorial/TUT022.yml b/reqs/tutorial/TUT022.yml index 34b5122fc..cd9aab3b1 100644 --- a/reqs/tutorial/TUT022.yml +++ b/reqs/tutorial/TUT022.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: | Lists diff --git a/reqs/tutorial/TUT023.yml b/reqs/tutorial/TUT023.yml index 46279f0e6..858772470 100644 --- a/reqs/tutorial/TUT023.yml +++ b/reqs/tutorial/TUT023.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: | Nested list diff --git a/reqs/tutorial/TUT024.yml b/reqs/tutorial/TUT024.yml index 326632d1b..4ee901b9f 100644 --- a/reqs/tutorial/TUT024.yml +++ b/reqs/tutorial/TUT024.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: | Ordered list with empty items diff --git a/reqs/tutorial/TUT025.yml b/reqs/tutorial/TUT025.yml index f60dab882..ef503c633 100644 --- a/reqs/tutorial/TUT025.yml +++ b/reqs/tutorial/TUT025.yml @@ -1,4 +1,5 @@ active: true +custom-attrib: null derived: false header: | Another list example From 8c39d5352de28d6b44fa93b759a2918a470ea8ff Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Fri, 25 Oct 2024 14:56:47 +0100 Subject: [PATCH 8/9] feat: describe source control status in xlsx export --- doorstop/core/exporter.py | 6 ++++-- doorstop/core/vcs/__init__.py | 7 +++++++ doorstop/core/vcs/base.py | 5 +++++ doorstop/core/vcs/git.py | 3 +++ doorstop/core/vcs/mercurial.py | 5 +++++ doorstop/core/vcs/mockvcs.py | 4 ++++ doorstop/core/vcs/subversion.py | 3 +++ 7 files changed, 31 insertions(+), 2 deletions(-) diff --git a/doorstop/core/exporter.py b/doorstop/core/exporter.py index d6a3cc21c..aee5832b2 100644 --- a/doorstop/core/exporter.py +++ b/doorstop/core/exporter.py @@ -264,8 +264,10 @@ def _file_xlsx(obj, path, auto=False): return path -def _add_properties_sheet(wb, document_properties): +def _add_properties_sheet(wb, tree, document_properties): sheet = wb.create_sheet(title="Document Properties") + sheet.append(["Source Control Status:", tree.vcs.describe()]) + sheet.append([]) sheet.append([ "prefix", "settings key", @@ -298,7 +300,7 @@ def _get_xlsx(obj, path, auto): "attributes": obj2.attributes } if document_properties: - _add_properties_sheet(workbook, document_properties) + _add_properties_sheet(workbook, obj, document_properties) else: log.debug("xlsx export: exporting single Item/Document") first_sheet = _add_xlsx_sheet(workbook, obj, auto) diff --git a/doorstop/core/vcs/__init__.py b/doorstop/core/vcs/__init__.py index 5cc6a417c..fc65fc6ec 100644 --- a/doorstop/core/vcs/__init__.py +++ b/doorstop/core/vcs/__init__.py @@ -52,3 +52,10 @@ def load(path): log.warning("no working copy found at: {}".format(path)) return DEFAULT(path) + +def describe(path): + try: + find_root(path) + except: + return "Not in revision control" + diff --git a/doorstop/core/vcs/base.py b/doorstop/core/vcs/base.py index ed553bfdc..100d17f79 100644 --- a/doorstop/core/vcs/base.py +++ b/doorstop/core/vcs/base.py @@ -66,6 +66,11 @@ def commit(self, message=None): # pragma: no cover (abstract method) """Unlock files, commit, and push.""" raise NotImplementedError + @abstractmethod + def describe(self): # pragma: no cover (abstract method) + """Unlock files, commit, and push.""" + raise NotImplementedError + @property def ignores(self): """Yield glob expressions to ignore.""" diff --git a/doorstop/core/vcs/git.py b/doorstop/core/vcs/git.py index 3c7a804b2..7a7484f54 100644 --- a/doorstop/core/vcs/git.py +++ b/doorstop/core/vcs/git.py @@ -31,3 +31,6 @@ def commit(self, message=None): message = message or input("Commit message: ") self.call("git", "commit", "--all", "--message", message) self.call("git", "push") + + def describe(self): + return "git: " + self.call("git", "describe", "--dirty", return_stdout=True) diff --git a/doorstop/core/vcs/mercurial.py b/doorstop/core/vcs/mercurial.py index e0461912c..233ba359a 100644 --- a/doorstop/core/vcs/mercurial.py +++ b/doorstop/core/vcs/mercurial.py @@ -31,3 +31,8 @@ def commit(self, message=None): message = message or input("Commit message: ") self.call("hg", "commit", "--message", message) self.call("hg", "push") + + def describe(self): + return "mercurial: " + self.call("hg", "log", "-r", ".", "-T", + "{latesttag}{sub('^-0-.*', '', '-{latesttagdistance}-m{node|short}')}", + return_stdout=True) diff --git a/doorstop/core/vcs/mockvcs.py b/doorstop/core/vcs/mockvcs.py index 0e1e6dd7a..373fd19c3 100644 --- a/doorstop/core/vcs/mockvcs.py +++ b/doorstop/core/vcs/mockvcs.py @@ -33,3 +33,7 @@ def delete(self, path): def commit(self, message=None): log.debug("$ simulated commit") + + def describe(self): + log.debug("$ simulated describe") + return "mockvcs: simulated describe" diff --git a/doorstop/core/vcs/subversion.py b/doorstop/core/vcs/subversion.py index 1e37953e5..d39582c22 100644 --- a/doorstop/core/vcs/subversion.py +++ b/doorstop/core/vcs/subversion.py @@ -33,6 +33,9 @@ def commit(self, message=None): message = message or input("Commit message: ") self.call("svn", "commit", "--message", message) + def describe(self): + return "subversion: " + self.call("svnversion", return_stdout=True) + @property def ignores(self): if self._ignores_cache is None: From 7c6509ceea4e03f201df17a99751dfab712d2fb9 Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Mon, 28 Oct 2024 22:03:09 +0000 Subject: [PATCH 9/9] export: md: Write souurce control status in index.md, if generatedxx --- doorstop/core/publishers/markdown.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doorstop/core/publishers/markdown.py b/doorstop/core/publishers/markdown.py index 7b69c671d..34e008a76 100644 --- a/doorstop/core/publishers/markdown.py +++ b/doorstop/core/publishers/markdown.py @@ -46,6 +46,11 @@ def create_index(self, directory, index=INDEX, extensions=(".md",), tree=None): else: log.warning("no files for {}".format(index)) + # Source control status + common.write_text(" # Source Control Status", path) + common.write_text(, path) + + def _index_tree(self, tree, depth): """Recursively generate markdown index.