pl2html 0.4.0__tar.gz → 0.5.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.4.0
3
+ Version: 0.5.0
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.0'
@@ -10,8 +10,6 @@ from polars import (
10
10
  when as _when,
11
11
  )
12
12
 
13
- __version__ = '0.4.0'
14
-
15
13
 
16
14
  def _escape_polars_string(col_name: str) -> _Expr:
17
15
  """
@@ -126,10 +124,53 @@ def to_html(
126
124
  *,
127
125
  attrs: dict[str, dict[str, _Expr]] | None = None,
128
126
  exclude_columns: list[str] | None = None,
127
+ formatters: _Expr | list[_Expr] | None = None,
129
128
  ) -> str:
130
- """
131
- Compiles a Polars DataFrame safely into an HTML string layout.
132
- Accepts structural custom attributes mappings to handle layout modifications natively.
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
+ ... )
133
174
  """
134
175
  lf = df.lazy() if isinstance(df, _DataFrame) else df
135
176
 
@@ -139,19 +180,51 @@ def to_html(
139
180
  schema = lf.collect_schema()
140
181
  visible_columns = [c for c in schema.names() if c not in exclude_columns]
141
182
 
142
- cell_expressions = []
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
143
211
 
144
- # 2. Map structural cell loops
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 = []
145
220
  for c in visible_columns:
146
- cell_expressions.append(_build_cell_expr(c, schema[c], attrs))
221
+ cell_expressions.append(
222
+ _build_cell_expr(c, lf.collect_schema()[c], attrs)
223
+ )
147
224
 
148
- # 3. Concatenate columns horizontally into rows
149
225
  row_expr = _lit(' <tr>') + _concat_str(cell_expressions) + _lit('</tr>')
150
-
151
- # 4. Generate wrappers and frame the query graph
152
226
  html_header, html_footer = _build_html_skeleton(visible_columns)
153
227
 
154
- # 5. Execute the query graph and pull out the raw python string directly
155
228
  return (
156
229
  lf.select(row_expr.alias('html_row'))
157
230
  .select(
@@ -30,111 +30,98 @@ 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
- val = _col(columns)
52
- if scale_by != 1.0:
53
- 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('')
54
39
 
55
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
56
43
 
57
- # 1. Extract compact scaling suffix chain if enabled
58
- if compact:
59
- log10_expr = (
60
- _when(abs_val > 0).then(abs_val.log10()).otherwise(_lit(0.0))
61
- )
62
- thousands_exponent = (log10_expr / 3.0).floor().cast(_Int32) * 3
63
-
64
- if compact_system == 'engineering':
65
- suffix_chain = (
66
- _when(thousands_exponent == 3)
67
- .then(_lit('k'))
68
- .when(thousands_exponent == 6)
69
- .then(_lit('M'))
70
- .when(thousands_exponent == 9)
71
- .then(_lit('G'))
72
- .when(thousands_exponent == 12)
73
- .then(_lit('T'))
74
- .otherwise(_lit(''))
75
- )
76
- else:
77
- suffix_chain = (
78
- _when(thousands_exponent == 3)
79
- .then(_lit('K'))
80
- .when(thousands_exponent == 6)
81
- .then(_lit('M'))
82
- .when(thousands_exponent == 9)
83
- .then(_lit('B'))
84
- .when(thousands_exponent == 12)
85
- .then(_lit('T'))
86
- .otherwise(_lit(''))
87
- )
88
- divisor = _lit(10.0).pow(thousands_exponent.cast(_Float64))
89
- val_scaled = val / divisor
90
- else:
91
- val_scaled = val
92
- suffix_chain = _lit('')
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
+ )
50
+
51
+ system_chars = (
52
+ ('k', 'M', 'G', 'T')
53
+ if compact_system == 'engineering'
54
+ else ('K', 'M', 'B', 'T')
55
+ )
56
+
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)
93
81
 
94
- # 2. Dynamic Precision Handling (Significant Figures vs Fixed Decimals)
95
82
  if n_sigfig is not None:
96
83
  if n_sigfig < 1:
97
84
  raise ValueError('n_sigfig must be a positive integer >= 1')
98
85
 
99
- # Round natively using Polars' built-in significant figures feature
100
- rounded = val_scaled.round_sig_figs(n_sigfig)
86
+ rounded = safe_val.round_sig_figs(n_sigfig)
101
87
  abs_rounded = rounded.abs()
102
88
 
103
- # Determine the number of dynamic decimal places needed for padding per row
104
89
  log10_expr = (
105
90
  _when(abs_rounded > 0)
106
91
  .then(abs_rounded.log10())
107
92
  .otherwise(_lit(0.0))
108
93
  )
109
- # decimals required = n_sigfig - 1 - floor(log10(x))
110
94
  dynamic_decimals = (_lit(n_sigfig - 1) - log10_expr.floor()).cast(
111
95
  _Int32
112
96
  )
113
- # Ensure we don't try to pad negative decimal places for large numbers
114
97
  dynamic_decimals = (
115
98
  _when(dynamic_decimals < 0)
116
99
  .then(_lit(0))
117
100
  .otherwise(dynamic_decimals)
118
101
  )
119
102
  else:
120
- # Fallback to standard fixed decimal logic
121
- epsilon = (
122
- _when(val_scaled >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
123
- )
124
- 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)
125
105
  dynamic_decimals = _lit(decimals)
126
106
 
127
- # 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!
128
120
  int_part = rounded.cast(_Int64).abs().cast(_String)
129
121
  full_str = rounded.abs().cast(_String)
130
122
 
131
- raw_frac = (
132
- _when(full_str.str.contains(r'\.'))
133
- .then(full_str.str.split('.').list.get(1))
134
- .otherwise(_lit(''))
135
- )
123
+ raw_frac = full_str.str.extract(r'\.(.*)', 1).fill_null(_lit(''))
136
124
 
137
- # Use native Python string multiplication inside _lit()
138
125
  pad_len = n_sigfig if n_sigfig is not None else decimals
139
126
  frac_part = (
140
127
  _when(dynamic_decimals > 0)
@@ -142,7 +129,6 @@ def fmt_number(
142
129
  .otherwise(_lit(''))
143
130
  )
144
131
 
145
- # 4. Constructing Base Number String
146
132
  if use_seps:
147
133
  int_part = (
148
134
  int_part.str.reverse()
@@ -151,19 +137,56 @@ def fmt_number(
151
137
  .str.reverse()
152
138
  )
153
139
 
154
- base_num_str = (
140
+ return (
155
141
  _when(dynamic_decimals > 0)
156
142
  .then(int_part + _lit('.') + frac_part)
157
143
  .otherwise(int_part)
158
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
+ )
159
180
  base_num_str = base_num_str + suffix_chain
160
181
 
182
+ # --- PATTERN PARSING ---
161
183
  prefix, suffix = '', ''
162
184
  if pattern != '{x}':
163
185
  parts = pattern.split('{x}')
164
186
  if len(parts) == 2:
165
187
  prefix, suffix = parts[0], parts[1]
166
188
 
189
+ # --- STANDARD EXPRESSION GENERATION ---
167
190
  if accounting:
168
191
  formatted_expr = (
169
192
  _when(val < 0)
@@ -175,7 +198,6 @@ def fmt_number(
175
198
  + _lit(')')
176
199
  )
177
200
  .otherwise(
178
- # If force_sign is True, explicitly prepend '+' to positive values
179
201
  _lit('+' if force_sign else '')
180
202
  + _lit(prefix)
181
203
  + base_num_str
@@ -186,13 +208,22 @@ def fmt_number(
186
208
  formatted_expr = (
187
209
  _when(val < 0)
188
210
  .then(_lit('-') + _lit(prefix) + base_num_str + _lit(suffix))
189
- # Handle zero explicitly if you don't want a sign on exactly 0.0
190
211
  .when((val == 0) | (not force_sign))
191
212
  .then(_lit(prefix) + base_num_str + _lit(suffix))
192
213
  .otherwise(_lit('+') + _lit(prefix) + base_num_str + _lit(suffix))
193
214
  )
194
215
 
195
- return formatted_expr
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.0"
7
+ version = "0.5.0"
8
8
  description = "Add your description here"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
File without changes