paperthin 0.1.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.
@@ -0,0 +1,51 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ with:
14
+ fetch-depth: 0
15
+ fetch-tags: true
16
+
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+
21
+ - name: Build sdist and wheel
22
+ run: |
23
+ python -m pip install --upgrade build
24
+ python -m build
25
+
26
+ - name: Check distributions
27
+ run: |
28
+ python -m pip install --upgrade twine
29
+ twine check dist/*
30
+
31
+ - uses: actions/upload-artifact@v4
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+
36
+ publish:
37
+ needs: build
38
+ runs-on: ubuntu-latest
39
+ environment:
40
+ name: pypi
41
+ url: https://pypi.org/p/paperthin
42
+ permissions:
43
+ id-token: write
44
+ steps:
45
+ - uses: actions/download-artifact@v4
46
+ with:
47
+ name: dist
48
+ path: dist/
49
+
50
+ - name: Publish to PyPI
51
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__
2
+ test*
3
+
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .venv/
8
+ paperthin/_version.py
@@ -0,0 +1,7 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.16.2
4
+ hooks:
5
+ - id: ruff-check
6
+ args: [--fix]
7
+ - id: ruff-format
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Stefano Cretti
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: paperthin
3
+ Version: 0.1.0
4
+ Summary: Python library to easily create HTML reports
5
+ Project-URL: Homepage, https://github.com/StefanoCretti/paperthin
6
+ Project-URL: Repository, https://github.com/StefanoCretti/paperthin
7
+ Author-email: Stefano Cretti <stefanocretti99@gmail.com>
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: BSD License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: ipykernel>=7.3
20
+ Requires-Dist: jupytext>=1.19
21
+ Requires-Dist: matplotlib>=3.11
22
+ Requires-Dist: nbclient>=0.11
23
+ Requires-Dist: nbconvert>=7.17
24
+ Requires-Dist: pandas>=3.0
25
+ Requires-Dist: pillow>=12.3
26
+ Requires-Dist: polars>=1.43
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: traitlets>=5.15
29
+ Provides-Extra: dev
30
+ Requires-Dist: pre-commit; extra == 'dev'
31
+ Requires-Dist: ruff; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # Paperthin
35
+
36
+ ## About
37
+ Paperthin is a lightweight Python library to easily create HTML reports.
38
+
39
+ The idea is very simple:
40
+
41
+ - You provide various items to add to the report (plots, figures, tables...).
42
+ - Paperthin stitches them into a nicely formatted HTML report.
43
+ - You can then share the report with collaborators (no installations needed on their side).
44
+
45
+ Why use Paperthin over a plain Jupyter Notebook?
46
+
47
+ - As it is plain Python, it is very easy to automate (no papermill variable
48
+ injection, takes care of the rendering).
49
+ - Minimal but clean formatting (sections, collapsible descriptions, rendering
50
+ presets for many types of data).
51
+ - Embeds the data in the report and provides download buttons to retrieve it.
52
+ - Easy to inspect in git diffs (no need to learn Jupytext syntax).
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ pip install paperthin
58
+ ```
59
+
60
+ Requires Python 3.12+.
61
+
62
+ ## Quick start
63
+
64
+ - Create a `Report` object
65
+ - Add `Section`s (graphical dividers) and `Entry` items with `+`
66
+ - Call `report.make_report(path)`.
67
+
68
+ ```python
69
+ from matplotlib import pyplot as plt
70
+ from paperthin import Entry, Report, Section
71
+
72
+ fig, ax = plt.subplots()
73
+ ax.plot([1, 2, 3], [1, 4, 9])
74
+
75
+ report = (
76
+ Report("My report")
77
+ + Section("Plots")
78
+ + Entry.plot(
79
+ fig,
80
+ title="Squares",
81
+ info="A simple plot with its underlying data attached.",
82
+ )
83
+ )
84
+
85
+ report.make_report("report.html")
86
+ ```
87
+
88
+ This produces a single, self-contained `report.html` file you can open in a
89
+ browser or send to anyone: no Python or Jupyter required on their end.
90
+
91
+
92
+ ## Entries
93
+
94
+ `Entry` currently provides constructors for the following data types:
95
+
96
+ | Constructor | Source |
97
+ | --------------------- | ------------------------------------------ |
98
+ | `Entry.plot(...)` | matplotlib `Figure` |
99
+ | `Entry.image(...)` | path to a `.png` or `.svg` file |
100
+ | `Entry.tabular(...)` | polars/pandas `DataFrame`, or csv/tsv path |
101
+ | `Entry.value(...)` | a single `str` or `float` |
102
+ | `Entry.config(...)` | `dict`, or path to a yaml/json file |
103
+
104
+ ## Documentation
105
+ This README covers the basics. Extended documentation is planned, including
106
+ how to create custom entries, and will be linked here once available.
107
+
108
+ ## Citation
109
+ If paperthin is useful in your work, please consider citing the repo:
110
+ https://github.com/StefanoCretti/paperthin
111
+
112
+ ## License
113
+ BSD-3-Clause. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,80 @@
1
+ # Paperthin
2
+
3
+ ## About
4
+ Paperthin is a lightweight Python library to easily create HTML reports.
5
+
6
+ The idea is very simple:
7
+
8
+ - You provide various items to add to the report (plots, figures, tables...).
9
+ - Paperthin stitches them into a nicely formatted HTML report.
10
+ - You can then share the report with collaborators (no installations needed on their side).
11
+
12
+ Why use Paperthin over a plain Jupyter Notebook?
13
+
14
+ - As it is plain Python, it is very easy to automate (no papermill variable
15
+ injection, takes care of the rendering).
16
+ - Minimal but clean formatting (sections, collapsible descriptions, rendering
17
+ presets for many types of data).
18
+ - Embeds the data in the report and provides download buttons to retrieve it.
19
+ - Easy to inspect in git diffs (no need to learn Jupytext syntax).
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install paperthin
25
+ ```
26
+
27
+ Requires Python 3.12+.
28
+
29
+ ## Quick start
30
+
31
+ - Create a `Report` object
32
+ - Add `Section`s (graphical dividers) and `Entry` items with `+`
33
+ - Call `report.make_report(path)`.
34
+
35
+ ```python
36
+ from matplotlib import pyplot as plt
37
+ from paperthin import Entry, Report, Section
38
+
39
+ fig, ax = plt.subplots()
40
+ ax.plot([1, 2, 3], [1, 4, 9])
41
+
42
+ report = (
43
+ Report("My report")
44
+ + Section("Plots")
45
+ + Entry.plot(
46
+ fig,
47
+ title="Squares",
48
+ info="A simple plot with its underlying data attached.",
49
+ )
50
+ )
51
+
52
+ report.make_report("report.html")
53
+ ```
54
+
55
+ This produces a single, self-contained `report.html` file you can open in a
56
+ browser or send to anyone: no Python or Jupyter required on their end.
57
+
58
+
59
+ ## Entries
60
+
61
+ `Entry` currently provides constructors for the following data types:
62
+
63
+ | Constructor | Source |
64
+ | --------------------- | ------------------------------------------ |
65
+ | `Entry.plot(...)` | matplotlib `Figure` |
66
+ | `Entry.image(...)` | path to a `.png` or `.svg` file |
67
+ | `Entry.tabular(...)` | polars/pandas `DataFrame`, or csv/tsv path |
68
+ | `Entry.value(...)` | a single `str` or `float` |
69
+ | `Entry.config(...)` | `dict`, or path to a yaml/json file |
70
+
71
+ ## Documentation
72
+ This README covers the basics. Extended documentation is planned, including
73
+ how to create custom entries, and will be linked here once available.
74
+
75
+ ## Citation
76
+ If paperthin is useful in your work, please consider citing the repo:
77
+ https://github.com/StefanoCretti/paperthin
78
+
79
+ ## License
80
+ BSD-3-Clause. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,5 @@
1
+ from ._version import __version__
2
+ from .components import Entry, Section
3
+ from .report import Report
4
+
5
+ __all__ = ("Entry", "Report", "Section", "__version__")
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,13 @@
1
+ from .config import ConfigAdapter
2
+ from .image import ImageAdapter
3
+ from .plot import PlotAdapter
4
+ from .tabular import TabularAdapter
5
+ from .value import ValueAdapter
6
+
7
+ __all__ = [
8
+ "ConfigAdapter",
9
+ "ImageAdapter",
10
+ "PlotAdapter",
11
+ "TabularAdapter",
12
+ "ValueAdapter",
13
+ ]
@@ -0,0 +1,44 @@
1
+ import json
2
+ import pathlib
3
+ from collections.abc import Iterable
4
+ from html import escape
5
+
6
+ import yaml
7
+
8
+ from ..html_helpers import DownloadButton
9
+ from . import types as ct
10
+
11
+
12
+ class ConfigAdapter:
13
+ def __init__(self, source: ct.ConfigSource, output: ct.ConfigOutput):
14
+
15
+ if isinstance(source, str):
16
+ match pathlib.Path(source).suffix:
17
+ case ".json":
18
+ with open(source) as f:
19
+ source = json.load(f)
20
+ case ".yaml" | ".yml":
21
+ with open(source) as f:
22
+ source = yaml.safe_load(f)
23
+ case _:
24
+ raise ValueError(f"{source} is not a valid json or yaml file.")
25
+
26
+ self._data = source
27
+ self._output: ct.ConfigOutput = output
28
+
29
+ def get_display(self) -> str:
30
+ text = yaml.safe_dump(self._data, sort_keys=False, default_flow_style=False)
31
+ return f'<pre class="pt-config">{escape(text)}</pre>'
32
+
33
+ def get_buttons(self, title: str) -> Iterable[DownloadButton]:
34
+
35
+ match self._output:
36
+ case "yaml":
37
+ content = yaml.safe_dump(
38
+ self._data, sort_keys=False, default_flow_style=False
39
+ ).encode("utf-8")
40
+ case "json":
41
+ content = json.dumps(self._data, indent=2, default=str).encode("utf-8")
42
+
43
+ button = DownloadButton.from_format(title, content, self._output)
44
+ return (button,)
@@ -0,0 +1,55 @@
1
+ import base64
2
+ import io
3
+ import pathlib
4
+ from collections.abc import Iterable
5
+
6
+ from PIL import Image
7
+
8
+ from ..html_helpers import MIMES, DownloadButton
9
+ from . import types as ct
10
+ from .tabular import TabularAdapter
11
+
12
+
13
+ class ImageAdapter:
14
+ def __init__(
15
+ self,
16
+ source: ct.ImageSource,
17
+ data: ct.TabularSource | None = None,
18
+ scale: float = 1.0,
19
+ ):
20
+ path = pathlib.Path(source)
21
+ self._content = path.read_bytes()
22
+ self._data = TabularAdapter(data, "tsv") if data is not None else None
23
+ self._scale = scale
24
+
25
+ self._output: ct.ImageOutput
26
+ match path.suffix:
27
+ case ".png":
28
+ self._output = "png"
29
+ case ".svg":
30
+ self._output = "svg"
31
+ case _:
32
+ raise ValueError(f"{source} is not a valid png or svg file.")
33
+
34
+ def get_display(self) -> str:
35
+ if self._output == "svg":
36
+ return self._content.decode("utf-8")
37
+
38
+ encoded = base64.b64encode(self._content).decode("ascii")
39
+
40
+ width, _height = Image.open(io.BytesIO(self._content)).size
41
+ scaled_width = round(width * self._scale)
42
+
43
+ return (
44
+ f'<img width="{scaled_width}" '
45
+ f'src="data:{MIMES[self._output]};base64,{encoded}">'
46
+ )
47
+
48
+ def get_buttons(self, title: str) -> Iterable[DownloadButton]:
49
+ button = DownloadButton.from_format(title, self._content, self._output)
50
+ buttons = [button]
51
+
52
+ if self._data is not None:
53
+ buttons.extend(self._data.get_buttons(title))
54
+
55
+ return buttons
@@ -0,0 +1,45 @@
1
+ import io
2
+ from collections.abc import Iterable
3
+
4
+ from ..html_helpers import DownloadButton
5
+ from . import types as ct
6
+ from .tabular import TabularAdapter
7
+
8
+
9
+ class PlotAdapter:
10
+ def __init__(
11
+ self,
12
+ source: ct.PlotSource,
13
+ output: ct.PlotOutput,
14
+ data: ct.TabularSource | None = None,
15
+ dpi: int = 300,
16
+ ):
17
+ self._fig = source
18
+ self._output: ct.PlotOutput = output
19
+ self._data = TabularAdapter(data, "tsv") if data is not None else None
20
+ self._dpi = dpi
21
+
22
+ # svg is always generated since it is used for display, regardless of output
23
+ buffer = io.BytesIO()
24
+ self._fig.savefig(buffer, format="svg", bbox_inches="tight")
25
+ self._svg = buffer.getvalue()
26
+
27
+ def get_display(self) -> str:
28
+ return self._svg.decode("utf-8")
29
+
30
+ def get_buttons(self, title: str) -> Iterable[DownloadButton]:
31
+
32
+ buttons: list[DownloadButton] = []
33
+
34
+ if self._output in ("svg", "svg+png"):
35
+ buttons.append(DownloadButton.from_format(title, self._svg, "svg"))
36
+
37
+ if self._output in ("png", "svg+png"):
38
+ buffer = io.BytesIO()
39
+ self._fig.savefig(buffer, format="png", bbox_inches="tight", dpi=self._dpi)
40
+ buttons.append(DownloadButton.from_format(title, buffer.getvalue(), "png"))
41
+
42
+ if self._data is not None:
43
+ buttons.extend(self._data.get_buttons(title))
44
+
45
+ return buttons
@@ -0,0 +1,49 @@
1
+ import pathlib
2
+ from collections.abc import Iterable
3
+ from html import escape
4
+
5
+ import pandas as pd
6
+ import polars as pl
7
+
8
+ from ..html_helpers import DownloadButton
9
+ from . import types as ct
10
+
11
+
12
+ class TabularAdapter:
13
+ def __init__(self, source: ct.TabularSource, output: ct.TabularOutput):
14
+
15
+ if isinstance(source, pd.DataFrame):
16
+ source = pl.from_pandas(source)
17
+
18
+ elif isinstance(source, str):
19
+ match pathlib.Path(source).suffix:
20
+ case ".csv":
21
+ source = pl.read_csv(source, separator=",")
22
+ case ".tsv":
23
+ source = pl.read_csv(source, separator="\t")
24
+ case _:
25
+ raise ValueError(f"{source} is not a valid csv or tsv file.")
26
+
27
+ self._data = source
28
+ self._output: ct.TabularOutput = output
29
+
30
+ def get_display(self) -> str:
31
+
32
+ header = "".join(f"<th>{escape(str(col))}</th>" for col in self._data.columns)
33
+ rows = "".join(
34
+ "<tr>"
35
+ + "".join(f"<td>{escape(str(value))}</td>" for value in row)
36
+ + "</tr>"
37
+ for row in self._data.iter_rows()
38
+ )
39
+ return f'<table class="dataframe"><thead><tr>{header}</tr></thead><tbody>{rows}</tbody></table>'
40
+
41
+ def get_buttons(self, title: str) -> Iterable[DownloadButton]:
42
+
43
+ separator = "\t" if self._output == "tsv" else ","
44
+ button = DownloadButton.from_format(
45
+ title,
46
+ self._data.write_csv(separator=separator).encode("utf-8"),
47
+ self._output,
48
+ )
49
+ return (button,)
@@ -0,0 +1,29 @@
1
+ from collections.abc import Iterable
2
+ from typing import Literal, Protocol
3
+
4
+ import pandas as pd
5
+ import polars as pl
6
+ from matplotlib import figure
7
+
8
+ from ..html_helpers import DownloadButton
9
+
10
+ type TabularSource = pl.DataFrame | pd.DataFrame | str
11
+ type TabularOutput = Literal["csv", "tsv"]
12
+
13
+ type PlotSource = figure.Figure
14
+ type PlotOutput = Literal["png", "svg", "svg+png"]
15
+
16
+ type ConfigSource = dict | str
17
+ type ConfigOutput = Literal["json", "yaml"]
18
+
19
+ type ValueSource = str | float
20
+ type ValueOutput = Literal["tsv"]
21
+
22
+ type ImageSource = str
23
+ type ImageOutput = Literal["png", "svg"]
24
+
25
+
26
+ class Adapter(Protocol):
27
+ def get_display(self) -> str: ...
28
+
29
+ def get_buttons(self, title: str) -> Iterable[DownloadButton]: ...
@@ -0,0 +1,21 @@
1
+ from collections.abc import Iterable
2
+ from html import escape
3
+
4
+ import polars as pl
5
+
6
+ from ..html_helpers import DownloadButton
7
+ from . import types as ct
8
+ from .tabular import TabularAdapter
9
+
10
+
11
+ class ValueAdapter:
12
+ def __init__(self, source: ct.ValueSource, output: ct.ValueOutput):
13
+ self._value = source
14
+ self._output: ct.ValueOutput = output
15
+
16
+ def get_display(self) -> str:
17
+ return escape(str(self._value))
18
+
19
+ def get_buttons(self, title: str) -> Iterable[DownloadButton]:
20
+ df = pl.DataFrame({"stat": title, "value": self._value})
21
+ return TabularAdapter(df, self._output).get_buttons(title)
@@ -0,0 +1,7 @@
1
+ from .entry import Entry
2
+ from .section import Section
3
+
4
+ __all__ = [
5
+ "Entry",
6
+ "Section",
7
+ ]
@@ -0,0 +1,229 @@
1
+ from .. import html_helpers as hh
2
+ from ..adapters import (
3
+ ConfigAdapter,
4
+ ImageAdapter,
5
+ PlotAdapter,
6
+ TabularAdapter,
7
+ ValueAdapter,
8
+ )
9
+ from ..adapters import types as ct
10
+
11
+
12
+ class Entry:
13
+ """Fundamental unit of a report, corresponding to an individual result.
14
+
15
+ An entry is a standardized way to display a result.
16
+ It is always composed of the same elements:
17
+ - A title (h3 size)
18
+ - A toggleable description of the result
19
+ - Some graphical representation of the result
20
+ - Download buttons for the data
21
+
22
+ Though it is possible to initialize an entry by providing all these
23
+ elements to the main class constructor, in most cases you should
24
+ use one of the data-type specific constructors.
25
+ The base constructor should be used only to create entries for
26
+ custom content types: pass any object implementing `get_display(self)
27
+ -> str` (the rendered HTML) and `get_buttons(self, title: str) ->
28
+ Iterable[DownloadButton]` (the download buttons).
29
+
30
+ See Also
31
+ --------
32
+ config : Create an entry from configs (dict, yaml, json).
33
+ image : Create an entry from an image (png, svg).
34
+ plot : Create an entry from a plot (matplotlib Figure).
35
+ tabular : Create an entry from tabular data (csv, tsv, df).
36
+ value : Create an entry from an individual value (str, float).
37
+
38
+ Parameters
39
+ ----------
40
+ content : Adapter
41
+ Object responsible for rendering the display and download buttons
42
+ (see above for the required shape).
43
+ title : str
44
+ The title of the entry.
45
+ info : str
46
+ Description of the content to place in the collapsible info section.
47
+ Supports HTML tags for formatting (bold, italics, ...).
48
+
49
+ """
50
+
51
+ def __init__(self, content: ct.Adapter, *, title: str, info: str):
52
+ self._content = content
53
+ self._title = title
54
+ self._info = info
55
+
56
+ @classmethod
57
+ def config(
58
+ cls,
59
+ source: ct.ConfigSource,
60
+ *,
61
+ title: str,
62
+ info: str,
63
+ output: ct.ConfigOutput = "yaml",
64
+ ) -> "Entry":
65
+ """Create an entry from a config (dict, yaml, or json).
66
+
67
+ Parameters
68
+ ----------
69
+ source : dict or str
70
+ The config to display as content. Can be a dict, or a path to a
71
+ yaml or json file.
72
+ title : str
73
+ The title of the entry.
74
+ info : str
75
+ Description of the content to place in the collapsible info section.
76
+ Supports HTML tags for formatting (bold, italics, ...).
77
+ output : {`yaml`, `json`}, optional
78
+ Format used for the download button generated for this data.
79
+ Default is `yaml`.
80
+
81
+ """
82
+
83
+ return Entry(ConfigAdapter(source, output), title=title, info=info)
84
+
85
+ @classmethod
86
+ def image(
87
+ cls,
88
+ source: ct.ImageSource,
89
+ *,
90
+ title: str,
91
+ info: str,
92
+ data: ct.TabularSource | None = None,
93
+ scale: float = 1.0,
94
+ ) -> "Entry":
95
+ """Create an entry from an image (png, svg).
96
+
97
+ Parameters
98
+ ----------
99
+ source : str
100
+ Path to a png or svg image file.
101
+ title : str
102
+ The title of the entry.
103
+ info : str
104
+ Description of the content to place in the collapsible info section.
105
+ Supports HTML tags for formatting (bold, italics, ...).
106
+ data : Tabular data, optional
107
+ Data used to generate the image. If provided, creates an additional
108
+ download button to fetch it as a tsv.
109
+ scale : float, optional
110
+ Factor applied to the image's natural width when displaying it
111
+ (e.g. `0.5` for half size). Only affects png images; the
112
+ downloaded file is always the original, unscaled source. Default
113
+ is `1.0`.
114
+
115
+ Note
116
+ ----
117
+ If trying to embed a matplotlib Figure directly, use the `plot`
118
+ constructor instead.
119
+ """
120
+
121
+ return Entry(ImageAdapter(source, data, scale), title=title, info=info)
122
+
123
+ @classmethod
124
+ def plot(
125
+ cls,
126
+ source: ct.PlotSource,
127
+ *,
128
+ title: str,
129
+ info: str,
130
+ data: ct.TabularSource | None = None,
131
+ output: ct.PlotOutput = "svg+png",
132
+ dpi: int = 300,
133
+ ) -> "Entry":
134
+ """Create an entry from a plot (matplotlib Figure).
135
+
136
+ Parameters
137
+ ----------
138
+ source : matplotlib Figure
139
+ The plot to display as content.
140
+ title : str
141
+ The title of the entry.
142
+ info : str
143
+ Description of the content to place in the collapsible info section.
144
+ Supports HTML tags for formatting (bold, italics, ...).
145
+ data : Tabular data, optional
146
+ Data used to generate the plot. If provided, creates an additional
147
+ download button to fetch it as a tsv.
148
+ output : {`svg`, `png`, `svg+png`}, optional
149
+ Output formats for which to create a download button.
150
+ Default is `svg+png`.
151
+ dpi : int, optional
152
+ Resolution used when rendering the png download. Default is `300`.
153
+
154
+ Note
155
+ ----
156
+ If trying to embed an image file, use the `image` constructor.
157
+ """
158
+
159
+ return Entry(PlotAdapter(source, output, data, dpi), title=title, info=info)
160
+
161
+ @classmethod
162
+ def tabular(
163
+ cls,
164
+ source: ct.TabularSource,
165
+ *,
166
+ title: str,
167
+ info: str,
168
+ output: ct.TabularOutput = "tsv",
169
+ ) -> "Entry":
170
+ """Create an entry from tabular data (e.g. csv, tsv, df).
171
+
172
+ Parameters
173
+ ----------
174
+ source : Tabular data
175
+ The tabular data to display as content. Can be a polars or
176
+ pandas DataFrame, or a path to a csv or tsv file.
177
+ title : str
178
+ The title of the entry.
179
+ info : str
180
+ Description of the content to place in the collapsible info section.
181
+ Supports HTML tags for formatting (bold, italics, ...).
182
+ output : {`tsv`, `csv`}, optional
183
+ Format used for the download button generated for this data.
184
+ Default is `tsv`.
185
+
186
+ Note
187
+ ----
188
+ If trying to attach tabular data to a plot, use the `data` parameter
189
+ of the `plot` constructor instead.
190
+ """
191
+
192
+ return Entry(TabularAdapter(source, output), title=title, info=info)
193
+
194
+ @classmethod
195
+ def value(
196
+ cls,
197
+ source: ct.ValueSource,
198
+ *,
199
+ title: str,
200
+ info: str,
201
+ output: ct.ValueOutput = "tsv",
202
+ ) -> "Entry":
203
+ """Create an entry from an individual value (str, float).
204
+
205
+ Parameters
206
+ ----------
207
+ source : str or float
208
+ The value to display as content.
209
+ title : str
210
+ The title of the entry.
211
+ info : str
212
+ Description of the content to place in the collapsible info section.
213
+ Supports HTML tags for formatting (bold, italics, ...).
214
+ output : {`tsv`}, optional
215
+ Format used for the download button generated for this data.
216
+ Default is `tsv`.
217
+
218
+ """
219
+
220
+ return Entry(ValueAdapter(source, output), title=title, info=info)
221
+
222
+ def get_content(self) -> str:
223
+ html = hh.get_html(
224
+ self._content.get_display(),
225
+ self._title,
226
+ self._info,
227
+ self._content.get_buttons(self._title),
228
+ )
229
+ return f"# %%\ndisplay(HTML({html!r}))"
@@ -0,0 +1,28 @@
1
+ class Section:
2
+ """Separator to group results into sections.
3
+
4
+ Inserts a title (h2) with highlighted background in the report.
5
+
6
+ Parameters
7
+ ----------
8
+ title : str
9
+ The title of the section.
10
+
11
+ """
12
+
13
+ def __init__(self, title: str):
14
+ self._title = title
15
+
16
+ @property
17
+ def title(self) -> str:
18
+ return self._title
19
+
20
+ def get_content(self) -> str:
21
+ """Return a markdown cell in jupytext percent format.
22
+
23
+ Returns
24
+ -------
25
+ str
26
+
27
+ """
28
+ return f"# %% [markdown]\n# ## {self.title}"
@@ -0,0 +1,5 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class Component(Protocol):
5
+ def get_content(self) -> str: ...
@@ -0,0 +1,122 @@
1
+ import base64
2
+ import re
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ type DownloadFormat = Literal["png", "svg", "tsv", "csv", "json", "yaml"]
8
+ MIMES: dict[DownloadFormat, str] = {
9
+ "png": "image/png",
10
+ "svg": "image/svg+xml",
11
+ "tsv": "text/tab-separated-values",
12
+ "csv": "text/csv",
13
+ "json": "application/json",
14
+ "yaml": "application/yaml",
15
+ }
16
+ CSS_STYLE: str = (
17
+ "h2 { background: #eef1f5; padding: 8px; border-radius: 6px; }"
18
+ ".pt-download-btn {"
19
+ " margin-left: 6px; padding: 2px 10px; border: 1px solid #ccc; background: white;"
20
+ " border-radius: 4px; text-decoration: none; font-size: 0.8em; color: #333;"
21
+ "}"
22
+ ".pt-buttons .pt-download-btn:link,"
23
+ ".pt-buttons .pt-download-btn:visited,"
24
+ ".pt-buttons .pt-download-btn:hover,"
25
+ ".pt-buttons .pt-download-btn:focus { text-decoration: none; }"
26
+ ".pt-row { margin-bottom: 10px; }"
27
+ ".pt-row-top { display: flex; align-items: baseline; gap: 8px; }"
28
+ ".pt-row-title { min-width: 0; }"
29
+ ".pt-row-title h3 { display: inline; margin: 0; }"
30
+ ".pt-info-checkbox { position: absolute; opacity: 0; width: 0; height: 0; }"
31
+ ".pt-info-icon {"
32
+ " display: inline-flex; align-items: center; justify-content: center;"
33
+ " vertical-align: middle; margin-left: 8px; cursor: pointer;"
34
+ " width: 17px; height: 17px; border-radius: 50%;"
35
+ " border: 1.3px solid #9aa4b0; color: #6b7280; font-size: 0.68rem;"
36
+ " font-weight: 700; font-family: Georgia, 'Times New Roman', serif;"
37
+ " line-height: 1;"
38
+ "}"
39
+ ".pt-info-checkbox:checked + .pt-info-icon {"
40
+ " background: #eef1f5; border-color: #6b7280; color: #1a1d21;"
41
+ "}"
42
+ ".pt-buttons { flex-shrink: 0; white-space: nowrap; margin-left: auto; }"
43
+ ".pt-desc-body {"
44
+ " display: none; margin-top: 8px; color: #555; font-size: 0.9em; line-height: 1.5;"
45
+ "}"
46
+ ".pt-row:has(.pt-info-checkbox:checked) .pt-desc-body { display: block; }"
47
+ # not needed yet: no figures in descriptions currently
48
+ # "/* .pt-desc-body svg { max-width: 100%; height: auto; } */"
49
+ "div.output_subarea { padding-top: 0 !important; max-width: 100% !important; }"
50
+ ".output_html svg { display: block; margin: 0 auto; }"
51
+ ".output_html img { display: block !important; margin: 0 auto !important; }"
52
+ ".output_html table.dataframe { margin-left: auto; margin-right: auto; }"
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class DownloadButton:
58
+ label: str
59
+ content: bytes
60
+ mime: str
61
+ file_name: str
62
+
63
+ _EMBED_TEMPLATE = (
64
+ '<a href="data:{mime};base64,{content}" '
65
+ 'download="{file_name}" '
66
+ 'class="pt-download-btn">'
67
+ "{label}</a>"
68
+ )
69
+
70
+ def get_embeddable(self) -> str:
71
+ """Create the raw html string for an individual content download button."""
72
+
73
+ return self._EMBED_TEMPLATE.format(
74
+ mime=self.mime,
75
+ content=base64.b64encode(self.content).decode("ascii"),
76
+ file_name=self.file_name,
77
+ label=self.label,
78
+ )
79
+
80
+ @classmethod
81
+ def from_format(
82
+ cls,
83
+ title: str,
84
+ content: bytes,
85
+ format: DownloadFormat,
86
+ ) -> "DownloadButton":
87
+ """Build a DownloadButton for the given format."""
88
+ return cls(format.upper(), content, MIMES[format], f"{title}.{format}")
89
+
90
+
91
+ def _slugify(text: str) -> str:
92
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
93
+
94
+
95
+ def get_html(
96
+ display: str,
97
+ title: str,
98
+ info: str,
99
+ buttons: Iterable[DownloadButton],
100
+ ) -> str:
101
+
102
+ downloads_div = (
103
+ f'<div class="pt-buttons">{"".join(b.get_embeddable() for b in buttons)}</div>'
104
+ if buttons
105
+ else ""
106
+ )
107
+
108
+ description_bar = (
109
+ '<div class="pt-row">'
110
+ '<div class="pt-row-top">'
111
+ '<div class="pt-row-title">'
112
+ f"<h3>{title}</h3>"
113
+ f'<input type="checkbox" id="pt-info-{_slugify(title)}" class="pt-info-checkbox">'
114
+ f'<label for="pt-info-{_slugify(title)}" class="pt-info-icon" title="Description">i</label>'
115
+ "</div>"
116
+ f"{downloads_div}"
117
+ "</div>"
118
+ f'<div class="pt-desc-body">{info}</div>'
119
+ "</div>"
120
+ )
121
+
122
+ return f"{description_bar}{display}"
File without changes
@@ -0,0 +1,89 @@
1
+ import sys
2
+
3
+ import jupytext
4
+ from nbclient import NotebookClient
5
+ from nbconvert import HTMLExporter
6
+ from traitlets.config import Config
7
+
8
+ from . import html_helpers as hh
9
+ from .components import types as ct
10
+
11
+
12
+ class Report:
13
+ """Container object to populate with results.
14
+
15
+ A report can be populated with any provided or user-defined component.
16
+ An object is a component if it implements the `get_content` method,
17
+ which returns a string representing one or more jupyter notebook cells
18
+ in jupytext percent format.
19
+
20
+ Components can be added using the `add` method or the overloaded `__add__`.
21
+ Components are rendered in the order they were added to the report.
22
+
23
+ Parameters
24
+ ----------
25
+ title : str
26
+ Title displayed at the top of the report (h1).
27
+
28
+ """
29
+
30
+ def __init__(self, title: str):
31
+ self._title = title
32
+ self._components: list[ct.Component] = []
33
+
34
+ def add(self, component: ct.Component) -> "Report":
35
+ """Add a component to the report.
36
+
37
+ Returns self to allow chaining add operations.
38
+
39
+ Parameters
40
+ ----------
41
+ component : object implementing `get_content(self) -> str`, see constructor.
42
+ An object to add at the end of the current queue of components.
43
+
44
+ Returns
45
+ -------
46
+ Report
47
+
48
+ """
49
+
50
+ self._components.append(component)
51
+ return self
52
+
53
+ __add__ = add
54
+
55
+ def make_report(self, path: str) -> None:
56
+ """Generate the rendered HTML report at the provided path.
57
+
58
+ Parameters
59
+ ----------
60
+ path : str
61
+ Path where to generate the report.
62
+
63
+ """
64
+
65
+ content = "# %%\nfrom IPython.display import HTML\n\n"
66
+ content += f"# %% [markdown]\n# # {self._title}\n\n"
67
+ content += "\n\n".join(c.get_content() for c in self._components)
68
+ nb = jupytext.reads(content, fmt="py:percent")
69
+
70
+ kernel_config = Config()
71
+ if sys.platform != "win32":
72
+ # local unix socket instead of tcp: silences ipykernel's
73
+ # unencrypted-transport warning, since ipc never leaves the machine
74
+ kernel_config.KernelManager.transport = "ipc"
75
+
76
+ NotebookClient(nb, config=kernel_config).execute()
77
+
78
+ config = Config()
79
+ config.HTMLExporter.exclude_input = True
80
+ config.HTMLExporter.exclude_input_prompt = True
81
+ config.HTMLExporter.exclude_output_prompt = True
82
+ config.HTMLExporter.template_name = "classic"
83
+
84
+ exporter = HTMLExporter(config=config)
85
+ html, _ = exporter.from_notebook_node(nb)
86
+ html = html.replace("</head>", f"<style>{hh.CSS_STYLE}</style></head>")
87
+
88
+ with open(path, "w", encoding="utf-8") as f:
89
+ f.write(html)
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "paperthin"
7
+ dynamic = ["version"]
8
+ description = "Python library to easily create HTML reports"
9
+ readme = "README.md"
10
+ license = "BSD-3-Clause"
11
+ requires-python = ">=3.12"
12
+ authors = [
13
+ { name = "Stefano Cretti", email = "stefanocretti99@gmail.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: BSD License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Libraries",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "ipykernel>=7.3",
27
+ "jupytext>=1.19",
28
+ "matplotlib>=3.11",
29
+ "nbclient>=0.11",
30
+ "nbconvert>=7.17",
31
+ "pandas>=3.0",
32
+ "pillow>=12.3",
33
+ "polars>=1.43",
34
+ "pyyaml>=6.0",
35
+ "traitlets>=5.15",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ dev = ["ruff", "pre-commit"]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/StefanoCretti/paperthin"
43
+ Repository = "https://github.com/StefanoCretti/paperthin"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["paperthin"]
47
+
48
+ [tool.hatch.version]
49
+ source = "vcs"
50
+
51
+ [tool.hatch.build.hooks.vcs]
52
+ version-file = "paperthin/_version.py"
53
+
54
+ [tool.ruff]
55
+ target-version = "py312"