Skip to content
Merged
58 changes: 58 additions & 0 deletions docs/cli/creation.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,64 @@ text: 'My default text.'
The command line specified default values override values from the document
configuration.

# Add Items with a Name in the UID

By default, new items get a number assigned by Doorstop for their UID together
with the document prefix and separator. Doorstop allows you to specifiy an
explicit number or a name for the item UID. Names can be only used if the
document was created with a separator. Names cannot contain separators.
Allowed separators are '-', '\_', and '.'.

As an example, we create a document with a '-' separator:
```sh
$ doorstop create -s - REQ ./reqs
building tree...
created document: REQ (@/reqs)
```

You can add items as normal:
```sh
$ doorstop add REQ
building tree...
added item: REQ-001 (@/reqs/REQ-001.yml)
```

The first item has an UID of `REQ-001`. Please note that this UID has the
separator of the document included. You can specify the number part of the UID
for a new item:
```sh
$ doorstop add -n 3 REQ
building tree...
added item: REQ-003 (@/reqs/REQ-003.yml)
```

You can specify the name part of the UID for a new item:
```sh
$ doorstop add -n FOOBAR REQ
building tree...
added item: REQ-FOOBAR (@/reqs/REQ-FOOBAR.yml)
```

You can continue to add items as normal:
```sh
$ doorstop add REQ
building tree...
added item: REQ-004 (@/reqs/REQ-004.yml)
```

Your document contains now the following items:
```sh
$ doorstop publish REQ
building tree...
1.0 REQ-001

1.1 REQ-003

1.2 REQ-FOOBAR

1.3 REQ-004
```

# Document Configuration

The settings and attribute options of each document are stored in a
Expand Down
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.

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
22 changes: 22 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,18 @@ 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',
'--number',
Comment on lines +236 to +237
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add the new --separator, --name, and --number options as an examples in doorstop/docs/cli/creation.md?

metavar='NANU',
help=(
"use the specified name or number NANU instead of an automatically "
"generated number for the UID (together with the document prefix "
"and separator); the NANU must be a number or a string which does "
"not contain separator characters"
),
)
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 --name XYZ REQ")
self.assertTrue(os.path.isfile('REQ-XYZ.yml'))

self.doorstop("add --number 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
Loading