Completed
Push — develop ( bff36c...0ce659 )
by Jace
15s queued 11s
created

doorstop.core.publisher._extract_uid()   A

Complexity

Conditions 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
dl 0
loc 5
rs 10
c 0
b 0
f 0
cc 2
nop 1
1
# SPDX-License-Identifier: LGPL-3.0-only
2
3
"""Functions to publish documents and items."""
4
5
import os
6
import tempfile
7
import textwrap
8
9
import bottle
10
import markdown
11
from bottle import template as bottle_template
12
from plantuml_markdown import PlantUMLMarkdownExtension
13
14
from doorstop import common, settings
15
from doorstop.common import DoorstopError
16
from doorstop.core import Document
17
from doorstop.core.types import is_item, is_tree, iter_documents, iter_items
18
19
EXTENSIONS = (
20
    'markdown.extensions.extra',
21
    'markdown.extensions.sane_lists',
22
    'mdx_outline',
23
    'mdx_math',
24
    PlantUMLMarkdownExtension(
25
        server='http://www.plantuml.com/plantuml',
26
        cachedir=tempfile.gettempdir(),
27
        format='svg',
28
        classes='class1,class2',
29
        title='UML',
30
        alt='UML Diagram',
31
    ),
32
)
33
CSS = os.path.join(os.path.dirname(__file__), 'files', 'doorstop.css')
34
HTMLTEMPLATE = 'sidebar'
35
INDEX = 'index.html'
36
MATRIX = 'traceability.csv'
37
38
log = common.logger(__name__)
39
40
41
def publish(
42
    obj,
43
    path,
44
    ext=None,
45
    linkify=None,
46
    index=None,
47
    matrix=None,
48
    template=None,
49
    toc=True,
50
    **kwargs,
51
):
52
    """Publish an object to a given format.
53
54
    The function can be called in two ways:
55
56
    1. document or item-like object + output file path
57
    2. tree-like object + output directory path
58
59
    :param obj: (1) Item, list of Items, Document or (2) Tree
60
    :param path: (1) output file path or (2) output directory path
61
    :param ext: file extension to override output extension
62
    :param linkify: turn links into hyperlinks (for Markdown or HTML)
63
    :param index: create an index.html (for HTML)
64
    :param matrix: create a traceability matrix, traceability.csv
65
66
    :raises: :class:`doorstop.common.DoorstopError` for unknown file formats
67
68
    :return: output location if files created, else None
69
70
    """
71
    # Determine the output format
72
    ext = ext or os.path.splitext(path)[-1] or '.html'
73
    check(ext)
74
    if linkify is None:
75
        linkify = is_tree(obj) and ext in ['.html', '.md']
76
    if index is None:
77
        index = is_tree(obj) and ext == '.html'
78
    if matrix is None:
79
        matrix = is_tree(obj)
80
81
    if is_tree(obj):
82
        assets_dir = os.path.join(path, Document.ASSETS)  # path is a directory name
83
    else:
84
        assets_dir = os.path.join(
85
            os.path.dirname(path), Document.ASSETS
86
        )  # path is a filename
87
88
    if os.path.isdir(assets_dir):
89
        log.info('Deleting contents of assets directory %s', assets_dir)
90
        common.delete_contents(assets_dir)
91
    else:
92
        os.makedirs(assets_dir)
93
94
    # If publish html and then markdown ensure that the html template are still available
95
    if not template:
96
        template = HTMLTEMPLATE
97
    template_assets = os.path.join(os.path.dirname(__file__), 'files', 'assets')
98
    if os.path.isdir(template_assets):
99
        log.info("Copying %s to %s", template_assets, assets_dir)
100
        common.copy_dir_contents(template_assets, assets_dir)
101
102
    # Publish documents
103
    count = 0
104
    for obj2, path2 in iter_documents(obj, path, ext):
105
        count += 1
106
107
        # Publish content to the specified path
108
        log.info("publishing to {}...".format(path2))
109
        lines = publish_lines(
110
            obj2, ext, linkify=linkify, template=template, toc=toc, **kwargs
111
        )
112
        common.write_lines(lines, path2)
113
        if obj2.copy_assets(assets_dir):
114
            log.info('Copied assets from %s to %s', obj.assets, assets_dir)
115
116
    # Create index
117
    if index and count:
118
        _index(path, tree=obj if is_tree(obj) else None)
119
120
    # Create traceability matrix
121
    if index and matrix and count:
122
        _matrix(path, tree=obj if is_tree(obj) else None)
123
124
    # Return the published path
125
    if count:
126
        msg = "published to {} file{}".format(count, 's' if count > 1 else '')
127
        log.info(msg)
128
        return path
129
    else:
130
        log.warning("nothing to publish")
131
        return None
132
133
134
def _index(directory, index=INDEX, extensions=('.html',), tree=None):
135
    """Create an HTML index of all files in a directory.
136
137
    :param directory: directory for index
138
    :param index: filename for index
139
    :param extensions: file extensions to include
140
    :param tree: optional tree to determine index structure
141
142
    """
143
    # Get paths for the index index
144
    filenames = []
145
    for filename in os.listdir(directory):
146
        if filename.endswith(extensions) and filename != INDEX:
147
            filenames.append(os.path.join(filename))
148
149
    # Create the index
150
    if filenames:
151
        path = os.path.join(directory, index)
152
        log.info("creating an {}...".format(index))
153
        lines = _lines_index(sorted(filenames), tree=tree)
154
        common.write_lines(lines, path)
155
    else:
156
        log.warning("no files for {}".format(index))
157
158
159
def _lines_index(filenames, charset='UTF-8', tree=None):
160
    """Yield lines of HTML for index.html.
161
162
    :param filesnames: list of filenames to add to the index
163
    :param charset: character encoding for output
164
    :param tree: optional tree to determine index structure
165
166
    """
167
    yield '<!DOCTYPE html>'
168
    yield '<head>'
169
    yield (
170
        '<meta http-equiv="content-type" content="text/html; '
171
        'charset={charset}">'.format(charset=charset)
172
    )
173
    yield '<style type="text/css">'
174
    yield from _lines_css()
175
    yield '</style>'
176
    yield '</head>'
177
    yield '<body>'
178
    # Tree structure
179
    text = tree.draw() if tree else None
180
    if text:
181
        yield ''
182
        yield '<h3>Tree Structure:</h3>'
183
        yield '<pre><code>' + text + '</pre></code>'
184
    # Additional files
185
    if filenames:
186
        if text:
187
            yield ''
188
            yield '<hr>'
189
        yield ''
190
        yield '<h3>Published Documents:</h3>'
191
        yield '<p>'
192
        yield '<ul>'
193
        for filename in filenames:
194
            name = os.path.splitext(filename)[0]
195
            yield '<li> <a href="{f}">{n}</a> </li>'.format(f=filename, n=name)
196
        yield '</ul>'
197
        yield '</p>'
198
    # Traceability table
199
    documents = tree.documents if tree else None
200
    if documents:
201
        if text or filenames:
202
            yield ''
203
            yield '<hr>'
204
        yield ''
205
        # table
206
        yield '<h3>Item Traceability:</h3>'
207
        yield '<p>'
208
        yield '<table>'
209
        # header
210
        for document in documents:  # pylint: disable=not-an-iterable
211
            yield '<col width="100">'
212
        yield '<tr>'
213
        for document in documents:  # pylint: disable=not-an-iterable
214
            link = '<a href="{p}.html">{p}</a>'.format(p=document.prefix)
215
            yield ('  <th height="25" align="center"> {link} </th>'.format(link=link))
216
        yield '</tr>'
217
        # data
218
        for index, row in enumerate(tree.get_traceability()):
219
            if index % 2:
220
                yield '<tr class="alt">'
221
            else:
222
                yield '<tr>'
223
            for item in row:
224
                if item is None:
225
                    link = ''
226
                else:
227
                    link = _format_html_item_link(item)
228
                yield '  <td height="25" align="center"> {} </td>'.format(link)
229
            yield '</tr>'
230
        yield '</table>'
231
        yield '</p>'
232
    yield ''
233
    yield '</body>'
234
    yield '</html>'
235
236
237
def _lines_css():
238
    """Yield lines of CSS to embedded in HTML."""
239
    yield ''
240
    for line in common.read_lines(CSS):
241
        yield line.rstrip()
242
    yield ''
243
244
245
def _matrix(directory, tree, filename=MATRIX, ext=None):
246
    """Create a traceability matrix for all the items.
247
248
    :param directory: directory for matrix
249
    :param tree: tree to access the traceability data
250
    :param filename: filename for matrix
251
    :param ext: file extensionto use for the matrix
252
253
    """
254
    # Get path and format extension
255
    path = os.path.join(directory, filename)
256
    ext = ext or os.path.splitext(path)[-1] or '.csv'
257
258
    # Create the matrix
259
    if tree:
260
        log.info("creating an {}...".format(filename))
261
        content = _matrix_content(tree)
262
        common.write_csv(content, path)
263
    else:
264
        log.warning("no data for {}".format(filename))
265
266
267
def _extract_prefix(document):
268
    if document:
269
        return document.prefix
270
    else:
271
        return None
272
273
274
def _extract_uid(item):
275
    if item:
276
        return item.uid
277
    else:
278
        return None
279
280
281
def _matrix_content(tree):
282
    """Yield rows of content for the traceability matrix."""
283
    yield tuple(map(_extract_prefix, tree.documents))
284
    for row in tree.get_traceability():
285
        yield tuple(map(_extract_uid, row))
286
287
288
def publish_lines(obj, ext='.txt', **kwargs):
289
    """Yield lines for a report in the specified format.
290
291
    :param obj: Item, list of Items, or Document to publish
292
    :param ext: file extension to specify the output format
293
294
    :raises: :class:`doorstop.common.DoorstopError` for unknown file formats
295
296
    """
297
    gen = check(ext)
298
    log.debug("yielding {} as lines of {}...".format(obj, ext))
299
    yield from gen(obj, **kwargs)
300
301
302
def _lines_text(obj, indent=8, width=79, **_):
303
    """Yield lines for a text report.
304
305
    :param obj: Item, list of Items, or Document to publish
306
    :param indent: number of spaces to indent text
307
    :param width: maximum line length
308
309
    :return: iterator of lines of text
310
311
    """
312
    for item in iter_items(obj):
313
314
        level = _format_level(item.level)
315
316
        if item.heading:
317
318
            # Level and Text
319
            if settings.PUBLISH_HEADING_LEVELS:
320
                yield "{lev:<{s}}{t}".format(lev=level, s=indent, t=item.text)
321
            else:
322
                yield "{t}".format(t=item.text)
323
324
        else:
325
326
            # Level and UID
327
            if item.header:
328
                yield "{lev:<{s}}{u} {header}".format(
329
                    lev=level, s=indent, u=item.uid, header=item.header
330
                )
331
            else:
332
                yield "{lev:<{s}}{u}".format(lev=level, s=indent, u=item.uid)
333
334
            # Text
335
            if item.text:
336
                yield ""  # break before text
337
                for line in item.text.splitlines():
338
                    yield from _chunks(line, width, indent)
339
340
                    if not line:
341
                        yield ""  # break between paragraphs
342
343
            # Reference
344
            if item.ref:
345
                yield ""  # break before reference
346
                ref = _format_text_ref(item)
347
                yield from _chunks(ref, width, indent)
348
349
            # References
350
            if item.references:
351
                yield ""  # break before references
352
                ref = _format_text_references(item)
353
                yield from _chunks(ref, width, indent)
354
355
            # Links
356
            if item.links:
357
                yield ""  # break before links
358
                if settings.PUBLISH_CHILD_LINKS:
359
                    label = "Parent links: "
360
                else:
361
                    label = "Links: "
362
                slinks = label + ', '.join(str(l) for l in item.links)
363
                yield from _chunks(slinks, width, indent)
364
            if settings.PUBLISH_CHILD_LINKS:
365
                links = item.find_child_links()
366
                if links:
367
                    yield ""  # break before links
368
                    slinks = "Child links: " + ', '.join(str(l) for l in links)
369
                    yield from _chunks(slinks, width, indent)
370
371
        yield ""  # break between items
372
373
374
def _chunks(text, width, indent):
375
    """Yield wrapped lines of text."""
376
    yield from textwrap.wrap(
377
        text, width, initial_indent=' ' * indent, subsequent_indent=' ' * indent
378
    )
379
380
381
def _lines_markdown(obj, **kwargs):
382
    """Yield lines for a Markdown report.
383
384
    :param obj: Item, list of Items, or Document to publish
385
    :param linkify: turn links into hyperlinks (for conversion to HTML)
386
387
    :return: iterator of lines of text
388
389
    """
390
    linkify = kwargs.get('linkify', False)
391
    for item in iter_items(obj):
392
393
        heading = '#' * item.depth
394
        level = _format_level(item.level)
395
396
        if item.heading:
397
            text_lines = item.text.splitlines()
398
            # Level and Text
399
            if settings.PUBLISH_HEADING_LEVELS:
400
                standard = "{h} {lev} {t}".format(
401
                    h=heading, lev=level, t=text_lines[0] if text_lines else ''
402
                )
403
            else:
404
                standard = "{h} {t}".format(
405
                    h=heading, t=text_lines[0] if text_lines else ''
406
                )
407
            attr_list = _format_md_attr_list(item, True)
408
            yield standard + attr_list
409
            yield from text_lines[1:]
410
        else:
411
412
            uid = item.uid
413
            if settings.ENABLE_HEADERS:
414
                if item.header:
415
                    uid = '{h} <small>{u}</small>'.format(h=item.header, u=item.uid)
416
                else:
417
                    uid = '{u}'.format(u=item.uid)
418
419
            # Level and UID
420
            if settings.PUBLISH_BODY_LEVELS:
421
                standard = "{h} {lev} {u}".format(h=heading, lev=level, u=uid)
422
            else:
423
                standard = "{h} {u}".format(h=heading, u=uid)
424
425
            attr_list = _format_md_attr_list(item, True)
426
            yield standard + attr_list
427
428
            # Text
429
            if item.text:
430
                yield ""  # break before text
431
                yield from item.text.splitlines()
432
433
            # Reference
434
            if item.ref:
435
                yield ""  # break before reference
436
                yield _format_md_ref(item)
437
438
            # Reference
439
            if item.references:
440
                yield ""  # break before reference
441
                yield _format_md_references(item)
442
443
            # Parent links
444
            if item.links:
445
                yield ""  # break before links
446
                items2 = item.parent_items
447
                if settings.PUBLISH_CHILD_LINKS:
448
                    label = "Parent links:"
449
                else:
450
                    label = "Links:"
451
                links = _format_md_links(items2, linkify)
452
                label_links = _format_md_label_links(label, links, linkify)
453
                yield label_links
454
455
            # Child links
456
            if settings.PUBLISH_CHILD_LINKS:
457
                items2 = item.find_child_items()
458
                if items2:
459
                    yield ""  # break before links
460
                    label = "Child links:"
461
                    links = _format_md_links(items2, linkify)
462
                    label_links = _format_md_label_links(label, links, linkify)
463
                    yield label_links
464
465
        yield ""  # break between items
466
467
468
def _format_level(level):
469
    """Convert a level to a string and keep zeros if not a top level."""
470
    text = str(level)
471
    if text.endswith('.0') and len(text) > 3:
472
        text = text[:-2]
473
    return text
474
475
476
def _format_md_attr_list(item, linkify):
477
    """Create a Markdown attribute list for a heading."""
478
    return " {{#{u} }}".format(u=item.uid) if linkify else ''
479
480
481
def _format_text_ref(item):
482
    """Format an external reference in text."""
483
    if settings.CHECK_REF:
484
        path, line = item.find_ref()
485
        path = path.replace('\\', '/')  # always use unix-style paths
486
        if line:
487
            return "Reference: {p} (line {line})".format(p=path, line=line)
488
        else:
489
            return "Reference: {p}".format(p=path)
490
    else:
491
        return "Reference: '{r}'".format(r=item.ref)
492
493
494 View Code Duplication
def _format_text_references(item):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
495
    """Format an external reference in text."""
496
    if settings.CHECK_REF:
497
        ref = item.find_references()
498
        text_refs = []
499
        for ref_item in ref:
500
            path, line = ref_item
501
            path = path.replace('\\', '/')  # always use unix-style paths
502
            if line:
503
                text_refs.append("{p} (line {line})".format(p=path, line=line))
504
            else:
505
                text_refs.append("{p}".format(p=path))
506
        return "Reference: {}".format(', '.join(ref for ref in text_refs))
507
    else:
508
        references = item.references
509
        text_refs = []
510
        for ref_item in references:
511
            path = ref_item['path']
512
            path = path.replace('\\', '/')  # always use unix-style paths
513
            text_refs.append("'{p}'".format(p=path))
514
        return "Reference: {}".format(', '.join(text_ref for text_ref in text_refs))
515
516
517
def _format_md_ref(item):
518
    """Format an external reference in Markdown."""
519
    if settings.CHECK_REF:
520
        path, line = item.find_ref()
521
        path = path.replace('\\', '/')  # always use unix-style paths
522
        if line:
523
            return "> `{p}` (line {line})".format(p=path, line=line)
524
        else:
525
            return "> `{p}`".format(p=path)
526
    else:
527
        return "> '{r}'".format(r=item.ref)
528
529
530 View Code Duplication
def _format_md_references(item):
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
531
    """Format an external reference in Markdown."""
532
    if settings.CHECK_REF:
533
        references = item.find_references()
534
        text_refs = []
535
        for ref_item in references:
536
            path, line = ref_item
537
            path = path.replace('\\', '/')  # always use unix-style paths
538
539
            if line:
540
                text_refs.append("> `{p}` (line {line})".format(p=path, line=line))
541
            else:
542
                text_refs.append("> `{p}`".format(p=path))
543
544
        return '\n'.join(ref for ref in text_refs)
545
    else:
546
        references = item.references
547
        text_refs = []
548
        for ref_item in references:
549
            path = ref_item["path"]
550
            path = path.replace('\\', '/')  # always use unix-style paths
551
            text_refs.append("> '{r}'".format(r=path))
552
        return '\n'.join(ref for ref in text_refs)
553
554
555
def _format_md_links(items, linkify):
556
    """Format a list of linked items in Markdown."""
557
    links = []
558
    for item in items:
559
        link = _format_md_item_link(item, linkify=linkify)
560
        links.append(link)
561
    return ', '.join(links)
562
563
564
def _format_md_item_link(item, linkify=True):
565
    """Format an item link in Markdown."""
566
    if linkify and is_item(item):
567
        if item.header:
568
            return "[{u} {h}]({p}.html#{u})".format(
569
                u=item.uid, h=item.header, p=item.document.prefix
570
            )
571
        return "[{u}]({p}.html#{u})".format(u=item.uid, p=item.document.prefix)
572
    else:
573
        return str(item.uid)  # if not `Item`, assume this is an `UnknownItem`
574
575
576
def _format_html_item_link(item, linkify=True):
577
    """Format an item link in HTML."""
578
    if linkify and is_item(item):
579
        if item.header:
580
            link = '<a href="{p}.html#{u}">{u} {h}</a>'.format(
581
                u=item.uid, h=item.header, p=item.document.prefix
582
            )
583
        else:
584
            link = '<a href="{p}.html#{u}">{u}</a>'.format(
585
                u=item.uid, p=item.document.prefix
586
            )
587
        return link
588
    else:
589
        return str(item.uid)  # if not `Item`, assume this is an `UnknownItem`
590
591
592
def _format_md_label_links(label, links, linkify):
593
    """Join a string of label and links with formatting."""
594
    if linkify:
595
        return "*{lb}* {ls}".format(lb=label, ls=links)
596
    else:
597
        return "*{lb} {ls}*".format(lb=label, ls=links)
598
599
600
def _table_of_contents_md(obj, linkify=None):
601
    toc = '### Table of Contents\n\n'
602
603
    for item in iter_items(obj):
604
        if item.depth == 1:
605
            prefix = ' * '
606
        else:
607
            prefix = '    ' * (item.depth - 1)
608
            prefix += '* '
609
610
        if item.heading:
611
            lines = item.text.splitlines()
612
            heading = lines[0] if lines else ''
613
        elif item.header:
614
            heading = "{h}".format(h=item.header)
615
        else:
616
            heading = item.uid
617
618
        if settings.PUBLISH_HEADING_LEVELS:
619
            level = _format_level(item.level)
620
            lbl = '{lev} {h}'.format(lev=level, h=heading)
621
        else:
622
            lbl = heading
623
624
        if linkify:
625
            line = '{p}[{lbl}](#{uid})\n'.format(p=prefix, lbl=lbl, uid=item.uid)
626
        else:
627
            line = '{p}{lbl}\n'.format(p=prefix, lbl=lbl)
628
        toc += line
629
    return toc
630
631
632
def _lines_html(
633
    obj, linkify=False, extensions=EXTENSIONS, template=HTMLTEMPLATE, toc=True
634
):
635
    """Yield lines for an HTML report.
636
637
    :param obj: Item, list of Items, or Document to publish
638
    :param linkify: turn links into hyperlinks
639
640
    :return: iterator of lines of text
641
642
    """
643
    # Determine if a full HTML document should be generated
644
    try:
645
        iter(obj)
646
    except TypeError:
647
        document = False
648
    else:
649
        document = True
650
    # Generate HTML
651
652
    text = '\n'.join(_lines_markdown(obj, linkify=linkify))
653
    body = markdown.markdown(text, extensions=extensions)
654
655
    if toc:
656
        toc_md = _table_of_contents_md(obj, True)
657
        toc_html = markdown.markdown(toc_md, extensions=extensions)
658
    else:
659
        toc_html = ''
660
661
    if document:
662
        try:
663
            bottle.TEMPLATE_PATH.insert(
664
                0, os.path.join(os.path.dirname(__file__), '..', 'views')
665
            )
666
            if 'baseurl' not in bottle.SimpleTemplate.defaults:
667
                bottle.SimpleTemplate.defaults['baseurl'] = ''
668
            html = bottle_template(
669
                template, body=body, toc=toc_html, parent=obj.parent, document=obj
670
            )
671
        except Exception:
672
            log.error("Problem parsing the template %s", template)
673
            raise
674
        yield '\n'.join(html.split(os.linesep))
675
    else:
676
        yield body
677
678
679
# Mapping from file extension to lines generator
680
FORMAT_LINES = {'.txt': _lines_text, '.md': _lines_markdown, '.html': _lines_html}
681
682
683
def check(ext):
684
    """Confirm an extension is supported for publish.
685
686
    :raises: :class:`doorstop.common.DoorstopError` for unknown formats
687
688
    :return: lines generator if available
689
690
    """
691
    exts = ', '.join(ext for ext in FORMAT_LINES)
692
    msg = "unknown publish format: {} (options: {})".format(ext or None, exts)
693
    exc = DoorstopError(msg)
694
695
    try:
696
        gen = FORMAT_LINES[ext]
697
    except KeyError:
698
        raise exc from None
699
    else:
700
        log.debug("found lines generator for: {}".format(ext))
701
        return gen
702