briefing 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- briefing/__init__.py +192 -0
- briefing/_error.py +2 -0
- briefing/_frames.py +91 -0
- briefing/blocks/__init__.py +37 -0
- briefing/blocks/asset.py +124 -0
- briefing/blocks/base.py +113 -0
- briefing/blocks/layout.py +205 -0
- briefing/blocks/text.py +206 -0
- briefing/lab/__init__.py +49 -0
- briefing/lab/_blocks.py +125 -0
- briefing/lab/_render_datadive.py +158 -0
- briefing/lab/_render_profile.py +242 -0
- briefing/lab/static/lab.js +376 -0
- briefing/py.typed +0 -0
- briefing/renderers/__init__.py +5 -0
- briefing/renderers/formatting.py +176 -0
- briefing/renderers/html.py +352 -0
- briefing/renderers/normalize.py +55 -0
- briefing/renderers/plot.py +240 -0
- briefing/renderers/registry.py +102 -0
- briefing/renderers/table.py +159 -0
- briefing/static/report.css +608 -0
- briefing/static/report.js +156 -0
- briefing/templates/report.html.j2 +27 -0
- briefing-0.1.0.dist-info/METADATA +688 -0
- briefing-0.1.0.dist-info/RECORD +28 -0
- briefing-0.1.0.dist-info/WHEEL +4 -0
- briefing-0.1.0.dist-info/licenses/LICENSE +201 -0
briefing/__init__.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""briefing — build beautiful, self-contained HTML reports from Python analysis.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import briefing as bf
|
|
6
|
+
|
|
7
|
+
report = bf.Briefing(
|
|
8
|
+
bf.Text("# My Analysis"),
|
|
9
|
+
bf.Group(
|
|
10
|
+
bf.BigNumber("Accuracy", "92.4%", change="+1.2%", is_upward_change=True),
|
|
11
|
+
bf.BigNumber("F1 Score", 0.89),
|
|
12
|
+
columns=2,
|
|
13
|
+
),
|
|
14
|
+
bf.Select(
|
|
15
|
+
bf.Plot(fig, label="Chart"),
|
|
16
|
+
bf.DataTable(df, label="Data"),
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
bf.save(report, "analysis.html")
|
|
21
|
+
"""
|
|
22
|
+
import typing as t
|
|
23
|
+
import warnings
|
|
24
|
+
import webbrowser
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# ── lab: custom-visualisation blocks (briefing.lab.DataProfile / .DataDive) ───
|
|
29
|
+
# Same wheel; importing it registers its renderers. `briefing[lab]` only adds
|
|
30
|
+
# the pandas dependency those blocks need at render time.
|
|
31
|
+
from briefing import lab
|
|
32
|
+
from briefing._error import BriefingError
|
|
33
|
+
|
|
34
|
+
# ── blocks ────────────────────────────────────────────────────────────────────
|
|
35
|
+
from briefing.blocks import (
|
|
36
|
+
HTML,
|
|
37
|
+
Alert,
|
|
38
|
+
AlertLevel,
|
|
39
|
+
BigNumber,
|
|
40
|
+
Block,
|
|
41
|
+
Briefing,
|
|
42
|
+
Code,
|
|
43
|
+
DataTable,
|
|
44
|
+
Formula,
|
|
45
|
+
Group,
|
|
46
|
+
Page,
|
|
47
|
+
Plot,
|
|
48
|
+
Select,
|
|
49
|
+
SelectType,
|
|
50
|
+
Table,
|
|
51
|
+
Text,
|
|
52
|
+
Toggle,
|
|
53
|
+
VAlign,
|
|
54
|
+
wrap_block,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# ── formatting ────────────────────────────────────────────────────────────────
|
|
58
|
+
from briefing.renderers.formatting import Formatting, TextAlignment, Width
|
|
59
|
+
|
|
60
|
+
# ── renderer ──────────────────────────────────────────────────────────────────
|
|
61
|
+
from briefing.renderers.html import render_report
|
|
62
|
+
|
|
63
|
+
# ── public API ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save(
|
|
67
|
+
blocks: Briefing | list[t.Any] | object,
|
|
68
|
+
path: str,
|
|
69
|
+
*,
|
|
70
|
+
open: bool = False, # noqa: A002
|
|
71
|
+
name: str = "Report",
|
|
72
|
+
formatting: Formatting | None = None,
|
|
73
|
+
now: datetime | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Save *blocks* as a self-contained HTML file at *path*.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
blocks: A :class:`~briefing.Briefing` instance, a list of blocks, or a
|
|
79
|
+
single block. Lists and single blocks are automatically wrapped.
|
|
80
|
+
path: Destination file path (e.g. ``"report.html"``).
|
|
81
|
+
open: Open the file in your default browser after saving.
|
|
82
|
+
name: Document title shown in the browser tab and report header.
|
|
83
|
+
formatting: A :class:`~briefing.Formatting` instance controlling the theme.
|
|
84
|
+
now: Pin the header timestamp for byte-reproducible output (see also the
|
|
85
|
+
``SOURCE_DATE_EPOCH`` environment variable).
|
|
86
|
+
|
|
87
|
+
Example::
|
|
88
|
+
|
|
89
|
+
bf.save(report, "analysis.html", name="Q1 Analysis", open=True)
|
|
90
|
+
"""
|
|
91
|
+
wrapped = Briefing.wrap(blocks)
|
|
92
|
+
html = render_report(wrapped, name=name, formatting=formatting, now=now)
|
|
93
|
+
dest = Path(path)
|
|
94
|
+
dest.write_text(html, encoding="utf-8")
|
|
95
|
+
if open:
|
|
96
|
+
webbrowser.open(dest.resolve().as_uri())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def stringify(
|
|
100
|
+
blocks: Briefing | list[t.Any] | object,
|
|
101
|
+
*,
|
|
102
|
+
name: str = "Report",
|
|
103
|
+
formatting: Formatting | None = None,
|
|
104
|
+
now: datetime | None = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Render *blocks* to a self-contained HTML string.
|
|
107
|
+
|
|
108
|
+
Useful for inline display in Jupyter notebooks::
|
|
109
|
+
|
|
110
|
+
from IPython.display import HTML, display
|
|
111
|
+
display(HTML(bf.stringify(report)))
|
|
112
|
+
|
|
113
|
+
Pass *now* (or set ``SOURCE_DATE_EPOCH``) for byte-reproducible output.
|
|
114
|
+
"""
|
|
115
|
+
wrapped = Briefing.wrap(blocks)
|
|
116
|
+
return render_report(wrapped, name=name, formatting=formatting, now=now)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__version__ = "0.1.0"
|
|
120
|
+
|
|
121
|
+
__all__: list[str] = [
|
|
122
|
+
# error
|
|
123
|
+
"BriefingError",
|
|
124
|
+
# blocks — text
|
|
125
|
+
"Alert",
|
|
126
|
+
"AlertLevel",
|
|
127
|
+
"BigNumber",
|
|
128
|
+
"Code",
|
|
129
|
+
"Formula",
|
|
130
|
+
"HTML",
|
|
131
|
+
"Text",
|
|
132
|
+
# blocks — layout
|
|
133
|
+
"Block",
|
|
134
|
+
"Briefing",
|
|
135
|
+
"Group",
|
|
136
|
+
"Page",
|
|
137
|
+
"Select",
|
|
138
|
+
"SelectType",
|
|
139
|
+
"Toggle",
|
|
140
|
+
"VAlign",
|
|
141
|
+
"wrap_block",
|
|
142
|
+
# blocks — asset
|
|
143
|
+
"DataTable",
|
|
144
|
+
"Plot",
|
|
145
|
+
"Table",
|
|
146
|
+
# custom-visualisation subpackage (bf.lab.DataProfile / bf.lab.DataDive)
|
|
147
|
+
"lab",
|
|
148
|
+
# formatting
|
|
149
|
+
"Formatting",
|
|
150
|
+
"TextAlignment",
|
|
151
|
+
"Width",
|
|
152
|
+
# api
|
|
153
|
+
"save",
|
|
154
|
+
"stringify",
|
|
155
|
+
# meta
|
|
156
|
+
"__version__",
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── deprecated aliases (removed after 0.2) ───────────────────────────────────
|
|
161
|
+
|
|
162
|
+
#: old name -> (new name, object)
|
|
163
|
+
_RENAMED: dict[str, tuple[str, object]] = {
|
|
164
|
+
"Blocks": ("Briefing", Briefing),
|
|
165
|
+
"BaseBlock": ("Block", Block),
|
|
166
|
+
"save_report": ("save", save),
|
|
167
|
+
"stringify_report": ("stringify", stringify),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#: names that moved into the briefing.lab subpackage
|
|
171
|
+
_MOVED_TO_LAB: frozenset[str] = frozenset({"DataProfile", "DataDive"})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def __getattr__(name: str) -> t.Any:
|
|
175
|
+
if name in _RENAMED:
|
|
176
|
+
new, obj = _RENAMED[name]
|
|
177
|
+
warnings.warn(
|
|
178
|
+
f"briefing.{name} is deprecated and will be removed after 0.2 — "
|
|
179
|
+
f"use briefing.{new}.",
|
|
180
|
+
DeprecationWarning,
|
|
181
|
+
stacklevel=2,
|
|
182
|
+
)
|
|
183
|
+
return obj
|
|
184
|
+
if name in _MOVED_TO_LAB:
|
|
185
|
+
warnings.warn(
|
|
186
|
+
f"briefing.{name} moved to briefing.lab.{name} (pip install briefing[lab]) "
|
|
187
|
+
f"and will be removed after 0.2 — use briefing.lab.{name}.",
|
|
188
|
+
DeprecationWarning,
|
|
189
|
+
stacklevel=2,
|
|
190
|
+
)
|
|
191
|
+
return getattr(lab, name)
|
|
192
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
briefing/_error.py
ADDED
briefing/_frames.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Dataframe intake — accept more than just pandas.
|
|
2
|
+
|
|
3
|
+
``briefing`` renders tables and profiles against a pandas DataFrame internally,
|
|
4
|
+
but pandas is an optional extra (``pip install briefing[pandas]``) and callers
|
|
5
|
+
should not be forced to use pandas as *their* dataframe library.
|
|
6
|
+
|
|
7
|
+
:func:`to_pandas` normalises whatever a data block is handed into a pandas
|
|
8
|
+
DataFrame:
|
|
9
|
+
|
|
10
|
+
* a pandas DataFrame passes straight through;
|
|
11
|
+
* anything implementing the dataframe interchange protocol (``__dataframe__`` —
|
|
12
|
+
polars, pyarrow, modin, cuDF, vaex, …) is converted via
|
|
13
|
+
``pandas.api.interchange.from_dataframe``;
|
|
14
|
+
* anything else exposing ``.to_pandas()`` (pyarrow Table, polars, …) uses that;
|
|
15
|
+
* a plain ``dict`` / list-of-rows is passed to the ``pandas.DataFrame``
|
|
16
|
+
constructor.
|
|
17
|
+
|
|
18
|
+
A native pandas-free rendering path is out of scope for now (tracked in the
|
|
19
|
+
development plan); this keeps the door open to other dataframe libraries without
|
|
20
|
+
that rewrite.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import contextlib
|
|
25
|
+
import typing as t
|
|
26
|
+
|
|
27
|
+
from briefing._error import BriefingError
|
|
28
|
+
|
|
29
|
+
if t.TYPE_CHECKING:
|
|
30
|
+
import pandas as pd
|
|
31
|
+
|
|
32
|
+
_INSTALL_HINT = "install it with: pip install briefing[pandas]"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _import_pandas() -> t.Any:
|
|
36
|
+
try:
|
|
37
|
+
import pandas
|
|
38
|
+
except ImportError as exc: # pragma: no cover - exercised only without pandas
|
|
39
|
+
raise BriefingError(f"This block needs pandas — {_INSTALL_HINT}") from exc
|
|
40
|
+
return pandas
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_pandas_dataframe(obj: object) -> bool:
|
|
44
|
+
"""True if *obj* is a pandas DataFrame, without importing pandas eagerly."""
|
|
45
|
+
return any(
|
|
46
|
+
f"{c.__module__}.{c.__qualname__}" == "pandas.core.frame.DataFrame"
|
|
47
|
+
for c in type(obj).__mro__
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def looks_like_dataframe(obj: object) -> bool:
|
|
52
|
+
"""Heuristic for auto-wrapping: does *obj* look like a tabular frame?"""
|
|
53
|
+
return (
|
|
54
|
+
is_pandas_dataframe(obj)
|
|
55
|
+
or hasattr(obj, "__dataframe__")
|
|
56
|
+
or (hasattr(obj, "to_pandas") and callable(obj.to_pandas))
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_pandas(obj: object, *, block: str = "This block") -> pd.DataFrame:
|
|
61
|
+
"""Return *obj* as a pandas DataFrame, converting from other libraries.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
obj: a pandas / polars / pyarrow / interchange-protocol frame, or
|
|
65
|
+
something the ``pandas.DataFrame`` constructor accepts.
|
|
66
|
+
block: name used in error messages (e.g. ``"DataTable"``).
|
|
67
|
+
"""
|
|
68
|
+
pandas = _import_pandas()
|
|
69
|
+
|
|
70
|
+
if isinstance(obj, pandas.DataFrame):
|
|
71
|
+
return t.cast("pd.DataFrame", obj)
|
|
72
|
+
|
|
73
|
+
# Dataframe interchange protocol — polars, pyarrow, modin, cuDF, vaex, …
|
|
74
|
+
if hasattr(obj, "__dataframe__"):
|
|
75
|
+
with contextlib.suppress(Exception):
|
|
76
|
+
return t.cast("pd.DataFrame", pandas.api.interchange.from_dataframe(obj))
|
|
77
|
+
|
|
78
|
+
# pyarrow.Table, polars.DataFrame, …
|
|
79
|
+
to_pd = getattr(obj, "to_pandas", None)
|
|
80
|
+
if callable(to_pd):
|
|
81
|
+
return t.cast("pd.DataFrame", to_pd())
|
|
82
|
+
|
|
83
|
+
# dict of columns / list of row dicts / numpy structured array …
|
|
84
|
+
if isinstance(obj, dict | list):
|
|
85
|
+
return t.cast("pd.DataFrame", pandas.DataFrame(obj))
|
|
86
|
+
|
|
87
|
+
raise BriefingError(
|
|
88
|
+
f"{block} could not turn {type(obj).__module__}.{type(obj).__qualname__} "
|
|
89
|
+
"into a table. Pass a pandas / polars / pyarrow DataFrame, an object "
|
|
90
|
+
"implementing the dataframe interchange protocol, or a dict of columns."
|
|
91
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""briefing.blocks — core block types re-exported from one place.
|
|
2
|
+
|
|
3
|
+
Custom-visualisation blocks (DataProfile, DataDive) live in ``briefing.lab``.
|
|
4
|
+
"""
|
|
5
|
+
from briefing.blocks.asset import DataTable, Plot, Table
|
|
6
|
+
from briefing.blocks.base import Block, BlockId, BlockOrPrimitive, ContainerBlock, wrap_block
|
|
7
|
+
from briefing.blocks.layout import Briefing, Group, Page, Select, SelectType, Toggle, VAlign
|
|
8
|
+
from briefing.blocks.text import HTML, Alert, AlertLevel, BigNumber, Code, Formula, Text
|
|
9
|
+
|
|
10
|
+
__all__: list[str] = [
|
|
11
|
+
# base
|
|
12
|
+
"Block",
|
|
13
|
+
"BlockId",
|
|
14
|
+
"BlockOrPrimitive",
|
|
15
|
+
"ContainerBlock",
|
|
16
|
+
"wrap_block",
|
|
17
|
+
# text
|
|
18
|
+
"Alert",
|
|
19
|
+
"AlertLevel",
|
|
20
|
+
"BigNumber",
|
|
21
|
+
"Code",
|
|
22
|
+
"Formula",
|
|
23
|
+
"HTML",
|
|
24
|
+
"Text",
|
|
25
|
+
# layout
|
|
26
|
+
"Briefing",
|
|
27
|
+
"Group",
|
|
28
|
+
"Page",
|
|
29
|
+
"Select",
|
|
30
|
+
"SelectType",
|
|
31
|
+
"Toggle",
|
|
32
|
+
"VAlign",
|
|
33
|
+
# asset
|
|
34
|
+
"DataTable",
|
|
35
|
+
"Plot",
|
|
36
|
+
"Table",
|
|
37
|
+
]
|
briefing/blocks/asset.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Asset-based blocks: Plot, Table, DataTable.
|
|
2
|
+
|
|
3
|
+
These blocks hold external data (figures, DataFrames) and are serialised
|
|
4
|
+
into the HTML as inline assets during the render pass.
|
|
5
|
+
|
|
6
|
+
``Table`` / ``DataTable`` accept any dataframe library ``briefing`` can
|
|
7
|
+
normalise (see :mod:`briefing._frames`); the data is stored internally as a
|
|
8
|
+
pandas DataFrame.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typing as t
|
|
13
|
+
import warnings
|
|
14
|
+
|
|
15
|
+
from briefing._frames import is_pandas_dataframe, to_pandas
|
|
16
|
+
from briefing.blocks.base import _MAX_CAPTION_LEN, Block, BlockId, _truncate
|
|
17
|
+
|
|
18
|
+
if t.TYPE_CHECKING:
|
|
19
|
+
import pandas as pd
|
|
20
|
+
from pandas.io.formats.style import Styler
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Plot(Block):
|
|
24
|
+
"""Chart / figure block — library agnostic.
|
|
25
|
+
|
|
26
|
+
Auto-detects the figure type at render time:
|
|
27
|
+
- **Plotly** → embedded as interactive HTML (inline JS)
|
|
28
|
+
- **Altair / Vega-Lite** → embedded as Vega spec (inline runtime)
|
|
29
|
+
- **Matplotlib / Seaborn / Plotnine** → embedded as inline SVG
|
|
30
|
+
- **Bokeh** → embedded with inline resources
|
|
31
|
+
|
|
32
|
+
Example::
|
|
33
|
+
|
|
34
|
+
fl.Plot(plotly_fig, caption="Revenue over time")
|
|
35
|
+
fl.Plot(altair_chart, label="Chart", responsive=True)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
figure: t.Any,
|
|
41
|
+
caption: str | None = None,
|
|
42
|
+
responsive: bool = True,
|
|
43
|
+
scale: float = 1.0,
|
|
44
|
+
name: BlockId | None = None,
|
|
45
|
+
label: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(name=name, label=label)
|
|
48
|
+
self.figure = figure
|
|
49
|
+
self.caption = _truncate(caption, _MAX_CAPTION_LEN) if caption else caption
|
|
50
|
+
self.responsive = responsive
|
|
51
|
+
self.scale = scale
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Table(Block):
|
|
55
|
+
"""Static HTML table rendered from a dataframe or a pandas Styler.
|
|
56
|
+
|
|
57
|
+
Best for multidimensional DataFrames where you want pandas' Styler
|
|
58
|
+
formatting to be preserved. A Styler is used verbatim; any other frame
|
|
59
|
+
(pandas, polars, pyarrow, …) is normalised to pandas.
|
|
60
|
+
|
|
61
|
+
Example::
|
|
62
|
+
|
|
63
|
+
bf.Table(df)
|
|
64
|
+
bf.Table(df.style.highlight_max(color="lightgreen"))
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
data: pd.DataFrame | Styler | t.Any,
|
|
70
|
+
caption: str | None = None,
|
|
71
|
+
name: BlockId | None = None,
|
|
72
|
+
label: str | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
super().__init__(name=name, label=label)
|
|
75
|
+
# A Styler carries its own formatting rules — keep it as-is. Everything
|
|
76
|
+
# else goes through the dataframe intake layer.
|
|
77
|
+
if type(data).__name__ == "Styler" or is_pandas_dataframe(data):
|
|
78
|
+
self.data = data
|
|
79
|
+
else:
|
|
80
|
+
self.data = to_pandas(data, block="Table")
|
|
81
|
+
self.caption = _truncate(caption, _MAX_CAPTION_LEN) if caption else caption
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class DataTable(Block):
|
|
85
|
+
"""Interactive, sortable and searchable table rendered from a pandas DataFrame.
|
|
86
|
+
|
|
87
|
+
Handles large datasets via client-side pagination. Viewers can also sort
|
|
88
|
+
columns and filter rows by typing in the search box.
|
|
89
|
+
|
|
90
|
+
Example::
|
|
91
|
+
|
|
92
|
+
fl.DataTable(df, caption="Top 1 000 orders")
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
#: Maximum rows rendered by default; larger DataFrames are truncated with a warning.
|
|
96
|
+
MAX_ROWS: t.ClassVar[int] = 10_000
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
df: pd.DataFrame | t.Any,
|
|
101
|
+
caption: str | None = None,
|
|
102
|
+
max_rows: int = MAX_ROWS,
|
|
103
|
+
name: BlockId | None = None,
|
|
104
|
+
label: str | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__(name=name, label=label)
|
|
107
|
+
df = to_pandas(df, block="DataTable")
|
|
108
|
+
|
|
109
|
+
if len(df) > max_rows:
|
|
110
|
+
warnings.warn(
|
|
111
|
+
f"DataTable: DataFrame has {len(df):,} rows — truncating to {max_rows:,}. "
|
|
112
|
+
"Increase 'max_rows' to render more.",
|
|
113
|
+
stacklevel=2,
|
|
114
|
+
)
|
|
115
|
+
df = df.head(max_rows)
|
|
116
|
+
|
|
117
|
+
self.df = df
|
|
118
|
+
self.caption = _truncate(caption, _MAX_CAPTION_LEN) if caption else caption
|
|
119
|
+
self.max_rows = max_rows
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── public re-exports ─────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
__all__: list[str] = ["DataTable", "Plot", "Table"]
|
briefing/blocks/base.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Core block primitives: Block, ContainerBlock, and wrap_block."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import typing as t
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from briefing._error import BriefingError
|
|
9
|
+
|
|
10
|
+
_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
|
|
11
|
+
_MAX_LABEL_LEN = 256
|
|
12
|
+
_MAX_CAPTION_LEN = 512
|
|
13
|
+
|
|
14
|
+
BlockId = str
|
|
15
|
+
BlockOrPrimitive = t.Union["Block", t.Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Block:
|
|
19
|
+
"""Base class for all briefing blocks.
|
|
20
|
+
|
|
21
|
+
All blocks carry an optional ``name`` (a stable ID for referencing the
|
|
22
|
+
block) and an optional ``label`` (a human-readable display string used
|
|
23
|
+
e.g. as a tab title inside a Select).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
name: BlockId | None = None,
|
|
29
|
+
label: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if name is not None and not _NAME_RE.match(name):
|
|
32
|
+
raise BriefingError(
|
|
33
|
+
f"Invalid block name {name!r}: must start with a letter and contain "
|
|
34
|
+
"only letters, digits, underscores, or hyphens."
|
|
35
|
+
)
|
|
36
|
+
self.name = name
|
|
37
|
+
self.label = _truncate(label, _MAX_LABEL_LEN) if label else label
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
parts = []
|
|
41
|
+
if self.name:
|
|
42
|
+
parts.append(f"name={self.name!r}")
|
|
43
|
+
if self.label:
|
|
44
|
+
parts.append(f"label={self.label!r}")
|
|
45
|
+
return f"{self.__class__.__name__}({', '.join(parts)})"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ContainerBlock(Block):
|
|
49
|
+
"""Block that holds a list of child blocks (forms a subtree)."""
|
|
50
|
+
|
|
51
|
+
#: Subclasses can raise the bar; checked during rendering, not construction.
|
|
52
|
+
min_blocks: t.ClassVar[int] = 1
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*arg_blocks: BlockOrPrimitive,
|
|
57
|
+
blocks: Sequence[BlockOrPrimitive] | None = None,
|
|
58
|
+
name: BlockId | None = None,
|
|
59
|
+
label: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(name=name, label=label)
|
|
62
|
+
resolved = list(blocks if blocks is not None else arg_blocks)
|
|
63
|
+
self.blocks: list[Block] = [wrap_block(b) for b in resolved]
|
|
64
|
+
|
|
65
|
+
def __iter__(self) -> t.Iterator[Block]:
|
|
66
|
+
return iter(self.blocks)
|
|
67
|
+
|
|
68
|
+
def __len__(self) -> int:
|
|
69
|
+
return len(self.blocks)
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str:
|
|
72
|
+
return (
|
|
73
|
+
f"{self.__class__.__name__}("
|
|
74
|
+
f"{len(self.blocks)} block(s)"
|
|
75
|
+
+ (f", name={self.name!r}" if self.name else "")
|
|
76
|
+
+ ")"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def wrap_block(b: BlockOrPrimitive) -> Block:
|
|
81
|
+
"""Auto-wrap primitives into appropriate blocks.
|
|
82
|
+
|
|
83
|
+
Supported auto-wrapping:
|
|
84
|
+
- ``str`` → :class:`~briefing.blocks.text.Text`
|
|
85
|
+
- a dataframe → :class:`~briefing.blocks.asset.DataTable`
|
|
86
|
+
(pandas / polars / pyarrow / dataframe-interchange objects)
|
|
87
|
+
"""
|
|
88
|
+
if isinstance(b, Block):
|
|
89
|
+
return b
|
|
90
|
+
|
|
91
|
+
if isinstance(b, str):
|
|
92
|
+
from briefing.blocks.text import Text
|
|
93
|
+
|
|
94
|
+
return Text(text=b)
|
|
95
|
+
|
|
96
|
+
from briefing._frames import looks_like_dataframe
|
|
97
|
+
|
|
98
|
+
if looks_like_dataframe(b):
|
|
99
|
+
from briefing.blocks.asset import DataTable
|
|
100
|
+
|
|
101
|
+
return DataTable(b)
|
|
102
|
+
|
|
103
|
+
raise BriefingError(
|
|
104
|
+
f"Cannot auto-wrap {type(b).__name__!r} into a briefing block. "
|
|
105
|
+
"Pass a briefing block, a string, or a dataframe."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _truncate(s: str, max_len: int) -> str:
|
|
113
|
+
return s[: max_len - 3] + "..." if len(s) > max_len else s
|