pl2html 0.5.0__tar.gz → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pl2html
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Add your description here
5
5
  Requires-Dist: polars>=1.42.1
6
6
  Requires-Python: >=3.14
@@ -0,0 +1 @@
1
+ __version__ = '0.6.0'
@@ -0,0 +1,274 @@
1
+ from html import escape as _escape
2
+
3
+ from polars import (
4
+ DataFrame as _DataFrame,
5
+ DataType as _DataType,
6
+ Expr as _Expr,
7
+ LazyFrame as _LazyFrame,
8
+ String as _String,
9
+ col as _col,
10
+ concat_str as _concat_str,
11
+ lit as _lit,
12
+ when as _when,
13
+ )
14
+
15
+
16
+ def _escape_expr(expr: _Expr) -> _Expr:
17
+ """
18
+ Escapes an expression's string representations for secure HTML compatibility.
19
+ Using literal=True is critical; otherwise, the '&' character in the replacement
20
+ string acts as a regex match expansion macro and corrupts the entities.
21
+ """
22
+ return (
23
+ expr.cast(_String)
24
+ .str.replace_all('&', '&', literal=True)
25
+ .str.replace_all('<', '&lt;', literal=True)
26
+ .str.replace_all('>', '&gt;', literal=True)
27
+ .str.replace_all('"', '&quot;', literal=True)
28
+ .str.replace_all("'", '&#x27;', literal=True)
29
+ )
30
+
31
+
32
+ def _format_integer(col_name: str) -> _Expr:
33
+ """Adds thousands separators to integers using look-around-free logic."""
34
+ return (
35
+ _col(col_name)
36
+ .fill_null(0)
37
+ .cast(_String)
38
+ # Polars regex engine doesn't support look-behinds (e.g., (?<=\d)(?=(\d{3})+$)).
39
+ # We reverse the string, match chunks of 3 digits from the right, add commas,
40
+ # and then reverse it back to normal.
41
+ .str.reverse()
42
+ .str.replace_all(r'\d{3}', '$0,')
43
+ .str.reverse()
44
+ .str.replace(r'^,', '')
45
+ .str.replace(r'^-,', '-')
46
+ )
47
+
48
+
49
+ def _format_float(col_name: str) -> _Expr:
50
+ """Formats floats with 3 decimals and thousands separators."""
51
+ rounded_str = _col(col_name).round(3).cast(_String)
52
+
53
+ parts = rounded_str.str.split_exact('.', 1)
54
+ int_part = parts.struct.field('field_0')
55
+ dec_part = parts.struct.field('field_1')
56
+
57
+ formatted_int = (
58
+ int_part.str.reverse()
59
+ .str.replace_all(r'\d{3}', '$0,')
60
+ .str.reverse()
61
+ .str.replace(r'^,', '')
62
+ .str.replace(r'^-,', '-')
63
+ )
64
+
65
+ return (
66
+ _when(dec_part.is_not_null() & (dec_part != ''))
67
+ .then(formatted_int + _lit('.') + dec_part)
68
+ .otherwise(formatted_int)
69
+ )
70
+
71
+
72
+ def _build_cell_expr(
73
+ col_name: str,
74
+ dtype: _DataType,
75
+ attrs: dict[str, dict[str, _Expr]],
76
+ ) -> _Expr:
77
+ """Resolves attribute expressions before user formatters are applied."""
78
+ if dtype.is_integer():
79
+ fmt_expr = _format_integer(col_name)
80
+ elif dtype.is_float():
81
+ fmt_expr = _format_float(col_name)
82
+ else:
83
+ fmt_expr = _escape_expr(_col(col_name))
84
+
85
+ fmt_expr = fmt_expr.fill_null('')
86
+
87
+ attr_expr_list = []
88
+ if col_name in attrs:
89
+ for attr_name, val_expr in attrs[col_name].items():
90
+ val_str_expr = val_expr.cast(_String)
91
+
92
+ compiled_attr = (
93
+ _when(val_str_expr.is_not_null() & (val_str_expr != ''))
94
+ .then(_lit(f' {attr_name}="') + val_str_expr + _lit('"'))
95
+ .otherwise(_lit(''))
96
+ )
97
+ attr_expr_list.append(compiled_attr)
98
+
99
+ if attr_expr_list:
100
+ opening_td = _lit('<td') + _concat_str(attr_expr_list) + _lit('>')
101
+ else:
102
+ opening_td = _lit('<td>')
103
+
104
+ return opening_td + fmt_expr + _lit('</td>')
105
+
106
+
107
+ def _build_html_skeleton(visible_columns: list[str]) -> tuple[_Expr, _Expr]:
108
+ header_parts = ['<table>', ' <thead>\n <tr>']
109
+
110
+ for c in visible_columns:
111
+ # NOTE: quote=False allows names like `Alice's "score"` to render naturally.
112
+ # This is safe ONLY because the string is confined strictly inside text nodes (<th>).
113
+ # DO NOT reuse these variables within HTML attributes.
114
+ header_parts.append(f' <th>{_escape(c, quote=False)}</th>')
115
+
116
+ header_parts.append(' </tr>\n </thead>\n <tbody>')
117
+
118
+ html_header = _lit('\n'.join(header_parts) + '\n')
119
+ html_footer = _lit('\n </tbody>\n</table>')
120
+ return html_header, html_footer
121
+
122
+
123
+ def _resolve_attributes(
124
+ lf: _LazyFrame,
125
+ df: _DataFrame,
126
+ attrs: dict[str, dict[str, _Expr]],
127
+ visible_columns: list[str],
128
+ ) -> tuple[_LazyFrame, dict[str, dict[str, _Expr]]]:
129
+ """Compiles dynamic attribute expressions using the raw, unformatted numeric data."""
130
+ attr_exprs = []
131
+ attr_aliases = {}
132
+
133
+ for col_name, attr_map in attrs.items():
134
+ if col_name in visible_columns:
135
+ for attr_name, expr in attr_map.items():
136
+ alias_key = f'__attr_{col_name}_{attr_name}'
137
+ attr_exprs.append(_escape_expr(expr).alias(alias_key))
138
+ attr_aliases[(col_name, attr_name)] = alias_key
139
+
140
+ if not attr_exprs:
141
+ return lf, attrs
142
+
143
+ # Evaluate dynamic style contexts on the unformatted DataFrame
144
+ resolved_attrs_df = df.select(attr_exprs)
145
+
146
+ # Re-inject styles as true native structural data streams
147
+ lf = lf.with_columns(resolved_attrs_df)
148
+
149
+ # Remap the attributes dictionary to look at the new column references
150
+ new_attrs = {col: attr_map.copy() for col, attr_map in attrs.items()}
151
+
152
+ for (col_name, attr_name), alias in attr_aliases.items():
153
+ new_attrs[col_name][attr_name] = _col(alias)
154
+
155
+ return lf, new_attrs
156
+
157
+
158
+ def _apply_user_formatters(
159
+ lf: _LazyFrame, formatters: _Expr | list[_Expr] | None
160
+ ) -> _LazyFrame:
161
+ """Applies layout transformation modifications/conversions to columns."""
162
+ if formatters is None:
163
+ return lf
164
+ if isinstance(formatters, _Expr):
165
+ formatters = [formatters]
166
+ return lf.with_columns(formatters)
167
+
168
+
169
+ def _compile_html(
170
+ lf: _LazyFrame,
171
+ visible_columns: list[str],
172
+ attrs: dict[str, dict[str, _Expr]],
173
+ ) -> str:
174
+ """Constructs the raw structural HTML string from the resolved LazyFrame pipeline."""
175
+ # NOTE: Recompute the schema after applying user formatters. Formatters may
176
+ # cast numeric columns to String, and _build_cell_expr uses the current dtype
177
+ # to decide whether default numeric formatting (e.g. .round()) should be
178
+ # applied. Using the original schema would incorrectly attempt numeric
179
+ # formatting on already-formatted string columns.
180
+ current_schema = lf.collect_schema()
181
+
182
+ cell_expressions = [
183
+ _build_cell_expr(c, current_schema[c], attrs) for c in visible_columns
184
+ ]
185
+
186
+ row_expr = _lit(' <tr>') + _concat_str(cell_expressions) + _lit('</tr>')
187
+ html_header, html_footer = _build_html_skeleton(visible_columns)
188
+
189
+ return (
190
+ lf.select(row_expr.alias('html_row'))
191
+ .select(
192
+ (
193
+ html_header + _col('html_row').str.join('\n') + html_footer
194
+ ).alias('html_table')
195
+ )
196
+ .collect()
197
+ .item()
198
+ )
199
+
200
+
201
+ def to_html(
202
+ df: _DataFrame | _LazyFrame,
203
+ *,
204
+ attrs: dict[str, dict[str, _Expr]] | None = None,
205
+ exclude_columns: list[str] | None = None,
206
+ formatters: _Expr | list[_Expr] | None = None,
207
+ ) -> str:
208
+ """Compiles a Polars DataFrame safely into an HTML string layout.
209
+
210
+ Accepts structural custom attribute mappings to handle layout modifications
211
+ and cell styling natively.
212
+
213
+ Args:
214
+ df: The source Polars DataFrame or LazyFrame containing the data.
215
+ attrs: A dictionary mapping column names to cell attributes (e.g., style,
216
+ class). The inner dictionary values can be raw Polars expressions that
217
+ evaluate dynamically based on the column values.
218
+ exclude_columns: A list of column names to omit from the final rendered
219
+ HTML table. See note below on structural execution context.
220
+ formatters: A single Polars expression or a list of expressions used to
221
+ format column values into display strings (e.g., using `fmt_number`).
222
+
223
+ Note on Execution Order (formatters and exclude_columns):
224
+ Both `formatters` and `exclude_columns` serve critical roles in preserving
225
+ the numeric evaluation state of the DataFrame before HTML compilation:
226
+
227
+ 1. formatters: Usually, formatting expressions can be applied directly to
228
+ the DataFrame via `.with_columns()` before calling `to_html`. However,
229
+ doing so converts numeric columns into `String` types too early, causing
230
+ downstream style calculations (e.g., string division errors) to fail.
231
+ Passing them here guarantees formatting runs *after* style resolution.
232
+
233
+ 2. exclude_columns: Dropping intermediate or helper columns before passing
234
+ the DataFrame to `to_html` removes the data context required by your
235
+ `attrs` expressions. By using `exclude_columns`, helper columns remain
236
+ fully accessible during the mathematical style resolution phase (Step 1),
237
+ but are cleanly filtered out before generating the HTML structural matrix.
238
+
239
+ Example:
240
+ >>> import polars as pl
241
+ >>> from pl2html import to_html
242
+ >>>
243
+ >>> # 'max_threshold' is a helper column required for styling but not rendering
244
+ >>> df = pl.DataFrame({
245
+ ... "sprd": [10.0, 50.0, 100.0],
246
+ ... "max_threshold": [120.0, 120.0, 120.0]
247
+ ... })
248
+ >>>
249
+ >>> # Style depends on the unrendered 'max_threshold' column
250
+ >>> attrs = {"sprd": {"style": pl.col("sprd") / pl.col("max_threshold")}}
251
+ >>>
252
+ >>> # Safe execution: 'max_threshold' is preserved for math, then omitted from output
253
+ >>> html = to_html(
254
+ ... df,
255
+ ... attrs=attrs,
256
+ ... exclude_columns=["max_threshold"]
257
+ ... )
258
+ """
259
+ lf = df.lazy() if isinstance(df, _DataFrame) else df
260
+ exclude_columns = exclude_columns or []
261
+ attrs = attrs or {}
262
+
263
+ # Setup the evaluation context data
264
+ df_eager = lf.collect()
265
+ lf = df_eager.lazy()
266
+
267
+ visible_columns = [
268
+ c for c in df_eager.schema.names() if c not in exclude_columns
269
+ ]
270
+
271
+ lf, attrs = _resolve_attributes(lf, df_eager, attrs, visible_columns)
272
+ lf = _apply_user_formatters(lf, formatters)
273
+
274
+ return _compile_html(lf, visible_columns, attrs)
@@ -4,7 +4,7 @@ build-backend = 'uv_build'
4
4
 
5
5
  [project]
6
6
  name = "pl2html"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "Add your description here"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -1 +0,0 @@
1
- __version__ = '0.5.0'
@@ -1,237 +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
-
14
- def _escape_polars_string(col_name: str) -> _Expr:
15
- """
16
- Escapes a column's string representations for secure HTML compatibility.
17
- Because standard HTML entities use look-arounds or loops, we can fall back to
18
- a native character loop or custom mapper if extensive characters are used,
19
- but basic safety replaces characters sequentially.
20
- """
21
- return (
22
- _col(col_name)
23
- .cast(_String)
24
- .str.replace_all('&', '&amp;')
25
- .str.replace_all('<', '&lt;')
26
- .str.replace_all('>', '&gt;')
27
- .str.replace_all('"', '&quot;')
28
- .str.replace_all("'", '&#x27;')
29
- )
30
-
31
-
32
- def _format_integer(col_name: str) -> _Expr:
33
- """Adds thousands separators to integers using look-around-free logic."""
34
- return (
35
- _col(col_name)
36
- .fill_null(0)
37
- .cast(_String)
38
- .str.reverse()
39
- .str.replace_all(r'\d{3}', '$0,')
40
- .str.reverse()
41
- .str.replace(r'^,', '')
42
- .str.replace(r'^-,', '-')
43
- )
44
-
45
-
46
- def _format_float(col_name: str) -> _Expr:
47
- """Formats floats with 3 decimals and thousands separators."""
48
- rounded_str = _col(col_name).round(3).cast(_String)
49
- int_part = rounded_str.str.split_exact('.', 1).struct.field('field_0')
50
- dec_part = rounded_str.str.split_exact('.', 1).struct.field('field_1')
51
-
52
- formatted_int = (
53
- int_part.str.reverse()
54
- .str.replace_all(r'\d{3}', '$0,')
55
- .str.reverse()
56
- .str.replace(r'^,', '')
57
- .str.replace(r'^-,', '-')
58
- )
59
-
60
- return (
61
- _when(dec_part.is_not_null() & (dec_part != ''))
62
- .then(formatted_int + _lit('.') + dec_part)
63
- .otherwise(formatted_int)
64
- )
65
-
66
-
67
- def _build_cell_expr(
68
- col_name: str,
69
- dtype: _DataType,
70
- attrs: dict[str, dict[str, _Expr]],
71
- ) -> _Expr:
72
- """
73
- Applies secure auto-escaping or data formatting overrides, then dynamically
74
- compiles HTML attributes into a valid single opening <td> tag block.
75
- """
76
- # 1. Base Data Evaluation (Formatted and HTML Escaped safely)
77
- if dtype.is_integer():
78
- fmt_expr = _format_integer(col_name)
79
- elif dtype.is_float():
80
- fmt_expr = _format_float(col_name)
81
- else:
82
- fmt_expr = _escape_polars_string(col_name)
83
-
84
- fmt_expr = fmt_expr.fill_null('')
85
-
86
- # 2. Build Attributes Expression Natively (e.g., style="...", class="...")
87
- attr_expr_list = []
88
- if col_name in attrs:
89
- for attr_name, val_expr in attrs[col_name].items():
90
- # If the expression returns null or empty for a specific row, skip writing the attribute
91
- compiled_attr = (
92
- _when(val_expr.is_not_null() & (val_expr != ''))
93
- .then(
94
- _lit(f' {attr_name}="')
95
- + val_expr.cast(_String)
96
- + _lit('"')
97
- )
98
- .otherwise(_lit(''))
99
- )
100
- attr_expr_list.append(compiled_attr)
101
-
102
- # Combine attribute strings together
103
- if attr_expr_list:
104
- opening_td = _lit('<td') + _concat_str(attr_expr_list) + _lit('>')
105
- else:
106
- opening_td = _lit('<td>')
107
-
108
- return opening_td + fmt_expr + _lit('</td>')
109
-
110
-
111
- def _build_html_skeleton(visible_columns: list[str]) -> tuple[_Expr, _Expr]:
112
- header_parts = ['<table>', ' <thead>\n <tr>']
113
- for c in visible_columns:
114
- header_parts.append(f' <th>{c}</th>')
115
- header_parts.append(' </tr>\n </thead>\n <tbody>')
116
-
117
- html_header = _lit('\n'.join(header_parts) + '\n')
118
- html_footer = _lit('\n </tbody>\n</table>')
119
- return html_header, html_footer
120
-
121
-
122
- def to_html(
123
- df: _DataFrame | _LazyFrame,
124
- *,
125
- attrs: dict[str, dict[str, _Expr]] | None = None,
126
- exclude_columns: list[str] | None = None,
127
- formatters: _Expr | list[_Expr] | None = None,
128
- ) -> str:
129
- """Compiles a Polars DataFrame safely into an HTML string layout.
130
-
131
- Accepts structural custom attribute mappings to handle layout modifications
132
- and cell styling natively.
133
-
134
- Args:
135
- df: The source Polars DataFrame or LazyFrame containing the data.
136
- attrs: A dictionary mapping column names to cell attributes (e.g., style,
137
- class). The inner dictionary values can be raw Polars expressions that
138
- evaluate dynamically based on the column values.
139
- exclude_columns: A list of column names to omit from the rendered HTML table.
140
- formatters: A single Polars expression or a list of expressions used to
141
- format column values into display strings (e.g., using `fmt_number`).
142
-
143
- Returns:
144
- A raw HTML string representation of the styled and formatted table.
145
-
146
- Note on Formatters Execution Order:
147
- Usually, formatting expressions can be applied directly to the DataFrame
148
- via `.with_columns()` before calling `to_html`. However, if your `attrs`
149
- contain expressions that require numerical operations on data values (such
150
- as computing percentage ranks or maximum thresholds via `rank_color`),
151
- pre-formatting will convert those columns into `String` types too early and
152
- cause downstream calculation panics (e.g., string division errors).
153
-
154
- By passing your formatting expressions to the `formatters` parameter
155
- instead, `to_html` guarantees they are scheduled to execute *after* the
156
- numerical style attributes have been safely resolved against the raw data.
157
-
158
- Example:
159
- >>> import polars as pl
160
- >>> from pl2html import to_html
161
- >>> from pl2html.formats import fmt_number
162
- >>>
163
- >>> df = pl.DataFrame({"sprd": [10.0, 50.0, 100.0]})
164
- >>>
165
- >>> # A style rule that requires the column to remain numeric (Float64)
166
- >>> attrs = {"sprd": {"style": pl.col("sprd") / pl.col("sprd").max()}}
167
- >>>
168
- >>> # Safe execution: styles are computed first, then text formatting is applied
169
- >>> html = to_html(
170
- ... df,
171
- ... attrs=attrs,
172
- ... formatters=fmt_number(columns=["sprd"], decimals=2)
173
- ... )
174
- """
175
- lf = df.lazy() if isinstance(df, _DataFrame) else df
176
-
177
- exclude_columns = exclude_columns or []
178
- attrs = attrs or {}
179
-
180
- schema = lf.collect_schema()
181
- visible_columns = [c for c in schema.names() if c not in exclude_columns]
182
-
183
- # === STEP 1: RESOLVE MATH/STYLE EXPRESSIONS ON NUMERIC DATA FIRST ===
184
- # If there are style expressions, evaluate them into static string literals
185
- # before applying the text formatters.
186
- style_selects = []
187
- style_maps = {}
188
-
189
- for col_name, styles in attrs.items():
190
- if col_name in visible_columns and 'style' in styles:
191
- expr_key = f'__style_{col_name}'
192
- style_selects.append(styles['style'].alias(expr_key))
193
- style_maps[col_name] = expr_key
194
-
195
- if style_selects:
196
- # Collect just the evaluated style values using the numeric dataframe state
197
- df = lf.collect()
198
- lf = df.lazy()
199
- resolved_styles_df = df.select(style_selects)
200
-
201
- # Replace the lazy expressions in attrs with concrete string series expressions
202
- new_attrs = {}
203
- for col_name, styles in attrs.items():
204
- new_attrs[col_name] = styles.copy()
205
- if col_name in style_maps:
206
- # Inject the pre-calculated style vector back as a harmless literal expression
207
- new_attrs[col_name]['style'] = _lit(
208
- resolved_styles_df.get_column(style_maps[col_name])
209
- )
210
- attrs = new_attrs
211
-
212
- # === STEP 2: APPLY FORMATTERS TO CONVERT COLUMNS TO STRINGS ===
213
- if formatters is not None:
214
- if isinstance(formatters, _Expr):
215
- formatters = [formatters]
216
- lf = lf.with_columns(formatters)
217
-
218
- # === STEP 3: BUILD HTML CELLS (Now 100% safe from string division errors!) ===
219
- cell_expressions = []
220
- for c in visible_columns:
221
- cell_expressions.append(
222
- _build_cell_expr(c, lf.collect_schema()[c], attrs)
223
- )
224
-
225
- row_expr = _lit(' <tr>') + _concat_str(cell_expressions) + _lit('</tr>')
226
- html_header, html_footer = _build_html_skeleton(visible_columns)
227
-
228
- return (
229
- lf.select(row_expr.alias('html_row'))
230
- .select(
231
- (
232
- html_header + _col('html_row').str.join('\n') + html_footer
233
- ).alias('html_table')
234
- )
235
- .collect()
236
- .item()
237
- )
File without changes
File without changes
File without changes