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