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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pl2html
3
- Version: 0.4.1
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 (`__init__.py`)
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 (`__init__.py`)
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('<', '&lt;', literal=True)
24
+ .str.replace_all('>', '&gt;', literal=True)
25
+ .str.replace_all('"', '&quot;', literal=True)
26
+ .str.replace_all("'", '&#x27;', 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
- @_multicolumn
34
- def fmt_number(
35
- *,
36
- columns: _Columns,
37
- decimals: int = 2,
38
- scale_by: float = 1.0,
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
- # 1. Extract compact scaling suffix chain if enabled
59
- if compact:
60
- log10_expr = (
61
- _when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
62
- )
63
- thousands_exponent = (log10_expr / 3.0).floor().cast(_Int32) * 3
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
- # Force exponent to 0 for fractional numbers (< 1.0) so we don't scale up without micro-suffixes
66
- thousands_exponent = (
67
- _when(thousands_exponent < 0)
68
- .then(_lit(0))
69
- .otherwise(thousands_exponent)
70
- )
51
+ system_chars = (
52
+ ('k', 'M', 'G', 'T')
53
+ if compact_system == 'engineering'
54
+ else ('K', 'M', 'B', 'T')
55
+ )
71
56
 
72
- if compact_system == 'engineering':
73
- suffix_chain = (
74
- _when(thousands_exponent == 3)
75
- .then(_lit('k'))
76
- .when(thousands_exponent == 6)
77
- .then(_lit('M'))
78
- .when(thousands_exponent == 9)
79
- .then(_lit('G'))
80
- .when(thousands_exponent == 12)
81
- .then(_lit('T'))
82
- .otherwise(_lit(''))
83
- )
84
- else:
85
- suffix_chain = (
86
- _when(thousands_exponent == 3)
87
- .then(_lit('K'))
88
- .when(thousands_exponent == 6)
89
- .then(_lit('M'))
90
- .when(thousands_exponent == 9)
91
- .then(_lit('B'))
92
- .when(thousands_exponent == 12)
93
- .then(_lit('T'))
94
- .otherwise(_lit(''))
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 = val_scaled.round_sig_figs(n_sigfig)
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
- _when(val_scaled >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
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
- # 3. Component Extraction & Alignment
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
- base_num_str = (
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
- return formatted_expr.alias(col_name)
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
@@ -4,7 +4,7 @@ build-backend = 'uv_build'
4
4
 
5
5
  [project]
6
6
  name = "pl2html"
7
- version = "0.4.1"
7
+ version = "0.5.1"
8
8
  description = "Add your description here"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -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('&', '&amp;')
27
- .str.replace_all('<', '&lt;')
28
- .str.replace_all('>', '&gt;')
29
- .str.replace_all('"', '&quot;')
30
- .str.replace_all("'", '&#x27;')
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