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
|
|||
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 |
This check looks for lines that are too long. You can specify the maximum line length.