|
1
|
|
|
#!/usr/bin/env python |
|
2
|
|
|
# -*- coding: utf-8 -*- |
|
3
|
|
|
# SPDX-License-Identifier: LGPL-3.0-only |
|
4
|
|
|
|
|
5
|
|
|
"""Representation of a hierarchy of documents.""" |
|
6
|
|
|
|
|
7
|
|
|
import sys |
|
8
|
|
|
from itertools import chain |
|
9
|
|
|
from typing import Dict, List, Optional, Union |
|
10
|
|
|
|
|
11
|
|
|
from doorstop import common, settings |
|
12
|
|
|
from doorstop.common import DoorstopError, DoorstopWarning |
|
13
|
|
|
from doorstop.core import vcs |
|
14
|
|
|
from doorstop.core.base import BaseValidatable |
|
15
|
|
|
from doorstop.core.document import Document |
|
16
|
|
|
from doorstop.core.item import Item |
|
17
|
|
|
from doorstop.core.types import UID, Prefix |
|
18
|
|
|
|
|
19
|
|
|
UTF8 = "utf-8" |
|
20
|
|
|
CP437 = "cp437" |
|
21
|
|
|
ASCII = "ascii" |
|
22
|
|
|
|
|
23
|
|
|
BOX = { |
|
24
|
|
|
"end": {UTF8: "│ ", CP437: "┬ ", ASCII: "| "}, |
|
25
|
|
|
"tee": {UTF8: "├── ", CP437: "├── ", ASCII: "+-- "}, |
|
26
|
|
|
"bend": {UTF8: "└── ", CP437: "└── ", ASCII: "+-- "}, |
|
27
|
|
|
"pipe": {UTF8: "│ ", CP437: "│ ", ASCII: "| "}, |
|
28
|
|
|
"space": {UTF8: " ", CP437: " ", ASCII: " "}, |
|
29
|
|
|
} |
|
30
|
|
|
|
|
31
|
|
|
log = common.logger(__name__) |
|
32
|
|
|
|
|
33
|
|
|
|
|
34
|
|
|
class Tree(BaseValidatable): # pylint: disable=R0902 |
|
35
|
|
|
"""A bidirectional tree structure to store a hierarchy of documents. |
|
36
|
|
|
|
|
37
|
|
|
Although requirements link "upwards", bidirectionality simplifies |
|
38
|
|
|
document processing and validation. |
|
39
|
|
|
|
|
40
|
|
|
""" |
|
41
|
|
|
|
|
42
|
|
|
@staticmethod |
|
43
|
|
|
def from_list(documents, root=None): |
|
44
|
|
|
"""Initialize a new tree from a list of documents. |
|
45
|
|
|
|
|
46
|
|
|
:param documents: list of :class:`~doorstop.core.document.Document` |
|
47
|
|
|
:param root: path to root of the project |
|
48
|
|
|
|
|
49
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` when the tree |
|
50
|
|
|
cannot be built |
|
51
|
|
|
|
|
52
|
|
|
:return: new :class:`~doorstop.core.tree.Tree` |
|
53
|
|
|
|
|
54
|
|
|
""" |
|
55
|
|
|
if not documents: |
|
56
|
|
|
return Tree(document=None, root=root) |
|
57
|
|
|
unplaced = list(documents) |
|
58
|
|
|
for document in list(unplaced): |
|
59
|
|
|
if document.parent is None: |
|
60
|
|
|
log.info("root of the tree: {}".format(document)) |
|
61
|
|
|
tree = Tree(document) |
|
62
|
|
|
document.tree = tree |
|
63
|
|
|
unplaced.remove(document) |
|
64
|
|
|
break |
|
65
|
|
|
else: |
|
66
|
|
|
raise DoorstopError("no root document") |
|
67
|
|
|
|
|
68
|
|
|
while unplaced: |
|
69
|
|
|
count = len(unplaced) |
|
70
|
|
|
for document in list(unplaced): |
|
71
|
|
|
if document.parent is None: |
|
72
|
|
|
log.info("root of the tree: {}".format(document)) |
|
73
|
|
|
message = "multiple root documents:\n- {}: {}\n- {}: {}".format( |
|
74
|
|
|
tree.document.prefix, |
|
75
|
|
|
tree.document.path, |
|
76
|
|
|
document.prefix, |
|
77
|
|
|
document.path, |
|
78
|
|
|
) |
|
79
|
|
|
raise DoorstopError(message) |
|
80
|
|
|
try: |
|
81
|
|
|
tree._place(document) # pylint: disable=W0212 |
|
82
|
|
|
except DoorstopError as error: |
|
83
|
|
|
log.debug(error) |
|
84
|
|
|
else: |
|
85
|
|
|
log.info("added to tree: {}".format(document)) |
|
86
|
|
|
document.tree = tree |
|
87
|
|
|
unplaced.remove(document) |
|
88
|
|
|
|
|
89
|
|
|
if len(unplaced) == count: # no more documents could be placed |
|
90
|
|
|
log.debug("unplaced documents: {}".format(unplaced)) |
|
91
|
|
|
msg = "unplaced document: {}".format(unplaced[0]) |
|
92
|
|
|
raise DoorstopError(msg) |
|
93
|
|
|
|
|
94
|
|
|
return tree |
|
95
|
|
|
|
|
96
|
|
|
def __init__(self, document, parent=None, root=None): |
|
97
|
|
|
self.document = document |
|
98
|
|
|
self.root = root or document.root # enables mock testing |
|
99
|
|
|
self.parent = parent |
|
100
|
|
|
self.children: List[Tree] = [] |
|
101
|
|
|
self._vcs = None # working copy reference loaded in a property |
|
102
|
|
|
self.request_next_number = None # server method injected by clients |
|
103
|
|
|
self._loaded = False |
|
104
|
|
|
self._item_cache: Dict[Union[str, UID], Item] = {} |
|
105
|
|
|
self._document_cache: Dict[str, Optional[Document]] = {} |
|
106
|
|
|
|
|
107
|
|
|
def __repr__(self): |
|
108
|
|
|
return "<Tree {}>".format(self._draw_line()) |
|
109
|
|
|
|
|
110
|
|
|
def __str__(self): |
|
111
|
|
|
return self._draw_line() |
|
112
|
|
|
|
|
113
|
|
|
def __len__(self): |
|
114
|
|
|
if self.document: |
|
115
|
|
|
return 1 + sum(len(child) for child in self.children) |
|
116
|
|
|
else: |
|
117
|
|
|
return 0 |
|
118
|
|
|
|
|
119
|
|
|
def __bool__(self): |
|
120
|
|
|
"""Even empty trees should be considered truthy.""" |
|
121
|
|
|
return True |
|
122
|
|
|
|
|
123
|
|
|
def __getitem__(self, key): |
|
124
|
|
|
raise IndexError("{} cannot be indexed by key".format(self.__class__)) |
|
125
|
|
|
|
|
126
|
|
|
def __iter__(self): |
|
127
|
|
|
if self.document: |
|
128
|
|
|
yield self.document |
|
129
|
|
|
yield from chain(*(iter(c) for c in self.children)) |
|
|
|
|
|
|
130
|
|
|
|
|
131
|
|
|
def _place(self, document): |
|
132
|
|
|
"""Attempt to place the document in the current tree. |
|
133
|
|
|
|
|
134
|
|
|
:param document: :class:`doorstop.core.document.Document` to add |
|
135
|
|
|
|
|
136
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the document |
|
137
|
|
|
cannot yet be placed |
|
138
|
|
|
|
|
139
|
|
|
""" |
|
140
|
|
|
log.debug("trying to add {}...".format(document)) |
|
141
|
|
|
if not self.document: # tree is empty |
|
142
|
|
|
if document.parent: |
|
143
|
|
|
msg = "unknown parent for {}: {}".format(document, document.parent) |
|
144
|
|
|
raise DoorstopError(msg) |
|
145
|
|
|
self.document = document |
|
146
|
|
|
|
|
147
|
|
|
elif document.parent: # tree has documents, document has parent |
|
148
|
|
|
if document.parent.lower() == self.document.prefix.lower(): |
|
149
|
|
|
# Current document is the parent |
|
150
|
|
|
node = Tree(document, self) |
|
151
|
|
|
self.children.append(node) |
|
152
|
|
|
|
|
153
|
|
|
else: |
|
154
|
|
|
# Search for the parent |
|
155
|
|
|
for child in self.children: |
|
156
|
|
|
try: |
|
157
|
|
|
child._place(document) # pylint: disable=W0212 |
|
158
|
|
|
except DoorstopError: |
|
159
|
|
|
pass # the error is raised later |
|
160
|
|
|
else: |
|
161
|
|
|
break |
|
162
|
|
|
else: |
|
163
|
|
|
msg = "unknown parent for {}: {}".format(document, document.parent) |
|
164
|
|
|
raise DoorstopError(msg) |
|
165
|
|
|
|
|
166
|
|
|
else: # tree has documents, but no parent specified for document |
|
167
|
|
|
msg = "no parent specified for {}".format(document) |
|
168
|
|
|
log.info(msg) |
|
169
|
|
|
prefixes = ", ".join(document.prefix for document in self) |
|
170
|
|
|
log.info("parent options: {}".format(prefixes)) |
|
171
|
|
|
raise DoorstopError(msg) |
|
172
|
|
|
|
|
173
|
|
|
for document2 in self: |
|
174
|
|
|
children = self._get_prefix_of_children(document2) |
|
175
|
|
|
document2.children = children |
|
176
|
|
|
|
|
177
|
|
|
# attributes ############################################################# |
|
178
|
|
|
|
|
179
|
|
|
@property |
|
180
|
|
|
def documents(self): |
|
181
|
|
|
"""Get an list of documents in the tree.""" |
|
182
|
|
|
return list(self) |
|
183
|
|
|
|
|
184
|
|
|
@property |
|
185
|
|
|
def vcs(self): |
|
186
|
|
|
"""Get the working copy.""" |
|
187
|
|
|
if self._vcs is None: |
|
188
|
|
|
self._vcs = vcs.load(self.root) |
|
189
|
|
|
return self._vcs |
|
190
|
|
|
|
|
191
|
|
|
# actions ################################################################ |
|
192
|
|
|
|
|
193
|
|
|
# decorators are applied to methods in the associated classes |
|
194
|
|
|
def create_document( |
|
195
|
|
|
self, path, value, sep=None, digits=None, parent=None, itemformat=None |
|
196
|
|
|
): # pylint: disable=R0913 |
|
197
|
|
|
"""Create a new document and add it to the tree. |
|
198
|
|
|
|
|
199
|
|
|
:param path: directory path for the new document |
|
200
|
|
|
:param value: document or prefix |
|
201
|
|
|
:param sep: separator between prefix and numbers |
|
202
|
|
|
:param digits: number of digits for the document's numbers |
|
203
|
|
|
:param parent: parent document's prefix |
|
204
|
|
|
:param itemformat: file format for storing items |
|
205
|
|
|
|
|
206
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the |
|
207
|
|
|
document cannot be created |
|
208
|
|
|
|
|
209
|
|
|
:return: newly created and placed document |
|
210
|
|
|
:class:`~doorstop.core.document.Document` |
|
211
|
|
|
|
|
212
|
|
|
""" |
|
213
|
|
|
prefix = Prefix(value) |
|
214
|
|
|
|
|
215
|
|
|
# Check if a document with the same name already exists in the tree. |
|
216
|
|
|
for d in self.documents: |
|
217
|
|
|
if d.prefix == value: |
|
218
|
|
|
raise DoorstopError( |
|
219
|
|
|
"The document name is already in use ({}).".format(d.path) |
|
220
|
|
|
) |
|
221
|
|
|
|
|
222
|
|
|
document = Document.new( |
|
223
|
|
|
self, |
|
224
|
|
|
path, |
|
225
|
|
|
self.root, |
|
226
|
|
|
prefix, |
|
227
|
|
|
sep=sep, |
|
228
|
|
|
digits=digits, |
|
229
|
|
|
parent=parent, |
|
230
|
|
|
itemformat=itemformat, |
|
231
|
|
|
) |
|
232
|
|
|
try: |
|
233
|
|
|
self._place(document) |
|
234
|
|
|
except DoorstopError: |
|
235
|
|
|
msg = "deleting unplaced directory {}...".format(document.path) |
|
236
|
|
|
log.debug(msg) |
|
237
|
|
|
document.delete() |
|
238
|
|
|
raise |
|
239
|
|
|
else: |
|
240
|
|
|
log.info("added to tree: {}".format(document)) |
|
241
|
|
|
return document |
|
242
|
|
|
|
|
243
|
|
|
# decorators are applied to methods in the associated classes |
|
244
|
|
|
def add_item(self, value, number=None, level=None, reorder=True): |
|
245
|
|
|
"""Add a new item to an existing document by prefix. |
|
246
|
|
|
|
|
247
|
|
|
:param value: document or prefix |
|
248
|
|
|
:param number: desired item number |
|
249
|
|
|
:param level: desired item level |
|
250
|
|
|
:param reorder: update levels of document items |
|
251
|
|
|
|
|
252
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the item |
|
253
|
|
|
cannot be created |
|
254
|
|
|
|
|
255
|
|
|
:return: newly created :class:`~doorstop.core.item.Item` |
|
256
|
|
|
|
|
257
|
|
|
""" |
|
258
|
|
|
prefix = Prefix(value) |
|
259
|
|
|
document = self.find_document(prefix) |
|
260
|
|
|
item = document.add_item(number=number, level=level, reorder=reorder) |
|
261
|
|
|
return item |
|
262
|
|
|
|
|
263
|
|
|
# decorators are applied to methods in the associated classes |
|
264
|
|
|
def remove_item(self, value, reorder=True): |
|
265
|
|
|
"""Remove an item from a document by UID. |
|
266
|
|
|
|
|
267
|
|
|
:param value: item or UID |
|
268
|
|
|
:param reorder: update levels of document items |
|
269
|
|
|
|
|
270
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the item |
|
271
|
|
|
cannot be removed |
|
272
|
|
|
|
|
273
|
|
|
:return: removed :class:`~doorstop.core.item.Item` |
|
274
|
|
|
|
|
275
|
|
|
""" |
|
276
|
|
|
uid = UID(value) |
|
277
|
|
|
for document in self: |
|
278
|
|
|
try: |
|
279
|
|
|
document.find_item(uid) |
|
280
|
|
|
except DoorstopError: |
|
281
|
|
|
pass # item not found in that document |
|
282
|
|
|
else: |
|
283
|
|
|
item = document.remove_item(uid, reorder=reorder) |
|
284
|
|
|
return item |
|
285
|
|
|
|
|
286
|
|
|
raise DoorstopError(UID.UNKNOWN_MESSAGE.format(k="", u=uid)) |
|
287
|
|
|
|
|
288
|
|
|
def check_for_cycle(self, item, cid, path): |
|
289
|
|
|
"""Check if a cyclic dependency would be created. |
|
290
|
|
|
|
|
291
|
|
|
:param item: an item on the dependency path |
|
292
|
|
|
:param cid: the child item's UID |
|
293
|
|
|
:param path: the path of UIDs from the child item to the item |
|
294
|
|
|
|
|
295
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the link |
|
296
|
|
|
would create a cyclic dependency |
|
297
|
|
|
""" |
|
298
|
|
|
for did in item.links: |
|
299
|
|
|
path2 = path + [did] |
|
300
|
|
|
if did in path: |
|
301
|
|
|
s = " -> ".join(list(map(str, path2))) |
|
302
|
|
|
msg = "link would create a cyclic dependency: {}".format(s) |
|
303
|
|
|
raise DoorstopError(msg) |
|
304
|
|
|
dep = self.find_item(did, _kind="dependency") |
|
305
|
|
|
self.check_for_cycle(dep, cid, path2) |
|
306
|
|
|
|
|
307
|
|
|
# decorators are applied to methods in the associated classes |
|
308
|
|
|
def link_items(self, cid, pid): |
|
309
|
|
|
"""Add a new link between two items by UIDs. |
|
310
|
|
|
|
|
311
|
|
|
:param cid: child item's UID (or child item) |
|
312
|
|
|
:param pid: parent item's UID (or parent item) |
|
313
|
|
|
|
|
314
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the link |
|
315
|
|
|
cannot be created |
|
316
|
|
|
|
|
317
|
|
|
:return: child :class:`~doorstop.core.item.Item`, |
|
318
|
|
|
parent :class:`~doorstop.core.item.Item` |
|
319
|
|
|
|
|
320
|
|
|
""" |
|
321
|
|
|
log.info("linking {} to {}...".format(cid, pid)) |
|
322
|
|
|
# Find child item |
|
323
|
|
|
child = self.find_item(cid, _kind="child") |
|
324
|
|
|
# Find parent item |
|
325
|
|
|
parent = self.find_item(pid, _kind="parent") |
|
326
|
|
|
# Add link if it is not a self reference or cyclic dependency |
|
327
|
|
|
if child is parent: |
|
328
|
|
|
raise DoorstopError("link would be self reference") |
|
329
|
|
|
self.check_for_cycle(parent, child.uid, [child.uid, parent.uid]) |
|
330
|
|
|
child.link(parent.uid) |
|
331
|
|
|
return child, parent |
|
332
|
|
|
|
|
333
|
|
|
# decorators are applied to methods in the associated classes` |
|
334
|
|
|
def unlink_items(self, cid, pid): |
|
335
|
|
|
"""Remove a link between two items by UIDs. |
|
336
|
|
|
|
|
337
|
|
|
:param cid: child item's UID (or child item) |
|
338
|
|
|
:param pid: parent item's UID (or parent item) |
|
339
|
|
|
|
|
340
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the link |
|
341
|
|
|
cannot be removed |
|
342
|
|
|
|
|
343
|
|
|
:return: child :class:`~doorstop.core.item.Item`, |
|
344
|
|
|
parent :class:`~doorstop.core.item.Item` |
|
345
|
|
|
|
|
346
|
|
|
""" |
|
347
|
|
|
log.info("unlinking '{}' from '{}'...".format(cid, pid)) |
|
348
|
|
|
# Find child item |
|
349
|
|
|
child = self.find_item(cid, _kind="child") |
|
350
|
|
|
# Find parent item |
|
351
|
|
|
parent = self.find_item(pid, _kind="parent") |
|
352
|
|
|
# Remove link |
|
353
|
|
|
child.unlink(parent.uid) |
|
354
|
|
|
return child, parent |
|
355
|
|
|
|
|
356
|
|
|
# decorators are applied to methods in the associated classes |
|
357
|
|
|
def edit_item(self, uid, tool=None, launch=False): |
|
358
|
|
|
"""Open an item for editing by UID. |
|
359
|
|
|
|
|
360
|
|
|
:param uid: item's UID (or item) |
|
361
|
|
|
:param tool: alternative text editor to open the item |
|
362
|
|
|
:param launch: open the text editor |
|
363
|
|
|
|
|
364
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the item |
|
365
|
|
|
cannot be found |
|
366
|
|
|
|
|
367
|
|
|
:return: edited :class:`~doorstop.core.item.Item` |
|
368
|
|
|
|
|
369
|
|
|
""" |
|
370
|
|
|
# Find the item |
|
371
|
|
|
item = self.find_item(uid) |
|
372
|
|
|
# Edit the item |
|
373
|
|
|
if launch: |
|
374
|
|
|
item.edit(tool=tool) |
|
375
|
|
|
# Return the item |
|
376
|
|
|
return item |
|
377
|
|
|
|
|
378
|
|
|
def find_document(self, value) -> Document: |
|
379
|
|
|
"""Get a document by its prefix. |
|
380
|
|
|
|
|
381
|
|
|
:param value: document or prefix |
|
382
|
|
|
|
|
383
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the document |
|
384
|
|
|
cannot be found |
|
385
|
|
|
|
|
386
|
|
|
:return: matching :class:`~doorstop.core.document.Document` |
|
387
|
|
|
|
|
388
|
|
|
""" |
|
389
|
|
|
prefix = Prefix(value) |
|
390
|
|
|
log.debug("looking for document '{}'...".format(prefix)) |
|
391
|
|
|
try: |
|
392
|
|
|
document = self._document_cache[prefix] |
|
393
|
|
|
if document: |
|
394
|
|
|
log.trace("found cached document: {}".format(document)) # type: ignore |
|
395
|
|
|
return document |
|
396
|
|
|
else: |
|
397
|
|
|
log.trace("found cached unknown: {}".format(prefix)) # type: ignore |
|
398
|
|
|
except KeyError: |
|
399
|
|
|
for document in self: |
|
400
|
|
|
if not document: |
|
401
|
|
|
# TODO: mypy seems to think document can be None here, but that shouldn't be possible |
|
402
|
|
|
continue |
|
403
|
|
|
if document.prefix == prefix: |
|
404
|
|
|
log.trace("found document: {}".format(document)) # type: ignore |
|
405
|
|
|
if settings.CACHE_DOCUMENTS: |
|
406
|
|
|
self._document_cache[prefix] = document |
|
407
|
|
|
log.trace( # type: ignore |
|
408
|
|
|
"cached document: {}".format(document) |
|
409
|
|
|
) |
|
410
|
|
|
return document |
|
411
|
|
|
log.debug("could not find document: {}".format(prefix)) |
|
412
|
|
|
if settings.CACHE_DOCUMENTS: |
|
413
|
|
|
self._document_cache[prefix] = None |
|
414
|
|
|
log.trace("cached unknown: {}".format(prefix)) # type: ignore |
|
415
|
|
|
|
|
416
|
|
|
raise DoorstopError(Prefix.UNKNOWN_MESSAGE.format(prefix)) |
|
417
|
|
|
|
|
418
|
|
|
def find_item(self, value, _kind=""): |
|
419
|
|
|
"""Get an item by its UID. |
|
420
|
|
|
|
|
421
|
|
|
:param value: item or UID |
|
422
|
|
|
|
|
423
|
|
|
:raises: :class:`~doorstop.common.DoorstopError` if the item |
|
424
|
|
|
cannot be found |
|
425
|
|
|
|
|
426
|
|
|
:return: matching :class:`~doorstop.core.item.Item` |
|
427
|
|
|
|
|
428
|
|
|
""" |
|
429
|
|
|
uid = UID(value) |
|
430
|
|
|
_kind = (" " + _kind) if _kind else _kind # for logging messages |
|
431
|
|
|
log.debug("looking for{} item '{}'...".format(_kind, uid)) |
|
432
|
|
|
try: |
|
433
|
|
|
item = self._item_cache[uid] |
|
434
|
|
|
if item: |
|
435
|
|
|
log.trace("found cached item: {}".format(item)) # type: ignore |
|
436
|
|
|
if item.active: |
|
437
|
|
|
return item |
|
438
|
|
|
else: |
|
439
|
|
|
log.trace("item is inactive: {}".format(item)) # type: ignore |
|
440
|
|
|
else: |
|
441
|
|
|
log.trace("found cached unknown: {}".format(uid)) # type: ignore |
|
442
|
|
|
except KeyError: |
|
443
|
|
|
for document in self: |
|
444
|
|
|
try: |
|
445
|
|
|
item = document.find_item(uid, _kind=_kind) |
|
446
|
|
|
except DoorstopError: |
|
447
|
|
|
pass # item not found in that document |
|
448
|
|
|
else: |
|
449
|
|
|
log.trace("found item: {}".format(item)) # type: ignore |
|
450
|
|
|
if settings.CACHE_ITEMS: |
|
451
|
|
|
self._item_cache[uid] = item |
|
452
|
|
|
log.trace("cached item: {}".format(item)) # type: ignore |
|
453
|
|
|
if item.active: |
|
454
|
|
|
return item |
|
455
|
|
|
else: |
|
456
|
|
|
log.trace("item is inactive: {}".format(item)) # type: ignore |
|
457
|
|
|
|
|
458
|
|
|
log.debug("could not find item: {}".format(uid)) |
|
459
|
|
|
if settings.CACHE_ITEMS: |
|
460
|
|
|
self._item_cache[uid] = None # type: ignore |
|
461
|
|
|
log.trace("cached unknown: {}".format(uid)) # type: ignore |
|
462
|
|
|
|
|
463
|
|
|
raise DoorstopError(UID.UNKNOWN_MESSAGE.format(k=_kind, u=uid)) |
|
464
|
|
|
|
|
465
|
|
|
def get_issues(self, skip=None, document_hook=None, item_hook=None): |
|
466
|
|
|
"""Yield all the tree's issues. |
|
467
|
|
|
|
|
468
|
|
|
:param skip: list of document prefixes to skip |
|
469
|
|
|
:param document_hook: function to call for custom document validation |
|
470
|
|
|
:param item_hook: function to call for custom item validation |
|
471
|
|
|
|
|
472
|
|
|
:return: generator of :class:`~doorstop.common.DoorstopError`, |
|
473
|
|
|
:class:`~doorstop.common.DoorstopWarning`, |
|
474
|
|
|
:class:`~doorstop.common.DoorstopInfo` |
|
475
|
|
|
|
|
476
|
|
|
""" |
|
477
|
|
|
hook = document_hook if document_hook else lambda **kwargs: [] |
|
478
|
|
|
documents = list(self) |
|
479
|
|
|
# Check for documents |
|
480
|
|
|
if not documents: |
|
481
|
|
|
yield DoorstopWarning("no documents") |
|
482
|
|
|
# Check each document |
|
483
|
|
|
for document in documents: |
|
484
|
|
|
for issue in chain( |
|
485
|
|
|
hook(document=document, tree=self), |
|
486
|
|
|
document.get_issues(skip=skip, item_hook=item_hook), |
|
487
|
|
|
): |
|
488
|
|
|
# Prepend the document's prefix to yielded exceptions |
|
489
|
|
|
if isinstance(issue, Exception): |
|
490
|
|
|
yield type(issue)("{}: {}".format(document.prefix, issue)) |
|
491
|
|
|
|
|
492
|
|
|
def get_traceability(self): |
|
493
|
|
|
"""Return sorted rows of traceability slices. |
|
494
|
|
|
|
|
495
|
|
|
:return: list of list of :class:`~doorstop.core.item.Item` or `None` |
|
496
|
|
|
|
|
497
|
|
|
""" |
|
498
|
|
|
|
|
499
|
|
|
def by_uid(row): |
|
500
|
|
|
row2 = [] |
|
501
|
|
|
for item in row: |
|
502
|
|
|
if item: |
|
503
|
|
|
row2.append("0" + str(item.uid)) |
|
504
|
|
|
else: |
|
505
|
|
|
row2.append("1") # force `None` to sort after items |
|
506
|
|
|
return row2 |
|
507
|
|
|
|
|
508
|
|
|
# Create mapping of document prefix to slice index |
|
509
|
|
|
mapping = {} |
|
510
|
|
|
for index, document in enumerate(self.documents): |
|
511
|
|
|
mapping[document.prefix] = index |
|
512
|
|
|
|
|
513
|
|
|
# Collect all rows |
|
514
|
|
|
rows = set() |
|
515
|
|
|
for index, document in enumerate(self.documents): |
|
516
|
|
|
for item in document: |
|
517
|
|
|
if item.active: |
|
518
|
|
|
for row in self._iter_rows(item, mapping): |
|
519
|
|
|
rows.add(row) |
|
520
|
|
|
|
|
521
|
|
|
# Sort rows |
|
522
|
|
|
return sorted(rows, key=by_uid) |
|
523
|
|
|
|
|
524
|
|
|
def _get_prefix_of_children(self, document): |
|
525
|
|
|
"""Return the prefixes of the children of this document.""" |
|
526
|
|
|
for child in self.children: |
|
527
|
|
|
if child.document == document: |
|
528
|
|
|
children = [c.document.prefix for c in child.children] |
|
529
|
|
|
return children |
|
530
|
|
|
children = [c.document.prefix for c in self.children] |
|
531
|
|
|
return children |
|
532
|
|
|
|
|
533
|
|
|
def _iter_rows( |
|
534
|
|
|
self, item, mapping, parent=True, child=True, row=None |
|
535
|
|
|
): # pylint: disable=R0913 |
|
536
|
|
|
"""Generate all traceability row slices. |
|
537
|
|
|
|
|
538
|
|
|
:param item: base :class:`~doorstop.core.item.Item` for slicing |
|
539
|
|
|
:param mapping: `dict` of document prefix to slice index |
|
540
|
|
|
:param parent: indicate recursion is in the parent direction |
|
541
|
|
|
:param child: indicates recursion is in the child direction |
|
542
|
|
|
:param row: currently generated row |
|
543
|
|
|
|
|
544
|
|
|
""" |
|
545
|
|
|
|
|
546
|
|
|
class Row(list): |
|
547
|
|
|
"""List type that tracks upper and lower boundaries.""" |
|
548
|
|
|
|
|
549
|
|
|
def __init__(self, *args, parent=False, child=False, **kwargs): |
|
550
|
|
|
super().__init__(*args, **kwargs) |
|
551
|
|
|
# Flags to indicate upper and lower bounds have been hit |
|
552
|
|
|
self.parent = parent |
|
553
|
|
|
self.child = child |
|
554
|
|
|
|
|
555
|
|
|
if item.normative: |
|
556
|
|
|
# Start the next row or copy from recursion |
|
557
|
|
|
if row is None: |
|
558
|
|
|
row = Row([None] * len(mapping)) |
|
559
|
|
|
else: |
|
560
|
|
|
row = Row(row, parent=row.parent, child=row.child) |
|
561
|
|
|
|
|
562
|
|
|
# Add the current item to the row |
|
563
|
|
|
row[mapping[item.document.prefix]] = item |
|
564
|
|
|
|
|
565
|
|
|
# Recurse to the next parent/child item |
|
566
|
|
|
if parent: |
|
567
|
|
|
items = item.parent_items |
|
568
|
|
|
for item2 in items: |
|
569
|
|
|
yield from self._iter_rows(item2, mapping, child=False, row=row) |
|
570
|
|
|
if not items: |
|
571
|
|
|
row.parent = True |
|
572
|
|
|
if child: |
|
573
|
|
|
items = item.child_items |
|
574
|
|
|
for item2 in items: |
|
575
|
|
|
yield from self._iter_rows(item2, mapping, parent=False, row=row) |
|
576
|
|
|
if not items: |
|
577
|
|
|
row.child = True |
|
578
|
|
|
|
|
579
|
|
|
# Yield the row if both boundaries have been hit |
|
580
|
|
|
if row.parent and row.child: |
|
581
|
|
|
yield tuple(row) |
|
582
|
|
|
|
|
583
|
|
|
def load(self, reload=False): |
|
584
|
|
|
"""Load the tree's documents and items. |
|
585
|
|
|
|
|
586
|
|
|
Unlike the :class:`~doorstop.core.document.Document` and |
|
587
|
|
|
:class:`~doorstop.core.item.Item` class, this load method is not |
|
588
|
|
|
used internally. Its purpose is to force the loading of |
|
589
|
|
|
content in large trees where lazy loading may cause long delays |
|
590
|
|
|
late in processing. |
|
591
|
|
|
|
|
592
|
|
|
""" |
|
593
|
|
|
if self._loaded and not reload: |
|
594
|
|
|
return |
|
595
|
|
|
log.info("loading the tree...") |
|
596
|
|
|
for document in self: |
|
597
|
|
|
document.load(reload=True) |
|
598
|
|
|
# Set meta attributes |
|
599
|
|
|
self._loaded = True |
|
600
|
|
|
|
|
601
|
|
|
def draw(self, encoding=None, html_links=False): |
|
602
|
|
|
"""Get the tree structure as text. |
|
603
|
|
|
|
|
604
|
|
|
:param encoding: limit character set to: |
|
605
|
|
|
|
|
606
|
|
|
- `'utf-8'` - all characters |
|
607
|
|
|
- `'cp437'` - Code Page 437 characters |
|
608
|
|
|
- (other) - ASCII characters |
|
609
|
|
|
|
|
610
|
|
|
""" |
|
611
|
|
|
encoding = encoding or getattr(sys.stdout, "encoding", None) |
|
612
|
|
|
encoding = encoding.lower() if encoding else None |
|
613
|
|
|
return "\n".join(self._draw_lines(encoding, html_links)) |
|
614
|
|
|
|
|
615
|
|
|
def _draw_line(self): |
|
616
|
|
|
"""Get the tree structure in one line.""" |
|
617
|
|
|
# Build parent prefix string (`getattr` to enable mock testing) |
|
618
|
|
|
prefix = getattr(self.document, "prefix", "") or str(self.document) |
|
619
|
|
|
# Build children prefix strings |
|
620
|
|
|
children = ", ".join( |
|
621
|
|
|
c._draw_line() for c in self.children # pylint: disable=protected-access |
|
622
|
|
|
) |
|
623
|
|
|
# Format the tree |
|
624
|
|
|
if children: |
|
625
|
|
|
return "{} <- [ {} ]".format(prefix, children) |
|
626
|
|
|
else: |
|
627
|
|
|
return "{}".format(prefix) |
|
628
|
|
|
|
|
629
|
|
|
def _draw_lines(self, encoding, html_links=False): |
|
630
|
|
|
"""Generate lines of the tree structure.""" |
|
631
|
|
|
# Build parent prefix string (`getattr` to enable mock testing) |
|
632
|
|
|
prefix = getattr(self.document, "prefix", "") or str(self.document) |
|
633
|
|
|
if html_links: |
|
634
|
|
|
prefix = '<a href="documents/{0}.html">{0}</a>'.format(prefix) |
|
635
|
|
|
yield prefix |
|
636
|
|
|
# Build child prefix strings |
|
637
|
|
|
for count, child in enumerate(self.children, start=1): |
|
638
|
|
|
if count == 1: |
|
639
|
|
|
yield self._symbol("end", encoding) |
|
640
|
|
|
else: |
|
641
|
|
|
yield self._symbol("pipe", encoding) |
|
642
|
|
|
if count < len(self.children): |
|
643
|
|
|
base = self._symbol("pipe", encoding) |
|
644
|
|
|
indent = self._symbol("tee", encoding) |
|
645
|
|
|
else: |
|
646
|
|
|
base = self._symbol("space", encoding) |
|
647
|
|
|
indent = self._symbol("bend", encoding) |
|
648
|
|
|
for index, line in enumerate( |
|
649
|
|
|
# pylint: disable=protected-access |
|
650
|
|
|
child._draw_lines(encoding, html_links) |
|
651
|
|
|
): |
|
652
|
|
|
if index == 0: |
|
653
|
|
|
yield indent + line |
|
654
|
|
|
else: |
|
655
|
|
|
yield base + line |
|
656
|
|
|
|
|
657
|
|
|
@staticmethod |
|
658
|
|
|
def _symbol(name, encoding): |
|
659
|
|
|
"""Get a drawing symbol based on encoding.""" |
|
660
|
|
|
if encoding not in (UTF8, CP437): |
|
661
|
|
|
encoding = ASCII |
|
662
|
|
|
return BOX[name][encoding] |
|
663
|
|
|
|
|
664
|
|
|
# decorators are applied to methods in the associated classes |
|
665
|
|
|
def delete(self): |
|
666
|
|
|
"""Delete the tree and its documents and items.""" |
|
667
|
|
|
for document in self: |
|
668
|
|
|
document.delete() |
|
669
|
|
|
self.document = None |
|
670
|
|
|
self.children = [] |
|
671
|
|
|
|