doorstop.core.publisher._lines_index()   F
last analyzed

Complexity

Conditions 16

Size

Total Lines 76
Code Lines 60

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 56
CRAP Score 16

Importance

Changes 0
Metric Value
eloc 60
dl 0
loc 76
rs 2.4
c 0
b 0
f 0
ccs 56
cts 56
cp 1
cc 16
nop 3
crap 16

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like doorstop.core.publisher._lines_index() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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