Completed
Push — develop ( 3e9776...c3c8a7 )
by Jace
03:01
created

doorstop/core/publisher.py (1 issue)

1
"""Functions to publish documents and items."""
2
3 1
import os
4 1
import textwrap
5
6 1
import markdown
7
8 1
from doorstop import common
9 1
from doorstop.common import DoorstopError
10 1
from doorstop.core.types import iter_documents, iter_items, is_tree, is_item
11 1
from doorstop import settings
12
13 1
EXTENSIONS = [
14
    'markdown.extensions.extra',
15
    'markdown.extensions.sane_lists',
16
]
17 1
CSS = os.path.join(os.path.dirname(__file__), 'files', 'doorstop.css')
18 1
INDEX = 'index.html'
19
20 1
log = common.logger(__name__)
21
22
23 1
def publish(obj, path, ext=None, linkify=None, index=None, **kwargs):
24
    """Publish an object to a given format.
25
26
    The function can be called in two ways:
27
28
    1. document or item-like object + output file path
29
    2. tree-like object + output directory path
30
31
    :param obj: (1) Item, list of Items, Document or (2) Tree
32
    :param path: (1) output file path or (2) output directory path
33
    :param ext: file extension to override output extension
34
    :param linkify: turn links into hyperlinks (for Markdown or HTML)
35
    :param index: create an index.html (for HTML)
36
37
    :raises: :class:`doorstop.common.DoorstopError` for unknown file formats
38
39
    :return: output location if files created, else None
40
41
    """
42
    # Determine the output format
43 1
    ext = ext or os.path.splitext(path)[-1] or '.html'
44 1
    check(ext)
45 1
    if linkify is None:
46 1
        linkify = is_tree(obj) and ext in ['.html', '.md']
47 1
    if index is None:
48 1
        index = is_tree(obj) and ext == '.html'
49
50
    # Publish documents
51 1
    count = 0
52 1
    for obj2, path2 in iter_documents(obj, path, ext):
53 1
        count += 1
54
55
        # Publish content to the specified path
56 1
        common.create_dirname(path2)
57 1
        log.info("publishing to {}...".format(path2))
58 1
        lines = publish_lines(obj2, ext, linkify=linkify, **kwargs)
59 1
        common.write_lines(lines, path2)
60 1
        if obj2.assets:
61 1
            src = obj2.assets
62 1
            dst = os.path.join(os.path.dirname(path2), obj2.ASSETS)
63 1
            common.copy(src, dst)
64
65
    # Create index
66 1
    if index and count:
67 1
        _index(path, tree=obj if is_tree(obj) else None)
68
69
    # Return the published path
70 1
    if count:
71 1
        msg = "published to {} file{}".format(count, 's' if count > 1 else '')
72 1
        log.info(msg)
73 1
        return path
74
    else:
75 1
        log.warning("nothing to publish")
76 1
        return None
77
78
79 1
def _index(directory, index=INDEX, extensions=('.html',), tree=None):
80
    """Create an HTML index of all files in a directory.
81
82
    :param directory: directory for index
83
    :param index: filename for index
84
    :param extensions: file extensions to include
85
    :param tree: optional tree to determine index structure
86
87
    """
88
    # Get paths for the index index
89 1
    filenames = []
90 1
    for filename in os.listdir(directory):
91 1
        if filename.endswith(extensions) and filename != INDEX:
92 1
            filenames.append(os.path.join(filename))
93
94
    # Create the index
95 1
    if filenames:
96 1
        path = os.path.join(directory, index)
97 1
        log.info("creating an {}...".format(index))
98 1
        lines = _lines_index(sorted(filenames), tree=tree)
99 1
        common.write_lines(lines, path)
100
    else:
101 1
        log.warning("no files for {}".format(index))
102
103
104 1
def _lines_index(filenames, charset='UTF-8', tree=None):
105
    """Yield lines of HTML for index.html.
106
107
    :param filesnames: list of filenames to add to the index
108
    :param charset: character encoding for output
109
    :param tree: optional tree to determine index structure
110
111
    """
112 1
    yield '<!DOCTYPE html>'
113 1
    yield '<head>'
114 1
    yield ('<meta http-equiv="content-type" content="text/html; '
115
           'charset={charset}">'.format(charset=charset))
116 1
    yield '<style type="text/css">'
117 1
    yield from _lines_css()
118 1
    yield '</style>'
119 1
    yield '</head>'
120 1
    yield '<body>'
121
    # Tree structure
122 1
    text = tree.draw() if tree else None
123 1
    if text:
124 1
        yield ''
125 1
        yield '<h3>Tree Structure:</h3>'
126 1
        yield '<pre><code>' + text + '</pre></code>'
127
    # Additional files
128 1
    if filenames:
129 1
        if text:
130 1
            yield ''
131 1
            yield '<hr>'
132 1
        yield ''
133 1
        yield '<h3>Published Documents:</h3>'
134 1
        yield '<p>'
135 1
        yield '<ul>'
136 1
        for filename in filenames:
137 1
            name = os.path.splitext(filename)[0]
138 1
            yield '<li> <a href="{f}">{n}</a> </li>'.format(f=filename, n=name)
139 1
        yield '</ul>'
140 1
        yield '</p>'
141
    # Traceability table
142 1
    documents = tree.documents if tree else None
143 1
    if documents:
144 1
        if text or filenames:
145 1
            yield ''
146 1
            yield '<hr>'
147 1
        yield ''
148
        # table
149 1
        yield '<h3>Item Traceability:</h3>'
150 1
        yield '<p>'
151 1
        yield '<table>'
152
        # header
153 1
        for document in documents:
154 1
            yield '<col width="100">'
155 1
        yield '<tr>'
156 1
        for document in documents:
157 1
            link = '<a href="{p}.html">{p}</a>'.format(p=document.prefix)
158 1
            yield '  <th height="25" align="center"> {l} </th>'.format(l=link)
159 1
        yield '</tr>'
160
        # data
161 1
        for index, row in enumerate(tree.get_traceability()):
162 1
            if index % 2:
163 1
                yield '<tr class="alt">'
164
            else:
165 1
                yield '<tr>'
166 1
            for item in row:
167 1
                if item is None:
168 1
                    link = ''
169
                else:
170 1
                    link = _format_html_item_link(item)
171 1
                yield '  <td height="25" align="center"> {} </td>'.format(link)
172 1
            yield '</tr>'
173 1
        yield '</table>'
174 1
        yield '</p>'
175 1
    yield ''
176 1
    yield '</body>'
177 1
    yield '</html>'
178
179
180 1
def _lines_css():
181
    """Yield lines of CSS to embedded in HTML."""
182 1
    yield ''
183 1
    for line in common.read_lines(CSS):
184 1
        yield line.rstrip()
185 1
    yield ''
186
187
188 1
def publish_lines(obj, ext='.txt', **kwargs):
189
    """Yield lines for a report in the specified format.
190
191
    :param obj: Item, list of Items, or Document to publish
192
    :param ext: file extension to specify the output format
193
194
    :raises: :class:`doorstop.common.DoorstopError` for unknown file formats
195
196
    """
197 1
    gen = check(ext)
198 1
    log.debug("yielding {} as lines of {}...".format(obj, ext))
199 1
    yield from gen(obj, **kwargs)
200
201
202 1
def _lines_text(obj, indent=8, width=79, **_):
203
    """Yield lines for a text report.
204
205
    :param obj: Item, list of Items, or Document to publish
206
    :param indent: number of spaces to indent text
207
    :param width: maximum line length
208
209
    :return: iterator of lines of text
210
211
    """
212 1
    for item in iter_items(obj):
213
214 1
        level = _format_level(item.level)
215
216 1
        if item.heading:
217
218
            # Level and Text
219 1
            if settings.PUBLISH_HEADING_LEVELS:
220 1
                yield "{l:<{s}}{t}".format(l=level, s=indent, t=item.text)
221
            else:
222 1
                yield "{t}".format(t=item.text)
223
224
        else:
225
226
            # Level and UID
227 1
            yield "{l:<{s}}{u}".format(l=level, s=indent, u=item.uid)
228
229
            # Text
230 1
            if item.text:
231 1
                yield ""  # break before text
232 1
                for line in item.text.splitlines():
233 1
                    yield from _chunks(line, width, indent)
234
235
                    if not line:  # pragma: no cover (integration test)
236
                        yield ""  # break between paragraphs
237
238
            # Reference
239 1
            if item.ref:
240 1
                yield ""  # break before reference
241 1
                ref = _format_text_ref(item)
242 1
                yield from _chunks(ref, width, indent)
243
244
            # Links
245 1
            if item.links:
246 1
                yield ""  # break before links
247 1
                if settings.PUBLISH_CHILD_LINKS:
248 1
                    label = "Parent links: "
249
                else:
250 1
                    label = "Links: "
251 1
                slinks = label + ', '.join(str(l) for l in item.links)
252 1
                yield from _chunks(slinks, width, indent)
253 1
            if settings.PUBLISH_CHILD_LINKS:
254 1
                links = item.find_child_links()
255 1
                if links:
256 1
                    yield ""  # break before links
257 1
                    slinks = "Child links: " + ', '.join(str(l) for l in links)
258 1
                    yield from _chunks(slinks, width, indent)
259
260 1
        yield ""  # break between items
261
262
263 1
def _chunks(text, width, indent):
264
    """Yield wrapped lines of text."""
265 1
    yield from textwrap.wrap(text, width,
266
                             initial_indent=' ' * indent,
267
                             subsequent_indent=' ' * indent)
268
269
270 1
def _lines_markdown(obj, linkify=False):
271
    """Yield lines for a Markdown report.
272
273
    :param obj: Item, list of Items, or Document to publish
274
    :param linkify: turn links into hyperlinks (for conversion to HTML)
275
276
    :return: iterator of lines of text
277
278
    """
279 1
    for item in iter_items(obj):
280
281 1
        heading = '#' * item.depth
282 1
        level = _format_level(item.level)
283
284 1
        if item.heading:
285 1
            text_lines = item.text.splitlines()
286
            # Level and Text
287 1
            if settings.PUBLISH_HEADING_LEVELS:
288 1
                standard = "{h} {l} {t}".format(h=heading, l=level, t=text_lines[0])
0 ignored issues
show
This line is too long as per the coding-style (84/80).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
289
            else:
290 1
                standard = "{h} {t}".format(h=heading, t=item.text)
291 1
            attr_list = _format_md_attr_list(item, linkify)
292 1
            yield standard + attr_list
293 1
            yield from text_lines[1:]
294
        else:
295
296
            # Level and UID
297 1
            if settings.PUBLISH_BODY_LEVELS:
298 1
                standard = "{h} {l} {u}".format(h=heading, l=level, u=item.uid)
299
            else:
300 1
                standard = "{h} {u}".format(h=heading, u=item.uid)
301 1
            attr_list = _format_md_attr_list(item, linkify)
302 1
            yield standard + attr_list
303
304
            # Text
305 1
            if item.text:
306 1
                yield ""  # break before text
307 1
                yield from item.text.splitlines()
308
309
            # Reference
310 1
            if item.ref:
311 1
                yield ""  # break before reference
312 1
                yield _format_md_ref(item)
313
314
            # Parent links
315 1
            if item.links:
316 1
                yield ""  # break before links
317 1
                items2 = item.parent_items
318 1
                if settings.PUBLISH_CHILD_LINKS:
319 1
                    label = "Parent links:"
320
                else:
321 1
                    label = "Links:"
322 1
                links = _format_md_links(items2, linkify)
323 1
                label_links = _format_md_label_links(label, links, linkify)
324 1
                yield label_links
325
326
            # Child links
327 1
            if settings.PUBLISH_CHILD_LINKS:
328 1
                items2 = item.find_child_items()
329 1
                if items2:
330 1
                    yield ""  # break before links
331 1
                    label = "Child links:"
332 1
                    links = _format_md_links(items2, linkify)
333 1
                    label_links = _format_md_label_links(label, links, linkify)
334 1
                    yield label_links
335
336 1
        yield ""  # break between items
337
338
339 1
def _format_level(level):
340
    """Convert a level to a string and keep zeros if not a top level."""
341 1
    text = str(level)
342 1
    if text.endswith('.0') and len(text) > 3:
343 1
        text = text[:-2]
344 1
    return text
345
346
347 1
def _format_md_attr_list(item, linkify):
348
    """Create a Markdown attribute list for a heading."""
349 1
    return " {{#{u} }}".format(u=item.uid) if linkify else ''
350
351
352 1
def _format_text_ref(item):
353
    """Format an external reference in text."""
354 1
    if settings.CHECK_REF:
355 1
        path, line = item.find_ref()
356 1
        path = path.replace('\\', '/')  # always use unix-style paths
357 1
        if line:
358 1
            return "Reference: {p} (line {l})".format(p=path, l=line)
359
        else:
360 1
            return "Reference: {p}".format(p=path)
361
    else:
362 1
        return "Reference: '{r}'".format(r=item.ref)
363
364
365 1
def _format_md_ref(item):
366
    """Format an external reference in Markdown."""
367 1
    if settings.CHECK_REF:
368 1
        path, line = item.find_ref()
369 1
        path = path.replace('\\', '/')  # always use unix-style paths
370 1
        if line:
371 1
            return "> `{p}` (line {l})".format(p=path, l=line)
372
        else:
373 1
            return "> `{p}`".format(p=path)
374
    else:
375 1
        return "> '{r}'".format(r=item.ref)
376
377
378 1
def _format_md_links(items, linkify):
379
    """Format a list of linked items in Markdown."""
380 1
    links = []
381 1
    for item in items:
382 1
        link = _format_md_item_link(item, linkify=linkify)
383 1
        links.append(link)
384 1
    return ', '.join(links)
385
386
387 1
def _format_md_item_link(item, linkify=True):
388
    """Format an item link in Markdown."""
389 1
    if linkify and is_item(item):
390 1
        return "[{u}]({p}.html#{u})".format(u=item.uid, p=item.document.prefix)
391
    else:
392 1
        return str(item.uid)  # if not `Item`, assume this is an `UnknownItem`
393
394
395 1
def _format_html_item_link(item, linkify=True):
396
    """Format an item link in HTML."""
397 1
    if linkify and is_item(item):
398 1
        link = '<a href="{p}.html#{u}">{u}</a>'.format(u=item.uid,
399
                                                       p=item.document.prefix)
400 1
        return link
401
    else:
402 1
        return str(item.uid)  # if not `Item`, assume this is an `UnknownItem`
403
404
405 1
def _format_md_label_links(label, links, linkify):
406
    """Join a string of label and links with formatting."""
407 1
    if linkify:
408 1
        return "*{lb}* {ls}".format(lb=label, ls=links)
409
    else:
410 1
        return "*{lb} {ls}*".format(lb=label, ls=links)
411
412
413 1
def _lines_html(obj, linkify=False, extensions=EXTENSIONS, charset='UTF-8'):
414
    """Yield lines for an HTML report.
415
416
    :param obj: Item, list of Items, or Document to publish
417
    :param linkify: turn links into hyperlinks
418
419
    :return: iterator of lines of text
420
421
    """
422
    # Determine if a full HTML document should be generated
423 1
    try:
424 1
        iter(obj)
425 1
    except TypeError:
426 1
        document = False
427
    else:
428 1
        document = True
429
    # Generate HTML
430 1
    if document:
431 1
        yield '<!DOCTYPE html>'
432 1
        yield '<head>'
433 1
        yield ('<meta http-equiv="content-type" content="text/html; '
434
               'charset={charset}">'.format(charset=charset))
435 1
        yield '<style type="text/css">'
436 1
        yield from _lines_css()
437 1
        yield '</style>'
438 1
        yield '</head>'
439 1
        yield '<body>'
440 1
    text = '\n'.join(_lines_markdown(obj, linkify=linkify))
441 1
    html = markdown.markdown(text, extensions=extensions)
442 1
    yield from html.splitlines()
443 1
    if document:
444 1
        yield '</body>'
445 1
        yield '</html>'
446
447
448
# Mapping from file extension to lines generator
449 1
FORMAT_LINES = {'.txt': _lines_text,
450
                '.md': _lines_markdown,
451
                '.html': _lines_html}
452
453
454 1
def check(ext):
455
    """Confirm an extension is supported for publish.
456
457
    :raises: :class:`doorstop.common.DoorstopError` for unknown formats
458
459
    :return: lines generator if available
460
461
    """
462 1
    exts = ', '.join(ext for ext in FORMAT_LINES)
463 1
    msg = "unknown publish format: {} (options: {})".format(ext or None, exts)
464 1
    exc = DoorstopError(msg)
465
466 1
    try:
467 1
        gen = FORMAT_LINES[ext]
468 1
    except KeyError:
469 1
        raise exc from None
470
    else:
471 1
        log.debug("found lines generator for: {}".format(ext))
472
        return gen
473