Skip to content

Commit

Permalink
Merge 06b89cc into bb600ee
Browse files Browse the repository at this point in the history
  • Loading branch information
sebhub committed Oct 22, 2019
2 parents bb600ee + 06b89cc commit 95c6732
Show file tree
Hide file tree
Showing 12 changed files with 254 additions and 101 deletions.
8 changes: 5 additions & 3 deletions docs/reference/item.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ Doorstop items are files formatted using YAML. When a new item is added using
`doorstop add`, Doorstop will create a YAML file and populate it with all
required attributes (key-value pairs). The UID of an item is defined by its
file name without the extension. An UID consists of two parts, the prefix and a
number. The parts are divided by an optional separator. The prefix is
determined by the document to which the item belongs. The number is
automatically assigned by Doorstop.
number or name. The two parts are divided by an optional separator. The prefix
and separator are determined by the document to which the item belongs. By
default, the number is automatically assigned by Doorstop. Optionally, a user
can specify a name for the UID during item creation. The name must not contain
separator characters or digits.

Example item:
```yaml
Expand Down
10 changes: 8 additions & 2 deletions doorstop/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ def run_create(args, cwd, _, catch=True):

# create a new document
document = tree.create_document(
args.path, args.prefix, parent=args.parent, digits=args.digits
args.path,
args.prefix,
parent=args.parent,
digits=args.digits,
sep=args.separator,
)

if not success:
Expand Down Expand Up @@ -179,7 +183,9 @@ def run_add(args, cwd, _, catch=True):

# add items to it
for _ in range(args.count):
item = document.add_item(level=args.level, defaults=args.defaults)
item = document.add_item(
level=args.level, defaults=args.defaults, name=args.name
)
utilities.show("added item: {} ({})".format(item.uid, item.relpath))

# Edit item if requested
Expand Down
20 changes: 20 additions & 0 deletions doorstop/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,16 @@ def _create(subs, shared):
help="number of digits in item UIDs",
default=document.Document.DEFAULT_DIGITS,
)
sub.add_argument(
'-s',
'--separator',
metavar='SEP',
help=(
"separator between the prefix and the number or name in an "
"item UID; the only valid separators are '-', '_', and '.'"
),
default=document.Document.DEFAULT_SEP,
)


def _delete(subs, shared):
Expand All @@ -221,6 +231,16 @@ def _add(subs, shared):
)
sub.add_argument('prefix', help="document prefix for the new item")
sub.add_argument('-l', '--level', help="desired item level (e.g. 1.2.3)")
sub.add_argument(
'-n',
'--name',
help=(
"use the specified NAME instead of an automatically "
"generated number for the UID (together with the document prefix "
"and separator); the NAME must not contain separator characters "
"or digits"
),
)
sub.add_argument(
'-c',
'--count',
Expand Down
8 changes: 7 additions & 1 deletion doorstop/cli/tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def test_add_no_server(self):
def test_add_custom_server(self, mock_add_item):
"""Verify 'doorstop add' can be called with a custom server."""
self.assertIs(None, main(['add', 'TUT', '--server', '1.2.3.4']))
mock_add_item.assert_called_once_with(defaults=None, level=None)
mock_add_item.assert_called_once_with(defaults=None, level=None, name=None)

def test_add_force(self):
"""Verify 'doorstop add' can be called with a missing server."""
Expand Down Expand Up @@ -417,6 +417,12 @@ def test_clear_item(self, mock_clear):
self.assertIs(None, main(['clear', 'tut2']))
self.assertEqual(1, mock_clear.call_count)

@patch('doorstop.core.item.Item.clear')
def test_clear_item_parent(self, mock_clear):
"""Verify 'doorstop clear' can be called with an item and parent."""
self.assertIs(None, main(['clear', 'tut2', 'req2']))
self.assertEqual(1, mock_clear.call_count)

def test_clear_item_unknown(self):
"""Verify 'doorstop clear' returns an error on an unknown item."""
self.assertRaises(SystemExit, main, ['clear', '--item', 'FAKE001'])
Expand Down
30 changes: 30 additions & 0 deletions doorstop/cli/tests/tutorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,36 @@ def test_custom_defaults(self):
cp.stdout,
)

def test_item_with_name(self):
"""Verify new item with custom defaults is working."""

self.doorstop("create -s - REQ .")

self.doorstop("add -n ABC REQ")
self.assertTrue(os.path.isfile('REQ-ABC.yml'))

self.doorstop("add -n 9 REQ")
self.assertTrue(os.path.isfile('REQ-009.yml'))

self.doorstop("add -n XYZ REQ")
self.assertTrue(os.path.isfile('REQ-XYZ.yml'))

self.doorstop("add -n 99 REQ")
self.assertTrue(os.path.isfile('REQ-099.yml'))

cp = self.doorstop("publish REQ", stdout=subprocess.PIPE)
self.assertIn(
b'''1.0 REQ-ABC
1.1 REQ-009
1.2 REQ-XYZ
1.3 REQ-099
''',
cp.stdout,
)


if __name__ == '__main__':
logging.basicConfig(format="%(message)s", level=logging.INFO)
Expand Down
37 changes: 30 additions & 7 deletions doorstop/core/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ def new(
:return: new :class:`~doorstop.core.document.Document`
"""
# TODO: raise a specific exception for invalid separator characters?
assert not sep or sep in settings.SEP_CHARS
# Check separator
if sep and sep not in settings.SEP_CHARS:
raise DoorstopError("invalid UID separator '{}'".format(sep))

config = os.path.join(path, Document.CONFIG)

# Check for an existing document
Expand Down Expand Up @@ -379,7 +381,7 @@ def depth(self):
def next_number(self):
"""Get the next item number for the document."""
try:
number = max(item.number for item in self) + 1
number = max(item.uid.number for item in self) + 1
except ValueError:
number = 1
log.debug("next number (local): {}".format(number))
Expand Down Expand Up @@ -424,7 +426,7 @@ def index(self):
# actions ################################################################

# decorators are applied to methods in the associated classes
def add_item(self, number=None, level=None, reorder=True, defaults=None):
def add_item(self, number=None, level=None, reorder=True, defaults=None, name=None):
"""Create a new item for the document and return it.
:param number: desired item number
Expand All @@ -434,8 +436,30 @@ def add_item(self, number=None, level=None, reorder=True, defaults=None):
:return: added :class:`~doorstop.core.item.Item`
"""
number = max(number or 0, self.next_number)
log.debug("next number: {}".format(number))
uid = None
if name is None:
number = max(number or 0, self.next_number)
log.debug("next number: {}".format(number))
uid = UID(self.prefix, self.sep, number, self.digits)
else:
try:
uid = UID(self.prefix, self.sep, int(name), self.digits)
except ValueError:
if not self.sep:
msg = "cannot add item with name '{}' to document '{}' without a separator".format(
name, self.prefix
)
raise DoorstopError(msg)
if self.sep not in settings.SEP_CHARS:
msg = "cannot add item with name '{}' to document '{}' with an invalid separator '{}'".format(
name, self.prefix, self.sep
)
raise DoorstopError(msg)
uid = UID(self.prefix, self.sep, name)
if uid.prefix != self.prefix or uid.name != name:
msg = "invalid item name '{}'".format(name)
raise DoorstopError(msg)

try:
last = self.items[-1]
except IndexError:
Expand All @@ -454,7 +478,6 @@ def add_item(self, number=None, level=None, reorder=True, defaults=None):
# constructed items in case the loading fails.
more_defaults = self._load_with_include(defaults) if defaults else None

uid = UID(self.prefix, self.sep, number, self.digits)
item = Item.new(self.tree, self, self.path, self.root, uid, level=next_level)
if self._attribute_defaults:
item.set_attributes(self._attribute_defaults)
Expand Down
13 changes: 0 additions & 13 deletions doorstop/core/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,16 +262,6 @@ def uid(self):
filename = os.path.basename(self.path)
return UID(os.path.splitext(filename)[0])

@property
def prefix(self):
"""Get the item UID's prefix."""
return self.uid.prefix

@property
def number(self):
"""Get the item UID's number."""
return self.uid.number

@property # type: ignore
@auto_load
def level(self):
Expand Down Expand Up @@ -747,9 +737,6 @@ def uid(self):
"""Get the item's UID."""
return self._uid

prefix = Item.prefix
number = Item.number

@property
def relpath(self):
"""Get the unknown item's relative path string."""
Expand Down
53 changes: 51 additions & 2 deletions doorstop/core/tests/test_document.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# SPDX-License-Identifier: LGPL-3.0-only
# pylint: disable=C0302

"""Unit tests for the doorstop.core.document module."""

Expand All @@ -14,7 +15,7 @@
from doorstop.common import DoorstopError, DoorstopInfo, DoorstopWarning
from doorstop.core.document import Document
from doorstop.core.tests import EMPTY, FILES, NEW, ROOT, MockDocument, MockItem
from doorstop.core.types import Level
from doorstop.core.types import UID, Level

YAML_DEFAULT = """
settings:
Expand Down Expand Up @@ -310,6 +311,13 @@ def test_new_existing(self):
"""Verify an exception is raised if the document already exists."""
self.assertRaises(DoorstopError, Document.new, None, FILES, ROOT, prefix='DUPL')

def test_new_invalid_sep(self):
"""Verify an exception is raised if the separator is invalid."""
msg = "invalid UID separator 'X'"
self.assertRaisesRegex(
DoorstopError, msg, Document.new, None, FILES, ROOT, prefix='NEW', sep='X'
)

@patch('doorstop.core.document.Document', MockDocument)
def test_new_cache(self):
"""Verify a new documents are cached."""
Expand Down Expand Up @@ -457,6 +465,47 @@ def test_add_item_with_number(self, mock_new):
None, self.document, FILES, ROOT, 'REQ999', level=Level('2.2')
)

def test_add_item_with_no_sep(self):
"""Verify an item cannot be added to a document without a separator with a name."""
msg = "cannot add item with name 'ABC' to document 'REQ' without a separator"
self.assertRaisesRegex(DoorstopError, msg, self.document.add_item, name='ABC')

def test_add_item_with_invalid_sep(self):
"""Verify an item cannot be added to a document with an invalid separator with a name."""
self.document._data['sep'] = 'X'
msg = "cannot add item with name 'ABC' to document 'REQ' with an invalid separator 'X'"
self.assertRaisesRegex(DoorstopError, msg, self.document.add_item, name='ABC')

def test_add_item_with_invalid_name(self):
"""Verify an item cannot be added to a document with an invalid name."""
self.document.sep = '-'
msg = "invalid item name 'A-B'"
self.assertRaisesRegex(DoorstopError, msg, self.document.add_item, name='A-B')
msg = "invalid item name 'A_B'"
self.assertRaisesRegex(DoorstopError, msg, self.document.add_item, name='A_B')
msg = "invalid item name 'A.B'"
self.assertRaisesRegex(DoorstopError, msg, self.document.add_item, name='A.B')
msg = "invalid item name 'X/Y'"
self.assertRaisesRegex(DoorstopError, msg, self.document.add_item, name='X/Y')

@patch('doorstop.core.item.Item.new')
def test_add_item_with_name(self, mock_new):
"""Verify an item can be added to a document with a name."""
self.document.sep = '-'
self.document.add_item(name='ABC')
mock_new.assert_called_once_with(
None, self.document, FILES, ROOT, 'REQ-ABC', level=Level('2.2')
)

@patch('doorstop.core.item.Item.new')
def test_add_item_with_number_name(self, mock_new):
"""Verify an item can be added to a document with a number as name."""
self.document.sep = '-'
self.document.add_item(name='99')
mock_new.assert_called_once_with(
None, self.document, FILES, ROOT, 'REQ-099', level=Level('2.2')
)

@patch('doorstop.core.item.Item.set_attributes')
def test_add_item_with_defaults(self, mock_set_attributes):
"""Verify an item can be added to a document with defaults."""
Expand All @@ -478,7 +527,7 @@ def test_add_item_empty(self, mock_new):
def test_add_item_after_header(self, mock_new):
"""Verify the next item after a header is indented."""
mock_item = Mock()
mock_item.number = 1
mock_item.uid = UID('REQ001')
mock_item.level = Level('1.0')
self.document._iter = Mock(return_value=[mock_item])
self.document.add_item()
Expand Down
25 changes: 5 additions & 20 deletions doorstop/core/tests/test_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,16 +221,6 @@ def test_relpath(self):
self.assertEqual(text, self.item.relpath)
self.assertRaises(AttributeError, setattr, self.item, 'relpath', '.')

def test_prefix(self):
"""Verify an item's prefix can be read but not set."""
self.assertEqual('RQ', self.item.prefix)
self.assertRaises(AttributeError, setattr, self.item, 'prefix', 'REQ')

def test_number(self):
"""Verify an item's number can be read but not set."""
self.assertEqual(1, self.item.number)
self.assertRaises(AttributeError, setattr, self.item, 'number', 2)

def test_level(self):
"""Verify an item's level can be set and read."""
self.item.level = (1, 2, 3)
Expand Down Expand Up @@ -826,22 +816,17 @@ def test_uid(self):
self.assertEqual('RQ001', self.item.uid)
self.assertRaises(AttributeError, setattr, self.item, 'uid', 'RQ002')

def test_le(self):
"""Verify unknown item's UID less operator."""
self.assertTrue(self.item < UnknownItem('RQ002'))
self.assertFalse(self.item < self.item)

def test_relpath(self):
"""Verify an item's relative path string can be read but not set."""
text = "@{}{}".format(os.sep, '???')
self.assertEqual(text, self.item.relpath)
self.assertRaises(AttributeError, setattr, self.item, 'relpath', '.')

def test_prefix(self):
"""Verify an item's prefix can be read but not set."""
self.assertEqual('RQ', self.item.prefix)
self.assertRaises(AttributeError, setattr, self.item, 'prefix', 'REQ')

def test_number(self):
"""Verify an item's number can be read but not set."""
self.assertEqual(1, self.item.number)
self.assertRaises(AttributeError, setattr, self.item, 'number', 2)

@patch('doorstop.core.item.log.debug')
def test_attributes(self, mock_warning):
"""Verify all other `Item` attributes raise an exception."""
Expand Down
Loading

0 comments on commit 95c6732

Please sign in to comment.