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.
Files changed (32) hide show
  1. {reportsmith-0.2.0/reportsmith.egg-info → reportsmith-0.4.0}/PKG-INFO +38 -5
  2. {reportsmith-0.2.0 → reportsmith-0.4.0}/README.md +30 -3
  3. {reportsmith-0.2.0 → reportsmith-0.4.0}/pyproject.toml +25 -2
  4. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/__init__.py +10 -2
  5. reportsmith-0.4.0/reportsmith/data.py +214 -0
  6. reportsmith-0.4.0/reportsmith/elements.py +386 -0
  7. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/inline.py +25 -2
  8. reportsmith-0.4.0/reportsmith/py.typed +0 -0
  9. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/html.py +96 -2
  10. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/latex.py +49 -3
  11. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/markdown.py +55 -0
  12. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/pdf.py +195 -4
  13. reportsmith-0.4.0/reportsmith/toc.py +117 -0
  14. {reportsmith-0.2.0 → reportsmith-0.4.0/reportsmith.egg-info}/PKG-INFO +38 -5
  15. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/SOURCES.txt +7 -1
  16. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/requires.txt +4 -0
  17. reportsmith-0.4.0/tests/test_callouts_inline.py +318 -0
  18. reportsmith-0.4.0/tests/test_data.py +299 -0
  19. reportsmith-0.4.0/tests/test_toc.py +268 -0
  20. reportsmith-0.2.0/reportsmith/elements.py +0 -193
  21. {reportsmith-0.2.0 → reportsmith-0.4.0}/LICENSE +0 -0
  22. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/renderers/__init__.py +0 -0
  23. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith/report.py +0 -0
  24. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/dependency_links.txt +0 -0
  25. {reportsmith-0.2.0 → reportsmith-0.4.0}/reportsmith.egg-info/top_level.txt +0 -0
  26. {reportsmith-0.2.0 → reportsmith-0.4.0}/setup.cfg +0 -0
  27. {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_elements.py +0 -0
  28. {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_inline.py +0 -0
  29. {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_latex.py +0 -0
  30. {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_pdf.py +0 -0
  31. {reportsmith-0.2.0 → reportsmith-0.4.0}/tests/test_renderers.py +0 -0
  32. {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.2.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://github.com/mstojke/reportsmith/blob/main/docs/user-guide.md
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
+ [![Tests](https://github.com/mstojke/reportsmith/actions/workflows/tests.yml/badge.svg)](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()` — render correctly in both formats
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
- The test suite (151 tests) lives in `tests/`:
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 (requires `pytest-cov`):
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
+ [![Tests](https://github.com/mstojke/reportsmith/actions/workflows/tests.yml/badge.svg)](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()` — render correctly in both formats
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
- The test suite (151 tests) lives in `tests/`:
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 (requires `pytest-cov`):
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://github.com/mstojke/reportsmith/blob/main/docs/user-guide.md"
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.2.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)