Skip to content
40 changes: 26 additions & 14 deletions doorstop/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion doorstop/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 12 additions & 6 deletions doorstop/cli/tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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."""
Expand All @@ -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."""
Expand All @@ -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)
Expand Down Expand Up @@ -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"])
Expand Down
128 changes: 74 additions & 54 deletions doorstop/core/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import yaml

from doorstop import common, settings
from doorstop import common, settings as dsettings
from doorstop.common import (
DoorstopError,
DoorstopInfo,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -192,36 +192,63 @@ 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 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":
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)

for key, value in settings.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)

@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
Expand All @@ -235,8 +262,15 @@ def load(self, reload=False):
)
raise DoorstopError(msg)

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.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:
Expand All @@ -246,27 +280,13 @@ def load(self, reload=False):
def save(self):
"""Save the document's properties to its file."""
log.debug("saving {}...".format(repr(self)))
# Format the data items

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
# Dump the data to YAML
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)
Expand Down Expand Up @@ -313,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
Expand Down Expand Up @@ -392,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?

Expand Down Expand Up @@ -480,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
Expand Down Expand Up @@ -516,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
)
Expand Down Expand Up @@ -608,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:"
Expand All @@ -623,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
Expand Down Expand Up @@ -854,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()
Expand Down
Loading