pl2html 0.4.1__tar.gz → 0.5.1__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.
- {pl2html-0.4.1 → pl2html-0.5.1}/PKG-INFO +5 -5
- {pl2html-0.4.1 → pl2html-0.5.1}/README.md +4 -4
- pl2html-0.5.1/pl2html/__init__.py +1 -0
- pl2html-0.5.1/pl2html/compiler.py +267 -0
- {pl2html-0.4.1 → pl2html-0.5.1}/pl2html/formats.py +109 -78
- {pl2html-0.4.1 → pl2html-0.5.1}/pyproject.toml +1 -1
- pl2html-0.4.1/pl2html/__init__.py +0 -164
- {pl2html-0.4.1 → pl2html-0.5.1}/pl2html/styles.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pl2html
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Add your description here
|
|
5
5
|
Requires-Dist: polars>=1.42.1
|
|
6
6
|
Requires-Python: >=3.14
|
|
@@ -40,7 +40,7 @@ By default, `pl2html` infers datatypes to securely render human-readable tables.
|
|
|
40
40
|
|
|
41
41
|
```python
|
|
42
42
|
import polars as pl
|
|
43
|
-
from pl2html import to_html
|
|
43
|
+
from pl2html.compiler import to_html
|
|
44
44
|
|
|
45
45
|
df = pl.DataFrame(
|
|
46
46
|
{
|
|
@@ -78,7 +78,7 @@ Output:
|
|
|
78
78
|
|
|
79
79
|
```python
|
|
80
80
|
import polars as pl
|
|
81
|
-
from pl2html import to_html
|
|
81
|
+
from pl2html.compiler import to_html
|
|
82
82
|
from pl2html import formats as fmt
|
|
83
83
|
|
|
84
84
|
df = pl.DataFrame(
|
|
@@ -107,7 +107,7 @@ Instead of embedding hacky structural HTML tags in your data, use the `attrs` pa
|
|
|
107
107
|
|
|
108
108
|
```python
|
|
109
109
|
import polars as pl
|
|
110
|
-
from pl2html import to_html
|
|
110
|
+
from pl2html.compiler import to_html
|
|
111
111
|
from pl2html.styles import data_color, rank_color
|
|
112
112
|
|
|
113
113
|
df = pl.DataFrame(
|
|
@@ -167,7 +167,7 @@ Output (github may override table colors, but they are there):
|
|
|
167
167
|
|
|
168
168
|
The library is explicitly divided into three decoupled layers:
|
|
169
169
|
|
|
170
|
-
### 1. Core Compiler (`
|
|
170
|
+
### 1. Core Compiler (`compiler.py`)
|
|
171
171
|
Responsible for reading the dataframe schema, iterating horizontally over visible columns, and joining tokens into structural row arrays natively in Rust.
|
|
172
172
|
* Exposed via `to_html(df, *, attrs=None, exclude_columns=None)`.
|
|
173
173
|
* Automatically assigns high-performance format fallbacks:
|
|
@@ -31,7 +31,7 @@ By default, `pl2html` infers datatypes to securely render human-readable tables.
|
|
|
31
31
|
|
|
32
32
|
```python
|
|
33
33
|
import polars as pl
|
|
34
|
-
from pl2html import to_html
|
|
34
|
+
from pl2html.compiler import to_html
|
|
35
35
|
|
|
36
36
|
df = pl.DataFrame(
|
|
37
37
|
{
|
|
@@ -69,7 +69,7 @@ Output:
|
|
|
69
69
|
|
|
70
70
|
```python
|
|
71
71
|
import polars as pl
|
|
72
|
-
from pl2html import to_html
|
|
72
|
+
from pl2html.compiler import to_html
|
|
73
73
|
from pl2html import formats as fmt
|
|
74
74
|
|
|
75
75
|
df = pl.DataFrame(
|
|
@@ -98,7 +98,7 @@ Instead of embedding hacky structural HTML tags in your data, use the `attrs` pa
|
|
|
98
98
|
|
|
99
99
|
```python
|
|
100
100
|
import polars as pl
|
|
101
|
-
from pl2html import to_html
|
|
101
|
+
from pl2html.compiler import to_html
|
|
102
102
|
from pl2html.styles import data_color, rank_color
|
|
103
103
|
|
|
104
104
|
df = pl.DataFrame(
|
|
@@ -158,7 +158,7 @@ Output (github may override table colors, but they are there):
|
|
|
158
158
|
|
|
159
159
|
The library is explicitly divided into three decoupled layers:
|
|
160
160
|
|
|
161
|
-
### 1. Core Compiler (`
|
|
161
|
+
### 1. Core Compiler (`compiler.py`)
|
|
162
162
|
Responsible for reading the dataframe schema, iterating horizontally over visible columns, and joining tokens into structural row arrays natively in Rust.
|
|
163
163
|
* Exposed via `to_html(df, *, attrs=None, exclude_columns=None)`.
|
|
164
164
|
* Automatically assigns high-performance format fallbacks:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.5.1'
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from polars import (
|
|
2
|
+
DataFrame as _DataFrame,
|
|
3
|
+
DataType as _DataType,
|
|
4
|
+
Expr as _Expr,
|
|
5
|
+
LazyFrame as _LazyFrame,
|
|
6
|
+
String as _String,
|
|
7
|
+
col as _col,
|
|
8
|
+
concat_str as _concat_str,
|
|
9
|
+
lit as _lit,
|
|
10
|
+
when as _when,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _escape_expr(expr: _Expr) -> _Expr:
|
|
15
|
+
"""
|
|
16
|
+
Escapes an expression's string representations for secure HTML compatibility.
|
|
17
|
+
Using literal=True is critical; otherwise, the '&' character in the replacement
|
|
18
|
+
string acts as a regex match expansion macro and corrupts the entities.
|
|
19
|
+
"""
|
|
20
|
+
return (
|
|
21
|
+
expr.cast(_String)
|
|
22
|
+
.str.replace_all('&', '&', literal=True)
|
|
23
|
+
.str.replace_all('<', '<', literal=True)
|
|
24
|
+
.str.replace_all('>', '>', literal=True)
|
|
25
|
+
.str.replace_all('"', '"', literal=True)
|
|
26
|
+
.str.replace_all("'", ''', literal=True)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_integer(col_name: str) -> _Expr:
|
|
31
|
+
"""Adds thousands separators to integers using look-around-free logic."""
|
|
32
|
+
return (
|
|
33
|
+
_col(col_name)
|
|
34
|
+
.fill_null(0)
|
|
35
|
+
.cast(_String)
|
|
36
|
+
# Polars regex engine doesn't support look-behinds (e.g., (?<=\d)(?=(\d{3})+$)).
|
|
37
|
+
# We reverse the string, match chunks of 3 digits from the right, add commas,
|
|
38
|
+
# and then reverse it back to normal.
|
|
39
|
+
.str.reverse()
|
|
40
|
+
.str.replace_all(r'\d{3}', '$0,')
|
|
41
|
+
.str.reverse()
|
|
42
|
+
.str.replace(r'^,', '')
|
|
43
|
+
.str.replace(r'^-,', '-')
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _format_float(col_name: str) -> _Expr:
|
|
48
|
+
"""Formats floats with 3 decimals and thousands separators."""
|
|
49
|
+
rounded_str = _col(col_name).round(3).cast(_String)
|
|
50
|
+
|
|
51
|
+
parts = rounded_str.str.split_exact('.', 1)
|
|
52
|
+
int_part = parts.struct.field('field_0')
|
|
53
|
+
dec_part = parts.struct.field('field_1')
|
|
54
|
+
|
|
55
|
+
formatted_int = (
|
|
56
|
+
int_part.str.reverse()
|
|
57
|
+
.str.replace_all(r'\d{3}', '$0,')
|
|
58
|
+
.str.reverse()
|
|
59
|
+
.str.replace(r'^,', '')
|
|
60
|
+
.str.replace(r'^-,', '-')
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
_when(dec_part.is_not_null() & (dec_part != ''))
|
|
65
|
+
.then(formatted_int + _lit('.') + dec_part)
|
|
66
|
+
.otherwise(formatted_int)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _build_cell_expr(
|
|
71
|
+
col_name: str,
|
|
72
|
+
dtype: _DataType,
|
|
73
|
+
attrs: dict[str, dict[str, _Expr]],
|
|
74
|
+
) -> _Expr:
|
|
75
|
+
"""Resolves attribute expressions before user formatters are applied."""
|
|
76
|
+
if dtype.is_integer():
|
|
77
|
+
fmt_expr = _format_integer(col_name)
|
|
78
|
+
elif dtype.is_float():
|
|
79
|
+
fmt_expr = _format_float(col_name)
|
|
80
|
+
else:
|
|
81
|
+
fmt_expr = _escape_expr(_col(col_name))
|
|
82
|
+
|
|
83
|
+
fmt_expr = fmt_expr.fill_null('')
|
|
84
|
+
|
|
85
|
+
attr_expr_list = []
|
|
86
|
+
if col_name in attrs:
|
|
87
|
+
for attr_name, val_expr in attrs[col_name].items():
|
|
88
|
+
val_str_expr = val_expr.cast(_String)
|
|
89
|
+
|
|
90
|
+
compiled_attr = (
|
|
91
|
+
_when(val_str_expr.is_not_null() & (val_str_expr != ''))
|
|
92
|
+
.then(_lit(f' {attr_name}="') + val_str_expr + _lit('"'))
|
|
93
|
+
.otherwise(_lit(''))
|
|
94
|
+
)
|
|
95
|
+
attr_expr_list.append(compiled_attr)
|
|
96
|
+
|
|
97
|
+
if attr_expr_list:
|
|
98
|
+
opening_td = _lit('<td') + _concat_str(attr_expr_list) + _lit('>')
|
|
99
|
+
else:
|
|
100
|
+
opening_td = _lit('<td>')
|
|
101
|
+
|
|
102
|
+
return opening_td + fmt_expr + _lit('</td>')
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_html_skeleton(visible_columns: list[str]) -> tuple[_Expr, _Expr]:
|
|
106
|
+
header_parts = ['<table>', ' <thead>\n <tr>']
|
|
107
|
+
for c in visible_columns:
|
|
108
|
+
header_parts.append(f' <th>{c}</th>')
|
|
109
|
+
header_parts.append(' </tr>\n </thead>\n <tbody>')
|
|
110
|
+
|
|
111
|
+
html_header = _lit('\n'.join(header_parts) + '\n')
|
|
112
|
+
html_footer = _lit('\n </tbody>\n</table>')
|
|
113
|
+
return html_header, html_footer
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_attributes(
|
|
117
|
+
lf: _LazyFrame,
|
|
118
|
+
df: _DataFrame,
|
|
119
|
+
attrs: dict[str, dict[str, _Expr]],
|
|
120
|
+
visible_columns: list[str],
|
|
121
|
+
) -> tuple[_LazyFrame, dict[str, dict[str, _Expr]]]:
|
|
122
|
+
"""Compiles dynamic attribute expressions using the raw, unformatted numeric data."""
|
|
123
|
+
attr_exprs = []
|
|
124
|
+
attr_aliases = {}
|
|
125
|
+
|
|
126
|
+
for col_name, attr_map in attrs.items():
|
|
127
|
+
if col_name in visible_columns:
|
|
128
|
+
for attr_name, expr in attr_map.items():
|
|
129
|
+
alias_key = f'__attr_{col_name}_{attr_name}'
|
|
130
|
+
attr_exprs.append(_escape_expr(expr).alias(alias_key))
|
|
131
|
+
attr_aliases[(col_name, attr_name)] = alias_key
|
|
132
|
+
|
|
133
|
+
if not attr_exprs:
|
|
134
|
+
return lf, attrs
|
|
135
|
+
|
|
136
|
+
# Evaluate dynamic style contexts on the unformatted DataFrame
|
|
137
|
+
resolved_attrs_df = df.select(attr_exprs)
|
|
138
|
+
|
|
139
|
+
# Re-inject styles as true native structural data streams
|
|
140
|
+
lf = lf.with_columns(resolved_attrs_df)
|
|
141
|
+
|
|
142
|
+
# Remap the attributes dictionary to look at the new column references
|
|
143
|
+
new_attrs = {col: attr_map.copy() for col, attr_map in attrs.items()}
|
|
144
|
+
|
|
145
|
+
for (col_name, attr_name), alias in attr_aliases.items():
|
|
146
|
+
new_attrs[col_name][attr_name] = _col(alias)
|
|
147
|
+
|
|
148
|
+
return lf, new_attrs
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _apply_user_formatters(
|
|
152
|
+
lf: _LazyFrame, formatters: _Expr | list[_Expr] | None
|
|
153
|
+
) -> _LazyFrame:
|
|
154
|
+
"""Applies layout transformation modifications/conversions to columns."""
|
|
155
|
+
if formatters is None:
|
|
156
|
+
return lf
|
|
157
|
+
if isinstance(formatters, _Expr):
|
|
158
|
+
formatters = [formatters]
|
|
159
|
+
return lf.with_columns(formatters)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _compile_html(
|
|
163
|
+
lf: _LazyFrame,
|
|
164
|
+
visible_columns: list[str],
|
|
165
|
+
attrs: dict[str, dict[str, _Expr]],
|
|
166
|
+
) -> str:
|
|
167
|
+
"""Constructs the raw structural HTML string from the resolved LazyFrame pipeline."""
|
|
168
|
+
# NOTE: Recompute the schema after applying user formatters. Formatters may
|
|
169
|
+
# cast numeric columns to String, and _build_cell_expr uses the current dtype
|
|
170
|
+
# to decide whether default numeric formatting (e.g. .round()) should be
|
|
171
|
+
# applied. Using the original schema would incorrectly attempt numeric
|
|
172
|
+
# formatting on already-formatted string columns.
|
|
173
|
+
current_schema = lf.collect_schema()
|
|
174
|
+
|
|
175
|
+
cell_expressions = [
|
|
176
|
+
_build_cell_expr(c, current_schema[c], attrs) for c in visible_columns
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
row_expr = _lit(' <tr>') + _concat_str(cell_expressions) + _lit('</tr>')
|
|
180
|
+
html_header, html_footer = _build_html_skeleton(visible_columns)
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
lf.select(row_expr.alias('html_row'))
|
|
184
|
+
.select(
|
|
185
|
+
(
|
|
186
|
+
html_header + _col('html_row').str.join('\n') + html_footer
|
|
187
|
+
).alias('html_table')
|
|
188
|
+
)
|
|
189
|
+
.collect()
|
|
190
|
+
.item()
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def to_html(
|
|
195
|
+
df: _DataFrame | _LazyFrame,
|
|
196
|
+
*,
|
|
197
|
+
attrs: dict[str, dict[str, _Expr]] | None = None,
|
|
198
|
+
exclude_columns: list[str] | None = None,
|
|
199
|
+
formatters: _Expr | list[_Expr] | None = None,
|
|
200
|
+
) -> str:
|
|
201
|
+
"""Compiles a Polars DataFrame safely into an HTML string layout.
|
|
202
|
+
|
|
203
|
+
Accepts structural custom attribute mappings to handle layout modifications
|
|
204
|
+
and cell styling natively.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
df: The source Polars DataFrame or LazyFrame containing the data.
|
|
208
|
+
attrs: A dictionary mapping column names to cell attributes (e.g., style,
|
|
209
|
+
class). The inner dictionary values can be raw Polars expressions that
|
|
210
|
+
evaluate dynamically based on the column values.
|
|
211
|
+
exclude_columns: A list of column names to omit from the final rendered
|
|
212
|
+
HTML table. See note below on structural execution context.
|
|
213
|
+
formatters: A single Polars expression or a list of expressions used to
|
|
214
|
+
format column values into display strings (e.g., using `fmt_number`).
|
|
215
|
+
|
|
216
|
+
Note on Execution Order (formatters and exclude_columns):
|
|
217
|
+
Both `formatters` and `exclude_columns` serve critical roles in preserving
|
|
218
|
+
the numeric evaluation state of the DataFrame before HTML compilation:
|
|
219
|
+
|
|
220
|
+
1. formatters: Usually, formatting expressions can be applied directly to
|
|
221
|
+
the DataFrame via `.with_columns()` before calling `to_html`. However,
|
|
222
|
+
doing so converts numeric columns into `String` types too early, causing
|
|
223
|
+
downstream style calculations (e.g., string division errors) to fail.
|
|
224
|
+
Passing them here guarantees formatting runs *after* style resolution.
|
|
225
|
+
|
|
226
|
+
2. exclude_columns: Dropping intermediate or helper columns before passing
|
|
227
|
+
the DataFrame to `to_html` removes the data context required by your
|
|
228
|
+
`attrs` expressions. By using `exclude_columns`, helper columns remain
|
|
229
|
+
fully accessible during the mathematical style resolution phase (Step 1),
|
|
230
|
+
but are cleanly filtered out before generating the HTML structural matrix.
|
|
231
|
+
|
|
232
|
+
Example:
|
|
233
|
+
>>> import polars as pl
|
|
234
|
+
>>> from pl2html import to_html
|
|
235
|
+
>>>
|
|
236
|
+
>>> # 'max_threshold' is a helper column required for styling but not rendering
|
|
237
|
+
>>> df = pl.DataFrame({
|
|
238
|
+
... "sprd": [10.0, 50.0, 100.0],
|
|
239
|
+
... "max_threshold": [120.0, 120.0, 120.0]
|
|
240
|
+
... })
|
|
241
|
+
>>>
|
|
242
|
+
>>> # Style depends on the unrendered 'max_threshold' column
|
|
243
|
+
>>> attrs = {"sprd": {"style": pl.col("sprd") / pl.col("max_threshold")}}
|
|
244
|
+
>>>
|
|
245
|
+
>>> # Safe execution: 'max_threshold' is preserved for math, then omitted from output
|
|
246
|
+
>>> html = to_html(
|
|
247
|
+
... df,
|
|
248
|
+
... attrs=attrs,
|
|
249
|
+
... exclude_columns=["max_threshold"]
|
|
250
|
+
... )
|
|
251
|
+
"""
|
|
252
|
+
lf = df.lazy() if isinstance(df, _DataFrame) else df
|
|
253
|
+
exclude_columns = exclude_columns or []
|
|
254
|
+
attrs = attrs or {}
|
|
255
|
+
|
|
256
|
+
# Setup the evaluation context data
|
|
257
|
+
df_eager = lf.collect()
|
|
258
|
+
lf = df_eager.lazy()
|
|
259
|
+
|
|
260
|
+
visible_columns = [
|
|
261
|
+
c for c in df_eager.schema.names() if c not in exclude_columns
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
lf, attrs = _resolve_attributes(lf, df_eager, attrs, visible_columns)
|
|
265
|
+
lf = _apply_user_formatters(lf, formatters)
|
|
266
|
+
|
|
267
|
+
return _compile_html(lf, visible_columns, attrs)
|
|
@@ -30,81 +30,60 @@ def _multicolumn(func):
|
|
|
30
30
|
_Columns = str | list[str]
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
compact: bool = False,
|
|
40
|
-
compact_system: _Literal['financial', 'engineering'] = 'financial',
|
|
41
|
-
use_seps: bool = True,
|
|
42
|
-
accounting: bool = False,
|
|
43
|
-
pattern: str = '{x}',
|
|
44
|
-
n_sigfig: int | None = None,
|
|
45
|
-
force_sign: bool = False,
|
|
46
|
-
) -> _Expr:
|
|
47
|
-
"""
|
|
48
|
-
Highly optimized, native Polars numeric formatter matching great_tables features.
|
|
49
|
-
Runs entirely in the parallel Rust engine without dropping into Python row loops.
|
|
50
|
-
"""
|
|
51
|
-
col_name = columns if isinstance(columns, str) else str(columns)
|
|
52
|
-
val = _col(columns)
|
|
53
|
-
if scale_by != 1.0:
|
|
54
|
-
val = val * scale_by
|
|
33
|
+
def _apply_compact_scaling(
|
|
34
|
+
val: _Expr, compact: bool, compact_system: str
|
|
35
|
+
) -> tuple[_Expr, _Expr]:
|
|
36
|
+
"""Handles logic block 1: Extracts compact suffix rules and scales values down."""
|
|
37
|
+
if not compact:
|
|
38
|
+
return val, _lit('')
|
|
55
39
|
|
|
56
40
|
abs_val = val.abs()
|
|
41
|
+
log10_expr = _when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
|
|
42
|
+
thousands_exponent = (log10_expr / 3.0).floor().cast(_Int32) * 3
|
|
57
43
|
|
|
58
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
44
|
+
# Force exponent to 0 for fractional numbers (< 1.0)
|
|
45
|
+
thousands_exponent = (
|
|
46
|
+
_when(thousands_exponent < 0)
|
|
47
|
+
.then(_lit(0))
|
|
48
|
+
.otherwise(thousands_exponent)
|
|
49
|
+
)
|
|
64
50
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
51
|
+
system_chars = (
|
|
52
|
+
('k', 'M', 'G', 'T')
|
|
53
|
+
if compact_system == 'engineering'
|
|
54
|
+
else ('K', 'M', 'B', 'T')
|
|
55
|
+
)
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
divisor = _lit(10.0).pow(thousands_exponent.cast(_Float64))
|
|
97
|
-
val_scaled = val / divisor
|
|
98
|
-
else:
|
|
99
|
-
val_scaled = val
|
|
100
|
-
suffix_chain = _lit('')
|
|
57
|
+
suffix_chain = (
|
|
58
|
+
_when(thousands_exponent == 3)
|
|
59
|
+
.then(_lit(system_chars[0]))
|
|
60
|
+
.when(thousands_exponent == 6)
|
|
61
|
+
.then(_lit(system_chars[1]))
|
|
62
|
+
.when(thousands_exponent == 9)
|
|
63
|
+
.then(_lit(system_chars[2]))
|
|
64
|
+
.when(thousands_exponent == 12)
|
|
65
|
+
.then(_lit(system_chars[3]))
|
|
66
|
+
.otherwise(_lit(''))
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
divisor = _lit(10.0).pow(thousands_exponent.cast(_Float64))
|
|
70
|
+
return val / divisor, suffix_chain
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _compute_dynamic_precision(
|
|
74
|
+
val_scaled: _Expr, decimals: int, n_sigfig: int | None
|
|
75
|
+
) -> tuple[_Expr, _Expr | int]:
|
|
76
|
+
"""Handles logic block 2: Computes precision bounds safely avoiding non-finite floats, preserving nulls."""
|
|
77
|
+
|
|
78
|
+
# FIX: Explicitly target only NaN and Infinity, leaving nulls untouched
|
|
79
|
+
is_anomaly = val_scaled.is_nan() | val_scaled.is_infinite()
|
|
80
|
+
safe_val = _when(is_anomaly).then(_lit(0.0)).otherwise(val_scaled)
|
|
101
81
|
|
|
102
|
-
# 2. Dynamic Precision Handling (Significant Figures vs Fixed Decimals)
|
|
103
82
|
if n_sigfig is not None:
|
|
104
83
|
if n_sigfig < 1:
|
|
105
84
|
raise ValueError('n_sigfig must be a positive integer >= 1')
|
|
106
85
|
|
|
107
|
-
rounded =
|
|
86
|
+
rounded = safe_val.round_sig_figs(n_sigfig)
|
|
108
87
|
abs_rounded = rounded.abs()
|
|
109
88
|
|
|
110
89
|
log10_expr = (
|
|
@@ -121,21 +100,27 @@ def fmt_number(
|
|
|
121
100
|
.otherwise(dynamic_decimals)
|
|
122
101
|
)
|
|
123
102
|
else:
|
|
124
|
-
epsilon = (
|
|
125
|
-
|
|
126
|
-
)
|
|
127
|
-
rounded = (val_scaled + epsilon).round(decimals)
|
|
103
|
+
epsilon = _when(safe_val >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
|
|
104
|
+
rounded = (safe_val + epsilon).round(decimals)
|
|
128
105
|
dynamic_decimals = _lit(decimals)
|
|
129
106
|
|
|
130
|
-
|
|
107
|
+
return rounded, dynamic_decimals
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _extract_and_align_components(
|
|
111
|
+
rounded: _Expr,
|
|
112
|
+
decimals: int,
|
|
113
|
+
n_sigfig: int | None,
|
|
114
|
+
dynamic_decimals: _Expr | int,
|
|
115
|
+
use_seps: bool,
|
|
116
|
+
) -> _Expr:
|
|
117
|
+
"""Handles logic blocks 3 & 4: Cuts string tokens cleanly."""
|
|
118
|
+
# Since rounded is calculated from safe_val above, it contains no NaN/inf here.
|
|
119
|
+
# The .cast(_Int64) is now completely safe from panicking!
|
|
131
120
|
int_part = rounded.cast(_Int64).abs().cast(_String)
|
|
132
121
|
full_str = rounded.abs().cast(_String)
|
|
133
122
|
|
|
134
|
-
raw_frac = (
|
|
135
|
-
_when(full_str.str.contains(r'\.'))
|
|
136
|
-
.then(full_str.str.split('.').list.get(1))
|
|
137
|
-
.otherwise(_lit(''))
|
|
138
|
-
)
|
|
123
|
+
raw_frac = full_str.str.extract(r'\.(.*)', 1).fill_null(_lit(''))
|
|
139
124
|
|
|
140
125
|
pad_len = n_sigfig if n_sigfig is not None else decimals
|
|
141
126
|
frac_part = (
|
|
@@ -144,7 +129,6 @@ def fmt_number(
|
|
|
144
129
|
.otherwise(_lit(''))
|
|
145
130
|
)
|
|
146
131
|
|
|
147
|
-
# 4. Constructing Base Number String
|
|
148
132
|
if use_seps:
|
|
149
133
|
int_part = (
|
|
150
134
|
int_part.str.reverse()
|
|
@@ -153,19 +137,56 @@ def fmt_number(
|
|
|
153
137
|
.str.reverse()
|
|
154
138
|
)
|
|
155
139
|
|
|
156
|
-
|
|
140
|
+
return (
|
|
157
141
|
_when(dynamic_decimals > 0)
|
|
158
142
|
.then(int_part + _lit('.') + frac_part)
|
|
159
143
|
.otherwise(int_part)
|
|
160
144
|
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@_multicolumn
|
|
148
|
+
def fmt_number(
|
|
149
|
+
*,
|
|
150
|
+
columns: _Columns,
|
|
151
|
+
decimals: int = 2,
|
|
152
|
+
scale_by: float = 1.0,
|
|
153
|
+
compact: bool = False,
|
|
154
|
+
compact_system: _Literal['financial', 'engineering'] = 'financial',
|
|
155
|
+
use_seps: bool = True,
|
|
156
|
+
accounting: bool = False,
|
|
157
|
+
pattern: str = '{x}',
|
|
158
|
+
n_sigfig: int | None = None,
|
|
159
|
+
force_sign: bool = False,
|
|
160
|
+
) -> _Expr:
|
|
161
|
+
"""
|
|
162
|
+
Highly optimized, native Polars numeric formatter matching great_tables features.
|
|
163
|
+
Runs entirely in the parallel Rust engine without dropping into Python row loops.
|
|
164
|
+
"""
|
|
165
|
+
col_name = columns if isinstance(columns, str) else str(columns)
|
|
166
|
+
val = _col(columns)
|
|
167
|
+
if scale_by != 1.0:
|
|
168
|
+
val = val * scale_by
|
|
169
|
+
|
|
170
|
+
# --- HELPER ORCHESTRATION ---
|
|
171
|
+
val_scaled, suffix_chain = _apply_compact_scaling(
|
|
172
|
+
val, compact, compact_system
|
|
173
|
+
)
|
|
174
|
+
rounded, dynamic_decimals = _compute_dynamic_precision(
|
|
175
|
+
val_scaled, decimals, n_sigfig
|
|
176
|
+
)
|
|
177
|
+
base_num_str = _extract_and_align_components(
|
|
178
|
+
rounded, decimals, n_sigfig, dynamic_decimals, use_seps
|
|
179
|
+
)
|
|
161
180
|
base_num_str = base_num_str + suffix_chain
|
|
162
181
|
|
|
182
|
+
# --- PATTERN PARSING ---
|
|
163
183
|
prefix, suffix = '', ''
|
|
164
184
|
if pattern != '{x}':
|
|
165
185
|
parts = pattern.split('{x}')
|
|
166
186
|
if len(parts) == 2:
|
|
167
187
|
prefix, suffix = parts[0], parts[1]
|
|
168
188
|
|
|
189
|
+
# --- STANDARD EXPRESSION GENERATION ---
|
|
169
190
|
if accounting:
|
|
170
191
|
formatted_expr = (
|
|
171
192
|
_when(val < 0)
|
|
@@ -192,7 +213,17 @@ def fmt_number(
|
|
|
192
213
|
.otherwise(_lit('+') + _lit(prefix) + base_num_str + _lit(suffix))
|
|
193
214
|
)
|
|
194
215
|
|
|
195
|
-
|
|
216
|
+
# --- CRUCIAL FIX: NON-FINITE GLOBAL GUARD MASK ---
|
|
217
|
+
# Intercepts expression evaluation to bypass Int64 panics on NaN and Infinity tokens
|
|
218
|
+
final_guarded_expr = (
|
|
219
|
+
_when(val.is_nan())
|
|
220
|
+
.then(_lit('NaN'))
|
|
221
|
+
.when(val.is_infinite())
|
|
222
|
+
.then(_when(val < 0).then(_lit('-inf')).otherwise(_lit('inf')))
|
|
223
|
+
.otherwise(formatted_expr)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return final_guarded_expr.alias(col_name)
|
|
196
227
|
|
|
197
228
|
|
|
198
229
|
@_multicolumn
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
from polars import (
|
|
2
|
-
DataFrame as _DataFrame,
|
|
3
|
-
DataType as _DataType,
|
|
4
|
-
Expr as _Expr,
|
|
5
|
-
LazyFrame as _LazyFrame,
|
|
6
|
-
String as _String,
|
|
7
|
-
col as _col,
|
|
8
|
-
concat_str as _concat_str,
|
|
9
|
-
lit as _lit,
|
|
10
|
-
when as _when,
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
__version__ = '0.4.1'
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _escape_polars_string(col_name: str) -> _Expr:
|
|
17
|
-
"""
|
|
18
|
-
Escapes a column's string representations for secure HTML compatibility.
|
|
19
|
-
Because standard HTML entities use look-arounds or loops, we can fall back to
|
|
20
|
-
a native character loop or custom mapper if extensive characters are used,
|
|
21
|
-
but basic safety replaces characters sequentially.
|
|
22
|
-
"""
|
|
23
|
-
return (
|
|
24
|
-
_col(col_name)
|
|
25
|
-
.cast(_String)
|
|
26
|
-
.str.replace_all('&', '&')
|
|
27
|
-
.str.replace_all('<', '<')
|
|
28
|
-
.str.replace_all('>', '>')
|
|
29
|
-
.str.replace_all('"', '"')
|
|
30
|
-
.str.replace_all("'", ''')
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _format_integer(col_name: str) -> _Expr:
|
|
35
|
-
"""Adds thousands separators to integers using look-around-free logic."""
|
|
36
|
-
return (
|
|
37
|
-
_col(col_name)
|
|
38
|
-
.fill_null(0)
|
|
39
|
-
.cast(_String)
|
|
40
|
-
.str.reverse()
|
|
41
|
-
.str.replace_all(r'\d{3}', '$0,')
|
|
42
|
-
.str.reverse()
|
|
43
|
-
.str.replace(r'^,', '')
|
|
44
|
-
.str.replace(r'^-,', '-')
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def _format_float(col_name: str) -> _Expr:
|
|
49
|
-
"""Formats floats with 3 decimals and thousands separators."""
|
|
50
|
-
rounded_str = _col(col_name).round(3).cast(_String)
|
|
51
|
-
int_part = rounded_str.str.split_exact('.', 1).struct.field('field_0')
|
|
52
|
-
dec_part = rounded_str.str.split_exact('.', 1).struct.field('field_1')
|
|
53
|
-
|
|
54
|
-
formatted_int = (
|
|
55
|
-
int_part.str.reverse()
|
|
56
|
-
.str.replace_all(r'\d{3}', '$0,')
|
|
57
|
-
.str.reverse()
|
|
58
|
-
.str.replace(r'^,', '')
|
|
59
|
-
.str.replace(r'^-,', '-')
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
_when(dec_part.is_not_null() & (dec_part != ''))
|
|
64
|
-
.then(formatted_int + _lit('.') + dec_part)
|
|
65
|
-
.otherwise(formatted_int)
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _build_cell_expr(
|
|
70
|
-
col_name: str,
|
|
71
|
-
dtype: _DataType,
|
|
72
|
-
attrs: dict[str, dict[str, _Expr]],
|
|
73
|
-
) -> _Expr:
|
|
74
|
-
"""
|
|
75
|
-
Applies secure auto-escaping or data formatting overrides, then dynamically
|
|
76
|
-
compiles HTML attributes into a valid single opening <td> tag block.
|
|
77
|
-
"""
|
|
78
|
-
# 1. Base Data Evaluation (Formatted and HTML Escaped safely)
|
|
79
|
-
if dtype.is_integer():
|
|
80
|
-
fmt_expr = _format_integer(col_name)
|
|
81
|
-
elif dtype.is_float():
|
|
82
|
-
fmt_expr = _format_float(col_name)
|
|
83
|
-
else:
|
|
84
|
-
fmt_expr = _escape_polars_string(col_name)
|
|
85
|
-
|
|
86
|
-
fmt_expr = fmt_expr.fill_null('')
|
|
87
|
-
|
|
88
|
-
# 2. Build Attributes Expression Natively (e.g., style="...", class="...")
|
|
89
|
-
attr_expr_list = []
|
|
90
|
-
if col_name in attrs:
|
|
91
|
-
for attr_name, val_expr in attrs[col_name].items():
|
|
92
|
-
# If the expression returns null or empty for a specific row, skip writing the attribute
|
|
93
|
-
compiled_attr = (
|
|
94
|
-
_when(val_expr.is_not_null() & (val_expr != ''))
|
|
95
|
-
.then(
|
|
96
|
-
_lit(f' {attr_name}="')
|
|
97
|
-
+ val_expr.cast(_String)
|
|
98
|
-
+ _lit('"')
|
|
99
|
-
)
|
|
100
|
-
.otherwise(_lit(''))
|
|
101
|
-
)
|
|
102
|
-
attr_expr_list.append(compiled_attr)
|
|
103
|
-
|
|
104
|
-
# Combine attribute strings together
|
|
105
|
-
if attr_expr_list:
|
|
106
|
-
opening_td = _lit('<td') + _concat_str(attr_expr_list) + _lit('>')
|
|
107
|
-
else:
|
|
108
|
-
opening_td = _lit('<td>')
|
|
109
|
-
|
|
110
|
-
return opening_td + fmt_expr + _lit('</td>')
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _build_html_skeleton(visible_columns: list[str]) -> tuple[_Expr, _Expr]:
|
|
114
|
-
header_parts = ['<table>', ' <thead>\n <tr>']
|
|
115
|
-
for c in visible_columns:
|
|
116
|
-
header_parts.append(f' <th>{c}</th>')
|
|
117
|
-
header_parts.append(' </tr>\n </thead>\n <tbody>')
|
|
118
|
-
|
|
119
|
-
html_header = _lit('\n'.join(header_parts) + '\n')
|
|
120
|
-
html_footer = _lit('\n </tbody>\n</table>')
|
|
121
|
-
return html_header, html_footer
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def to_html(
|
|
125
|
-
df: _DataFrame | _LazyFrame,
|
|
126
|
-
*,
|
|
127
|
-
attrs: dict[str, dict[str, _Expr]] | None = None,
|
|
128
|
-
exclude_columns: list[str] | None = None,
|
|
129
|
-
) -> str:
|
|
130
|
-
"""
|
|
131
|
-
Compiles a Polars DataFrame safely into an HTML string layout.
|
|
132
|
-
Accepts structural custom attributes mappings to handle layout modifications natively.
|
|
133
|
-
"""
|
|
134
|
-
lf = df.lazy() if isinstance(df, _DataFrame) else df
|
|
135
|
-
|
|
136
|
-
exclude_columns = exclude_columns or []
|
|
137
|
-
attrs = attrs or {}
|
|
138
|
-
|
|
139
|
-
schema = lf.collect_schema()
|
|
140
|
-
visible_columns = [c for c in schema.names() if c not in exclude_columns]
|
|
141
|
-
|
|
142
|
-
cell_expressions = []
|
|
143
|
-
|
|
144
|
-
# 2. Map structural cell loops
|
|
145
|
-
for c in visible_columns:
|
|
146
|
-
cell_expressions.append(_build_cell_expr(c, schema[c], attrs))
|
|
147
|
-
|
|
148
|
-
# 3. Concatenate columns horizontally into rows
|
|
149
|
-
row_expr = _lit(' <tr>') + _concat_str(cell_expressions) + _lit('</tr>')
|
|
150
|
-
|
|
151
|
-
# 4. Generate wrappers and frame the query graph
|
|
152
|
-
html_header, html_footer = _build_html_skeleton(visible_columns)
|
|
153
|
-
|
|
154
|
-
# 5. Execute the query graph and pull out the raw python string directly
|
|
155
|
-
return (
|
|
156
|
-
lf.select(row_expr.alias('html_row'))
|
|
157
|
-
.select(
|
|
158
|
-
(
|
|
159
|
-
html_header + _col('html_row').str.join('\n') + html_footer
|
|
160
|
-
).alias('html_table')
|
|
161
|
-
)
|
|
162
|
-
.collect()
|
|
163
|
-
.item()
|
|
164
|
-
)
|
|
File without changes
|