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 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
@@ -0,0 +1,2 @@
1
+ class BriefingError(Exception):
2
+ """Base exception for all briefing errors."""
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
+ ]
@@ -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"]
@@ -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