reportsmith 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {reportsmith-0.2.0/reportsmith.egg-info → reportsmith-0.3.0}/PKG-INFO +8 -3
- {reportsmith-0.2.0 → reportsmith-0.3.0}/README.md +6 -1
- {reportsmith-0.2.0 → reportsmith-0.3.0}/pyproject.toml +1 -1
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/__init__.py +3 -1
- reportsmith-0.3.0/reportsmith/data.py +214 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/elements.py +132 -1
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/html.py +48 -2
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/latex.py +8 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/markdown.py +23 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/pdf.py +81 -0
- reportsmith-0.3.0/reportsmith/toc.py +111 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0/reportsmith.egg-info}/PKG-INFO +8 -3
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/SOURCES.txt +5 -1
- reportsmith-0.3.0/tests/test_data.py +293 -0
- reportsmith-0.3.0/tests/test_toc.py +264 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/LICENSE +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/inline.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/renderers/__init__.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith/report.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/dependency_links.txt +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/requires.txt +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/reportsmith.egg-info/top_level.txt +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/setup.cfg +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_elements.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_inline.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_latex.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_pdf.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_renderers.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.3.0}/tests/test_reportsmith.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reportsmith
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A small, dependency-free library for building reports and exporting them to Markdown, HTML, LaTeX and PDF
|
|
5
5
|
Author: Michal Stojke
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/mstojke/reportsmith
|
|
8
|
-
Project-URL: Documentation, https://
|
|
8
|
+
Project-URL: Documentation, https://reportsmith.readthedocs.io/en/latest/index.html
|
|
9
9
|
Project-URL: Changelog, https://github.com/mstojke/reportsmith/blob/main/CHANGELOG.md
|
|
10
10
|
Project-URL: Issues, https://github.com/mstojke/reportsmith/issues
|
|
11
11
|
Keywords: report,markdown,html,latex,pdf,export,document
|
|
@@ -35,6 +35,8 @@ A small, dependency-free Python library for building reports programmatically an
|
|
|
35
35
|
## Features
|
|
36
36
|
|
|
37
37
|
- Fluent builder API with chainable methods and nested sections (`with report.section(...)`)
|
|
38
|
+
- Table of contents in every format: linked lists in Markdown/HTML, `\tableofcontents` in LaTeX, clickable entries with page numbers in PDF
|
|
39
|
+
- Tables straight from data: lists of dicts, CSV files, or pandas DataFrames, with number formatting, missing-value placeholders, and automatic right-alignment of numeric columns
|
|
38
40
|
- Elements: headings, paragraphs, tables (with alignment and captions), bullet/numbered lists (nestable), code blocks, block quotes, images, horizontal rules, and format-specific raw passthrough
|
|
39
41
|
- Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()` — render correctly in both formats
|
|
40
42
|
- Correct escaping everywhere: HTML entities, Markdown table pipes, code fences that widen when the source contains ` ``` `
|
|
@@ -57,6 +59,7 @@ Or just copy the `reportsmith/` package into your project — it has no dependen
|
|
|
57
59
|
from reportsmith import Report, bold, link
|
|
58
60
|
|
|
59
61
|
report = Report("Q3 Sales Report", author="Jane Doe", subtitle="Quarterly summary")
|
|
62
|
+
report.toc() # table of contents
|
|
60
63
|
|
|
61
64
|
with report.section("Overview") as s:
|
|
62
65
|
s.paragraph(["Revenue was ", bold("up 12%"), " quarter over quarter."])
|
|
@@ -116,9 +119,11 @@ Builder methods (all chainable, also available on sections):
|
|
|
116
119
|
| `code(source, language="")` | Fenced/`<pre>` code block |
|
|
117
120
|
| `bullet_list(items)` / `numbered_list(items)` | List; items may be text or a nested `ListElement` |
|
|
118
121
|
| `table(headers, rows, caption="", align=None)` | Table; `align` entries are `"left"`, `"center"`, `"right"` |
|
|
122
|
+
| `table_from_records(records)` / `table_from_csv(source)` / `table_from_dataframe(df)` | Table built from data; options `columns`, `headers`, `formats`, `missing`, `align`, `caption` |
|
|
119
123
|
| `image(src, alt="", title="")` | Image |
|
|
120
124
|
| `horizontal_rule()` | Rule |
|
|
121
125
|
| `raw(content, format="both")` | Verbatim content for `"markdown"`, `"html"`, `"latex"`, or `"both"` (Markdown + HTML) |
|
|
126
|
+
| `toc(title="Contents", depth=3)` | Table of contents for the whole report; `depth` 1–5 sets how many heading levels are listed |
|
|
122
127
|
| `add(element)` | Any pre-built element |
|
|
123
128
|
|
|
124
129
|
Anywhere text is accepted you may pass a plain string or a list mixing strings with `bold()`, `italic()`, `code()`, and `link(text, href)` spans.
|
|
@@ -133,7 +138,7 @@ Anywhere text is accepted you may pass a plain string or a list mixing strings w
|
|
|
133
138
|
|
|
134
139
|
## Running tests
|
|
135
140
|
|
|
136
|
-
The test suite (
|
|
141
|
+
The test suite (256 tests) lives in `tests/`:
|
|
137
142
|
|
|
138
143
|
```bash
|
|
139
144
|
python -m pytest
|
|
@@ -5,6 +5,8 @@ A small, dependency-free Python library for building reports programmatically an
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- Fluent builder API with chainable methods and nested sections (`with report.section(...)`)
|
|
8
|
+
- Table of contents in every format: linked lists in Markdown/HTML, `\tableofcontents` in LaTeX, clickable entries with page numbers in PDF
|
|
9
|
+
- Tables straight from data: lists of dicts, CSV files, or pandas DataFrames, with number formatting, missing-value placeholders, and automatic right-alignment of numeric columns
|
|
8
10
|
- Elements: headings, paragraphs, tables (with alignment and captions), bullet/numbered lists (nestable), code blocks, block quotes, images, horizontal rules, and format-specific raw passthrough
|
|
9
11
|
- Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()` — render correctly in both formats
|
|
10
12
|
- Correct escaping everywhere: HTML entities, Markdown table pipes, code fences that widen when the source contains ` ``` `
|
|
@@ -27,6 +29,7 @@ Or just copy the `reportsmith/` package into your project — it has no dependen
|
|
|
27
29
|
from reportsmith import Report, bold, link
|
|
28
30
|
|
|
29
31
|
report = Report("Q3 Sales Report", author="Jane Doe", subtitle="Quarterly summary")
|
|
32
|
+
report.toc() # table of contents
|
|
30
33
|
|
|
31
34
|
with report.section("Overview") as s:
|
|
32
35
|
s.paragraph(["Revenue was ", bold("up 12%"), " quarter over quarter."])
|
|
@@ -86,9 +89,11 @@ Builder methods (all chainable, also available on sections):
|
|
|
86
89
|
| `code(source, language="")` | Fenced/`<pre>` code block |
|
|
87
90
|
| `bullet_list(items)` / `numbered_list(items)` | List; items may be text or a nested `ListElement` |
|
|
88
91
|
| `table(headers, rows, caption="", align=None)` | Table; `align` entries are `"left"`, `"center"`, `"right"` |
|
|
92
|
+
| `table_from_records(records)` / `table_from_csv(source)` / `table_from_dataframe(df)` | Table built from data; options `columns`, `headers`, `formats`, `missing`, `align`, `caption` |
|
|
89
93
|
| `image(src, alt="", title="")` | Image |
|
|
90
94
|
| `horizontal_rule()` | Rule |
|
|
91
95
|
| `raw(content, format="both")` | Verbatim content for `"markdown"`, `"html"`, `"latex"`, or `"both"` (Markdown + HTML) |
|
|
96
|
+
| `toc(title="Contents", depth=3)` | Table of contents for the whole report; `depth` 1–5 sets how many heading levels are listed |
|
|
92
97
|
| `add(element)` | Any pre-built element |
|
|
93
98
|
|
|
94
99
|
Anywhere text is accepted you may pass a plain string or a list mixing strings with `bold()`, `italic()`, `code()`, and `link(text, href)` spans.
|
|
@@ -103,7 +108,7 @@ Anywhere text is accepted you may pass a plain string or a list mixing strings w
|
|
|
103
108
|
|
|
104
109
|
## Running tests
|
|
105
110
|
|
|
106
|
-
The test suite (
|
|
111
|
+
The test suite (256 tests) lives in `tests/`:
|
|
107
112
|
|
|
108
113
|
```bash
|
|
109
114
|
python -m pytest
|
|
@@ -21,7 +21,7 @@ classifiers = [
|
|
|
21
21
|
|
|
22
22
|
[project.urls]
|
|
23
23
|
Homepage = "https://github.com/mstojke/reportsmith"
|
|
24
|
-
Documentation = "https://
|
|
24
|
+
Documentation = "https://reportsmith.readthedocs.io/en/latest/index.html"
|
|
25
25
|
Changelog = "https://github.com/mstojke/reportsmith/blob/main/CHANGELOG.md"
|
|
26
26
|
Issues = "https://github.com/mstojke/reportsmith/issues"
|
|
27
27
|
|
|
@@ -29,6 +29,7 @@ from .elements import (
|
|
|
29
29
|
Raw,
|
|
30
30
|
Section,
|
|
31
31
|
Table,
|
|
32
|
+
TableOfContents,
|
|
32
33
|
)
|
|
33
34
|
from .report import Report
|
|
34
35
|
from .renderers.markdown import MarkdownRenderer
|
|
@@ -36,7 +37,7 @@ from .renderers.html import HtmlRenderer
|
|
|
36
37
|
from .renderers.pdf import PdfRenderer
|
|
37
38
|
from .renderers.latex import LatexRenderer
|
|
38
39
|
|
|
39
|
-
__version__ = "0.
|
|
40
|
+
__version__ = "0.3.0"
|
|
40
41
|
|
|
41
42
|
__all__ = [
|
|
42
43
|
"Report",
|
|
@@ -44,6 +45,7 @@ __all__ = [
|
|
|
44
45
|
"Heading",
|
|
45
46
|
"Paragraph",
|
|
46
47
|
"Table",
|
|
48
|
+
"TableOfContents",
|
|
47
49
|
"ListElement",
|
|
48
50
|
"CodeBlock",
|
|
49
51
|
"Image",
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Build tables from structured data: records, CSV files and DataFrames.
|
|
2
|
+
|
|
3
|
+
pandas is never imported: DataFrames are read through their public
|
|
4
|
+
``columns`` / ``itertuples()`` interface, so reportsmith stays
|
|
5
|
+
dependency-free and works with any DataFrame-like object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import numbers
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import IO, TYPE_CHECKING, Any, Callable, Iterable, Optional, Sequence, Union
|
|
17
|
+
|
|
18
|
+
from .inline import Inline, TextLike
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .elements import Table
|
|
22
|
+
|
|
23
|
+
#: A format string such as ``"{:,.2f}"`` or a callable returning cell text.
|
|
24
|
+
CellFormat = Union[str, Callable[[Any], TextLike]]
|
|
25
|
+
#: One format for every numeric cell, or a per-column mapping.
|
|
26
|
+
Formats = Union[CellFormat, Mapping[Any, CellFormat], None]
|
|
27
|
+
#: Display headers, either one per selected column or a key -> label mapping.
|
|
28
|
+
Headers = Union[Sequence[TextLike], Mapping[Any, TextLike], None]
|
|
29
|
+
|
|
30
|
+
_INT_RE = re.compile(r"[+-]?(?:0|[1-9]\d*)")
|
|
31
|
+
_FLOAT_RE = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+)(?:[eE][+-]?\d+)?|[+-]?\d+[eE][+-]?\d+")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_missing(value: Any) -> bool:
|
|
35
|
+
"""None, NaN (float, numpy or Decimal) and pandas ``NA`` / ``NaT``."""
|
|
36
|
+
if value is None or type(value).__name__ in ("NAType", "NaTType"):
|
|
37
|
+
return True
|
|
38
|
+
return isinstance(value, numbers.Number) and value != value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_numeric(value: Any) -> bool:
|
|
42
|
+
return isinstance(value, numbers.Number) and not isinstance(value, bool)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_number(text: str) -> Union[str, int, float]:
|
|
46
|
+
"""Convert numeric-looking CSV text to int/float; leave everything else.
|
|
47
|
+
|
|
48
|
+
Integers with leading zeros (IDs, ZIP codes) and thousands separators are
|
|
49
|
+
kept as text so no information is lost.
|
|
50
|
+
"""
|
|
51
|
+
stripped = text.strip()
|
|
52
|
+
if _INT_RE.fullmatch(stripped):
|
|
53
|
+
return int(stripped)
|
|
54
|
+
if _FLOAT_RE.fullmatch(stripped):
|
|
55
|
+
return float(stripped)
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_table(
|
|
60
|
+
keys: Sequence[Any],
|
|
61
|
+
rows: Sequence[Sequence[Any]],
|
|
62
|
+
*,
|
|
63
|
+
columns: Optional[Sequence[Any]] = None,
|
|
64
|
+
headers: Headers = None,
|
|
65
|
+
formats: Formats = None,
|
|
66
|
+
align: Optional[Sequence[str]] = None,
|
|
67
|
+
missing: str = "",
|
|
68
|
+
caption: str = "",
|
|
69
|
+
) -> "Table":
|
|
70
|
+
"""Shared core: select columns, format values and build a :class:`Table`."""
|
|
71
|
+
from .elements import Table
|
|
72
|
+
|
|
73
|
+
if columns is not None:
|
|
74
|
+
unknown = [c for c in columns if c not in keys]
|
|
75
|
+
if unknown:
|
|
76
|
+
raise ValueError(f"Unknown column(s): {', '.join(map(repr, unknown))}")
|
|
77
|
+
indices = [list(keys).index(c) for c in columns]
|
|
78
|
+
keys = list(columns)
|
|
79
|
+
rows = [[row[i] for i in indices] for row in rows]
|
|
80
|
+
keys = list(keys)
|
|
81
|
+
|
|
82
|
+
if headers is None:
|
|
83
|
+
labels: list = [str(k) for k in keys]
|
|
84
|
+
elif isinstance(headers, Mapping):
|
|
85
|
+
_check_keys(headers, keys, "headers")
|
|
86
|
+
labels = [headers.get(k, str(k)) for k in keys]
|
|
87
|
+
else:
|
|
88
|
+
labels = list(headers)
|
|
89
|
+
if len(labels) != len(keys):
|
|
90
|
+
raise ValueError(f"headers has {len(labels)} entries, expected {len(keys)}")
|
|
91
|
+
|
|
92
|
+
if formats is None or isinstance(formats, str) or callable(formats):
|
|
93
|
+
default_format, per_column = formats, {}
|
|
94
|
+
else:
|
|
95
|
+
_check_keys(formats, keys, "formats")
|
|
96
|
+
default_format, per_column = None, dict(formats)
|
|
97
|
+
|
|
98
|
+
columns_values = list(zip(*rows)) if rows else [() for _ in keys]
|
|
99
|
+
if align is None:
|
|
100
|
+
align = ["right" if _numeric_column(values) else "left" for values in columns_values]
|
|
101
|
+
|
|
102
|
+
cells = [
|
|
103
|
+
[
|
|
104
|
+
_format(value, per_column.get(key), default_format, missing)
|
|
105
|
+
for key, value in zip(keys, row)
|
|
106
|
+
]
|
|
107
|
+
for row in rows
|
|
108
|
+
]
|
|
109
|
+
return Table(labels, cells, caption=caption, align=align)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def from_records(
|
|
113
|
+
records: Iterable[Mapping[Any, Any]],
|
|
114
|
+
columns: Optional[Sequence[Any]] = None,
|
|
115
|
+
**options: Any,
|
|
116
|
+
) -> "Table":
|
|
117
|
+
records = list(records)
|
|
118
|
+
for i, record in enumerate(records):
|
|
119
|
+
if not isinstance(record, Mapping):
|
|
120
|
+
raise TypeError(f"Record {i} is a {type(record).__name__}, expected a mapping")
|
|
121
|
+
keys: list = []
|
|
122
|
+
for record in records: # union of keys in first-seen order
|
|
123
|
+
keys.extend(k for k in record if k not in keys)
|
|
124
|
+
if columns is not None and records:
|
|
125
|
+
# Unlike keys absent from some records (filled with ``missing``), a
|
|
126
|
+
# column absent from every record is almost certainly a typo.
|
|
127
|
+
unknown = [c for c in columns if c not in keys]
|
|
128
|
+
if unknown:
|
|
129
|
+
raise ValueError(f"Unknown column(s): {', '.join(map(repr, unknown))}")
|
|
130
|
+
elif columns is not None:
|
|
131
|
+
keys = list(columns)
|
|
132
|
+
rows = [[record.get(k) for k in keys] for record in records]
|
|
133
|
+
return build_table(keys, rows, columns=columns, **options)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def from_csv(
|
|
137
|
+
source: Union[str, Path, IO[str]],
|
|
138
|
+
columns: Optional[Sequence[str]] = None,
|
|
139
|
+
*,
|
|
140
|
+
delimiter: str = ",",
|
|
141
|
+
encoding: str = "utf-8",
|
|
142
|
+
parse_numbers: bool = True,
|
|
143
|
+
**options: Any,
|
|
144
|
+
) -> "Table":
|
|
145
|
+
if hasattr(source, "read"):
|
|
146
|
+
text = source.read() # type: ignore[union-attr]
|
|
147
|
+
else:
|
|
148
|
+
text = Path(source).read_text(encoding=encoding)
|
|
149
|
+
# utf-8-sig behaviour for any encoding: drop a leading byte-order mark.
|
|
150
|
+
lines = list(csv.reader(io.StringIO(text.lstrip("")), delimiter=delimiter))
|
|
151
|
+
lines = [line for line in lines if line] # skip blank lines
|
|
152
|
+
if not lines:
|
|
153
|
+
raise ValueError("CSV input is empty; expected a header row")
|
|
154
|
+
keys, body = lines[0], lines[1:]
|
|
155
|
+
rows: list[list] = []
|
|
156
|
+
for n, line in enumerate(body, start=2):
|
|
157
|
+
if len(line) > len(keys):
|
|
158
|
+
raise ValueError(f"CSV line {n} has {len(line)} fields, expected {len(keys)}")
|
|
159
|
+
values: list = [None if v == "" else v for v in line]
|
|
160
|
+
rows.append(values + [None] * (len(keys) - len(values)))
|
|
161
|
+
if parse_numbers:
|
|
162
|
+
for i in range(len(keys)):
|
|
163
|
+
# All or nothing per column, so a ZIP-code column holding both
|
|
164
|
+
# "00501" and "90210" stays text instead of mixing types.
|
|
165
|
+
parsed = [parse_number(row[i]) if row[i] is not None else None for row in rows]
|
|
166
|
+
if all(p is None or not isinstance(p, str) for p in parsed):
|
|
167
|
+
for row, value in zip(rows, parsed):
|
|
168
|
+
row[i] = value
|
|
169
|
+
return build_table(keys, rows, columns=columns, **options)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def from_dataframe(
|
|
173
|
+
df: Any,
|
|
174
|
+
columns: Optional[Sequence[Any]] = None,
|
|
175
|
+
*,
|
|
176
|
+
index: bool = False,
|
|
177
|
+
**options: Any,
|
|
178
|
+
) -> "Table":
|
|
179
|
+
if not (hasattr(df, "columns") and hasattr(df, "itertuples")):
|
|
180
|
+
raise TypeError(f"Expected a pandas-like DataFrame, got {type(df).__name__}")
|
|
181
|
+
keys = list(df.columns)
|
|
182
|
+
rows = [list(row) for row in df.itertuples(index=index, name=None)]
|
|
183
|
+
if index:
|
|
184
|
+
index_key = df.index.name if df.index.name is not None else ""
|
|
185
|
+
keys = [index_key] + keys
|
|
186
|
+
if columns is not None and index_key not in columns:
|
|
187
|
+
columns = [index_key] + list(columns)
|
|
188
|
+
return build_table(keys, rows, columns=columns, **options)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _check_keys(mapping: Mapping, keys: Sequence[Any], what: str) -> None:
|
|
192
|
+
unknown = [k for k in mapping if k not in keys]
|
|
193
|
+
if unknown:
|
|
194
|
+
raise ValueError(f"{what} names unknown column(s): {', '.join(map(repr, unknown))}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _numeric_column(values: Sequence[Any]) -> bool:
|
|
198
|
+
present = [v for v in values if not is_missing(v)]
|
|
199
|
+
return bool(present) and all(is_numeric(v) for v in present)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _format(value: Any, column_format: Optional[CellFormat], default_format: Optional[CellFormat], missing: str) -> TextLike:
|
|
203
|
+
if is_missing(value):
|
|
204
|
+
return missing
|
|
205
|
+
if isinstance(value, (list, tuple)):
|
|
206
|
+
return value # already TextLike (mixed str/Inline content)
|
|
207
|
+
fmt = column_format
|
|
208
|
+
if fmt is None and is_numeric(value):
|
|
209
|
+
fmt = default_format # a single format applies to numbers only
|
|
210
|
+
if fmt is None:
|
|
211
|
+
return value if isinstance(value, (str, Inline)) else str(value)
|
|
212
|
+
if callable(fmt):
|
|
213
|
+
return fmt(value)
|
|
214
|
+
return fmt.format(value)
|
|
@@ -8,10 +8,15 @@ with the same methods.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
-
from
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, TYPE_CHECKING, Any, Iterable, Optional, Sequence, Union
|
|
12
14
|
|
|
13
15
|
from .inline import InlineContent, TextLike, normalize
|
|
14
16
|
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .data import Formats, Headers
|
|
19
|
+
|
|
15
20
|
|
|
16
21
|
class Element:
|
|
17
22
|
"""Base class for all block elements."""
|
|
@@ -79,6 +84,85 @@ class Table(Element):
|
|
|
79
84
|
raise ValueError(f"Invalid alignment: {a!r}")
|
|
80
85
|
self.align = list(align) if align is not None else ["left"] * width
|
|
81
86
|
|
|
87
|
+
# -- construction from data ------------------------------------------
|
|
88
|
+
# Shared options: ``columns`` selects and orders columns; ``headers``
|
|
89
|
+
# relabels them (a list, or a column -> label mapping); ``formats`` is a
|
|
90
|
+
# format string / callable applied to every numeric cell, or a
|
|
91
|
+
# column -> format mapping applied to every value in that column;
|
|
92
|
+
# ``missing`` replaces None/NaN/NA; ``align=None`` right-aligns numeric
|
|
93
|
+
# columns and left-aligns the rest.
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_records(
|
|
97
|
+
cls,
|
|
98
|
+
records: Iterable[Mapping[Any, Any]],
|
|
99
|
+
columns: Optional[Sequence[Any]] = None,
|
|
100
|
+
*,
|
|
101
|
+
headers: Headers = None,
|
|
102
|
+
formats: Formats = None,
|
|
103
|
+
align: Optional[Sequence[str]] = None,
|
|
104
|
+
missing: str = "",
|
|
105
|
+
caption: str = "",
|
|
106
|
+
) -> "Table":
|
|
107
|
+
"""Build a table from a sequence of dicts (keys become headers)."""
|
|
108
|
+
from .data import from_records
|
|
109
|
+
|
|
110
|
+
return from_records(
|
|
111
|
+
records, columns, headers=headers, formats=formats,
|
|
112
|
+
align=align, missing=missing, caption=caption,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_csv(
|
|
117
|
+
cls,
|
|
118
|
+
source: Union[str, Path, IO[str]],
|
|
119
|
+
columns: Optional[Sequence[str]] = None,
|
|
120
|
+
*,
|
|
121
|
+
delimiter: str = ",",
|
|
122
|
+
encoding: str = "utf-8",
|
|
123
|
+
parse_numbers: bool = True,
|
|
124
|
+
headers: Headers = None,
|
|
125
|
+
formats: Formats = None,
|
|
126
|
+
align: Optional[Sequence[str]] = None,
|
|
127
|
+
missing: str = "",
|
|
128
|
+
caption: str = "",
|
|
129
|
+
) -> "Table":
|
|
130
|
+
"""Build a table from a CSV file path or open text file.
|
|
131
|
+
|
|
132
|
+
The first row is the header. With ``parse_numbers=True`` numeric
|
|
133
|
+
fields become numbers (so formats and auto-alignment apply);
|
|
134
|
+
integers with leading zeros stay text.
|
|
135
|
+
"""
|
|
136
|
+
from .data import from_csv
|
|
137
|
+
|
|
138
|
+
return from_csv(
|
|
139
|
+
source, columns, delimiter=delimiter, encoding=encoding,
|
|
140
|
+
parse_numbers=parse_numbers, headers=headers, formats=formats,
|
|
141
|
+
align=align, missing=missing, caption=caption,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def from_dataframe(
|
|
146
|
+
cls,
|
|
147
|
+
df: Any,
|
|
148
|
+
columns: Optional[Sequence[Any]] = None,
|
|
149
|
+
*,
|
|
150
|
+
index: bool = False,
|
|
151
|
+
headers: Headers = None,
|
|
152
|
+
formats: Formats = None,
|
|
153
|
+
align: Optional[Sequence[str]] = None,
|
|
154
|
+
missing: str = "",
|
|
155
|
+
caption: str = "",
|
|
156
|
+
) -> "Table":
|
|
157
|
+
"""Build a table from a pandas DataFrame (pandas is not required by
|
|
158
|
+
reportsmith itself). ``index=True`` adds the index as the first column."""
|
|
159
|
+
from .data import from_dataframe
|
|
160
|
+
|
|
161
|
+
return from_dataframe(
|
|
162
|
+
df, columns, index=index, headers=headers, formats=formats,
|
|
163
|
+
align=align, missing=missing, caption=caption,
|
|
164
|
+
)
|
|
165
|
+
|
|
82
166
|
|
|
83
167
|
class Image(Element):
|
|
84
168
|
def __init__(self, src: str, alt: str = "", title: str = ""):
|
|
@@ -108,6 +192,21 @@ class Raw(Element):
|
|
|
108
192
|
self.format = format
|
|
109
193
|
|
|
110
194
|
|
|
195
|
+
class TableOfContents(Element):
|
|
196
|
+
"""A table of contents listing the report's sections and headings.
|
|
197
|
+
|
|
198
|
+
Always covers the whole report, wherever it is placed. ``depth`` limits
|
|
199
|
+
how many heading levels are listed: 1 lists only top-level sections, 2
|
|
200
|
+
adds their subsections, and so on (up to 5).
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, title: str = "Contents", depth: int = 3):
|
|
204
|
+
if not 1 <= depth <= 5:
|
|
205
|
+
raise ValueError(f"TOC depth must be 1-5, got {depth}")
|
|
206
|
+
self.title = title
|
|
207
|
+
self.depth = depth
|
|
208
|
+
|
|
209
|
+
|
|
111
210
|
class Container(Element):
|
|
112
211
|
"""Mixin providing the fluent builder API shared by Report and Section."""
|
|
113
212
|
|
|
@@ -161,6 +260,33 @@ class Container(Element):
|
|
|
161
260
|
self.children.append(Table(headers, rows, caption, align))
|
|
162
261
|
return self
|
|
163
262
|
|
|
263
|
+
def table_from_records(
|
|
264
|
+
self,
|
|
265
|
+
records: Iterable[Mapping[Any, Any]],
|
|
266
|
+
columns: Optional[Sequence[Any]] = None,
|
|
267
|
+
**options: Any,
|
|
268
|
+
) -> "Container":
|
|
269
|
+
"""Add a table built from dicts; see :meth:`Table.from_records`."""
|
|
270
|
+
self.children.append(Table.from_records(records, columns, **options))
|
|
271
|
+
return self
|
|
272
|
+
|
|
273
|
+
def table_from_csv(
|
|
274
|
+
self,
|
|
275
|
+
source: Union[str, Path, IO[str]],
|
|
276
|
+
columns: Optional[Sequence[str]] = None,
|
|
277
|
+
**options: Any,
|
|
278
|
+
) -> "Container":
|
|
279
|
+
"""Add a table read from CSV; see :meth:`Table.from_csv`."""
|
|
280
|
+
self.children.append(Table.from_csv(source, columns, **options))
|
|
281
|
+
return self
|
|
282
|
+
|
|
283
|
+
def table_from_dataframe(
|
|
284
|
+
self, df: Any, columns: Optional[Sequence[Any]] = None, **options: Any
|
|
285
|
+
) -> "Container":
|
|
286
|
+
"""Add a table built from a DataFrame; see :meth:`Table.from_dataframe`."""
|
|
287
|
+
self.children.append(Table.from_dataframe(df, columns, **options))
|
|
288
|
+
return self
|
|
289
|
+
|
|
164
290
|
def image(self, src: str, alt: str = "", title: str = "") -> "Container":
|
|
165
291
|
self.children.append(Image(src, alt, title))
|
|
166
292
|
return self
|
|
@@ -173,6 +299,11 @@ class Container(Element):
|
|
|
173
299
|
self.children.append(Raw(content, format))
|
|
174
300
|
return self
|
|
175
301
|
|
|
302
|
+
def toc(self, title: str = "Contents", depth: int = 3) -> "Container":
|
|
303
|
+
"""Add a table of contents for the whole report."""
|
|
304
|
+
self.children.append(TableOfContents(title, depth))
|
|
305
|
+
return self
|
|
306
|
+
|
|
176
307
|
|
|
177
308
|
class Section(Container):
|
|
178
309
|
"""A titled group of elements; nests to any depth.
|
|
@@ -17,8 +17,10 @@ from ..elements import (
|
|
|
17
17
|
Raw,
|
|
18
18
|
Section,
|
|
19
19
|
Table,
|
|
20
|
+
TableOfContents,
|
|
20
21
|
)
|
|
21
22
|
from ..inline import Inline, InlineContent
|
|
23
|
+
from ..toc import TocEntry, build_tree, collect_entries, find_tocs, heading_anchors
|
|
22
24
|
|
|
23
25
|
if TYPE_CHECKING:
|
|
24
26
|
from ..report import Report
|
|
@@ -68,6 +70,12 @@ pre code { background: none; padding: 0; }
|
|
|
68
70
|
blockquote { border-left: 4px solid var(--border); color: var(--muted); margin: 1em 0; padding: 0.1em 1em; }
|
|
69
71
|
img { max-width: 100%; }
|
|
70
72
|
hr { border: none; border-top: 1px solid var(--border); margin: 2em 0; }
|
|
73
|
+
nav.toc { border: 1px solid var(--border); border-radius: 8px; padding: 0.8em 1.2em; margin: 1.5em 0; }
|
|
74
|
+
nav.toc .toc-title { font-weight: 600; margin-bottom: 0.3em; }
|
|
75
|
+
nav.toc ul { list-style: none; margin: 0; padding-left: 1.2em; }
|
|
76
|
+
nav.toc > ul { padding-left: 0; }
|
|
77
|
+
nav.toc a { text-decoration: none; }
|
|
78
|
+
nav.toc a:hover { text-decoration: underline; }
|
|
71
79
|
"""
|
|
72
80
|
|
|
73
81
|
|
|
@@ -99,6 +107,10 @@ class HtmlRenderer:
|
|
|
99
107
|
)
|
|
100
108
|
|
|
101
109
|
def _body(self, report: "Report") -> str:
|
|
110
|
+
self._report = report
|
|
111
|
+
# Headings only get ids when there is a TOC to link to them, so
|
|
112
|
+
# output for reports without one is unchanged.
|
|
113
|
+
self._anchors = heading_anchors(report) if any(find_tocs(report)) else {}
|
|
102
114
|
parts = [f"<h1>{escape(report.title)}</h1>\n"]
|
|
103
115
|
meta = []
|
|
104
116
|
if report.subtitle:
|
|
@@ -118,11 +130,11 @@ class HtmlRenderer:
|
|
|
118
130
|
def _render_element(self, el: Element) -> str:
|
|
119
131
|
if isinstance(el, Section):
|
|
120
132
|
level = el._level
|
|
121
|
-
out = [f"<h{level}>{self._inline(el.title_parts)}</h{level}>\n"]
|
|
133
|
+
out = [f"<h{level}{self._id(el)}>{self._inline(el.title_parts)}</h{level}>\n"]
|
|
122
134
|
out.extend(self._render_element(child) for child in el.children)
|
|
123
135
|
return "".join(out)
|
|
124
136
|
if isinstance(el, Heading):
|
|
125
|
-
return f"<h{el.level}>{self._inline(el.parts)}</h{el.level}>\n"
|
|
137
|
+
return f"<h{el.level}{self._id(el)}>{self._inline(el.parts)}</h{el.level}>\n"
|
|
126
138
|
if isinstance(el, Paragraph):
|
|
127
139
|
return f"<p>{self._inline(el.parts)}</p>\n"
|
|
128
140
|
if isinstance(el, Quote):
|
|
@@ -144,8 +156,42 @@ class HtmlRenderer:
|
|
|
144
156
|
return "<hr>\n"
|
|
145
157
|
if isinstance(el, Raw):
|
|
146
158
|
return el.content + "\n" if el.format in ("html", "both") else ""
|
|
159
|
+
if isinstance(el, TableOfContents):
|
|
160
|
+
return self._toc(el)
|
|
147
161
|
raise TypeError(f"Cannot render element of type {type(el).__name__}")
|
|
148
162
|
|
|
163
|
+
def _id(self, el: Element) -> str:
|
|
164
|
+
anchor = self._anchors.get(id(el))
|
|
165
|
+
return f' id="{escape(anchor, quote=True)}"' if anchor else ""
|
|
166
|
+
|
|
167
|
+
def _toc(self, el: TableOfContents) -> str:
|
|
168
|
+
label = escape(el.title or "Contents", quote=True)
|
|
169
|
+
out = [f'<nav class="toc" aria-label="{label}">\n']
|
|
170
|
+
if el.title:
|
|
171
|
+
out.append(f'<div class="toc-title">{escape(el.title)}</div>\n')
|
|
172
|
+
|
|
173
|
+
def walk(entries: list[TocEntry]) -> None:
|
|
174
|
+
out.append("<ul>\n")
|
|
175
|
+
for entry in entries:
|
|
176
|
+
anchor = escape(self._anchors[id(entry.element)], quote=True)
|
|
177
|
+
# Links can't nest inside the entry's own <a>; keep their text.
|
|
178
|
+
parts = [
|
|
179
|
+
p.text if isinstance(p, Inline) and p.style == "link" else p
|
|
180
|
+
for p in entry.parts
|
|
181
|
+
]
|
|
182
|
+
out.append(f'<li><a href="#{anchor}">{self._inline(parts)}</a>')
|
|
183
|
+
if entry.children:
|
|
184
|
+
out.append("\n")
|
|
185
|
+
walk(entry.children)
|
|
186
|
+
out.append("</li>\n")
|
|
187
|
+
out.append("</ul>\n")
|
|
188
|
+
|
|
189
|
+
entries = build_tree(collect_entries(self._report, el.depth))
|
|
190
|
+
if entries:
|
|
191
|
+
walk(entries)
|
|
192
|
+
out.append("</nav>\n")
|
|
193
|
+
return "".join(out)
|
|
194
|
+
|
|
149
195
|
def _list(self, el: ListElement) -> str:
|
|
150
196
|
tag = "ol" if el.ordered else "ul"
|
|
151
197
|
out = [f"<{tag}>\n"]
|
|
@@ -16,6 +16,7 @@ from ..elements import (
|
|
|
16
16
|
Raw,
|
|
17
17
|
Section,
|
|
18
18
|
Table,
|
|
19
|
+
TableOfContents,
|
|
19
20
|
)
|
|
20
21
|
from ..inline import Inline, InlineContent
|
|
21
22
|
|
|
@@ -171,6 +172,13 @@ class LatexRenderer:
|
|
|
171
172
|
return "\\par\\noindent\\rule{\\linewidth}{0.4pt}\\par\n\n"
|
|
172
173
|
if isinstance(el, Raw):
|
|
173
174
|
return el.content + "\n\n" if el.format == "latex" else ""
|
|
175
|
+
if isinstance(el, TableOfContents):
|
|
176
|
+
# Grouped so the renamed heading stays local to this TOC. Our
|
|
177
|
+
# depth 1 (top-level sections) is LaTeX's tocdepth 1 (\section).
|
|
178
|
+
return (
|
|
179
|
+
f"{{\\renewcommand{{\\contentsname}}{{{escape_latex(el.title)}}}"
|
|
180
|
+
f"\\setcounter{{tocdepth}}{{{el.depth}}}\\tableofcontents}}\n\n"
|
|
181
|
+
)
|
|
174
182
|
raise TypeError(f"Cannot render element of type {type(el).__name__}")
|
|
175
183
|
|
|
176
184
|
def _code(self, el: CodeBlock) -> str:
|
|
@@ -16,8 +16,10 @@ from ..elements import (
|
|
|
16
16
|
Raw,
|
|
17
17
|
Section,
|
|
18
18
|
Table,
|
|
19
|
+
TableOfContents,
|
|
19
20
|
)
|
|
20
21
|
from ..inline import Inline, InlineContent
|
|
22
|
+
from ..toc import TocEntry, build_tree, collect_entries, heading_anchors, plain_text
|
|
21
23
|
|
|
22
24
|
if TYPE_CHECKING:
|
|
23
25
|
from ..report import Report
|
|
@@ -29,6 +31,8 @@ class MarkdownRenderer:
|
|
|
29
31
|
"""Renders a Report to GitHub-flavored Markdown."""
|
|
30
32
|
|
|
31
33
|
def render(self, report: "Report") -> str:
|
|
34
|
+
self._report = report
|
|
35
|
+
self._anchors = heading_anchors(report)
|
|
32
36
|
blocks: list[str] = [f"# {report.title}"]
|
|
33
37
|
meta = self._metadata_lines(report)
|
|
34
38
|
if meta:
|
|
@@ -79,8 +83,27 @@ class MarkdownRenderer:
|
|
|
79
83
|
return ["---"]
|
|
80
84
|
if isinstance(el, Raw):
|
|
81
85
|
return [el.content] if el.format in ("markdown", "both") else []
|
|
86
|
+
if isinstance(el, TableOfContents):
|
|
87
|
+
return self._toc(el)
|
|
82
88
|
raise TypeError(f"Cannot render element of type {type(el).__name__}")
|
|
83
89
|
|
|
90
|
+
def _toc(self, el: TableOfContents) -> list[str]:
|
|
91
|
+
blocks = [f"**{el.title}**"] if el.title else []
|
|
92
|
+
lines: list[str] = []
|
|
93
|
+
|
|
94
|
+
def walk(entries: list[TocEntry], indent: int) -> None:
|
|
95
|
+
for entry in entries:
|
|
96
|
+
# Plain text: inline markup can't nest inside link text.
|
|
97
|
+
text = plain_text(entry.parts).replace("[", r"\[").replace("]", r"\]")
|
|
98
|
+
anchor = self._anchors[id(entry.element)]
|
|
99
|
+
lines.append(f"{' ' * indent}- [{text}](#{anchor})")
|
|
100
|
+
walk(entry.children, indent + 1)
|
|
101
|
+
|
|
102
|
+
walk(build_tree(collect_entries(self._report, el.depth)), 0)
|
|
103
|
+
if lines:
|
|
104
|
+
blocks.append("\n".join(lines))
|
|
105
|
+
return blocks
|
|
106
|
+
|
|
84
107
|
def _list_lines(self, el: ListElement, indent: int) -> list[str]:
|
|
85
108
|
lines: list[str] = []
|
|
86
109
|
pad = " " * indent
|