scales-python 1.4.0.9000__py3-none-any.whl

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.
scales/labels.py ADDED
@@ -0,0 +1,2144 @@
1
+ """
2
+ Label formatting functions for scales.
3
+
4
+ Python port of label functions from the R scales package
5
+ (https://github.com/r-lib/scales). Each ``label_*`` function is a
6
+ closure factory: it returns a callable that accepts a sequence of
7
+ values and returns a ``list[str]`` of formatted labels.
8
+
9
+ Direct formatting functions (``number``, ``comma``, ``dollar``, etc.)
10
+ are also provided for one-shot use without creating a closure first.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import re
17
+ import textwrap
18
+ from datetime import datetime, timezone, timedelta
19
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
20
+
21
+ import numpy as np
22
+ from numpy.typing import ArrayLike
23
+
24
+ from ._utils import precision as _precision_util
25
+
26
+ __all__ = [
27
+ # Closure factories
28
+ "label_number",
29
+ "label_comma",
30
+ "label_percent",
31
+ "label_dollar",
32
+ "label_currency",
33
+ "label_scientific",
34
+ "label_bytes",
35
+ "label_ordinal",
36
+ "label_pvalue",
37
+ "label_date",
38
+ "label_date_short",
39
+ "label_time",
40
+ "label_timespan",
41
+ "label_wrap",
42
+ "label_glue",
43
+ "label_parse",
44
+ "label_math",
45
+ "label_log",
46
+ "label_number_auto",
47
+ "label_number_si",
48
+ "label_dictionary",
49
+ "compose_label",
50
+ # Ordinal helpers
51
+ "ordinal_english",
52
+ "ordinal_french",
53
+ "ordinal_spanish",
54
+ # Direct formatting functions
55
+ "number",
56
+ "comma",
57
+ "dollar",
58
+ "percent",
59
+ "scientific",
60
+ "ordinal",
61
+ "pvalue",
62
+ # Core log formatting
63
+ "format_log",
64
+ # Scale cut helpers
65
+ "cut_short_scale",
66
+ "cut_long_scale",
67
+ "cut_time_scale",
68
+ "cut_si",
69
+ # Date utilities
70
+ "date_breaks",
71
+ "date_format",
72
+ "time_format",
73
+ # Legacy aliases
74
+ "comma_format",
75
+ "dollar_format",
76
+ "percent_format",
77
+ "scientific_format",
78
+ "ordinal_format",
79
+ "pvalue_format",
80
+ "number_format",
81
+ "number_bytes_format",
82
+ "number_bytes",
83
+ "parse_format",
84
+ "math_format",
85
+ "wrap_format",
86
+ "unit_format",
87
+ "format_format",
88
+ "number_options",
89
+ ]
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Internal helpers
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _precision(x: np.ndarray) -> float:
98
+ """Auto-detect precision for a numeric array."""
99
+ return _precision_util(x)
100
+
101
+
102
+ def _format_number(
103
+ value: float,
104
+ accuracy: float,
105
+ big_mark: Optional[str],
106
+ decimal_mark: str,
107
+ trim: bool,
108
+ ) -> str:
109
+ """Format a single number with the given accuracy and marks."""
110
+ if not np.isfinite(value):
111
+ if np.isnan(value):
112
+ return "NaN"
113
+ return "Inf" if value > 0 else "-Inf"
114
+
115
+ # Number of decimal places from accuracy
116
+ if accuracy >= 1:
117
+ ndigits = 0
118
+ else:
119
+ ndigits = max(0, -int(math.floor(math.log10(accuracy) - 1e-12)))
120
+
121
+ rounded = round(value, ndigits)
122
+
123
+ # Format with fixed decimals
124
+ formatted = f"{rounded:.{ndigits}f}"
125
+
126
+ if trim and ndigits > 0:
127
+ # Strip trailing zeros after decimal point
128
+ formatted = formatted.rstrip("0").rstrip(".")
129
+
130
+ # Apply big_mark (thousands separator)
131
+ if big_mark:
132
+ parts = formatted.split(".")
133
+ int_part = parts[0]
134
+ # Handle negative sign
135
+ sign = ""
136
+ if int_part.startswith("-"):
137
+ sign = "-"
138
+ int_part = int_part[1:]
139
+ # Insert separators from right
140
+ groups: list[str] = []
141
+ while len(int_part) > 3:
142
+ groups.append(int_part[-3:])
143
+ int_part = int_part[:-3]
144
+ groups.append(int_part)
145
+ int_part = big_mark.join(reversed(groups))
146
+ parts[0] = sign + int_part
147
+ formatted = ".".join(parts)
148
+
149
+ # Apply decimal_mark
150
+ if decimal_mark and decimal_mark != ".":
151
+ formatted = formatted.replace(".", decimal_mark, 1)
152
+
153
+ return formatted
154
+
155
+
156
+ def _apply_style(
157
+ formatted: str,
158
+ value: float,
159
+ style_positive: str,
160
+ style_negative: str,
161
+ ) -> str:
162
+ """Apply positive/negative styling to a formatted number string."""
163
+ if np.isnan(value):
164
+ return formatted
165
+
166
+ if value > 0:
167
+ if style_positive == "plus":
168
+ formatted = "+" + formatted
169
+ elif style_positive == "space":
170
+ formatted = " " + formatted
171
+ # "none" → no change
172
+ elif value < 0:
173
+ # The formatted string already has a '-' from Python formatting.
174
+ # We may need to replace it.
175
+ if style_negative == "minus":
176
+ formatted = formatted.replace("-", "\u2212", 1)
177
+ elif style_negative == "parens":
178
+ formatted = "(" + formatted.replace("-", "", 1) + ")"
179
+ # "hyphen" → keep the ASCII hyphen-minus (default)
180
+
181
+ return formatted
182
+
183
+
184
+ def _apply_scale_cut(
185
+ values: np.ndarray,
186
+ scale_cut: list[tuple[float, str]],
187
+ ) -> tuple[np.ndarray, list[str]]:
188
+ """
189
+ Apply scale_cut to values. Returns (scaled_values, suffixes).
190
+
191
+ Each entry in *scale_cut* is ``(threshold, suffix)``. Values are
192
+ divided by the largest threshold they exceed.
193
+ """
194
+ # Sort scale_cut by threshold ascending
195
+ sc = sorted(scale_cut, key=lambda t: t[0])
196
+ scaled = values.copy()
197
+ suffixes: list[str] = []
198
+
199
+ for v_idx in range(len(values)):
200
+ val = values[v_idx]
201
+ chosen_suffix = ""
202
+ chosen_divisor = 1.0
203
+ for threshold, sfx in sc:
204
+ if threshold == 0:
205
+ chosen_suffix = sfx
206
+ chosen_divisor = 1.0
207
+ elif abs(val) >= threshold:
208
+ chosen_suffix = sfx
209
+ chosen_divisor = threshold
210
+ scaled[v_idx] = val / chosen_divisor
211
+ suffixes.append(chosen_suffix)
212
+
213
+ return scaled, suffixes
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Scale-cut helpers
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ def cut_short_scale(space: bool = False) -> list[tuple[float, str]]:
222
+ """
223
+ Short scale suffixes: K, M, B, T.
224
+
225
+ Parameters
226
+ ----------
227
+ space : bool, optional
228
+ If ``True``, prepend a space before the suffix (default ``False``).
229
+
230
+ Returns
231
+ -------
232
+ list of (float, str)
233
+ Scale-cut specification.
234
+ """
235
+ sp = " " if space else ""
236
+ return [
237
+ (0, ""),
238
+ (1e3, f"{sp}K"),
239
+ (1e6, f"{sp}M"),
240
+ (1e9, f"{sp}B"),
241
+ (1e12, f"{sp}T"),
242
+ ]
243
+
244
+
245
+ def cut_long_scale(space: bool = False) -> list[tuple[float, str]]:
246
+ """
247
+ Long scale suffixes: K, M, B, T at 10^3, 10^6, 10^12, 10^18.
248
+
249
+ Parameters
250
+ ----------
251
+ space : bool, optional
252
+ If ``True``, prepend a space before the suffix (default ``False``).
253
+
254
+ Returns
255
+ -------
256
+ list of (float, str)
257
+ Scale-cut specification.
258
+ """
259
+ sp = " " if space else ""
260
+ return [
261
+ (0, ""),
262
+ (1e3, f"{sp}K"),
263
+ (1e6, f"{sp}M"),
264
+ (1e12, f"{sp}B"),
265
+ (1e18, f"{sp}T"),
266
+ ]
267
+
268
+
269
+ def cut_time_scale(space: bool = False) -> list[tuple[float, str]]:
270
+ """
271
+ Time scale suffixes: ns, us, ms, s, m, h, d, w.
272
+
273
+ Values are assumed to be in seconds.
274
+
275
+ Parameters
276
+ ----------
277
+ space : bool, optional
278
+ If ``True``, prepend a space before the suffix (default ``False``).
279
+
280
+ Returns
281
+ -------
282
+ list of (float, str)
283
+ Scale-cut specification.
284
+ """
285
+ sp = " " if space else ""
286
+ # R uses "\u03BCs" (Greek small mu + s) when UTF-8 is available —
287
+ # Python assumes UTF-8 everywhere, so emit it unconditionally.
288
+ return [
289
+ (0, ""),
290
+ (1e-9, f"{sp}ns"),
291
+ (1e-6, f"{sp}\u03bcs"),
292
+ (1e-3, f"{sp}ms"),
293
+ (1, f"{sp}s"),
294
+ (60, f"{sp}m"),
295
+ (3600, f"{sp}h"),
296
+ (86400, f"{sp}d"),
297
+ (604800, f"{sp}w"),
298
+ ]
299
+
300
+
301
+ def cut_si(unit: str) -> list[tuple[float, str]]:
302
+ """
303
+ Full SI prefix scale cuts from yocto (10^-24) to yotta (10^24).
304
+
305
+ Parameters
306
+ ----------
307
+ unit : str
308
+ Base unit string to append after the SI prefix.
309
+
310
+ Returns
311
+ -------
312
+ list of (float, str)
313
+ Scale-cut specification.
314
+ """
315
+ prefixes = [
316
+ (1e-24, "y"),
317
+ (1e-21, "z"),
318
+ (1e-18, "a"),
319
+ (1e-15, "f"),
320
+ (1e-12, "p"),
321
+ (1e-9, "n"),
322
+ (1e-6, "\u00b5"), # micro sign
323
+ (1e-3, "m"),
324
+ (1, ""),
325
+ (1e3, "k"),
326
+ (1e6, "M"),
327
+ (1e9, "G"),
328
+ (1e12, "T"),
329
+ (1e15, "P"),
330
+ (1e18, "E"),
331
+ (1e21, "Z"),
332
+ (1e24, "Y"),
333
+ ]
334
+ return [(0, "")] + [(val, f" {pfx}{unit}") for val, pfx in prefixes]
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Core direct formatting function: number()
339
+ # ---------------------------------------------------------------------------
340
+
341
+
342
+ def number(
343
+ x: ArrayLike,
344
+ accuracy: Optional[float] = None,
345
+ scale: float = 1,
346
+ prefix: str = "",
347
+ suffix: str = "",
348
+ big_mark: Optional[str] = None,
349
+ decimal_mark: Optional[str] = None,
350
+ style_positive: Optional[str] = None,
351
+ style_negative: Optional[str] = None,
352
+ scale_cut: Optional[list[tuple[float, str]]] = None,
353
+ trim: bool = True,
354
+ ) -> list[str]:
355
+ """
356
+ Format a numeric vector.
357
+
358
+ Parameters
359
+ ----------
360
+ x : array-like
361
+ Numeric values to format.
362
+ accuracy : float, optional
363
+ Rounding precision. ``None`` for auto-detect.
364
+ scale : float, optional
365
+ Multiplicative scaling factor (default 1).
366
+ prefix : str, optional
367
+ String prepended to each label.
368
+ suffix : str, optional
369
+ String appended to each label.
370
+ big_mark : str, optional
371
+ Thousands separator. ``None`` means no separator.
372
+ decimal_mark : str, optional
373
+ Decimal separator. ``None`` defaults to ``"."``.
374
+ style_positive : str, optional
375
+ Treatment of positive values: ``"none"``, ``"plus"``, or
376
+ ``"space"`` (default ``"none"``).
377
+ style_negative : str, optional
378
+ Treatment of negative values: ``"hyphen"`` (ASCII ``-``),
379
+ ``"minus"`` (Unicode minus ``\u2212``), or ``"parens"``
380
+ (default ``"hyphen"``).
381
+ scale_cut : list of (float, str), optional
382
+ SI-style suffix specification (see :func:`cut_short_scale`).
383
+ trim : bool, optional
384
+ Strip trailing zeros (default ``True``).
385
+
386
+ Returns
387
+ -------
388
+ list of str
389
+ Formatted strings.
390
+ """
391
+ x_arr = np.asarray(x, dtype=float)
392
+ x_scaled = x_arr * scale
393
+
394
+ # Resolve defaults from the module-level option store
395
+ # (`number_options()` — mirrors R's getOption("scales.*")). Python
396
+ # keeps an empty big_mark default so label output round-trips
397
+ # through float(); R uses " " instead. Users can opt in via
398
+ # `number_options(big_mark=" ")`.
399
+ if big_mark is None:
400
+ big_mark = str(_NUMBER_OPTIONS.get("big_mark", ""))
401
+ if decimal_mark is None:
402
+ decimal_mark = str(_NUMBER_OPTIONS.get("decimal_mark", "."))
403
+ if style_positive is None:
404
+ style_positive = str(_NUMBER_OPTIONS.get("style_positive", "none"))
405
+ if style_negative is None:
406
+ style_negative = str(_NUMBER_OPTIONS.get("style_negative", "hyphen"))
407
+
408
+ # Apply scale_cut
409
+ per_value_suffix: Optional[list[str]] = None
410
+ if scale_cut is not None:
411
+ x_scaled, per_value_suffix = _apply_scale_cut(x_scaled, scale_cut)
412
+
413
+ if accuracy is None:
414
+ accuracy = _precision(x_scaled)
415
+
416
+ results: list[str] = []
417
+ for i, val in enumerate(x_scaled.flat):
418
+ fmt = _format_number(val, accuracy, big_mark, decimal_mark, trim)
419
+ fmt = _apply_style(fmt, val, style_positive, style_negative)
420
+ sc_sfx = per_value_suffix[i] if per_value_suffix is not None else ""
421
+ results.append(f"{prefix}{fmt}{sc_sfx}{suffix}")
422
+
423
+ return results
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # Closure factory: label_number
428
+ # ---------------------------------------------------------------------------
429
+
430
+
431
+ def label_number(
432
+ accuracy: Optional[float] = None,
433
+ scale: float = 1,
434
+ prefix: str = "",
435
+ suffix: str = "",
436
+ big_mark: Optional[str] = None,
437
+ decimal_mark: Optional[str] = None,
438
+ style_positive: str = "none",
439
+ style_negative: str = "hyphen",
440
+ scale_cut: Optional[list[tuple[float, str]]] = None,
441
+ trim: bool = True,
442
+ ) -> Callable[[ArrayLike], list[str]]:
443
+ """
444
+ Label numbers with flexible formatting.
445
+
446
+ Returns a closure that formats numeric values according to the
447
+ parameters captured at construction time.
448
+
449
+ Parameters
450
+ ----------
451
+ accuracy : float, optional
452
+ Rounding precision. ``None`` for auto-detect.
453
+ scale : float, optional
454
+ Multiplicative scaling factor (default 1).
455
+ prefix : str, optional
456
+ Prepended to each label.
457
+ suffix : str, optional
458
+ Appended to each label.
459
+ big_mark : str, optional
460
+ Thousands separator.
461
+ decimal_mark : str, optional
462
+ Decimal separator.
463
+ style_positive : str, optional
464
+ ``"none"``, ``"plus"``, or ``"space"``.
465
+ style_negative : str, optional
466
+ ``"hyphen"``, ``"minus"``, or ``"parens"``.
467
+ scale_cut : list of (float, str), optional
468
+ SI-style suffix specification.
469
+ trim : bool, optional
470
+ Strip trailing zeros.
471
+
472
+ Returns
473
+ -------
474
+ callable
475
+ ``(x) -> list[str]``
476
+ """
477
+
478
+ def formatter(x: ArrayLike) -> list[str]:
479
+ return number(
480
+ x,
481
+ accuracy=accuracy,
482
+ scale=scale,
483
+ prefix=prefix,
484
+ suffix=suffix,
485
+ big_mark=big_mark,
486
+ decimal_mark=decimal_mark,
487
+ style_positive=style_positive,
488
+ style_negative=style_negative,
489
+ scale_cut=scale_cut,
490
+ trim=trim,
491
+ )
492
+
493
+ return formatter
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # label_comma / comma
498
+ # ---------------------------------------------------------------------------
499
+
500
+
501
+ def label_comma(**kwargs: Any) -> Callable[[ArrayLike], list[str]]:
502
+ """
503
+ Label numbers with comma as thousands separator.
504
+
505
+ Parameters
506
+ ----------
507
+ **kwargs
508
+ Passed to :func:`label_number`. ``big_mark`` defaults to ``","``.
509
+
510
+ Returns
511
+ -------
512
+ callable
513
+ ``(x) -> list[str]``
514
+ """
515
+ kwargs.setdefault("big_mark", ",")
516
+ return label_number(**kwargs)
517
+
518
+
519
+ def comma(x: ArrayLike, **kwargs: Any) -> list[str]:
520
+ """Format *x* with comma thousands separator."""
521
+ kwargs.setdefault("big_mark", ",")
522
+ return number(x, **kwargs)
523
+
524
+
525
+ # ---------------------------------------------------------------------------
526
+ # label_percent / percent
527
+ # ---------------------------------------------------------------------------
528
+
529
+
530
+ def label_percent(
531
+ accuracy: Optional[float] = None,
532
+ scale: float = 100,
533
+ suffix: str = "%",
534
+ **kwargs: Any,
535
+ ) -> Callable[[ArrayLike], list[str]]:
536
+ """
537
+ Label percentages.
538
+
539
+ Parameters
540
+ ----------
541
+ accuracy : float, optional
542
+ Rounding precision.
543
+ scale : float, optional
544
+ Multiplicative factor (default 100 converts proportions to %).
545
+ suffix : str, optional
546
+ Appended string (default ``"%"``).
547
+ **kwargs
548
+ Passed to :func:`label_number`.
549
+
550
+ Returns
551
+ -------
552
+ callable
553
+ ``(x) -> list[str]``
554
+ """
555
+ return label_number(accuracy=accuracy, scale=scale, suffix=suffix, **kwargs)
556
+
557
+
558
+ def percent(x: ArrayLike, accuracy: Optional[float] = None, scale: float = 100,
559
+ suffix: str = "%", **kwargs: Any) -> list[str]:
560
+ """Format *x* as percentages."""
561
+ return number(x, accuracy=accuracy, scale=scale, suffix=suffix, **kwargs)
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # label_dollar / label_currency / dollar
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ def _needs_cents(x: np.ndarray, threshold: float) -> bool:
570
+ """Mirror R's `needs_cents`: decide whether an auto-accuracy pass
571
+ should use 0.01 (cents) or 1 (whole units).
572
+
573
+ * Empty / all-NaN → False
574
+ * Max |x| > threshold → False (values too large; skip fractional)
575
+ * Otherwise → True iff **any** finite value is non-integer.
576
+ """
577
+ x = np.asarray(x, dtype=float)
578
+ finite = x[np.isfinite(x)]
579
+ if finite.size == 0:
580
+ return False
581
+ if np.nanmax(np.abs(finite)) > threshold:
582
+ return False
583
+ return bool(np.any(finite != np.floor(finite)))
584
+
585
+
586
+ def dollar(
587
+ x: ArrayLike,
588
+ accuracy: Optional[float] = None,
589
+ scale: float = 1,
590
+ prefix: Optional[str] = None,
591
+ suffix: Optional[str] = None,
592
+ big_mark: Optional[str] = None,
593
+ decimal_mark: Optional[str] = None,
594
+ trim: bool = True,
595
+ largest_with_cents: float = 100000,
596
+ style_negative: Optional[str] = None,
597
+ scale_cut: Optional[list[tuple[float, str]]] = None,
598
+ **kwargs: Any,
599
+ ) -> list[str]:
600
+ """Format *x* as currency.
601
+
602
+ Matches R's ``dollar``:
603
+
604
+ * Currency-specific defaults fall through ``_NUMBER_OPTIONS``
605
+ (``currency_prefix``, ``currency_suffix``, ``currency_big_mark``,
606
+ ``currency_decimal_mark``). The baked-in fallbacks are ``"$"``,
607
+ ``""``, ``","``, ``"."`` respectively.
608
+ * When ``accuracy`` is ``None`` *and* no ``scale_cut`` is given, the
609
+ accuracy is chosen by the ``largest_with_cents`` heuristic: use
610
+ ``0.01`` when ``max(|x * scale|) <= largest_with_cents`` *and* any
611
+ input has a fractional part; otherwise ``1``.
612
+ * When ``big_mark == decimal_mark == ","``, ``big_mark`` is swapped
613
+ to a space to avoid ambiguity.
614
+ """
615
+ if prefix is None:
616
+ prefix = str(_NUMBER_OPTIONS.get("currency_prefix", "$"))
617
+ if suffix is None:
618
+ suffix = str(_NUMBER_OPTIONS.get("currency_suffix", ""))
619
+ if big_mark is None:
620
+ big_mark = str(_NUMBER_OPTIONS.get("currency_big_mark", ","))
621
+ if decimal_mark is None:
622
+ decimal_mark = str(_NUMBER_OPTIONS.get("currency_decimal_mark", "."))
623
+
624
+ x_arr = np.asarray(x, dtype=float)
625
+ if x_arr.size == 0:
626
+ return []
627
+
628
+ if accuracy is None and scale_cut is None:
629
+ if _needs_cents(x_arr * scale, largest_with_cents):
630
+ accuracy = 0.01
631
+ else:
632
+ accuracy = 1
633
+
634
+ if big_mark == "," and decimal_mark == ",":
635
+ big_mark = " "
636
+
637
+ return number(
638
+ x_arr,
639
+ accuracy=accuracy,
640
+ scale=scale,
641
+ prefix=prefix,
642
+ suffix=suffix,
643
+ big_mark=big_mark,
644
+ decimal_mark=decimal_mark,
645
+ trim=trim,
646
+ style_negative=style_negative,
647
+ scale_cut=scale_cut,
648
+ **kwargs,
649
+ )
650
+
651
+
652
+ def label_currency(
653
+ accuracy: Optional[float] = None,
654
+ scale: float = 1,
655
+ prefix: Optional[str] = None,
656
+ suffix: Optional[str] = None,
657
+ big_mark: Optional[str] = None,
658
+ decimal_mark: Optional[str] = None,
659
+ trim: bool = True,
660
+ largest_with_fractional: float = 100000,
661
+ **kwargs: Any,
662
+ ) -> Callable[[ArrayLike], list[str]]:
663
+ """
664
+ Label currency values.
665
+
666
+ Thin closure around :func:`dollar` — matches R's ``label_currency``
667
+ wrapping ``dollar(..., largest_with_cents = largest_with_fractional)``.
668
+
669
+ Parameters
670
+ ----------
671
+ accuracy : float, optional
672
+ Fixed rounding precision. When ``None`` (default), accuracy is
673
+ auto-detected via the ``largest_with_fractional`` heuristic.
674
+ largest_with_fractional : float, optional
675
+ Threshold above which fractional accuracy is suppressed
676
+ (default ``100000``). See :func:`dollar`.
677
+ """
678
+
679
+ def formatter(x: ArrayLike) -> list[str]:
680
+ return dollar(
681
+ x,
682
+ accuracy=accuracy,
683
+ scale=scale,
684
+ prefix=prefix,
685
+ suffix=suffix,
686
+ big_mark=big_mark,
687
+ decimal_mark=decimal_mark,
688
+ trim=trim,
689
+ largest_with_cents=largest_with_fractional,
690
+ **kwargs,
691
+ )
692
+
693
+ return formatter
694
+
695
+
696
+ def label_dollar(
697
+ accuracy: Optional[float] = None,
698
+ scale: float = 1,
699
+ prefix: Optional[str] = None,
700
+ suffix: Optional[str] = None,
701
+ big_mark: Optional[str] = None,
702
+ decimal_mark: Optional[str] = None,
703
+ trim: bool = True,
704
+ largest_with_cents: float = 100000,
705
+ **kwargs: Any,
706
+ ) -> Callable[[ArrayLike], list[str]]:
707
+ """Label currency values (superseded alias of :func:`label_currency`)."""
708
+
709
+ def formatter(x: ArrayLike) -> list[str]:
710
+ return dollar(
711
+ x,
712
+ accuracy=accuracy,
713
+ scale=scale,
714
+ prefix=prefix,
715
+ suffix=suffix,
716
+ big_mark=big_mark,
717
+ decimal_mark=decimal_mark,
718
+ trim=trim,
719
+ largest_with_cents=largest_with_cents,
720
+ **kwargs,
721
+ )
722
+
723
+ return formatter
724
+
725
+
726
+ # ---------------------------------------------------------------------------
727
+ # label_scientific / scientific
728
+ # ---------------------------------------------------------------------------
729
+
730
+
731
+ def _format_scientific_single(
732
+ value: float,
733
+ digits: int,
734
+ decimal_mark: str,
735
+ trim: bool,
736
+ ) -> str:
737
+ """Format a single value in scientific notation."""
738
+ if not np.isfinite(value):
739
+ if np.isnan(value):
740
+ return "NaN"
741
+ return "Inf" if value > 0 else "-Inf"
742
+
743
+ if value == 0:
744
+ if trim:
745
+ return "0"
746
+ return f"0.{'0' * (digits - 1)}e+00"
747
+
748
+ exp = int(math.floor(math.log10(abs(value))))
749
+ coeff = value / (10.0 ** exp)
750
+ # Round coefficient
751
+ coeff = round(coeff, digits - 1)
752
+
753
+ # Format coefficient
754
+ ndecimals = max(0, digits - 1)
755
+ coeff_str = f"{coeff:.{ndecimals}f}"
756
+
757
+ if trim and ndecimals > 0:
758
+ coeff_str = coeff_str.rstrip("0").rstrip(".")
759
+
760
+ if decimal_mark != ".":
761
+ coeff_str = coeff_str.replace(".", decimal_mark, 1)
762
+
763
+ # Format exponent
764
+ exp_sign = "+" if exp >= 0 else "-"
765
+ exp_str = f"{abs(exp):02d}"
766
+
767
+ return f"{coeff_str}e{exp_sign}{exp_str}"
768
+
769
+
770
+ def scientific(
771
+ x: ArrayLike,
772
+ digits: int = 3,
773
+ scale: float = 1,
774
+ prefix: str = "",
775
+ suffix: str = "",
776
+ decimal_mark: Optional[str] = None,
777
+ trim: bool = True,
778
+ ) -> list[str]:
779
+ """
780
+ Format *x* in scientific notation.
781
+
782
+ Parameters
783
+ ----------
784
+ x : array-like
785
+ Numeric values.
786
+ digits : int, optional
787
+ Significant digits (default 3).
788
+ scale : float, optional
789
+ Multiplicative factor.
790
+ prefix : str, optional
791
+ Prepended string.
792
+ suffix : str, optional
793
+ Appended string.
794
+ decimal_mark : str, optional
795
+ Decimal separator.
796
+ trim : bool, optional
797
+ Strip trailing zeros.
798
+
799
+ Returns
800
+ -------
801
+ list of str
802
+ """
803
+ x_arr = np.asarray(x, dtype=float) * scale
804
+ if decimal_mark is None:
805
+ decimal_mark = "."
806
+
807
+ results: list[str] = []
808
+ for val in x_arr.flat:
809
+ fmt = _format_scientific_single(val, digits, decimal_mark, trim)
810
+ results.append(f"{prefix}{fmt}{suffix}")
811
+ return results
812
+
813
+
814
+ def label_scientific(
815
+ digits: int = 3,
816
+ scale: float = 1,
817
+ prefix: str = "",
818
+ suffix: str = "",
819
+ decimal_mark: Optional[str] = None,
820
+ trim: bool = True,
821
+ ) -> Callable[[ArrayLike], list[str]]:
822
+ """
823
+ Label numbers in scientific notation.
824
+
825
+ Parameters
826
+ ----------
827
+ digits : int, optional
828
+ Significant digits (default 3).
829
+ scale : float, optional
830
+ Multiplicative factor.
831
+ prefix : str, optional
832
+ Prepended string.
833
+ suffix : str, optional
834
+ Appended string.
835
+ decimal_mark : str, optional
836
+ Decimal separator.
837
+ trim : bool, optional
838
+ Strip trailing zeros.
839
+
840
+ Returns
841
+ -------
842
+ callable
843
+ ``(x) -> list[str]``
844
+ """
845
+
846
+ def formatter(x: ArrayLike) -> list[str]:
847
+ return scientific(
848
+ x, digits=digits, scale=scale, prefix=prefix,
849
+ suffix=suffix, decimal_mark=decimal_mark, trim=trim,
850
+ )
851
+
852
+ return formatter
853
+
854
+
855
+ # ---------------------------------------------------------------------------
856
+ # label_bytes
857
+ # ---------------------------------------------------------------------------
858
+
859
+ _SI_BYTE_UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
860
+ _BINARY_BYTE_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
861
+
862
+
863
+ def label_bytes(
864
+ units: str = "auto_si",
865
+ accuracy: float = 1,
866
+ scale: float = 1,
867
+ ) -> Callable[[ArrayLike], list[str]]:
868
+ """
869
+ Label byte sizes (e.g. 1 kB, 2 MiB).
870
+
871
+ Parameters
872
+ ----------
873
+ units : str, optional
874
+ ``"auto_si"`` (powers of 1000), ``"auto_binary"`` (powers of 1024),
875
+ or an explicit unit name like ``"kB"`` or ``"MiB"``.
876
+ accuracy : float, optional
877
+ Rounding precision (default 1).
878
+ scale : float, optional
879
+ Multiplicative factor applied before formatting.
880
+
881
+ Returns
882
+ -------
883
+ callable
884
+ ``(x) -> list[str]``
885
+ """
886
+
887
+ def formatter(x: ArrayLike) -> list[str]:
888
+ x_arr = np.asarray(x, dtype=float) * scale
889
+ results: list[str] = []
890
+
891
+ for val in x_arr.flat:
892
+ if not np.isfinite(val):
893
+ results.append("NaN" if np.isnan(val) else ("Inf" if val > 0 else "-Inf"))
894
+ continue
895
+
896
+ if units == "auto_si":
897
+ base = 1000
898
+ unit_list = _SI_BYTE_UNITS
899
+ idx = 0
900
+ abs_val = abs(val)
901
+ while abs_val >= base and idx < len(unit_list) - 1:
902
+ abs_val /= base
903
+ idx += 1
904
+ divisor = base ** idx
905
+ scaled_val = val / divisor if divisor else val
906
+ unit_str = unit_list[idx]
907
+ elif units == "auto_binary":
908
+ base = 1024
909
+ unit_list = _BINARY_BYTE_UNITS
910
+ idx = 0
911
+ abs_val = abs(val)
912
+ while abs_val >= base and idx < len(unit_list) - 1:
913
+ abs_val /= base
914
+ idx += 1
915
+ divisor = base ** idx
916
+ scaled_val = val / divisor if divisor else val
917
+ unit_str = unit_list[idx]
918
+ else:
919
+ # Explicit unit
920
+ unit_str = units
921
+ if units in _SI_BYTE_UNITS:
922
+ idx = _SI_BYTE_UNITS.index(units)
923
+ divisor = 1000 ** idx
924
+ elif units in _BINARY_BYTE_UNITS:
925
+ idx = _BINARY_BYTE_UNITS.index(units)
926
+ divisor = 1024 ** idx
927
+ else:
928
+ divisor = 1
929
+ scaled_val = val / divisor if divisor else val
930
+
931
+ fmt = _format_number(scaled_val, accuracy, None, ".", True)
932
+ results.append(f"{fmt} {unit_str}")
933
+
934
+ return results
935
+
936
+ return formatter
937
+
938
+
939
+ # ---------------------------------------------------------------------------
940
+ # Ordinal helpers
941
+ # ---------------------------------------------------------------------------
942
+
943
+
944
+ class OrdinalRules(list):
945
+ """R-style ordinal rule set: an ordered list of ``(suffix, regex)``.
946
+
947
+ Iterating yields ``(suffix, pattern)`` pairs in priority order — this
948
+ mirrors R's ``ordinal_english()`` return value (a named list of
949
+ regex strings where the **name** is the suffix and the **value** is
950
+ the pattern).
951
+
952
+ For backwards compatibility, instances are *also callable*: calling
953
+ with an integer returns the first matching suffix (Python's
954
+ historical API).
955
+ """
956
+
957
+ __slots__ = ()
958
+
959
+ def __call__(self, n: Any) -> str:
960
+ import re as _re
961
+ s = str(int(n))
962
+ for suffix, pattern in self:
963
+ if _re.search(pattern, s):
964
+ return suffix
965
+ return ""
966
+
967
+
968
+ def ordinal_english() -> OrdinalRules:
969
+ """
970
+ Return the English ordinal rule set.
971
+
972
+ Mirrors R's ``ordinal_english``: a list of ``(suffix, regex)`` pairs
973
+ applied in order, first match wins. Handles the 11/12/13 quirk via
974
+ lookbehind assertions.
975
+
976
+ Returns
977
+ -------
978
+ OrdinalRules
979
+ Priority-ordered ``[(suffix, pattern), ...]``, also callable as
980
+ ``rules(n) -> suffix``.
981
+ """
982
+ return OrdinalRules([
983
+ ("st", r"(?<!1)1$"),
984
+ ("nd", r"(?<!1)2$"),
985
+ ("rd", r"(?<!1)3$"),
986
+ ("th", r"(?<=1)[123]$"),
987
+ ("th", r"[0456789]$"),
988
+ ("th", r"."),
989
+ ])
990
+
991
+
992
+ def ordinal_french(
993
+ gender: str = "masculin",
994
+ plural: bool = False,
995
+ ) -> OrdinalRules:
996
+ """
997
+ Return the French ordinal rule set.
998
+
999
+ Mirrors R's ``ordinal_french``: only ``1`` gets the masculine/feminine
1000
+ first-position suffix (``"er"`` / ``"re"``); everything else gets
1001
+ ``"e"``. When ``plural`` is ``True``, both suffixes receive a
1002
+ trailing ``"s"``.
1003
+
1004
+ Parameters
1005
+ ----------
1006
+ gender : str, optional
1007
+ ``"masculin"`` or ``"feminin"`` (default ``"masculin"``).
1008
+ plural : bool, optional
1009
+ Use plural forms (default ``False``).
1010
+
1011
+ Returns
1012
+ -------
1013
+ OrdinalRules
1014
+ """
1015
+ if gender not in ("masculin", "feminin"):
1016
+ raise ValueError("gender must be 'masculin' or 'feminin'")
1017
+ first = "er" if gender == "masculin" else "re"
1018
+ rest = "e"
1019
+ if plural:
1020
+ first += "s"
1021
+ rest += "s"
1022
+ return OrdinalRules([(first, r"^1$"), (rest, r".")])
1023
+
1024
+
1025
+ def ordinal_spanish() -> OrdinalRules:
1026
+ """
1027
+ Return the Spanish ordinal rule set.
1028
+
1029
+ Mirrors R's ``ordinal_spanish``: a single rule with suffix ``".º"``
1030
+ matching every number.
1031
+ """
1032
+ return OrdinalRules([(".\u00ba", r".")])
1033
+
1034
+
1035
+ # ---------------------------------------------------------------------------
1036
+ # label_ordinal / ordinal
1037
+ # ---------------------------------------------------------------------------
1038
+
1039
+
1040
+ def ordinal(
1041
+ x: ArrayLike,
1042
+ prefix: str = "",
1043
+ suffix: str = "",
1044
+ big_mark: Optional[str] = None,
1045
+ rules: Optional[Any] = None,
1046
+ ) -> list[str]:
1047
+ """
1048
+ Format *x* as ordinals (1st, 2nd, 3rd, ...).
1049
+
1050
+ Parameters
1051
+ ----------
1052
+ x : array-like
1053
+ Numeric values (rounded to integers before formatting).
1054
+ prefix : str, optional
1055
+ Prepended string.
1056
+ suffix : str, optional
1057
+ Appended string.
1058
+ big_mark : str, optional
1059
+ Thousands separator.
1060
+ rules : :class:`OrdinalRules` or callable, optional
1061
+ Suffix rule set. May be:
1062
+
1063
+ * An :class:`OrdinalRules` instance (R-style list of
1064
+ ``(suffix, regex)``) — first match wins.
1065
+ * Any iterable of ``(suffix, regex)`` pairs — same behaviour.
1066
+ * A plain callable ``(int) -> str``.
1067
+ * ``None`` — defaults to :func:`ordinal_english` as in R.
1068
+
1069
+ Returns
1070
+ -------
1071
+ list of str
1072
+ """
1073
+ import re as _re
1074
+
1075
+ if rules is None:
1076
+ rules = ordinal_english()
1077
+
1078
+ # Normalise the rule set into a callable that maps int -> suffix.
1079
+ if callable(rules) and not isinstance(rules, (list, tuple)):
1080
+ rule_fn = rules
1081
+ else:
1082
+ rule_list = list(rules)
1083
+
1084
+ def rule_fn(n: int) -> str:
1085
+ s = str(int(n))
1086
+ for sfx, pat in rule_list:
1087
+ if _re.search(pat, s):
1088
+ return sfx
1089
+ return ""
1090
+
1091
+ x_arr = np.asarray(x, dtype=float)
1092
+ results: list[str] = []
1093
+
1094
+ for val in x_arr.flat:
1095
+ if not np.isfinite(val):
1096
+ results.append("NaN" if np.isnan(val) else ("Inf" if val > 0 else "-Inf"))
1097
+ continue
1098
+ int_val = int(round(val))
1099
+ num_str = _format_number(float(int_val), 1, big_mark, ".", True)
1100
+ ord_suffix = rule_fn(int_val)
1101
+ results.append(f"{prefix}{num_str}{ord_suffix}{suffix}")
1102
+
1103
+ return results
1104
+
1105
+
1106
+ def label_ordinal(
1107
+ prefix: str = "",
1108
+ suffix: str = "",
1109
+ big_mark: Optional[str] = None,
1110
+ rules: Optional[Callable[[int], str]] = None,
1111
+ ) -> Callable[[ArrayLike], list[str]]:
1112
+ """
1113
+ Label numbers as ordinals.
1114
+
1115
+ Parameters
1116
+ ----------
1117
+ prefix : str, optional
1118
+ Prepended string.
1119
+ suffix : str, optional
1120
+ Appended string.
1121
+ big_mark : str, optional
1122
+ Thousands separator.
1123
+ rules : callable, optional
1124
+ Ordinal suffix function (default :func:`ordinal_english`).
1125
+
1126
+ Returns
1127
+ -------
1128
+ callable
1129
+ ``(x) -> list[str]``
1130
+ """
1131
+
1132
+ def formatter(x: ArrayLike) -> list[str]:
1133
+ return ordinal(x, prefix=prefix, suffix=suffix,
1134
+ big_mark=big_mark, rules=rules)
1135
+
1136
+ return formatter
1137
+
1138
+
1139
+ # ---------------------------------------------------------------------------
1140
+ # label_pvalue / pvalue
1141
+ # ---------------------------------------------------------------------------
1142
+
1143
+
1144
+ def pvalue(
1145
+ x: ArrayLike,
1146
+ accuracy: float = 0.001,
1147
+ decimal_mark: Optional[str] = None,
1148
+ prefix: Optional[list[str]] = None,
1149
+ add_p: bool = False,
1150
+ ) -> list[str]:
1151
+ """
1152
+ Format p-values.
1153
+
1154
+ Parameters
1155
+ ----------
1156
+ x : array-like
1157
+ P-values to format.
1158
+ accuracy : float, optional
1159
+ Smallest displayable value (default 0.001).
1160
+ decimal_mark : str, optional
1161
+ Decimal separator.
1162
+ prefix : list of str, optional
1163
+ Length-3 list ``[less_than, normal, greater_than]``.
1164
+ Defaults to ``["<", "", ">"]``.
1165
+ add_p : bool, optional
1166
+ If ``True``, prepend ``"p"`` or ``"p "`` to each label.
1167
+
1168
+ Returns
1169
+ -------
1170
+ list of str
1171
+ """
1172
+ # Mirrors R's pvalue: prefix default is ("p<","p=","p>") if add_p else
1173
+ # ("<","",">"). The prefix is prepended verbatim — *no* added spaces.
1174
+ if prefix is None:
1175
+ prefix_list = ["p<", "p=", "p>"] if add_p else ["<", "", ">"]
1176
+ else:
1177
+ prefix_list = list(prefix)
1178
+ if len(prefix_list) != 3:
1179
+ raise ValueError("prefix must be a length-3 sequence")
1180
+
1181
+ if decimal_mark is None:
1182
+ decimal_mark = str(_NUMBER_OPTIONS.get("decimal_mark", "."))
1183
+
1184
+ x_arr = np.asarray(x, dtype=float)
1185
+ results: list[str] = []
1186
+
1187
+ for val in x_arr.flat:
1188
+ if not np.isfinite(val):
1189
+ results.append("NaN" if np.isnan(val) else str(val))
1190
+ continue
1191
+
1192
+ if val < accuracy:
1193
+ fmt = _format_number(accuracy, accuracy, None, decimal_mark, True)
1194
+ s = f"{prefix_list[0]}{fmt}"
1195
+ elif val > 1 - accuracy:
1196
+ fmt = _format_number(1 - accuracy, accuracy, None, decimal_mark, True)
1197
+ s = f"{prefix_list[2]}{fmt}"
1198
+ else:
1199
+ fmt = _format_number(val, accuracy, None, decimal_mark, False)
1200
+ s = f"{prefix_list[1]}{fmt}"
1201
+
1202
+ results.append(s)
1203
+
1204
+ return results
1205
+
1206
+
1207
+ def label_pvalue(
1208
+ accuracy: float = 0.001,
1209
+ decimal_mark: Optional[str] = None,
1210
+ prefix: Optional[list[str]] = None,
1211
+ add_p: bool = False,
1212
+ ) -> Callable[[ArrayLike], list[str]]:
1213
+ """
1214
+ Label p-values.
1215
+
1216
+ Parameters
1217
+ ----------
1218
+ accuracy : float, optional
1219
+ Smallest displayable value (default 0.001).
1220
+ decimal_mark : str, optional
1221
+ Decimal separator.
1222
+ prefix : list of str, optional
1223
+ Length-3 list ``[less_than, normal, greater_than]``.
1224
+ add_p : bool, optional
1225
+ Prepend ``"p"`` to labels.
1226
+
1227
+ Returns
1228
+ -------
1229
+ callable
1230
+ ``(x) -> list[str]``
1231
+ """
1232
+
1233
+ def formatter(x: ArrayLike) -> list[str]:
1234
+ return pvalue(x, accuracy=accuracy, decimal_mark=decimal_mark,
1235
+ prefix=prefix, add_p=add_p)
1236
+
1237
+ return formatter
1238
+
1239
+
1240
+ # ---------------------------------------------------------------------------
1241
+ # Date / time labels
1242
+ # ---------------------------------------------------------------------------
1243
+
1244
+
1245
+ def _to_datetime(val: Any, tz_obj: Any) -> Optional[datetime]:
1246
+ """Convert a value to a datetime, handling numpy datetime64 etc."""
1247
+ if isinstance(val, datetime):
1248
+ return val.astimezone(tz_obj) if val.tzinfo else val.replace(tzinfo=tz_obj)
1249
+ if isinstance(val, np.datetime64):
1250
+ # Convert to Python datetime via timestamp
1251
+ ts = (val - np.datetime64("1970-01-01T00:00:00")) / np.timedelta64(1, "s")
1252
+ return datetime.fromtimestamp(float(ts), tz=tz_obj)
1253
+ if isinstance(val, (int, float)):
1254
+ if not np.isfinite(val):
1255
+ return None
1256
+ return datetime.fromtimestamp(float(val), tz=tz_obj)
1257
+ return None
1258
+
1259
+
1260
+ def _make_tz(tz: str) -> timezone:
1261
+ """Create a timezone from a string. Supports 'UTC' and offset forms."""
1262
+ if tz.upper() == "UTC":
1263
+ return timezone.utc
1264
+ # Simple offset parsing for common cases
1265
+ m = re.match(r"^UTC([+-])(\d{1,2}):?(\d{2})?$", tz, re.IGNORECASE)
1266
+ if m:
1267
+ sign = 1 if m.group(1) == "+" else -1
1268
+ hours = int(m.group(2))
1269
+ minutes = int(m.group(3) or 0)
1270
+ return timezone(timedelta(hours=sign * hours, minutes=sign * minutes))
1271
+ # Fallback: try as UTC
1272
+ return timezone.utc
1273
+
1274
+
1275
+ def label_date(
1276
+ format: str = "%Y-%m-%d",
1277
+ tz: str = "UTC",
1278
+ ) -> Callable[[ArrayLike], list[str]]:
1279
+ """
1280
+ Label dates.
1281
+
1282
+ Parameters
1283
+ ----------
1284
+ format : str, optional
1285
+ ``strftime``-compatible format string (default ``"%Y-%m-%d"``).
1286
+ tz : str, optional
1287
+ Timezone (default ``"UTC"``).
1288
+
1289
+ Returns
1290
+ -------
1291
+ callable
1292
+ ``(x) -> list[str]``
1293
+ """
1294
+ tz_obj = _make_tz(tz)
1295
+
1296
+ def formatter(x: ArrayLike) -> list[str]:
1297
+ results: list[str] = []
1298
+ x_arr = np.asarray(x)
1299
+ for val in x_arr.flat:
1300
+ dt = _to_datetime(val, tz_obj)
1301
+ if dt is None:
1302
+ results.append("NA")
1303
+ else:
1304
+ results.append(dt.strftime(format))
1305
+ return results
1306
+
1307
+ return formatter
1308
+
1309
+
1310
+ def label_date_short(
1311
+ format: Optional[Sequence[Optional[str]]] = None,
1312
+ sep: str = "\n",
1313
+ leading: str = "0",
1314
+ tz: str = "UTC",
1315
+ locale: Optional[str] = None,
1316
+ ) -> Callable[[ArrayLike], list[str]]:
1317
+ """
1318
+ Label dates compactly, showing each component only when it changes.
1319
+
1320
+ Faithful port of R's ``label_date_short``:
1321
+
1322
+ * ``format`` is a length-4 vector of ``strftime`` codes for
1323
+ ``(year, month, day, hour)``. Default
1324
+ ``("%Y", "%b", "%d", "%H:%M")``.
1325
+ * A component is rendered for a given date only if that component
1326
+ (or any *larger* component) differs from the previous date — i.e.
1327
+ ``cumsum(changed(component)) >= 1`` per R.
1328
+ * Components that are *always* zero / first-of-period across the
1329
+ whole input are trimmed from the smallest up (e.g. the hour line
1330
+ is dropped when every value is at 00:00).
1331
+ * ``leading`` controls the character replacing a leading ``"0"`` in
1332
+ each rendered component. ``"0"`` keeps the zero (default);
1333
+ ``""`` removes it; ``"\\u2007"`` is a typographic figure space.
1334
+
1335
+ Parameters
1336
+ ----------
1337
+ format : sequence of 4 str, optional
1338
+ ``(year_fmt, month_fmt, day_fmt, hour_fmt)``.
1339
+ sep : str, optional
1340
+ Separator between rendered components (default newline).
1341
+ leading : str, optional
1342
+ Replacement for each component's leading ``"0"`` digit
1343
+ (default ``"0"``, i.e. no replacement).
1344
+ tz : str, optional
1345
+ Timezone name (default ``"UTC"``).
1346
+ locale : str, optional
1347
+ Locale name. Not implemented for month/day names (Python's
1348
+ ``strftime`` respects ``LC_TIME`` at the OS level); accepted for
1349
+ API parity.
1350
+
1351
+ Returns
1352
+ -------
1353
+ callable
1354
+ ``(x) -> list[str]``
1355
+ """
1356
+ _ = locale # accepted for API parity; OS-level locale is used
1357
+ tz_obj = _make_tz(tz)
1358
+ default_format = ["%Y", "%b", "%d", "%H:%M"]
1359
+ fmt = list(default_format) if format is None else list(format)
1360
+ if len(fmt) != 4:
1361
+ raise ValueError("format must be length 4 (year, month, day, hour)")
1362
+
1363
+ def formatter(x: ArrayLike) -> list[str]:
1364
+ x_arr = np.asarray(x)
1365
+ dts: list[Optional[datetime]] = [
1366
+ _to_datetime(v, tz_obj) for v in x_arr.flat
1367
+ ]
1368
+
1369
+ n = len(dts)
1370
+ if n == 0:
1371
+ return []
1372
+
1373
+ # Extract year/month/day/hour/minute arrays; None for NA.
1374
+ year = [d.year if d is not None else None for d in dts]
1375
+ month = [d.month if d is not None else None for d in dts]
1376
+ day = [d.day if d is not None else None for d in dts]
1377
+ hour = [d.hour if d is not None else None for d in dts]
1378
+ minute = [d.minute if d is not None else None for d in dts]
1379
+
1380
+ # changed[i]: True if i-th value differs from (i-1)-th, always
1381
+ # True at i==0 or when either neighbour is NA. Matches R's
1382
+ # `changed <- function(x) c(TRUE, is.na(x[-1]) | x[-1] != x[-1])`.
1383
+ def _changed(vals: list[Any]) -> list[bool]:
1384
+ out = [True] * n
1385
+ for i in range(1, n):
1386
+ if vals[i] is None or vals[i - 1] is None:
1387
+ out[i] = True
1388
+ else:
1389
+ out[i] = vals[i] != vals[i - 1]
1390
+ return out
1391
+
1392
+ ch_year = _changed(year)
1393
+ ch_month = _changed(month)
1394
+ ch_day = _changed(day)
1395
+
1396
+ # R's `cumsum(changes) >= 1` ensures that once a larger unit
1397
+ # changes, all smaller units are re-shown for that row.
1398
+ cum_year = [c for c in ch_year]
1399
+ cum_month = [(cy or cm) for cy, cm in zip(cum_year, ch_month)]
1400
+ cum_day = [(cym or cd) for cym, cd in zip(cum_month, ch_day)]
1401
+
1402
+ # Decide which *positions* are worth ever showing (the "firsts"
1403
+ # trim). Matches R's nested if-block.
1404
+ show_hour = not all(
1405
+ (h == 0 and m == 0) for h, m in zip(hour, minute) if h is not None
1406
+ )
1407
+ show_day = show_hour or not all(
1408
+ d == 1 for d in day if d is not None
1409
+ )
1410
+ show_month = show_day or not all(
1411
+ mo == 1 for mo in month if mo is not None
1412
+ )
1413
+
1414
+ fmt_year = fmt[0]
1415
+ fmt_month = fmt[1] if show_month else None
1416
+ fmt_day = fmt[2] if show_day else None
1417
+ fmt_hour = fmt[3] if show_hour else None
1418
+
1419
+ def _rstrip_leading(s: str) -> str:
1420
+ if leading == "0":
1421
+ return s
1422
+ # Replace a leading "0" digit in the whole string, and any
1423
+ # "0" directly after a separator (matches R's gsub).
1424
+ out = re.sub(r"^0", leading, s)
1425
+ if sep:
1426
+ out = out.replace(sep + "0", sep + leading)
1427
+ return out
1428
+
1429
+ results: list[str] = []
1430
+ for i, dt in enumerate(dts):
1431
+ if dt is None:
1432
+ results.append("NA")
1433
+ continue
1434
+
1435
+ parts: list[str] = []
1436
+ if fmt_hour is not None:
1437
+ parts.append(dt.strftime(fmt_hour))
1438
+ if fmt_day is not None and cum_day[i]:
1439
+ parts.append(dt.strftime(fmt_day))
1440
+ if fmt_month is not None and cum_month[i]:
1441
+ parts.append(dt.strftime(fmt_month))
1442
+ if cum_year[i]:
1443
+ parts.append(dt.strftime(fmt_year))
1444
+
1445
+ # R builds the matrix with smallest-first then reverses when
1446
+ # joining — i.e. largest unit first visually. We built
1447
+ # smallest-first too, so reverse here.
1448
+ parts = list(reversed(parts))
1449
+ results.append(_rstrip_leading(sep.join(parts)))
1450
+
1451
+ return results
1452
+
1453
+ return formatter
1454
+
1455
+
1456
+ def label_time(
1457
+ format: str = "%H:%M:%S",
1458
+ tz: str = "UTC",
1459
+ ) -> Callable[[ArrayLike], list[str]]:
1460
+ """
1461
+ Label times.
1462
+
1463
+ Parameters
1464
+ ----------
1465
+ format : str, optional
1466
+ ``strftime``-compatible format string (default ``"%H:%M:%S"``).
1467
+ tz : str, optional
1468
+ Timezone (default ``"UTC"``).
1469
+
1470
+ Returns
1471
+ -------
1472
+ callable
1473
+ ``(x) -> list[str]``
1474
+ """
1475
+ tz_obj = _make_tz(tz)
1476
+
1477
+ def formatter(x: ArrayLike) -> list[str]:
1478
+ results: list[str] = []
1479
+ x_arr = np.asarray(x)
1480
+ for val in x_arr.flat:
1481
+ dt = _to_datetime(val, tz_obj)
1482
+ if dt is None:
1483
+ results.append("NA")
1484
+ else:
1485
+ results.append(dt.strftime(format))
1486
+ return results
1487
+
1488
+ return formatter
1489
+
1490
+
1491
+ def label_timespan(
1492
+ unit: str = "secs",
1493
+ space: bool = False,
1494
+ ) -> Callable[[ArrayLike], list[str]]:
1495
+ """
1496
+ Label timespans with human-friendly units.
1497
+
1498
+ Parameters
1499
+ ----------
1500
+ unit : str, optional
1501
+ Input unit: ``"secs"``, ``"mins"``, ``"hours"``, ``"days"``,
1502
+ ``"weeks"`` (default ``"secs"``).
1503
+ space : bool, optional
1504
+ Insert space between number and unit (default ``False``).
1505
+
1506
+ Returns
1507
+ -------
1508
+ callable
1509
+ ``(x) -> list[str]``
1510
+ """
1511
+ unit_seconds: dict[str, float] = {
1512
+ "secs": 1,
1513
+ "mins": 60,
1514
+ "hours": 3600,
1515
+ "days": 86400,
1516
+ "weeks": 604800,
1517
+ }
1518
+
1519
+ # Unicode \u03bc mirrors R's cut_time_scale when UTF-8 is active.
1520
+ _thresholds = [
1521
+ (604800, "w"),
1522
+ (86400, "d"),
1523
+ (3600, "h"),
1524
+ (60, "m"),
1525
+ (1, "s"),
1526
+ (1e-3, "ms"),
1527
+ (1e-6, "\u03bcs"),
1528
+ (1e-9, "ns"),
1529
+ ]
1530
+
1531
+ def formatter(x: ArrayLike) -> list[str]:
1532
+ x_arr = np.asarray(x, dtype=float)
1533
+ scale_factor = unit_seconds.get(unit, 1)
1534
+ x_secs = x_arr * scale_factor
1535
+ sp = " " if space else ""
1536
+
1537
+ results: list[str] = []
1538
+ for val in x_secs.flat:
1539
+ if not np.isfinite(val):
1540
+ results.append("NaN" if np.isnan(val) else str(val))
1541
+ continue
1542
+
1543
+ if val == 0:
1544
+ results.append(f"0{sp}s")
1545
+ continue
1546
+
1547
+ abs_val = abs(val)
1548
+ chosen_div = 1.0
1549
+ chosen_unit = "s"
1550
+ for threshold, u in _thresholds:
1551
+ if abs_val >= threshold:
1552
+ chosen_div = threshold
1553
+ chosen_unit = u
1554
+ break
1555
+ else:
1556
+ # Smaller than 1 ns
1557
+ chosen_div = 1e-9
1558
+ chosen_unit = "ns"
1559
+
1560
+ scaled = val / chosen_div
1561
+ fmt = _format_number(scaled, _precision(np.array([scaled])), None, ".", True)
1562
+ results.append(f"{fmt}{sp}{chosen_unit}")
1563
+
1564
+ return results
1565
+
1566
+ return formatter
1567
+
1568
+
1569
+ # ---------------------------------------------------------------------------
1570
+ # label_wrap
1571
+ # ---------------------------------------------------------------------------
1572
+
1573
+
1574
+ def label_wrap(width: int) -> Callable[[ArrayLike], list[str]]:
1575
+ """
1576
+ Wrap label text at *width* characters.
1577
+
1578
+ Parameters
1579
+ ----------
1580
+ width : int
1581
+ Maximum line width.
1582
+
1583
+ Returns
1584
+ -------
1585
+ callable
1586
+ ``(x) -> list[str]``
1587
+ """
1588
+
1589
+ def formatter(x: ArrayLike) -> list[str]:
1590
+ if isinstance(x, str):
1591
+ x = [x]
1592
+ return [textwrap.fill(str(v), width=width) for v in np.asarray(x).flat]
1593
+
1594
+ return formatter
1595
+
1596
+
1597
+ # ---------------------------------------------------------------------------
1598
+ # label_glue
1599
+ # ---------------------------------------------------------------------------
1600
+
1601
+
1602
+ def label_glue(
1603
+ pattern: str = "{x}",
1604
+ ) -> Callable[[ArrayLike], list[str]]:
1605
+ """
1606
+ Label with ``str.format``-style patterns.
1607
+
1608
+ Parameters
1609
+ ----------
1610
+ pattern : str, optional
1611
+ Format pattern where ``{x}`` is replaced with the value
1612
+ (default ``"{x}"``).
1613
+
1614
+ Returns
1615
+ -------
1616
+ callable
1617
+ ``(x) -> list[str]``
1618
+ """
1619
+
1620
+ def formatter(x: ArrayLike) -> list[str]:
1621
+ return [pattern.format(x=v) for v in np.asarray(x).flat]
1622
+
1623
+ return formatter
1624
+
1625
+
1626
+ # ---------------------------------------------------------------------------
1627
+ # label_parse / label_math
1628
+ # ---------------------------------------------------------------------------
1629
+
1630
+
1631
+ def label_parse() -> Callable[[ArrayLike], list[str]]:
1632
+ """
1633
+ Return labels as-is (identity formatter).
1634
+
1635
+ In R this parses plotmath expressions; in Python it simply converts
1636
+ values to strings.
1637
+
1638
+ Returns
1639
+ -------
1640
+ callable
1641
+ ``(x) -> list[str]``
1642
+ """
1643
+
1644
+ def formatter(x: ArrayLike) -> list[str]:
1645
+ return [str(v) for v in np.asarray(x).flat]
1646
+
1647
+ return formatter
1648
+
1649
+
1650
+ def label_math(
1651
+ expr: Optional[str] = None,
1652
+ format_func: Optional[Callable[[ArrayLike], list[str]]] = None,
1653
+ ) -> Callable[[ArrayLike], list[str]]:
1654
+ """
1655
+ Label with mathematical formatting.
1656
+
1657
+ In R this wraps with plotmath expressions. In Python it applies
1658
+ *format_func* first and then optionally wraps each result with *expr*.
1659
+
1660
+ Parameters
1661
+ ----------
1662
+ expr : str, optional
1663
+ A pattern containing ``{x}`` to wrap each label.
1664
+ format_func : callable, optional
1665
+ Pre-formatter applied to values before wrapping.
1666
+
1667
+ Returns
1668
+ -------
1669
+ callable
1670
+ ``(x) -> list[str]``
1671
+ """
1672
+
1673
+ def formatter(x: ArrayLike) -> list[str]:
1674
+ if format_func is not None:
1675
+ labels = format_func(x)
1676
+ else:
1677
+ labels = [str(v) for v in np.asarray(x).flat]
1678
+ if expr is not None:
1679
+ labels = [expr.format(x=lbl) for lbl in labels]
1680
+ return labels
1681
+
1682
+ return formatter
1683
+
1684
+
1685
+ # ---------------------------------------------------------------------------
1686
+ # label_log / format_log
1687
+ # ---------------------------------------------------------------------------
1688
+
1689
+
1690
+ def format_log(
1691
+ x: ArrayLike,
1692
+ base: float = 10,
1693
+ signed: Optional[bool] = None,
1694
+ digits: int = 3,
1695
+ ) -> list[str]:
1696
+ """
1697
+ Format values as log expressions (e.g. ``"10^3"``).
1698
+
1699
+ Mirrors R's ``scales::format_log``: accepts **raw values** ``x`` and
1700
+ internally computes ``log(x, base)`` to obtain the exponent.
1701
+
1702
+ Parameters
1703
+ ----------
1704
+ x : array-like
1705
+ Raw numeric values (not already-logged).
1706
+ base : float, optional
1707
+ Logarithmic base (default 10).
1708
+ signed : bool, optional
1709
+ If ``None`` (default), sign prefixes are shown when any finite
1710
+ value is ``<= 0``. ``True`` forces signed formatting; ``False``
1711
+ disables it.
1712
+ digits : int, optional
1713
+ Significant digits for the exponent (default 3).
1714
+
1715
+ Returns
1716
+ -------
1717
+ list of str
1718
+ """
1719
+ x_arr = np.asarray(x, dtype=float)
1720
+ if x_arr.size == 0:
1721
+ return []
1722
+
1723
+ n = x_arr.size
1724
+ flat = x_arr.flatten()
1725
+ prefix = [""] * n
1726
+
1727
+ finite = flat[np.isfinite(flat)]
1728
+ if signed is None:
1729
+ signed = bool(np.any(finite <= 0))
1730
+
1731
+ signs = np.zeros(n, dtype=int)
1732
+ if signed:
1733
+ # sign(NaN) = 0 for our purposes; don't overwrite NaNs
1734
+ for i, v in enumerate(flat):
1735
+ if np.isnan(v):
1736
+ continue
1737
+ if v > 0:
1738
+ signs[i] = 1
1739
+ prefix[i] = "+"
1740
+ elif v < 0:
1741
+ signs[i] = -1
1742
+ prefix[i] = "-"
1743
+ else:
1744
+ signs[i] = 0
1745
+ flat = np.abs(flat)
1746
+ flat = np.where(flat == 0, 1.0, flat)
1747
+
1748
+ base_str = str(int(base)) if float(base).is_integer() else str(base)
1749
+ log_base = math.log(base)
1750
+
1751
+ def _zapsmall(v: float, tol: float = 1e-10) -> float:
1752
+ # Match R's zapsmall: values close to zero become exactly zero.
1753
+ return 0.0 if abs(v) < tol else v
1754
+
1755
+ def _fmt_exponent(v: float) -> str:
1756
+ v = _zapsmall(v)
1757
+ if float(v).is_integer():
1758
+ return str(int(v))
1759
+ # R's format(x, digits=3) uses significant digits.
1760
+ return f"{v:.{max(digits - 1, 0)}g}" if digits > 0 else f"{v:g}"
1761
+
1762
+ results: list[str] = []
1763
+ for i, v in enumerate(flat):
1764
+ if np.isnan(v):
1765
+ results.append("NaN")
1766
+ continue
1767
+ if np.isinf(v):
1768
+ results.append(str(v))
1769
+ continue
1770
+ exponent = math.log(v) / log_base
1771
+ exponent_str = _fmt_exponent(exponent)
1772
+ text = f"{prefix[i]}{base_str}^{exponent_str}"
1773
+ if signed and signs[i] == 0:
1774
+ text = "0"
1775
+ results.append(text)
1776
+ return results
1777
+
1778
+
1779
+ def label_log(
1780
+ base: float = 10,
1781
+ digits: int = 3,
1782
+ signed: Optional[bool] = None,
1783
+ ) -> Callable[[ArrayLike], list[str]]:
1784
+ """
1785
+ Label values on a log scale (e.g. ``"10^3"``).
1786
+
1787
+ Parameters
1788
+ ----------
1789
+ base : float, optional
1790
+ Logarithmic base (default 10).
1791
+ digits : int, optional
1792
+ Significant digits for non-integer exponents (default 3).
1793
+ signed : bool, optional
1794
+ Show sign on exponents. When ``None`` (default), signs appear
1795
+ whenever any finite input is ``<= 0``.
1796
+
1797
+ Returns
1798
+ -------
1799
+ callable
1800
+ ``(x) -> list[str]``
1801
+ """
1802
+
1803
+ def formatter(x: ArrayLike) -> list[str]:
1804
+ x_arr = np.asarray(x, dtype=float)
1805
+ text = format_log(x_arr, base=base, signed=signed, digits=digits)
1806
+ # Restore NaN labels like R's label_log (ret[is.na(x)] <- NA).
1807
+ for i, v in enumerate(x_arr.flatten()):
1808
+ if np.isnan(v):
1809
+ text[i] = "NaN"
1810
+ return text
1811
+
1812
+ return formatter
1813
+
1814
+
1815
+ # ---------------------------------------------------------------------------
1816
+ # label_number_auto
1817
+ # ---------------------------------------------------------------------------
1818
+
1819
+
1820
+ def label_number_auto() -> Callable[[ArrayLike], list[str]]:
1821
+ """
1822
+ Automatically choose between regular and scientific notation.
1823
+
1824
+ Switches to scientific notation when values are very large or very small.
1825
+
1826
+ Returns
1827
+ -------
1828
+ callable
1829
+ ``(x) -> list[str]``
1830
+ """
1831
+
1832
+ def formatter(x: ArrayLike) -> list[str]:
1833
+ x_arr = np.asarray(x, dtype=float)
1834
+ finite = x_arr[np.isfinite(x_arr)]
1835
+ if len(finite) == 0:
1836
+ return number(x_arr)
1837
+
1838
+ abs_max = np.max(np.abs(finite)) if len(finite) else 0
1839
+ abs_min_nonzero = np.min(np.abs(finite[finite != 0])) if np.any(finite != 0) else 1
1840
+
1841
+ # Use scientific if range spans many orders of magnitude or extreme values
1842
+ if abs_max >= 1e9 or (abs_min_nonzero > 0 and abs_min_nonzero < 1e-3):
1843
+ return scientific(x_arr)
1844
+ return number(x_arr)
1845
+
1846
+ return formatter
1847
+
1848
+
1849
+ # ---------------------------------------------------------------------------
1850
+ # label_number_si (deprecated)
1851
+ # ---------------------------------------------------------------------------
1852
+
1853
+
1854
+ def label_number_si(
1855
+ unit: str = "",
1856
+ accuracy: Optional[float] = None,
1857
+ scale: float = 1,
1858
+ suffix: str = "",
1859
+ ) -> Callable[[ArrayLike], list[str]]:
1860
+ """
1861
+ Label numbers with SI prefixes (deprecated).
1862
+
1863
+ Use :func:`label_number` with ``scale_cut=cut_si(unit)`` instead.
1864
+
1865
+ Parameters
1866
+ ----------
1867
+ unit : str, optional
1868
+ Base unit.
1869
+ accuracy : float, optional
1870
+ Rounding precision.
1871
+ scale : float, optional
1872
+ Multiplicative factor.
1873
+ suffix : str, optional
1874
+ Extra suffix after SI unit.
1875
+
1876
+ Returns
1877
+ -------
1878
+ callable
1879
+ ``(x) -> list[str]``
1880
+ """
1881
+ import warnings
1882
+ warnings.warn(
1883
+ "label_number_si() is deprecated. Use label_number(scale_cut=cut_si(unit)) instead.",
1884
+ DeprecationWarning,
1885
+ stacklevel=2,
1886
+ )
1887
+ return label_number(
1888
+ accuracy=accuracy,
1889
+ scale=scale,
1890
+ suffix=suffix,
1891
+ scale_cut=cut_si(unit),
1892
+ )
1893
+
1894
+
1895
+ # ---------------------------------------------------------------------------
1896
+ # label_dictionary
1897
+ # ---------------------------------------------------------------------------
1898
+
1899
+
1900
+ def label_dictionary(
1901
+ dictionary: Optional[Dict[Any, str]] = None,
1902
+ nomatch: Optional[str] = None,
1903
+ ) -> Callable[[ArrayLike], list[str]]:
1904
+ """
1905
+ Label values by dictionary lookup.
1906
+
1907
+ Parameters
1908
+ ----------
1909
+ dictionary : dict, optional
1910
+ Mapping from data values to label strings.
1911
+ nomatch : str, optional
1912
+ Fallback string when a value is not in the dictionary.
1913
+ ``None`` means the original value is converted to string.
1914
+
1915
+ Returns
1916
+ -------
1917
+ callable
1918
+ ``(x) -> list[str]``
1919
+ """
1920
+ if dictionary is None:
1921
+ dictionary = {}
1922
+
1923
+ def formatter(x: ArrayLike) -> list[str]:
1924
+ results: list[str] = []
1925
+ for val in np.asarray(x).flat:
1926
+ # Try various key types
1927
+ key = val.item() if hasattr(val, "item") else val
1928
+ if key in dictionary:
1929
+ results.append(str(dictionary[key]))
1930
+ elif nomatch is not None:
1931
+ results.append(nomatch)
1932
+ else:
1933
+ results.append(str(key))
1934
+ return results
1935
+
1936
+ return formatter
1937
+
1938
+
1939
+ # ---------------------------------------------------------------------------
1940
+ # compose_label
1941
+ # ---------------------------------------------------------------------------
1942
+
1943
+
1944
+ def compose_label(
1945
+ *formatters: Callable[[ArrayLike], list[str]],
1946
+ ) -> Callable[[ArrayLike], list[str]]:
1947
+ """
1948
+ Compose multiple label formatters, applying them in sequence.
1949
+
1950
+ Each formatter receives the output of the previous one.
1951
+
1952
+ Parameters
1953
+ ----------
1954
+ *formatters : callable
1955
+ Label functions to compose.
1956
+
1957
+ Returns
1958
+ -------
1959
+ callable
1960
+ ``(x) -> list[str]``
1961
+ """
1962
+
1963
+ def formatter(x: ArrayLike) -> list[str]:
1964
+ result = x
1965
+ for f in formatters:
1966
+ result = f(result)
1967
+ return result # type: ignore[return-value]
1968
+
1969
+ return formatter
1970
+
1971
+
1972
+ # ---------------------------------------------------------------------------
1973
+ # unit_format
1974
+ # ---------------------------------------------------------------------------
1975
+
1976
+
1977
+ def unit_format(
1978
+ unit: str = "m",
1979
+ scale: float = 1,
1980
+ sep: str = " ",
1981
+ **kwargs: Any,
1982
+ ) -> Callable[[ArrayLike], list[str]]:
1983
+ """
1984
+ Append a unit to formatted numbers.
1985
+
1986
+ Parameters
1987
+ ----------
1988
+ unit : str, optional
1989
+ Unit string (default ``"m"``).
1990
+ scale : float, optional
1991
+ Multiplicative factor.
1992
+ sep : str, optional
1993
+ Separator between number and unit (default ``" "``).
1994
+ **kwargs
1995
+ Passed to :func:`label_number`.
1996
+
1997
+ Returns
1998
+ -------
1999
+ callable
2000
+ ``(x) -> list[str]``
2001
+ """
2002
+ return label_number(scale=scale, suffix=f"{sep}{unit}", **kwargs)
2003
+
2004
+
2005
+ # ---------------------------------------------------------------------------
2006
+ # Date utility aliases
2007
+ # ---------------------------------------------------------------------------
2008
+
2009
+
2010
+ def date_breaks(width: str) -> Callable:
2011
+ """
2012
+ Return a break function for date axes.
2013
+
2014
+ Parameters
2015
+ ----------
2016
+ width : str
2017
+ Break width specification (e.g. ``"1 month"``). Delegates to
2018
+ ``breaks_width``.
2019
+
2020
+ Returns
2021
+ -------
2022
+ callable
2023
+ """
2024
+ from .breaks import breaks_width
2025
+ return breaks_width(width)
2026
+
2027
+
2028
+ def date_format(
2029
+ format: str = "%Y-%m-%d",
2030
+ tz: str = "UTC",
2031
+ ) -> Callable[[ArrayLike], list[str]]:
2032
+ """Alias for :func:`label_date`."""
2033
+ return label_date(format=format, tz=tz)
2034
+
2035
+
2036
+ def time_format(
2037
+ format: str = "%H:%M:%S",
2038
+ tz: str = "UTC",
2039
+ ) -> Callable[[ArrayLike], list[str]]:
2040
+ """Alias for :func:`label_time`."""
2041
+ return label_time(format=format, tz=tz)
2042
+
2043
+
2044
+ # ---------------------------------------------------------------------------
2045
+ # Legacy aliases
2046
+ # ---------------------------------------------------------------------------
2047
+
2048
+ comma_format = label_comma
2049
+ dollar_format = label_dollar
2050
+ percent_format = label_percent
2051
+ scientific_format = label_scientific
2052
+ ordinal_format = label_ordinal
2053
+ pvalue_format = label_pvalue
2054
+ number_format = label_number
2055
+ number_bytes_format = label_bytes
2056
+ number_bytes = label_bytes
2057
+ parse_format = label_parse
2058
+ math_format = label_math
2059
+ wrap_format = label_wrap
2060
+ format_format = label_glue
2061
+
2062
+
2063
+ # ---------------------------------------------------------------------------
2064
+ # Global number-formatting options
2065
+ # ---------------------------------------------------------------------------
2066
+
2067
+ # Module-level option store – mirrors R's `options(scales.*)` mechanism.
2068
+ # NOTE on `big_mark`: the default is an empty string rather than R's
2069
+ # figure space `" "`. This is an intentional Python divergence decided
2070
+ # 2026-04-16 so that label output round-trips through float(). Users
2071
+ # wanting R's visual style call `number_options(big_mark=" ")`.
2072
+ _NUMBER_OPTIONS: dict[str, object] = {
2073
+ "decimal_mark": ".",
2074
+ "big_mark": "",
2075
+ "style_positive": "none",
2076
+ "style_negative": "hyphen",
2077
+ "currency_prefix": "$",
2078
+ "currency_suffix": "",
2079
+ "currency_decimal_mark": ".",
2080
+ "currency_big_mark": ",",
2081
+ }
2082
+
2083
+
2084
+ def number_options(
2085
+ decimal_mark: str = ".",
2086
+ big_mark: str = "",
2087
+ style_positive: str = "none",
2088
+ style_negative: str = "hyphen",
2089
+ currency_prefix: str = "$",
2090
+ currency_suffix: str = "",
2091
+ currency_decimal_mark: str | None = None,
2092
+ currency_big_mark: str | None = None,
2093
+ ) -> dict[str, object]:
2094
+ """Set global default options for number formatting.
2095
+
2096
+ In R this sets ``options(scales.*)``. In Python the values are
2097
+ stored in the module-level ``_NUMBER_OPTIONS`` dict and can be
2098
+ read by label functions that wish to honour defaults.
2099
+
2100
+ Calling with no arguments resets all options to their defaults.
2101
+
2102
+ Parameters
2103
+ ----------
2104
+ decimal_mark : str
2105
+ Default decimal separator.
2106
+ big_mark : str
2107
+ Default thousands separator.
2108
+ style_positive : str
2109
+ How to display positive numbers: ``"none"``, ``"plus"``, or ``"space"``.
2110
+ style_negative : str
2111
+ How to display negative numbers: ``"hyphen"``, ``"minus"``, or ``"parens"``.
2112
+ currency_prefix : str
2113
+ Default currency prefix.
2114
+ currency_suffix : str
2115
+ Default currency suffix.
2116
+ currency_decimal_mark : str or None
2117
+ Decimal mark for currency (defaults to *decimal_mark*).
2118
+ currency_big_mark : str or None
2119
+ Big mark for currency (defaults to ``","`` if *currency_decimal_mark*
2120
+ is ``"."``, else ``"."``).
2121
+
2122
+ Returns
2123
+ -------
2124
+ dict
2125
+ Previous option values (before this call changed them).
2126
+ """
2127
+ prev = dict(_NUMBER_OPTIONS)
2128
+
2129
+ if currency_decimal_mark is None:
2130
+ currency_decimal_mark = decimal_mark
2131
+ if currency_big_mark is None:
2132
+ currency_big_mark = "," if currency_decimal_mark == "." else "."
2133
+
2134
+ _NUMBER_OPTIONS.update(
2135
+ decimal_mark=decimal_mark,
2136
+ big_mark=big_mark,
2137
+ style_positive=style_positive,
2138
+ style_negative=style_negative,
2139
+ currency_prefix=currency_prefix,
2140
+ currency_suffix=currency_suffix,
2141
+ currency_decimal_mark=currency_decimal_mark,
2142
+ currency_big_mark=currency_big_mark,
2143
+ )
2144
+ return prev