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.
@@ -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)
@@ -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)