report-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- report_toolkit/__init__.py +44 -0
- report_toolkit/_template.py +488 -0
- report_toolkit/_tree.py +76 -0
- report_toolkit/adapters.py +195 -0
- report_toolkit/composer.py +671 -0
- report_toolkit/model.py +152 -0
- report_toolkit/resources/artifacts.js +188 -0
- report_toolkit/resources/report.css +105 -0
- report_toolkit/themes/__init__.py +7 -0
- report_toolkit/themes/_validation.py +28 -0
- report_toolkit/themes/palette.py +240 -0
- report_toolkit/themes/style.py +71 -0
- report_toolkit/themes/theme.py +183 -0
- report_toolkit/writer.py +557 -0
- report_toolkit-0.1.0.dist-info/METADATA +136 -0
- report_toolkit-0.1.0.dist-info/RECORD +19 -0
- report_toolkit-0.1.0.dist-info/WHEEL +5 -0
- report_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- report_toolkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Compose analytical reports and render them as HTML."""
|
|
2
|
+
|
|
3
|
+
from ._template import TemplateError
|
|
4
|
+
from .adapters import Adapter, AdapterRegistry, RenderedArtifact, default_registry
|
|
5
|
+
from .composer import Report
|
|
6
|
+
from .model import (
|
|
7
|
+
Artifact,
|
|
8
|
+
Columns,
|
|
9
|
+
Container,
|
|
10
|
+
Document,
|
|
11
|
+
List,
|
|
12
|
+
Markdown,
|
|
13
|
+
Node,
|
|
14
|
+
Panel,
|
|
15
|
+
RawHTML,
|
|
16
|
+
Section,
|
|
17
|
+
)
|
|
18
|
+
from .themes import Palette, Style, Theme, get_palette, get_theme
|
|
19
|
+
from .writer import HTMLWriter
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
'Adapter',
|
|
23
|
+
'AdapterRegistry',
|
|
24
|
+
'Artifact',
|
|
25
|
+
'Columns',
|
|
26
|
+
'Container',
|
|
27
|
+
'Document',
|
|
28
|
+
'HTMLWriter',
|
|
29
|
+
'List',
|
|
30
|
+
'Markdown',
|
|
31
|
+
'Node',
|
|
32
|
+
'Palette',
|
|
33
|
+
'Panel',
|
|
34
|
+
'RawHTML',
|
|
35
|
+
'RenderedArtifact',
|
|
36
|
+
'Report',
|
|
37
|
+
'Section',
|
|
38
|
+
'Style',
|
|
39
|
+
'TemplateError',
|
|
40
|
+
'Theme',
|
|
41
|
+
'default_registry',
|
|
42
|
+
'get_palette',
|
|
43
|
+
'get_theme',
|
|
44
|
+
]
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Markdown template composition; no artifact rendering happens here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import string
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from contextlib import ExitStack
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from decimal import Decimal
|
|
12
|
+
from html import unescape
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
import mistune
|
|
16
|
+
from mistune import BlockParser, BlockState
|
|
17
|
+
|
|
18
|
+
_NAME = r'[A-Za-z_][A-Za-z0-9_]*'
|
|
19
|
+
_SCALARS = (str, int, float, bool, Decimal, date, datetime)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TemplateError(ValueError):
|
|
23
|
+
"""Invalid template syntax or context, with a source location."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _SourceState(BlockState):
|
|
27
|
+
def append_token(self, token):
|
|
28
|
+
token['_start'] = self.cursor
|
|
29
|
+
super().append_token(token)
|
|
30
|
+
|
|
31
|
+
def add_paragraph(self, text):
|
|
32
|
+
start = self.cursor
|
|
33
|
+
super().add_paragraph(text)
|
|
34
|
+
self.tokens[-1].setdefault('_start', start)
|
|
35
|
+
self.tokens[-1]['_end'] = start + len(text)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _SourceParser(BlockParser):
|
|
39
|
+
state_cls = _SourceState
|
|
40
|
+
|
|
41
|
+
def parse_block_html(self, match, state):
|
|
42
|
+
# Mistune uses this rule to end lazy list/blockquote continuations.
|
|
43
|
+
# Template blocks must end those continuations in the same way.
|
|
44
|
+
raw = match.group().lstrip()
|
|
45
|
+
if raw.startswith('{%'):
|
|
46
|
+
return self._methods['template'](match, state)
|
|
47
|
+
if re.fullmatch(self.specification['template_artifact'], match.group()):
|
|
48
|
+
return self._methods['template_artifact'](match, state)
|
|
49
|
+
return super().parse_block_html(match, state)
|
|
50
|
+
|
|
51
|
+
def parse_method(self, match, state):
|
|
52
|
+
start = state.cursor
|
|
53
|
+
before = {id(token) for token in state.tokens}
|
|
54
|
+
previous = dict(state.tokens[-1]) if state.tokens else None
|
|
55
|
+
end = super().parse_method(match, state)
|
|
56
|
+
if end:
|
|
57
|
+
added = [token for token in state.tokens if id(token) not in before]
|
|
58
|
+
if added:
|
|
59
|
+
added[0]['_start'] = start
|
|
60
|
+
for index, token in enumerate(added):
|
|
61
|
+
token['_end'] = (
|
|
62
|
+
added[index + 1]['_start'] if index + 1 < len(added) else end
|
|
63
|
+
)
|
|
64
|
+
elif state.tokens and state.tokens[-1] != previous:
|
|
65
|
+
state.tokens[-1]['_end'] = end
|
|
66
|
+
return end
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _escaped(source, position):
|
|
70
|
+
start = position
|
|
71
|
+
while start and source[start - 1] == '\\':
|
|
72
|
+
start -= 1
|
|
73
|
+
return (position - start) % 2 == 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Template:
|
|
77
|
+
def __init__(self, source, context, filename):
|
|
78
|
+
self.source = source.replace('\r\n', '\n').replace('\r', '\n')
|
|
79
|
+
self.context = context
|
|
80
|
+
self.filename = filename
|
|
81
|
+
self.offset = 0
|
|
82
|
+
self.variables = {}
|
|
83
|
+
self.resolved = {}
|
|
84
|
+
self.prefix = 'REPORTKIT' + uuid4().hex + 'VAR'
|
|
85
|
+
self.marker = re.compile(self.prefix + r'\d+END')
|
|
86
|
+
|
|
87
|
+
def error(self, message, line=1):
|
|
88
|
+
raise TemplateError(f'{self.filename}:{line + self.offset}: {message}')
|
|
89
|
+
|
|
90
|
+
def metadata(self):
|
|
91
|
+
if not re.match(r'\A---[ \t]*\n', self.source):
|
|
92
|
+
return {}
|
|
93
|
+
opening = self.source.index('\n') + 1
|
|
94
|
+
closing = re.search(r'^---[ \t]*(?:\n|$)', self.source[opening:], re.MULTILINE)
|
|
95
|
+
if closing is None:
|
|
96
|
+
self.error('Unclosed YAML front matter')
|
|
97
|
+
try:
|
|
98
|
+
import yaml
|
|
99
|
+
except ImportError as exc:
|
|
100
|
+
raise ImportError(
|
|
101
|
+
'YAML front matter requires report-toolkit[templates]; '
|
|
102
|
+
'install with: pip install "report-toolkit[templates]"'
|
|
103
|
+
) from exc
|
|
104
|
+
|
|
105
|
+
class UniqueLoader(yaml.SafeLoader):
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def mapping(loader, node):
|
|
109
|
+
result = {}
|
|
110
|
+
for key_node, value_node in node.value:
|
|
111
|
+
key = loader.construct_object(key_node)
|
|
112
|
+
if not isinstance(key, str):
|
|
113
|
+
self.error(
|
|
114
|
+
'Metadata keys must be strings', key_node.start_mark.line + 2
|
|
115
|
+
)
|
|
116
|
+
if key in result:
|
|
117
|
+
self.error(
|
|
118
|
+
f'Duplicate metadata key: {key}', key_node.start_mark.line + 2
|
|
119
|
+
)
|
|
120
|
+
result[key] = loader.construct_object(value_node)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
UniqueLoader.add_constructor('tag:yaml.org,2002:map', mapping)
|
|
124
|
+
try:
|
|
125
|
+
values = yaml.load(
|
|
126
|
+
self.source[opening : opening + closing.start()], Loader=UniqueLoader
|
|
127
|
+
)
|
|
128
|
+
except yaml.YAMLError as exc:
|
|
129
|
+
mark = getattr(exc, 'problem_mark', None)
|
|
130
|
+
self.error('Invalid YAML front matter', mark.line + 2 if mark else 1)
|
|
131
|
+
if values is None:
|
|
132
|
+
values = {}
|
|
133
|
+
if not isinstance(values, dict):
|
|
134
|
+
self.error('YAML front matter must be a metadata mapping')
|
|
135
|
+
for key in values:
|
|
136
|
+
if key not in {'title', 'description', 'author', 'date'}:
|
|
137
|
+
self.error(f'Unknown metadata key: {key}')
|
|
138
|
+
end = opening + closing.end()
|
|
139
|
+
self.offset = self.source[:end].count('\n')
|
|
140
|
+
self.source = self.source[end:]
|
|
141
|
+
return values
|
|
142
|
+
|
|
143
|
+
def mask(self):
|
|
144
|
+
# Unique markers let Markdown classify placeholders before substitution.
|
|
145
|
+
# In particular, placeholders in code/HTML stay untouched, and values
|
|
146
|
+
# cannot introduce Markdown structure or executable template syntax.
|
|
147
|
+
def replace(match):
|
|
148
|
+
if _escaped(self.source, match.start()):
|
|
149
|
+
return match.group()
|
|
150
|
+
marker = f'{self.prefix}{len(self.variables)}END'
|
|
151
|
+
line = self.source[: match.start()].count('\n') + 1
|
|
152
|
+
self.variables[marker] = (match.group(), line)
|
|
153
|
+
return marker
|
|
154
|
+
|
|
155
|
+
return re.sub(
|
|
156
|
+
r'\{\{[^\n]*?\}\}|\{\{[^\n]*$', replace, self.source, flags=re.MULTILINE
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def value(self, marker):
|
|
160
|
+
expression, line = self.variables[marker]
|
|
161
|
+
match = re.fullmatch(r'\{\{\s*(' + _NAME + r')\s*\}\}', expression)
|
|
162
|
+
if not match:
|
|
163
|
+
self.error('Expected {{ name }} with a simple context key', line)
|
|
164
|
+
name = match[1]
|
|
165
|
+
if name not in self.context:
|
|
166
|
+
self.error(f'Missing template variable: {name}', line)
|
|
167
|
+
value = self.context[name]
|
|
168
|
+
if value is None:
|
|
169
|
+
self.error(f'Template variable {name} cannot be None', line)
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
def scalar(self, marker):
|
|
173
|
+
value = self.value(marker)
|
|
174
|
+
if not isinstance(value, _SCALARS):
|
|
175
|
+
self.error(
|
|
176
|
+
'Artifacts require a standalone placeholder outside lists and blockquotes',
|
|
177
|
+
self.variables[marker][1],
|
|
178
|
+
)
|
|
179
|
+
return value.isoformat() if isinstance(value, (date, datetime)) else str(value)
|
|
180
|
+
|
|
181
|
+
def text(self, text):
|
|
182
|
+
return self.marker.sub(lambda m: self.scalar(m.group()), text)
|
|
183
|
+
|
|
184
|
+
def restore(self, text):
|
|
185
|
+
def replace(match):
|
|
186
|
+
key = match.group()
|
|
187
|
+
if key in self.resolved:
|
|
188
|
+
# Escape Markdown punctuation, including HTML delimiters.
|
|
189
|
+
# Narrative whitespace follows Markdown's prose conventions.
|
|
190
|
+
value = ' '.join(self.resolved[key].split())
|
|
191
|
+
return ''.join(
|
|
192
|
+
'\\' + char if char in string.punctuation else char
|
|
193
|
+
for char in value
|
|
194
|
+
)
|
|
195
|
+
return self.variables[key][0]
|
|
196
|
+
|
|
197
|
+
return self.marker.sub(replace, text)
|
|
198
|
+
|
|
199
|
+
def inspect(self, tokens, line):
|
|
200
|
+
html_elements = []
|
|
201
|
+
for token in tokens:
|
|
202
|
+
kind = token['type']
|
|
203
|
+
if kind == 'inline_html':
|
|
204
|
+
tag = re.match(r'<(/?)([A-Za-z][\w:-]*)\b', token['raw'])
|
|
205
|
+
if tag:
|
|
206
|
+
name = tag[2].lower()
|
|
207
|
+
if tag[1] and name in html_elements:
|
|
208
|
+
del html_elements[html_elements.index(name) :]
|
|
209
|
+
elif (
|
|
210
|
+
not tag[1]
|
|
211
|
+
and not token['raw'].endswith('/>')
|
|
212
|
+
and name
|
|
213
|
+
not in {
|
|
214
|
+
'area',
|
|
215
|
+
'base',
|
|
216
|
+
'br',
|
|
217
|
+
'col',
|
|
218
|
+
'embed',
|
|
219
|
+
'hr',
|
|
220
|
+
'img',
|
|
221
|
+
'input',
|
|
222
|
+
'link',
|
|
223
|
+
'meta',
|
|
224
|
+
'param',
|
|
225
|
+
'source',
|
|
226
|
+
'track',
|
|
227
|
+
'wbr',
|
|
228
|
+
}
|
|
229
|
+
):
|
|
230
|
+
html_elements.append(name)
|
|
231
|
+
continue
|
|
232
|
+
if html_elements or kind in {'block_code', 'codespan', 'block_html'}:
|
|
233
|
+
continue
|
|
234
|
+
if kind == 'template':
|
|
235
|
+
self.error(
|
|
236
|
+
'Template blocks cannot appear inside lists or blockquotes', line
|
|
237
|
+
)
|
|
238
|
+
if 'children' in token:
|
|
239
|
+
self.inspect(token['children'], line)
|
|
240
|
+
elif 'text' in token:
|
|
241
|
+
self.inspect(
|
|
242
|
+
mistune.InlineParser()(token['text'], self.state.env), line
|
|
243
|
+
)
|
|
244
|
+
elif kind == 'text':
|
|
245
|
+
raw = token['raw']
|
|
246
|
+
if '{%' in raw:
|
|
247
|
+
self.error('Template tags must occupy their own line', line)
|
|
248
|
+
for match in self.marker.finditer(raw):
|
|
249
|
+
key = match.group()
|
|
250
|
+
self.resolved[key] = self.scalar(key)
|
|
251
|
+
for value in token.get('attrs', {}).values():
|
|
252
|
+
if isinstance(value, str) and self.marker.search(value):
|
|
253
|
+
self.error(
|
|
254
|
+
'Variables are supported in narrative text, not link destinations or attributes',
|
|
255
|
+
line,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def plain(self, text):
|
|
259
|
+
def flatten(tokens):
|
|
260
|
+
result = []
|
|
261
|
+
for token in tokens:
|
|
262
|
+
if 'children' in token:
|
|
263
|
+
result.append(flatten(token['children']))
|
|
264
|
+
elif token['type'] in {'softbreak', 'linebreak'}:
|
|
265
|
+
result.append(' ')
|
|
266
|
+
elif token['type'] == 'text':
|
|
267
|
+
result.append(
|
|
268
|
+
self.marker.sub(
|
|
269
|
+
lambda m: self.resolved.get(
|
|
270
|
+
m.group(), self.variables[m.group()][0]
|
|
271
|
+
),
|
|
272
|
+
unescape(token.get('raw', '')),
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
elif token['type'] != 'inline_html':
|
|
276
|
+
result.append(
|
|
277
|
+
self.marker.sub(
|
|
278
|
+
lambda m: self.variables[m.group()][0],
|
|
279
|
+
unescape(token.get('raw', '')),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return ''.join(result)
|
|
283
|
+
|
|
284
|
+
return flatten(mistune.InlineParser()(text.strip(), self.state.env))
|
|
285
|
+
|
|
286
|
+
def parse(self, report):
|
|
287
|
+
source = self.mask()
|
|
288
|
+
if not source.endswith('\n'):
|
|
289
|
+
source += '\n'
|
|
290
|
+
parser = _SourceParser()
|
|
291
|
+
|
|
292
|
+
def directive(block, match, state):
|
|
293
|
+
end = state.find_line_end()
|
|
294
|
+
state.append_token(
|
|
295
|
+
{'type': 'template', 'raw': state.src[state.cursor : end].strip()}
|
|
296
|
+
)
|
|
297
|
+
return end
|
|
298
|
+
|
|
299
|
+
def placeholder(block, match, state):
|
|
300
|
+
marker = match.group().strip()
|
|
301
|
+
if isinstance(self.value(marker), _SCALARS):
|
|
302
|
+
return None
|
|
303
|
+
end = state.find_line_end()
|
|
304
|
+
state.append_token({'type': 'template_artifact', 'marker': marker})
|
|
305
|
+
return end
|
|
306
|
+
|
|
307
|
+
parser.register(
|
|
308
|
+
'template', r'^ {0,3}\{%[^\n]*(?:\n|$)', directive, before='fenced_code'
|
|
309
|
+
)
|
|
310
|
+
parser.register(
|
|
311
|
+
'template_artifact',
|
|
312
|
+
r'^ {0,3}' + self.marker.pattern + r'[ \t]*$',
|
|
313
|
+
placeholder,
|
|
314
|
+
before='fenced_code',
|
|
315
|
+
)
|
|
316
|
+
parser.specification['block_html'] += (
|
|
317
|
+
'|'
|
|
318
|
+
+ parser.specification['template']
|
|
319
|
+
+ '|'
|
|
320
|
+
+ parser.specification['template_artifact']
|
|
321
|
+
)
|
|
322
|
+
# Recognize invalid nested directives too, so they produce useful errors.
|
|
323
|
+
parser.list_rules.insert(0, 'template')
|
|
324
|
+
parser.block_quote_rules.insert(0, 'template')
|
|
325
|
+
state = _SourceState()
|
|
326
|
+
state.process(source)
|
|
327
|
+
parser.parse(state)
|
|
328
|
+
self.state = state
|
|
329
|
+
# Markdown references have document scope, even when their definition
|
|
330
|
+
# occurs inside a list or quote. Give every chunk the same definitions.
|
|
331
|
+
references = []
|
|
332
|
+
for reference in state.env['ref_links'].values():
|
|
333
|
+
label, url = reference['label'], reference['url']
|
|
334
|
+
definition = f'[{label}]: <{url}>'
|
|
335
|
+
if reference.get('title'):
|
|
336
|
+
title = reference['title'].replace('\\', '\\\\').replace('"', '\\"')
|
|
337
|
+
definition += f' "{title}"'
|
|
338
|
+
marker = self.marker.search(definition)
|
|
339
|
+
if marker:
|
|
340
|
+
self.error(
|
|
341
|
+
'Variables are not supported in reference definitions',
|
|
342
|
+
self.variables[marker.group()][1],
|
|
343
|
+
)
|
|
344
|
+
references.append(definition)
|
|
345
|
+
refs = '\n'.join(references)
|
|
346
|
+
|
|
347
|
+
pending = []
|
|
348
|
+
frames = []
|
|
349
|
+
with ExitStack() as scopes:
|
|
350
|
+
|
|
351
|
+
def flush():
|
|
352
|
+
if pending and ''.join(pending).strip():
|
|
353
|
+
report.markdown(
|
|
354
|
+
self.restore(''.join(pending))
|
|
355
|
+
+ ('\n\n' + refs if refs.strip() else '')
|
|
356
|
+
)
|
|
357
|
+
pending.clear()
|
|
358
|
+
|
|
359
|
+
for token in state.tokens:
|
|
360
|
+
start, end = token['_start'], token['_end']
|
|
361
|
+
line = source[:start].count('\n') + 1
|
|
362
|
+
kind = token['type']
|
|
363
|
+
raw = source[start:end]
|
|
364
|
+
if kind == 'template':
|
|
365
|
+
flush()
|
|
366
|
+
tag = token['raw']
|
|
367
|
+
match = re.fullmatch(r'\{%\s*(.*?)\s*%\}', tag)
|
|
368
|
+
if not match:
|
|
369
|
+
self.error('Malformed template tag', line)
|
|
370
|
+
instruction = match[1]
|
|
371
|
+
if instruction in {'endcolumns', 'endpanel'}:
|
|
372
|
+
expected = instruction[3:]
|
|
373
|
+
if not frames or frames[-1][0] != expected:
|
|
374
|
+
self.error(f'Unexpected {instruction}', line)
|
|
375
|
+
_, scope, _ = frames.pop()
|
|
376
|
+
scope.close()
|
|
377
|
+
continue
|
|
378
|
+
columns = re.fullmatch(r'columns\s+([1-9][0-9]*)', instruction)
|
|
379
|
+
panel = re.fullmatch(r'panel\s+("(?:[^"\\]|\\.)*")', instruction)
|
|
380
|
+
artifact = re.fullmatch(
|
|
381
|
+
r'artifact\s+(' + _NAME + r')(.*)',
|
|
382
|
+
instruction,
|
|
383
|
+
)
|
|
384
|
+
if columns or panel:
|
|
385
|
+
if columns:
|
|
386
|
+
context = report.columns(int(columns[1]))
|
|
387
|
+
name = 'columns'
|
|
388
|
+
else:
|
|
389
|
+
context = report.panel(
|
|
390
|
+
self.text(self.quoted(panel[1], line))
|
|
391
|
+
)
|
|
392
|
+
name = 'panel'
|
|
393
|
+
scope = scopes.enter_context(ExitStack())
|
|
394
|
+
scope.enter_context(context)
|
|
395
|
+
frames.append((name, scope, line))
|
|
396
|
+
elif artifact:
|
|
397
|
+
name = artifact[1]
|
|
398
|
+
if name not in self.context or self.context[name] is None:
|
|
399
|
+
self.error(
|
|
400
|
+
f'Missing or None artifact variable: {name}', line
|
|
401
|
+
)
|
|
402
|
+
value = self.context[name]
|
|
403
|
+
if isinstance(value, _SCALARS):
|
|
404
|
+
self.error(
|
|
405
|
+
'An artifact tag requires an analytical object', line
|
|
406
|
+
)
|
|
407
|
+
options = {}
|
|
408
|
+
remaining = artifact[2]
|
|
409
|
+
while remaining.strip():
|
|
410
|
+
attribute = re.match(
|
|
411
|
+
r'\s+([a-z_]+)=("(?:[^"\\]|\\.)*"|true|false)(?=\s|$)',
|
|
412
|
+
remaining,
|
|
413
|
+
)
|
|
414
|
+
if not attribute:
|
|
415
|
+
self.error('Invalid artifact attribute', line)
|
|
416
|
+
key, raw_value = attribute.groups()
|
|
417
|
+
if key not in {
|
|
418
|
+
'caption',
|
|
419
|
+
'width',
|
|
420
|
+
'center',
|
|
421
|
+
'expand',
|
|
422
|
+
}:
|
|
423
|
+
self.error(f'Unknown artifact attribute: {key}', line)
|
|
424
|
+
if key in options:
|
|
425
|
+
self.error(f'Duplicate artifact attribute: {key}', line)
|
|
426
|
+
options[key] = (
|
|
427
|
+
self.text(self.quoted(raw_value, line))
|
|
428
|
+
if raw_value.startswith('"')
|
|
429
|
+
else raw_value == 'true'
|
|
430
|
+
)
|
|
431
|
+
remaining = remaining[attribute.end() :]
|
|
432
|
+
try:
|
|
433
|
+
report.add(value, **options)
|
|
434
|
+
except (TypeError, ValueError) as exc:
|
|
435
|
+
self.error(str(exc), line)
|
|
436
|
+
|
|
437
|
+
else:
|
|
438
|
+
self.error('Unknown or invalid template tag', line)
|
|
439
|
+
elif kind == 'template_artifact':
|
|
440
|
+
flush()
|
|
441
|
+
report.add(self.value(token['marker']))
|
|
442
|
+
elif kind == 'heading':
|
|
443
|
+
flush()
|
|
444
|
+
self.inspect([token], line)
|
|
445
|
+
report.heading(token['attrs']['level'], self.plain(token['text']))
|
|
446
|
+
elif kind == 'paragraph' and self.marker.fullmatch(
|
|
447
|
+
token['text'].strip()
|
|
448
|
+
):
|
|
449
|
+
marker = token['text'].strip()
|
|
450
|
+
value = self.value(marker)
|
|
451
|
+
if isinstance(value, _SCALARS):
|
|
452
|
+
self.resolved[marker] = self.scalar(marker)
|
|
453
|
+
pending.append(raw)
|
|
454
|
+
else:
|
|
455
|
+
flush()
|
|
456
|
+
report.add(value)
|
|
457
|
+
else:
|
|
458
|
+
self.inspect([token], line)
|
|
459
|
+
pending.append(raw)
|
|
460
|
+
flush()
|
|
461
|
+
if frames:
|
|
462
|
+
name, _, line = frames[-1]
|
|
463
|
+
self.error(f'Unclosed {name} block', line)
|
|
464
|
+
report._sections = [[]]
|
|
465
|
+
return report
|
|
466
|
+
|
|
467
|
+
def quoted(self, value, line):
|
|
468
|
+
try:
|
|
469
|
+
return json.loads(value)
|
|
470
|
+
except ValueError:
|
|
471
|
+
self.error('Expected a valid double-quoted string', line)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def compose(report_cls, source, context, filename, overrides):
|
|
475
|
+
if not isinstance(source, str):
|
|
476
|
+
raise TypeError('template source must be a string')
|
|
477
|
+
if context is None:
|
|
478
|
+
context = {}
|
|
479
|
+
if not isinstance(context, Mapping):
|
|
480
|
+
raise TypeError('template context must be a mapping')
|
|
481
|
+
template = _Template(source, context, filename)
|
|
482
|
+
metadata = template.metadata()
|
|
483
|
+
metadata.update(overrides)
|
|
484
|
+
try:
|
|
485
|
+
report = report_cls(**metadata)
|
|
486
|
+
except (TypeError, ValueError) as exc:
|
|
487
|
+
raise TemplateError(f'{filename}:1: {exc}') from exc
|
|
488
|
+
return template.parse(report)
|
report_toolkit/_tree.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Plain-text inspection of a composed document without rendering artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textwrap import shorten
|
|
6
|
+
|
|
7
|
+
from .model import (
|
|
8
|
+
Artifact,
|
|
9
|
+
Columns,
|
|
10
|
+
Container,
|
|
11
|
+
Document,
|
|
12
|
+
List,
|
|
13
|
+
Markdown,
|
|
14
|
+
Node,
|
|
15
|
+
Panel,
|
|
16
|
+
RawHTML,
|
|
17
|
+
Section,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_Entry = tuple[str, list['_Entry']]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _preview(text: str) -> str:
|
|
24
|
+
return repr(shorten(text, width=72, placeholder='...'))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _list_entry(items: tuple, ordered: bool) -> _Entry:
|
|
28
|
+
children: list[_Entry] = []
|
|
29
|
+
for item in items:
|
|
30
|
+
if isinstance(item, str):
|
|
31
|
+
children.append((f'Item({_preview(item)})', []))
|
|
32
|
+
else:
|
|
33
|
+
children[-1][1].append(_list_entry(item, ordered))
|
|
34
|
+
return f'List(ordered={ordered})', children
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _node_entry(node: Node) -> _Entry:
|
|
38
|
+
if isinstance(node, List):
|
|
39
|
+
return _list_entry(node.items, node.ordered)
|
|
40
|
+
if isinstance(node, Artifact):
|
|
41
|
+
value_type = type(node.value)
|
|
42
|
+
label = f'Artifact({value_type.__module__}.{value_type.__qualname__}'
|
|
43
|
+
if node.caption is not None:
|
|
44
|
+
label += f', caption={_preview(node.caption)}'
|
|
45
|
+
label += ')'
|
|
46
|
+
elif isinstance(node, (Markdown, RawHTML)):
|
|
47
|
+
label = f'{type(node).__name__}({_preview(node.content)})'
|
|
48
|
+
elif isinstance(node, Section):
|
|
49
|
+
label = f'Section(level={node.level}, title={_preview(node.title)})'
|
|
50
|
+
elif isinstance(node, (Document, Panel)):
|
|
51
|
+
title = _preview(node.title) if node.title is not None else 'None'
|
|
52
|
+
label = f'{type(node).__name__}(title={title})'
|
|
53
|
+
elif isinstance(node, Columns):
|
|
54
|
+
label = f'Columns(count={node.count})'
|
|
55
|
+
else:
|
|
56
|
+
label = type(node).__name__
|
|
57
|
+
children = (
|
|
58
|
+
[_node_entry(child) for child in node.children]
|
|
59
|
+
if isinstance(node, Container)
|
|
60
|
+
else []
|
|
61
|
+
)
|
|
62
|
+
return label, children
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_tree(document: Document) -> str:
|
|
66
|
+
label, children = _node_entry(document)
|
|
67
|
+
lines = [label]
|
|
68
|
+
|
|
69
|
+
def visit(entries: list[_Entry], prefix: str) -> None:
|
|
70
|
+
for index, (label, children) in enumerate(entries):
|
|
71
|
+
last = index == len(entries) - 1
|
|
72
|
+
lines.append(prefix + ('└── ' if last else '├── ') + label)
|
|
73
|
+
visit(children, prefix + (' ' if last else '│ '))
|
|
74
|
+
|
|
75
|
+
visit(children, '')
|
|
76
|
+
return '\n'.join(lines)
|