reportsmith 0.2.0__tar.gz → 0.4.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.4.0}/PKG-INFO +38 -5
- {reportsmith-0.2.0 → reportsmith-0.4.0}/README.md +30 -3
- {reportsmith-0.2.0 → reportsmith-0.4.0}/pyproject.toml +25 -2
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/__init__.py +10 -2
- reportsmith-0.4.0/reportsmith/data.py +214 -0
- reportsmith-0.4.0/reportsmith/elements.py +386 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/inline.py +25 -2
- reportsmith-0.4.0/reportsmith/py.typed +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/html.py +96 -2
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/latex.py +49 -3
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/markdown.py +55 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/pdf.py +195 -4
- reportsmith-0.4.0/reportsmith/toc.py +117 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0/reportsmith.egg-info}/PKG-INFO +38 -5
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/SOURCES.txt +7 -1
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/requires.txt +4 -0
- reportsmith-0.4.0/tests/test_callouts_inline.py +318 -0
- reportsmith-0.4.0/tests/test_data.py +299 -0
- reportsmith-0.4.0/tests/test_toc.py +268 -0
- reportsmith-0.2.0/reportsmith/elements.py +0 -193
- {reportsmith-0.2.0 → reportsmith-0.4.0}/LICENSE +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/__init__.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/report.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/dependency_links.txt +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/top_level.txt +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/setup.cfg +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_elements.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_inline.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_latex.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_pdf.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_renderers.py +0 -0
- {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_reportsmith.py +0 -0
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: reportsmith
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Typing :: Typed
|
|
13
15
|
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
14
16
|
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
15
17
|
Classifier: Topic :: Text Processing :: Markup :: LaTeX
|
|
@@ -22,6 +24,10 @@ Provides-Extra: dev
|
|
|
22
24
|
Requires-Dist: pytest; extra == "dev"
|
|
23
25
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
26
|
Requires-Dist: fpdf2; extra == "dev"
|
|
27
|
+
Requires-Dist: pandas; extra == "dev"
|
|
28
|
+
Requires-Dist: pypdf; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy; extra == "dev"
|
|
25
31
|
Provides-Extra: docs
|
|
26
32
|
Requires-Dist: sphinx; extra == "docs"
|
|
27
33
|
Requires-Dist: myst-parser; extra == "docs"
|
|
@@ -30,13 +36,18 @@ Dynamic: license-file
|
|
|
30
36
|
|
|
31
37
|
# reportsmith
|
|
32
38
|
|
|
39
|
+
[](https://github.com/mstojke/reportsmith/actions/workflows/tests.yml)
|
|
40
|
+
|
|
33
41
|
A small, dependency-free Python library for building reports programmatically and exporting them to **Markdown**, a **standalone styled HTML** document, **LaTeX**, or (optionally) **PDF**.
|
|
34
42
|
|
|
35
43
|
## Features
|
|
36
44
|
|
|
37
45
|
- Fluent builder API with chainable methods and nested sections (`with report.section(...)`)
|
|
46
|
+
- Table of contents in every format: linked lists in Markdown/HTML, `\tableofcontents` in LaTeX, clickable entries with page numbers in PDF
|
|
47
|
+
- 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
48
|
- 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
|
-
- Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()` —
|
|
49
|
+
- Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()`, `strike()`, `sub()`, `sup()`, and numbered `footnote()`s — rendered natively in every format
|
|
50
|
+
- Callouts (note, tip, important, warning, caution): GitHub alerts in Markdown, coloured boxes in HTML, LaTeX and PDF
|
|
40
51
|
- Correct escaping everywhere: HTML entities, Markdown table pipes, code fences that widen when the source contains ` ``` `
|
|
41
52
|
- HTML export ships with a clean embedded stylesheet (light/dark aware) that you can replace with your own CSS
|
|
42
53
|
- LaTeX export: a compilable document (pdfLaTeX/XeLaTeX/LuaLaTeX) with booktabs tables, framed code blocks, hyperlinks, and PDF bookmarks — or a body fragment to `\input` into your own document
|
|
@@ -57,6 +68,7 @@ Or just copy the `reportsmith/` package into your project — it has no dependen
|
|
|
57
68
|
from reportsmith import Report, bold, link
|
|
58
69
|
|
|
59
70
|
report = Report("Q3 Sales Report", author="Jane Doe", subtitle="Quarterly summary")
|
|
71
|
+
report.toc() # table of contents
|
|
60
72
|
|
|
61
73
|
with report.section("Overview") as s:
|
|
62
74
|
s.paragraph(["Revenue was ", bold("up 12%"), " quarter over quarter."])
|
|
@@ -116,9 +128,12 @@ Builder methods (all chainable, also available on sections):
|
|
|
116
128
|
| `code(source, language="")` | Fenced/`<pre>` code block |
|
|
117
129
|
| `bullet_list(items)` / `numbered_list(items)` | List; items may be text or a nested `ListElement` |
|
|
118
130
|
| `table(headers, rows, caption="", align=None)` | Table; `align` entries are `"left"`, `"center"`, `"right"` |
|
|
131
|
+
| `table_from_records(records)` / `table_from_csv(source)` / `table_from_dataframe(df)` | Table built from data; options `columns`, `headers`, `formats`, `missing`, `align`, `caption` |
|
|
119
132
|
| `image(src, alt="", title="")` | Image |
|
|
120
133
|
| `horizontal_rule()` | Rule |
|
|
121
134
|
| `raw(content, format="both")` | Verbatim content for `"markdown"`, `"html"`, `"latex"`, or `"both"` (Markdown + HTML) |
|
|
135
|
+
| `toc(title="Contents", depth=3)` | Table of contents for the whole report; `depth` 1–5 sets how many heading levels are listed |
|
|
136
|
+
| `callout(kind="note", content=None, title="")` | Callout box (`"note"`, `"tip"`, `"important"`, `"warning"`, `"caution"`); returns the callout, usable as a context manager |
|
|
122
137
|
| `add(element)` | Any pre-built element |
|
|
123
138
|
|
|
124
139
|
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,18 +148,36 @@ Anywhere text is accepted you may pass a plain string or a list mixing strings w
|
|
|
133
148
|
|
|
134
149
|
## Running tests
|
|
135
150
|
|
|
136
|
-
|
|
151
|
+
Install the development dependencies (pytest, coverage, the optional fpdf2 and pandas integrations, pypdf for checking PDF output, ruff and mypy):
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pip install -e .[dev]
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The test suite lives in `tests/`. Tests for optional integrations are skipped when their package isn't installed.
|
|
137
158
|
|
|
138
159
|
```bash
|
|
139
160
|
python -m pytest
|
|
140
161
|
```
|
|
141
162
|
|
|
142
|
-
With a coverage report
|
|
163
|
+
With a coverage report:
|
|
143
164
|
|
|
144
165
|
```bash
|
|
145
166
|
python -m pytest --cov=reportsmith --cov-report=term-missing
|
|
146
167
|
```
|
|
147
168
|
|
|
169
|
+
Lint and type-check:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
ruff check .
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
mypy
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
CI runs all of these on Python 3.9–3.13, runs the suite once more with no optional dependencies installed, and compiles the LaTeX output of every example with pdfLaTeX, XeLaTeX, and LuaLaTeX.
|
|
180
|
+
|
|
148
181
|
## Changelog
|
|
149
182
|
|
|
150
183
|
See [CHANGELOG.md](https://github.com/mstojke/reportsmith/blob/main/CHANGELOG.md) — the format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
# reportsmith
|
|
2
2
|
|
|
3
|
+
[](https://github.com/mstojke/reportsmith/actions/workflows/tests.yml)
|
|
4
|
+
|
|
3
5
|
A small, dependency-free Python library for building reports programmatically and exporting them to **Markdown**, a **standalone styled HTML** document, **LaTeX**, or (optionally) **PDF**.
|
|
4
6
|
|
|
5
7
|
## Features
|
|
6
8
|
|
|
7
9
|
- Fluent builder API with chainable methods and nested sections (`with report.section(...)`)
|
|
10
|
+
- Table of contents in every format: linked lists in Markdown/HTML, `\tableofcontents` in LaTeX, clickable entries with page numbers in PDF
|
|
11
|
+
- 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
12
|
- 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
|
-
- Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()` —
|
|
13
|
+
- Inline formatting helpers: `bold()`, `italic()`, `code()`, `link()`, `strike()`, `sub()`, `sup()`, and numbered `footnote()`s — rendered natively in every format
|
|
14
|
+
- Callouts (note, tip, important, warning, caution): GitHub alerts in Markdown, coloured boxes in HTML, LaTeX and PDF
|
|
10
15
|
- Correct escaping everywhere: HTML entities, Markdown table pipes, code fences that widen when the source contains ` ``` `
|
|
11
16
|
- HTML export ships with a clean embedded stylesheet (light/dark aware) that you can replace with your own CSS
|
|
12
17
|
- LaTeX export: a compilable document (pdfLaTeX/XeLaTeX/LuaLaTeX) with booktabs tables, framed code blocks, hyperlinks, and PDF bookmarks — or a body fragment to `\input` into your own document
|
|
@@ -27,6 +32,7 @@ Or just copy the `reportsmith/` package into your project — it has no dependen
|
|
|
27
32
|
from reportsmith import Report, bold, link
|
|
28
33
|
|
|
29
34
|
report = Report("Q3 Sales Report", author="Jane Doe", subtitle="Quarterly summary")
|
|
35
|
+
report.toc() # table of contents
|
|
30
36
|
|
|
31
37
|
with report.section("Overview") as s:
|
|
32
38
|
s.paragraph(["Revenue was ", bold("up 12%"), " quarter over quarter."])
|
|
@@ -86,9 +92,12 @@ Builder methods (all chainable, also available on sections):
|
|
|
86
92
|
| `code(source, language="")` | Fenced/`<pre>` code block |
|
|
87
93
|
| `bullet_list(items)` / `numbered_list(items)` | List; items may be text or a nested `ListElement` |
|
|
88
94
|
| `table(headers, rows, caption="", align=None)` | Table; `align` entries are `"left"`, `"center"`, `"right"` |
|
|
95
|
+
| `table_from_records(records)` / `table_from_csv(source)` / `table_from_dataframe(df)` | Table built from data; options `columns`, `headers`, `formats`, `missing`, `align`, `caption` |
|
|
89
96
|
| `image(src, alt="", title="")` | Image |
|
|
90
97
|
| `horizontal_rule()` | Rule |
|
|
91
98
|
| `raw(content, format="both")` | Verbatim content for `"markdown"`, `"html"`, `"latex"`, or `"both"` (Markdown + HTML) |
|
|
99
|
+
| `toc(title="Contents", depth=3)` | Table of contents for the whole report; `depth` 1–5 sets how many heading levels are listed |
|
|
100
|
+
| `callout(kind="note", content=None, title="")` | Callout box (`"note"`, `"tip"`, `"important"`, `"warning"`, `"caution"`); returns the callout, usable as a context manager |
|
|
92
101
|
| `add(element)` | Any pre-built element |
|
|
93
102
|
|
|
94
103
|
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,18 +112,36 @@ Anywhere text is accepted you may pass a plain string or a list mixing strings w
|
|
|
103
112
|
|
|
104
113
|
## Running tests
|
|
105
114
|
|
|
106
|
-
|
|
115
|
+
Install the development dependencies (pytest, coverage, the optional fpdf2 and pandas integrations, pypdf for checking PDF output, ruff and mypy):
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install -e .[dev]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The test suite lives in `tests/`. Tests for optional integrations are skipped when their package isn't installed.
|
|
107
122
|
|
|
108
123
|
```bash
|
|
109
124
|
python -m pytest
|
|
110
125
|
```
|
|
111
126
|
|
|
112
|
-
With a coverage report
|
|
127
|
+
With a coverage report:
|
|
113
128
|
|
|
114
129
|
```bash
|
|
115
130
|
python -m pytest --cov=reportsmith --cov-report=term-missing
|
|
116
131
|
```
|
|
117
132
|
|
|
133
|
+
Lint and type-check:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
ruff check .
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
mypy
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
CI runs all of these on Python 3.9–3.13, runs the suite once more with no optional dependencies installed, and compiles the LaTeX output of every example with pdfLaTeX, XeLaTeX, and LuaLaTeX.
|
|
144
|
+
|
|
118
145
|
## Changelog
|
|
119
146
|
|
|
120
147
|
See [CHANGELOG.md](https://github.com/mstojke/reportsmith/blob/main/CHANGELOG.md) — the format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
@@ -14,6 +14,8 @@ authors = [{ name = "Michal Stojke" }]
|
|
|
14
14
|
keywords = ["report", "markdown", "html", "latex", "pdf", "export", "document"]
|
|
15
15
|
classifiers = [
|
|
16
16
|
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Typing :: Typed",
|
|
17
19
|
"Topic :: Text Processing :: Markup :: Markdown",
|
|
18
20
|
"Topic :: Text Processing :: Markup :: HTML",
|
|
19
21
|
"Topic :: Text Processing :: Markup :: LaTeX",
|
|
@@ -21,13 +23,13 @@ classifiers = [
|
|
|
21
23
|
|
|
22
24
|
[project.urls]
|
|
23
25
|
Homepage = "https://github.com/mstojke/reportsmith"
|
|
24
|
-
Documentation = "https://
|
|
26
|
+
Documentation = "https://reportsmith.readthedocs.io/en/latest/index.html"
|
|
25
27
|
Changelog = "https://github.com/mstojke/reportsmith/blob/main/CHANGELOG.md"
|
|
26
28
|
Issues = "https://github.com/mstojke/reportsmith/issues"
|
|
27
29
|
|
|
28
30
|
[project.optional-dependencies]
|
|
29
31
|
pdf = ["fpdf2"]
|
|
30
|
-
dev = ["pytest", "pytest-cov", "fpdf2"]
|
|
32
|
+
dev = ["pytest", "pytest-cov", "fpdf2", "pandas", "pypdf", "ruff", "mypy"]
|
|
31
33
|
docs = ["sphinx", "myst-parser", "sphinx-rtd-theme"]
|
|
32
34
|
|
|
33
35
|
[tool.setuptools.dynamic]
|
|
@@ -36,5 +38,26 @@ version = { attr = "reportsmith.__version__" }
|
|
|
36
38
|
[tool.setuptools.packages.find]
|
|
37
39
|
include = ["reportsmith*"]
|
|
38
40
|
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
reportsmith = ["py.typed"]
|
|
43
|
+
|
|
39
44
|
[tool.pytest.ini_options]
|
|
40
45
|
testpaths = ["tests"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff]
|
|
48
|
+
target-version = "py39"
|
|
49
|
+
extend-exclude = ["docs"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint]
|
|
52
|
+
# pyflakes, pycodestyle, and bugbear; long lines are allowed (tables of
|
|
53
|
+
# font paths and test fixtures read better unwrapped).
|
|
54
|
+
select = ["E", "F", "W", "B"]
|
|
55
|
+
ignore = ["E501"]
|
|
56
|
+
|
|
57
|
+
[tool.mypy]
|
|
58
|
+
# mypy itself needs 3.10+; 3.9 compatibility is covered by the test matrix.
|
|
59
|
+
python_version = "3.10"
|
|
60
|
+
files = ["reportsmith"]
|
|
61
|
+
# fpdf2 and pandas are optional and imported lazily.
|
|
62
|
+
ignore_missing_imports = true
|
|
63
|
+
warn_unused_ignores = true
|
|
@@ -17,8 +17,9 @@ Quick start::
|
|
|
17
17
|
report.save_html("report.html")
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
-
from .inline import Inline, bold, code, italic, link
|
|
20
|
+
from .inline import Inline, bold, code, footnote, italic, link, strike, sub, sup
|
|
21
21
|
from .elements import (
|
|
22
|
+
Callout,
|
|
22
23
|
CodeBlock,
|
|
23
24
|
Heading,
|
|
24
25
|
HorizontalRule,
|
|
@@ -29,6 +30,7 @@ from .elements import (
|
|
|
29
30
|
Raw,
|
|
30
31
|
Section,
|
|
31
32
|
Table,
|
|
33
|
+
TableOfContents,
|
|
32
34
|
)
|
|
33
35
|
from .report import Report
|
|
34
36
|
from .renderers.markdown import MarkdownRenderer
|
|
@@ -36,7 +38,7 @@ from .renderers.html import HtmlRenderer
|
|
|
36
38
|
from .renderers.pdf import PdfRenderer
|
|
37
39
|
from .renderers.latex import LatexRenderer
|
|
38
40
|
|
|
39
|
-
__version__ = "0.
|
|
41
|
+
__version__ = "0.4.0"
|
|
40
42
|
|
|
41
43
|
__all__ = [
|
|
42
44
|
"Report",
|
|
@@ -44,17 +46,23 @@ __all__ = [
|
|
|
44
46
|
"Heading",
|
|
45
47
|
"Paragraph",
|
|
46
48
|
"Table",
|
|
49
|
+
"TableOfContents",
|
|
47
50
|
"ListElement",
|
|
48
51
|
"CodeBlock",
|
|
49
52
|
"Image",
|
|
50
53
|
"Quote",
|
|
51
54
|
"HorizontalRule",
|
|
52
55
|
"Raw",
|
|
56
|
+
"Callout",
|
|
53
57
|
"Inline",
|
|
54
58
|
"bold",
|
|
55
59
|
"italic",
|
|
56
60
|
"code",
|
|
57
61
|
"link",
|
|
62
|
+
"strike",
|
|
63
|
+
"sub",
|
|
64
|
+
"sup",
|
|
65
|
+
"footnote",
|
|
58
66
|
"MarkdownRenderer",
|
|
59
67
|
"HtmlRenderer",
|
|
60
68
|
"PdfRenderer",
|
|
@@ -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()
|
|
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)
|