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

Complexity

Conditions 14

Size

Total Lines 76
Code Lines 39

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 38
CRAP Score 14.0245

Importance

Changes 0
Metric Value
eloc 39
dl 0
loc 76
rs 3.6
c 0
b 0
f 0
ccs 38
cts 40
cp 0.95
cc 14
nop 8
crap 14.0245

How to fix   Long Method    Complexity    Many Parameters   

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.publish() 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.

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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