pl2html 0.2.0__tar.gz → 0.3.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.2.0
3
+ Version: 0.3.0
4
4
  Summary: Add your description here
5
5
  Requires-Dist: polars>=1.42.1
6
6
  Requires-Python: >=3.14
@@ -205,3 +205,13 @@ Generates structural Polars expression payloads mapped to specific HTML style at
205
205
  .str.replace_all('"', '"')
206
206
  .str.replace_all("'", ''')
207
207
  ```
208
+
209
+ ### Design Inspiration & Why This Library Exists
210
+
211
+ Most of the API design and formatting parameters in this library are heavily inspired by [great-tables](https://posit-dev.github.io/great-tables/reference/). If you are looking for detailed descriptions or conceptual overviews of specific formatting arguments, the `great-tables` reference documentation serves as an excellent companion guide.
212
+
213
+ However, this library was born out of distinct architectural and environment constraints:
214
+
215
+ * **Zero Overhead Styling:** While `great-tables` is incredibly feature-rich, it often produces dense HTML styles and boilerplate structures that were unnecessary for the target use cases. This library optimizes for minimal, highly clean HTML output.
216
+ * **Modern Python Compatibility (Py 3.14+):** `great-tables` pulls in heavy external dependencies, including binary wheels like `multimark`. At the time of development, these dependencies did not support Python 3.14. To unblock workflows and maintain an ultra-lightweight, future-proof stack with zero complex binary dependencies, this native, Rust-backed Polars formatter was built from the ground up.
217
+
@@ -196,3 +196,13 @@ Generates structural Polars expression payloads mapped to specific HTML style at
196
196
  .str.replace_all('"', '"')
197
197
  .str.replace_all("'", ''')
198
198
  ```
199
+
200
+ ### Design Inspiration & Why This Library Exists
201
+
202
+ Most of the API design and formatting parameters in this library are heavily inspired by [great-tables](https://posit-dev.github.io/great-tables/reference/). If you are looking for detailed descriptions or conceptual overviews of specific formatting arguments, the `great-tables` reference documentation serves as an excellent companion guide.
203
+
204
+ However, this library was born out of distinct architectural and environment constraints:
205
+
206
+ * **Zero Overhead Styling:** While `great-tables` is incredibly feature-rich, it often produces dense HTML styles and boilerplate structures that were unnecessary for the target use cases. This library optimizes for minimal, highly clean HTML output.
207
+ * **Modern Python Compatibility (Py 3.14+):** `great-tables` pulls in heavy external dependencies, including binary wheels like `multimark`. At the time of development, these dependencies did not support Python 3.14. To unblock workflows and maintain an ultra-lightweight, future-proof stack with zero complex binary dependencies, this native, Rust-backed Polars formatter was built from the ground up.
208
+
@@ -10,7 +10,7 @@ from polars import (
10
10
  when as _when,
11
11
  )
12
12
 
13
- __version__ = '0.2.0'
13
+ __version__ = '0.3.0'
14
14
 
15
15
 
16
16
  def _escape_polars_string(col_name: str) -> _Expr:
@@ -17,12 +17,10 @@ from polars import (
17
17
  # --- 0. Decorator to cleanly support single or multiple columns ---
18
18
  def _multicolumn(func):
19
19
  @_wraps(func)
20
- def wrapper(columns: str | _Iterable[str], *args, **kwargs):
20
+ def wrapper(*, columns: str | _Iterable[str], **kwargs):
21
21
  if isinstance(columns, str):
22
- return func(columns, *args, **kwargs)
23
- # Automatically alias each expression to its original column name
24
- # so Polars knows exactly which column to overwrite/create.
25
- return [func(col, *args, **kwargs).alias(col) for col in columns]
22
+ return func(columns=columns, **kwargs)
23
+ return [func(columns=col, **kwargs).alias(col) for col in columns]
26
24
 
27
25
  return wrapper
28
26
 
@@ -34,6 +32,7 @@ _Columns = str | list[str]
34
32
 
35
33
  @_multicolumn
36
34
  def fmt_number(
35
+ *,
37
36
  columns: _Columns,
38
37
  decimals: int = 2,
39
38
  scale_by: float = 1.0,
@@ -42,6 +41,8 @@ def fmt_number(
42
41
  use_seps: bool = True,
43
42
  accounting: bool = False,
44
43
  pattern: str = '{x}',
44
+ n_sigfig: int | None = None,
45
+ force_sign: bool = False,
45
46
  ) -> _Expr:
46
47
  """
47
48
  Highly optimized, native Polars numeric formatter matching great_tables features.
@@ -90,38 +91,71 @@ def fmt_number(
90
91
  val_scaled = val
91
92
  suffix_chain = _lit('')
92
93
 
93
- # 2. Handle precision rounding and extract base components natively
94
- epsilon = _when(val_scaled >= 0).then(_lit(1e-9)).otherwise(_lit(-1e-9))
95
- rounded = (val_scaled + epsilon).round(decimals)
94
+ # 2. Dynamic Precision Handling (Significant Figures vs Fixed Decimals)
95
+ if n_sigfig is not None:
96
+ if n_sigfig < 1:
97
+ raise ValueError('n_sigfig must be a positive integer >= 1')
96
98
 
97
- if decimals > 0:
98
- int_part = rounded.cast(_Int64).abs().cast(_String)
99
- full_str = rounded.abs().cast(_String)
100
- frac_part = (
101
- _when(full_str.str.contains(r'\.'))
102
- .then(full_str.str.split('.').list.get(1).str.slice(0, decimals))
103
- .otherwise(_lit(''))
104
- ).str.pad_end(decimals, fill_char='0')
105
-
106
- if use_seps:
107
- int_part = (
108
- int_part.str.reverse()
109
- .str.replace_all(r'(\d{3})', r'${1},')
110
- .str.strip_suffix(',')
111
- .str.reverse()
112
- )
99
+ # Round natively using Polars' built-in significant figures feature
100
+ rounded = val_scaled.round_sig_figs(n_sigfig)
101
+ abs_rounded = rounded.abs()
113
102
 
114
- base_num_str = int_part + _lit('.') + frac_part
103
+ # Determine the number of dynamic decimal places needed for padding per row
104
+ log10_expr = (
105
+ _when(abs_rounded > 0)
106
+ .then(abs_rounded.log10())
107
+ .otherwise(_lit(0.0))
108
+ )
109
+ # decimals required = n_sigfig - 1 - floor(log10(x))
110
+ dynamic_decimals = (_lit(n_sigfig - 1) - log10_expr.floor()).cast(
111
+ _Int32
112
+ )
113
+ # Ensure we don't try to pad negative decimal places for large numbers
114
+ dynamic_decimals = (
115
+ _when(dynamic_decimals < 0)
116
+ .then(_lit(0))
117
+ .otherwise(dynamic_decimals)
118
+ )
115
119
  else:
116
- base_num_str = rounded.round(0).cast(_Int64).abs().cast(_String)
117
- if use_seps:
118
- base_num_str = (
119
- base_num_str.str.reverse()
120
- .str.replace_all(r'(\d{3})', r'${1},')
121
- .str.strip_suffix(',')
122
- .str.reverse()
123
- )
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)
125
+ dynamic_decimals = _lit(decimals)
126
+
127
+ # 3. Component Extraction & Alignment
128
+ int_part = rounded.cast(_Int64).abs().cast(_String)
129
+ full_str = rounded.abs().cast(_String)
130
+
131
+ raw_frac = (
132
+ _when(full_str.str.contains(r'\.'))
133
+ .then(full_str.str.split('.').list.get(1))
134
+ .otherwise(_lit(''))
135
+ )
136
+
137
+ # Use native Python string multiplication inside _lit()
138
+ pad_len = n_sigfig if n_sigfig is not None else decimals
139
+ frac_part = (
140
+ _when(dynamic_decimals > 0)
141
+ .then((raw_frac + _lit('0' * pad_len)).str.slice(0, dynamic_decimals))
142
+ .otherwise(_lit(''))
143
+ )
144
+
145
+ # 4. Constructing Base Number String
146
+ if use_seps:
147
+ int_part = (
148
+ int_part.str.reverse()
149
+ .str.replace_all(r'(\d{3})', r'${1},')
150
+ .str.strip_suffix(',')
151
+ .str.reverse()
152
+ )
124
153
 
154
+ base_num_str = (
155
+ _when(dynamic_decimals > 0)
156
+ .then(int_part + _lit('.') + frac_part)
157
+ .otherwise(int_part)
158
+ )
125
159
  base_num_str = base_num_str + suffix_chain
126
160
 
127
161
  prefix, suffix = '', ''
@@ -140,13 +174,22 @@ def fmt_number(
140
174
  + _lit(suffix)
141
175
  + _lit(')')
142
176
  )
143
- .otherwise(_lit(prefix) + base_num_str + _lit(suffix))
177
+ .otherwise(
178
+ # If force_sign is True, explicitly prepend '+' to positive values
179
+ _lit('+' if force_sign else '')
180
+ + _lit(prefix)
181
+ + base_num_str
182
+ + _lit(suffix)
183
+ )
144
184
  )
145
185
  else:
146
186
  formatted_expr = (
147
187
  _when(val < 0)
148
188
  .then(_lit('-') + _lit(prefix) + base_num_str + _lit(suffix))
149
- .otherwise(_lit(prefix) + base_num_str + _lit(suffix))
189
+ # Handle zero explicitly if you don't want a sign on exactly 0.0
190
+ .when((val == 0) | (not force_sign))
191
+ .then(_lit(prefix) + base_num_str + _lit(suffix))
192
+ .otherwise(_lit('+') + _lit(prefix) + base_num_str + _lit(suffix))
150
193
  )
151
194
 
152
195
  return formatted_expr
@@ -154,7 +197,11 @@ def fmt_number(
154
197
 
155
198
  @_multicolumn
156
199
  def fmt_percent(
157
- columns: _Columns, decimals: int = 2, use_seps: bool = True
200
+ *,
201
+ columns: _Columns,
202
+ decimals: int = 2,
203
+ use_seps: bool = True,
204
+ force_sign: bool = False,
158
205
  ) -> _Expr:
159
206
  """Formats columns as percentages, multiplying by 100 automatically."""
160
207
  return fmt_number(
@@ -163,16 +210,19 @@ def fmt_percent(
163
210
  scale_by=100.0,
164
211
  use_seps=use_seps,
165
212
  pattern='{x}%',
213
+ force_sign=force_sign,
166
214
  )
167
215
 
168
216
 
169
217
  @_multicolumn
170
218
  def fmt_currency(
219
+ *,
171
220
  columns: _Columns,
172
221
  symbol: str = '$',
173
222
  decimals: int = 2,
174
223
  use_seps: bool = True,
175
224
  accounting: bool = True,
225
+ force_sign: bool = False,
176
226
  ) -> _Expr:
177
227
  """Formats columns as localized currency values."""
178
228
  return fmt_number(
@@ -181,11 +231,13 @@ def fmt_currency(
181
231
  use_seps=use_seps,
182
232
  accounting=accounting,
183
233
  pattern=f'{symbol}{{x}}',
234
+ force_sign=force_sign,
184
235
  )
185
236
 
186
237
 
187
238
  @_multicolumn
188
239
  def fmt_scientific(
240
+ *,
189
241
  columns: _Columns,
190
242
  decimals: int = 2,
191
243
  scale_by: float = 1.0,
@@ -259,6 +311,7 @@ def fmt_scientific(
259
311
 
260
312
  @_multicolumn
261
313
  def fmt_bytes(
314
+ *,
262
315
  columns: _Columns,
263
316
  standard: _Literal['decimal', 'binary'] = 'decimal',
264
317
  decimals: int = 1,
@@ -375,6 +428,7 @@ def fmt_bytes(
375
428
 
376
429
  @_multicolumn
377
430
  def fmt_tf(
431
+ *,
378
432
  columns: _Columns,
379
433
  tf_style: _Literal[
380
434
  'true-false',
@@ -439,12 +493,12 @@ def fmt_tf(
439
493
 
440
494
 
441
495
  @_multicolumn
442
- def sub_missing(columns: _Columns, missing_text: str = '—') -> _Expr:
496
+ def sub_missing(*, columns: _Columns, missing_text: str = '—') -> _Expr:
443
497
  return _col(columns).fill_null(_lit(missing_text))
444
498
 
445
499
 
446
500
  @_multicolumn
447
- def sub_zero(columns: _Columns, zero_text: str = '—') -> _Expr:
501
+ def sub_zero(*, columns: _Columns, zero_text: str = '—') -> _Expr:
448
502
  return (
449
503
  _when(_col(columns) == 0)
450
504
  .then(_lit(zero_text))
@@ -454,6 +508,7 @@ def sub_zero(columns: _Columns, zero_text: str = '—') -> _Expr:
454
508
 
455
509
  @_multicolumn
456
510
  def fmt_integer(
511
+ *,
457
512
  columns: _Columns,
458
513
  scale_by: float = 1.0,
459
514
  compact: bool = False,
@@ -461,6 +516,7 @@ def fmt_integer(
461
516
  use_seps: bool = True,
462
517
  accounting: bool = False,
463
518
  pattern: str = '{x}',
519
+ force_sign: bool = False,
464
520
  ) -> _Expr:
465
521
  """Highly optimized native Polars integer formatter wrapper."""
466
522
  return fmt_number(
@@ -472,4 +528,5 @@ def fmt_integer(
472
528
  use_seps=use_seps,
473
529
  accounting=accounting,
474
530
  pattern=pattern,
531
+ force_sign=force_sign,
475
532
  )
@@ -4,7 +4,7 @@ build-backend = 'uv_build'
4
4
 
5
5
  [project]
6
6
  name = "pl2html"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Add your description here"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
File without changes