maidr 1.9.0__py3-none-any.whl → 1.11.0__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.
@@ -0,0 +1,679 @@
1
+ """
2
+ Format configuration utilities for extracting and representing axis formatting.
3
+
4
+ This module provides classes and utilities for detecting matplotlib axis formatters
5
+ and converting them to MAIDR-compatible format configurations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from typing import Any, Dict, Optional
14
+
15
+ from matplotlib.axes import Axes
16
+ from matplotlib.dates import DateFormatter
17
+ from matplotlib.ticker import (
18
+ Formatter,
19
+ FuncFormatter,
20
+ PercentFormatter,
21
+ ScalarFormatter,
22
+ StrMethodFormatter,
23
+ FormatStrFormatter,
24
+ )
25
+
26
+
27
+ class FormatType(str, Enum):
28
+ """
29
+ Enumeration of supported format types for MAIDR.
30
+
31
+ These types correspond to the formatting options supported by
32
+ the MAIDR JavaScript library's FormatterService.
33
+ """
34
+
35
+ CURRENCY = "currency"
36
+ PERCENT = "percent"
37
+ DATE = "date"
38
+ NUMBER = "number"
39
+ SCIENTIFIC = "scientific"
40
+ FIXED = "fixed"
41
+
42
+
43
+ @dataclass
44
+ class FormatConfig:
45
+ """
46
+ Configuration for axis value formatting.
47
+
48
+ This class represents a format configuration that can be serialized
49
+ to the MAIDR schema format. It supports both type-based formatting
50
+ and custom JavaScript function bodies.
51
+
52
+ Parameters
53
+ ----------
54
+ type : FormatType, optional
55
+ The type of formatting to apply.
56
+ function : str, optional
57
+ JavaScript function body for custom formatting.
58
+ This is evaluated by MAIDR JS using: new Function('value', functionBody)
59
+ Example: "return parseFloat(value).toFixed(2)"
60
+ decimals : int, optional
61
+ Number of decimal places to display.
62
+ currency : str, optional
63
+ Currency code (e.g., "USD", "EUR") for currency formatting.
64
+ locale : str, optional
65
+ BCP 47 locale string (e.g., "en-US") for locale-specific formatting.
66
+ dateFormat : str, optional
67
+ Date format string (e.g., "%b %d" for "Jan 15") for date formatting.
68
+
69
+ Examples
70
+ --------
71
+ >>> config = FormatConfig(type=FormatType.CURRENCY, decimals=2, currency="USD")
72
+ >>> config.to_dict()
73
+ {'type': 'currency', 'decimals': 2, 'currency': 'USD'}
74
+
75
+ >>> config = FormatConfig(function="return '$' + parseFloat(value).toFixed(2)")
76
+ >>> config.to_dict()
77
+ {'function': "return '$' + parseFloat(value).toFixed(2)"}
78
+ """
79
+
80
+ type: Optional[FormatType] = None
81
+ function: Optional[str] = None
82
+ decimals: Optional[int] = None
83
+ currency: Optional[str] = None
84
+ locale: Optional[str] = None
85
+ dateFormat: Optional[str] = None
86
+
87
+ def to_dict(self) -> Dict[str, Any]:
88
+ """
89
+ Convert the format configuration to a dictionary.
90
+
91
+ Returns
92
+ -------
93
+ Dict[str, Any]
94
+ Dictionary representation suitable for MAIDR schema.
95
+ Only includes non-None values. If function is provided,
96
+ it takes precedence over type-based formatting.
97
+ """
98
+ result: Dict[str, Any] = {}
99
+
100
+ # Function takes precedence - if provided, use it directly
101
+ if self.function is not None:
102
+ result["function"] = self.function
103
+ return result
104
+
105
+ # Otherwise use type-based formatting
106
+ if self.type is not None:
107
+ result["type"] = self.type.value
108
+
109
+ if self.decimals is not None:
110
+ result["decimals"] = self.decimals
111
+ if self.currency is not None:
112
+ result["currency"] = self.currency
113
+ if self.locale is not None:
114
+ result["locale"] = self.locale
115
+ if self.dateFormat is not None:
116
+ result["dateFormat"] = self.dateFormat
117
+
118
+ return result
119
+
120
+
121
+ class JSBodyConverter:
122
+ """
123
+ Converter for generating JavaScript function bodies from matplotlib formatters.
124
+
125
+ These function bodies are evaluated by MAIDR JS using:
126
+ new Function('value', functionBody)
127
+ """
128
+
129
+ # Mapping of Python strftime codes to JavaScript date formatting
130
+ # Common patterns with optimized JS bodies
131
+ STRFTIME_PATTERNS: Dict[str, str] = {
132
+ "%b %d": (
133
+ "var m=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];"
134
+ "var d=new Date(value);return m[d.getMonth()]+' '+d.getDate()"
135
+ ),
136
+ "%b %d, %Y": (
137
+ "var m=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];"
138
+ "var d=new Date(value);return m[d.getMonth()]+' '+d.getDate()+', '+d.getFullYear()"
139
+ ),
140
+ "%Y-%m-%d": (
141
+ "var d=new Date(value);"
142
+ "return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'"
143
+ "+String(d.getDate()).padStart(2,'0')"
144
+ ),
145
+ "%m/%d/%Y": (
146
+ "var d=new Date(value);"
147
+ "return String(d.getMonth()+1).padStart(2,'0')+'/'"
148
+ "+String(d.getDate()).padStart(2,'0')+'/'+d.getFullYear()"
149
+ ),
150
+ "%d/%m/%Y": (
151
+ "var d=new Date(value);"
152
+ "return String(d.getDate()).padStart(2,'0')+'/'"
153
+ "+String(d.getMonth()+1).padStart(2,'0')+'/'+d.getFullYear()"
154
+ ),
155
+ "%Y": "return new Date(value).getFullYear().toString()",
156
+ "%B %Y": (
157
+ "var m=['January','February','March','April','May','June',"
158
+ "'July','August','September','October','November','December'];"
159
+ "var d=new Date(value);return m[d.getMonth()]+' '+d.getFullYear()"
160
+ ),
161
+ "%H:%M": (
162
+ "var d=new Date(value);"
163
+ "return String(d.getHours()).padStart(2,'0')+':'"
164
+ "+String(d.getMinutes()).padStart(2,'0')"
165
+ ),
166
+ "%H:%M:%S": (
167
+ "var d=new Date(value);"
168
+ "return String(d.getHours()).padStart(2,'0')+':'"
169
+ "+String(d.getMinutes()).padStart(2,'0')+':'"
170
+ "+String(d.getSeconds()).padStart(2,'0')"
171
+ ),
172
+ "%I:%M %p": (
173
+ "var d=new Date(value);"
174
+ "var h=d.getHours();var ampm=h>=12?'PM':'AM';h=h%12;h=h?h:12;"
175
+ "return String(h).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+' '+ampm"
176
+ ),
177
+ }
178
+
179
+ @staticmethod
180
+ def date_format_to_js(fmt: str) -> str:
181
+ """
182
+ Convert Python strftime format to JavaScript function body.
183
+
184
+ Parameters
185
+ ----------
186
+ fmt : str
187
+ Python strftime format string (e.g., '%b %d', '%Y-%m-%d')
188
+
189
+ Returns
190
+ -------
191
+ str
192
+ JavaScript function body that formats dates similarly.
193
+ """
194
+ if fmt in JSBodyConverter.STRFTIME_PATTERNS:
195
+ return JSBodyConverter.STRFTIME_PATTERNS[fmt]
196
+
197
+ # Fallback: use toLocaleString for unrecognized formats
198
+ return "return new Date(value).toLocaleDateString()"
199
+
200
+ @staticmethod
201
+ def currency_format_to_js(symbol: str, decimals: int = 2) -> str:
202
+ """
203
+ Convert currency format to JavaScript function body.
204
+
205
+ Parameters
206
+ ----------
207
+ symbol : str
208
+ Currency symbol ($, €, £, ¥)
209
+ decimals : int
210
+ Number of decimal places
211
+
212
+ Returns
213
+ -------
214
+ str
215
+ JavaScript function body for currency formatting.
216
+ """
217
+ # Map symbols to locales
218
+ locale_map = {
219
+ "$": "en-US",
220
+ "€": "de-DE",
221
+ "£": "en-GB",
222
+ "¥": "ja-JP",
223
+ }
224
+ locale = locale_map.get(symbol, "en-US")
225
+
226
+ # Yen typically has no decimals
227
+ if symbol == "¥":
228
+ decimals = 0
229
+
230
+ return (
231
+ f"return '{symbol}'+parseFloat(value).toLocaleString('{locale}',"
232
+ f"{{minimumFractionDigits:{decimals},maximumFractionDigits:{decimals}}})"
233
+ )
234
+
235
+ @staticmethod
236
+ def number_format_to_js(decimals: Optional[int] = None) -> str:
237
+ """
238
+ Convert number format (with thousands separator) to JavaScript function body.
239
+
240
+ Parameters
241
+ ----------
242
+ decimals : int, optional
243
+ Number of decimal places
244
+
245
+ Returns
246
+ -------
247
+ str
248
+ JavaScript function body for number formatting.
249
+ """
250
+ if decimals is not None:
251
+ return (
252
+ f"return parseFloat(value).toLocaleString('en-US',"
253
+ f"{{minimumFractionDigits:{decimals},maximumFractionDigits:{decimals}}})"
254
+ )
255
+ return "return parseFloat(value).toLocaleString('en-US')"
256
+
257
+ @staticmethod
258
+ def fixed_format_to_js(decimals: int) -> str:
259
+ """
260
+ Convert fixed decimal format to JavaScript function body.
261
+
262
+ Parameters
263
+ ----------
264
+ decimals : int
265
+ Number of decimal places
266
+
267
+ Returns
268
+ -------
269
+ str
270
+ JavaScript function body for fixed decimal formatting.
271
+ """
272
+ return f"return parseFloat(value).toFixed({decimals})"
273
+
274
+ @staticmethod
275
+ def percent_format_to_js(decimals: int = 1, multiply: bool = True) -> str:
276
+ """
277
+ Convert percent format to JavaScript function body.
278
+
279
+ Parameters
280
+ ----------
281
+ decimals : int
282
+ Number of decimal places
283
+ multiply : bool
284
+ Whether to multiply by 100 (for values stored as decimals)
285
+
286
+ Returns
287
+ -------
288
+ str
289
+ JavaScript function body for percent formatting.
290
+ """
291
+ if multiply:
292
+ return f"return (parseFloat(value)*100).toFixed({decimals})+'%'"
293
+ return f"return parseFloat(value).toFixed({decimals})+'%'"
294
+
295
+ @staticmethod
296
+ def scientific_format_to_js(decimals: int = 2) -> str:
297
+ """
298
+ Convert scientific notation format to JavaScript function body.
299
+
300
+ Parameters
301
+ ----------
302
+ decimals : int
303
+ Number of decimal places in mantissa
304
+
305
+ Returns
306
+ -------
307
+ str
308
+ JavaScript function body for scientific notation formatting.
309
+ """
310
+ return f"return parseFloat(value).toExponential({decimals})"
311
+
312
+ @staticmethod
313
+ def default_format_to_js() -> str:
314
+ """
315
+ Return default JavaScript function body for unsupported formatters.
316
+
317
+ Returns
318
+ -------
319
+ str
320
+ JavaScript function body that returns value as string.
321
+ """
322
+ return "return String(value)"
323
+
324
+
325
+ class FormatConfigBuilder:
326
+ """
327
+ Builder for extracting format configurations from matplotlib formatters.
328
+
329
+ This class provides static methods to detect and parse matplotlib axis
330
+ formatters and convert them to FormatConfig objects with JavaScript
331
+ function bodies for MAIDR JS evaluation.
332
+
333
+ Examples
334
+ --------
335
+ >>> from matplotlib import pyplot as plt
336
+ >>> fig, ax = plt.subplots()
337
+ >>> ax.yaxis.set_major_formatter('${x:,.2f}')
338
+ >>> config = FormatConfigBuilder.from_formatter(ax.yaxis.get_major_formatter())
339
+ >>> config.function
340
+ "return '$'+parseFloat(value).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})"
341
+ """
342
+
343
+ # Currency symbol patterns for detection
344
+ CURRENCY_PATTERNS = {
345
+ "$": "USD",
346
+ "USD": "USD",
347
+ "€": "EUR",
348
+ "EUR": "EUR",
349
+ "£": "GBP",
350
+ "GBP": "GBP",
351
+ "¥": "JPY",
352
+ "JPY": "JPY",
353
+ }
354
+
355
+ @staticmethod
356
+ def from_formatter(formatter: Optional[Formatter]) -> Optional[FormatConfig]:
357
+ """
358
+ Create a FormatConfig from a matplotlib Formatter.
359
+
360
+ Parameters
361
+ ----------
362
+ formatter : Formatter or None
363
+ A matplotlib ticker Formatter object, or None.
364
+
365
+ Returns
366
+ -------
367
+ FormatConfig or None
368
+ The detected format configuration, or None if the formatter
369
+ type could not be determined.
370
+ """
371
+ if formatter is None:
372
+ return None
373
+
374
+ # Check for DateFormatter (for date/time axes)
375
+ if isinstance(formatter, DateFormatter):
376
+ return FormatConfigBuilder._parse_date_formatter(formatter)
377
+
378
+ # Check for PercentFormatter
379
+ if isinstance(formatter, PercentFormatter):
380
+ return FormatConfigBuilder._parse_percent_formatter(formatter)
381
+
382
+ # Check for StrMethodFormatter (most common for custom formats)
383
+ if isinstance(formatter, StrMethodFormatter):
384
+ return FormatConfigBuilder._parse_str_method_formatter(formatter)
385
+
386
+ # Check for FormatStrFormatter (old-style % formatting)
387
+ if isinstance(formatter, FormatStrFormatter):
388
+ return FormatConfigBuilder._parse_format_str_formatter(formatter)
389
+
390
+ # Check for ScalarFormatter with scientific notation
391
+ if isinstance(formatter, ScalarFormatter):
392
+ return FormatConfigBuilder._parse_scalar_formatter(formatter)
393
+
394
+ # Check for FuncFormatter (custom function)
395
+ if isinstance(formatter, FuncFormatter):
396
+ return FormatConfigBuilder._parse_func_formatter(formatter)
397
+
398
+ return None
399
+
400
+ @staticmethod
401
+ def _parse_date_formatter(formatter: DateFormatter) -> FormatConfig:
402
+ """Parse a DateFormatter to FormatConfig with JS function body."""
403
+ date_format = None
404
+ if hasattr(formatter, "fmt") and formatter.fmt is not None:
405
+ date_format = formatter.fmt
406
+
407
+ # Generate JS function body for date formatting
408
+ js_body = JSBodyConverter.date_format_to_js(date_format) if date_format else None
409
+
410
+ if js_body:
411
+ return FormatConfig(function=js_body)
412
+
413
+ # Fallback to type-based config
414
+ return FormatConfig(type=FormatType.DATE, dateFormat=date_format)
415
+
416
+ @staticmethod
417
+ def _parse_percent_formatter(formatter: PercentFormatter) -> FormatConfig:
418
+ """Parse a PercentFormatter to FormatConfig using type-based preset."""
419
+ decimals = None
420
+ if hasattr(formatter, "decimals") and formatter.decimals is not None:
421
+ decimals = int(formatter.decimals)
422
+
423
+ # Use type-based preset for percent
424
+ return FormatConfig(type=FormatType.PERCENT, decimals=decimals)
425
+
426
+ @staticmethod
427
+ def _parse_str_method_formatter(
428
+ formatter: StrMethodFormatter,
429
+ ) -> Optional[FormatConfig]:
430
+ """Parse a StrMethodFormatter to FormatConfig using hybrid approach."""
431
+ fmt = getattr(formatter, "fmt", None)
432
+ if fmt is None:
433
+ return None
434
+
435
+ return FormatConfigBuilder._parse_format_string_hybrid(fmt)
436
+
437
+ @staticmethod
438
+ def _parse_format_str_formatter(
439
+ formatter: FormatStrFormatter,
440
+ ) -> Optional[FormatConfig]:
441
+ """Parse a FormatStrFormatter (old-style %) to FormatConfig using type-based presets."""
442
+ fmt = getattr(formatter, "fmt", None)
443
+ if fmt is None:
444
+ return None
445
+
446
+ # Convert old-style format to type-based config
447
+ # e.g., "%.2f" -> fixed with 2 decimals
448
+ match = re.search(r"%\.?(\d*)([efg])", fmt, re.IGNORECASE)
449
+ if match:
450
+ decimals_str = match.group(1)
451
+ format_char = match.group(2).lower()
452
+
453
+ decimals = int(decimals_str) if decimals_str else None
454
+
455
+ if format_char == "e":
456
+ return FormatConfig(type=FormatType.SCIENTIFIC, decimals=decimals)
457
+ elif format_char in ("f", "g"):
458
+ return FormatConfig(type=FormatType.FIXED, decimals=decimals)
459
+
460
+ return None
461
+
462
+ @staticmethod
463
+ def _parse_scalar_formatter(formatter: ScalarFormatter) -> Optional[FormatConfig]:
464
+ """Parse a ScalarFormatter to FormatConfig using type-based preset.
465
+
466
+ ScalarFormatter is the default matplotlib formatter and often has _scientific=True
467
+ due to auto-detection. We only return a FormatConfig if useMathText is explicitly
468
+ enabled, as this indicates the user wants formatted scientific notation display.
469
+ """
470
+ # useMathText is only True when explicitly set by user via set_useMathText(True)
471
+ # This provides nice-looking scientific notation like 10^6 instead of 1e6
472
+ use_math_text = getattr(formatter, "_useMathText", False)
473
+ if use_math_text is True:
474
+ return FormatConfig(type=FormatType.SCIENTIFIC)
475
+
476
+ # Default ScalarFormatter - no explicit format configured by user
477
+ # We ignore _scientific since matplotlib auto-sets it based on data magnitude
478
+ return None
479
+
480
+ @staticmethod
481
+ def _parse_func_formatter(formatter: FuncFormatter) -> Optional[FormatConfig]:
482
+ """
483
+ Attempt to parse a FuncFormatter by examining the function.
484
+
485
+ This is a best-effort approach since FuncFormatter can contain
486
+ arbitrary functions. For unsupported functions, returns a default
487
+ formatter that shows the value as-is.
488
+ """
489
+ func = getattr(formatter, "func", None)
490
+ if func is None:
491
+ return None
492
+
493
+ # Try to get function source or docstring for hints
494
+ func_name = getattr(func, "__name__", "")
495
+
496
+ # Common naming conventions - generate JS function bodies
497
+ if "percent" in func_name.lower():
498
+ js_body = JSBodyConverter.percent_format_to_js(1, multiply=True)
499
+ return FormatConfig(function=js_body)
500
+ elif "currency" in func_name.lower() or "dollar" in func_name.lower():
501
+ js_body = JSBodyConverter.currency_format_to_js("$", 2)
502
+ return FormatConfig(function=js_body)
503
+ elif "date" in func_name.lower() or "time" in func_name.lower():
504
+ js_body = "return new Date(value).toLocaleDateString()"
505
+ return FormatConfig(function=js_body)
506
+
507
+ # For unknown FuncFormatters, return default (value as-is)
508
+ # This ensures the value is still displayed
509
+ return FormatConfig(function=JSBodyConverter.default_format_to_js())
510
+
511
+ @staticmethod
512
+ def _parse_format_string_hybrid(fmt: str) -> Optional[FormatConfig]:
513
+ """
514
+ Parse a format string using hybrid approach: type-based presets for simple
515
+ formats, JS functions for complex formats.
516
+
517
+ Parameters
518
+ ----------
519
+ fmt : str
520
+ A Python format string (e.g., "${x:,.2f}", "{x:.1%}").
521
+
522
+ Returns
523
+ -------
524
+ FormatConfig or None
525
+ The detected format configuration using appropriate approach.
526
+ """
527
+ if not fmt:
528
+ return None
529
+
530
+ decimals = FormatConfigBuilder._extract_decimals(fmt)
531
+
532
+ # Detect currency by symbol prefix - use type-based preset
533
+ for symbol, currency_code in FormatConfigBuilder.CURRENCY_PATTERNS.items():
534
+ if symbol in fmt:
535
+ return FormatConfig(
536
+ type=FormatType.CURRENCY, decimals=decimals, currency=currency_code
537
+ )
538
+
539
+ # Detect percent format (ends with %) like {x:.1%} - use type-based preset
540
+ if "%" in fmt and "{" in fmt:
541
+ if re.search(r"\{[^}]*%\}", fmt):
542
+ return FormatConfig(type=FormatType.PERCENT, decimals=decimals)
543
+
544
+ # Detect scientific notation like {x:.2e} - use type-based preset
545
+ if re.search(r"\{[^}]*[eE]\}", fmt):
546
+ return FormatConfig(type=FormatType.SCIENTIFIC, decimals=decimals)
547
+
548
+ # Detect number format with comma separators like {x:,.2f} or {x:,}
549
+ # - use type-based preset
550
+ if re.search(r"\{[^}]*,", fmt):
551
+ return FormatConfig(type=FormatType.NUMBER, decimals=decimals)
552
+
553
+ # Detect fixed-point format (no comma separator) like {x:.2f}
554
+ # - use type-based preset
555
+ match = re.search(r"\{[^}]*\.(\d+)f\}", fmt)
556
+ if match:
557
+ decimals = int(match.group(1))
558
+ return FormatConfig(type=FormatType.FIXED, decimals=decimals)
559
+
560
+ # No recognized format - return None (don't add format config)
561
+ return None
562
+
563
+ @staticmethod
564
+ def _parse_format_string(fmt: str) -> Optional[FormatConfig]:
565
+ """
566
+ Parse a format string to detect the format type and options.
567
+ (Legacy method - kept for backwards compatibility)
568
+
569
+ Parameters
570
+ ----------
571
+ fmt : str
572
+ A Python format string (e.g., "${x:,.2f}", "{x:.1%}").
573
+
574
+ Returns
575
+ -------
576
+ FormatConfig or None
577
+ The detected format configuration.
578
+ """
579
+ if not fmt:
580
+ return None
581
+
582
+ # Detect currency by symbol prefix
583
+ for symbol, currency_code in FormatConfigBuilder.CURRENCY_PATTERNS.items():
584
+ if symbol in fmt:
585
+ decimals = FormatConfigBuilder._extract_decimals(fmt)
586
+ return FormatConfig(
587
+ type=FormatType.CURRENCY, decimals=decimals, currency=currency_code
588
+ )
589
+
590
+ # Detect percent format (ends with %)
591
+ if "%" in fmt and "{" in fmt:
592
+ # Check if it's a percent format like {x:.1%}
593
+ if re.search(r"\{[^}]*%\}", fmt):
594
+ decimals = FormatConfigBuilder._extract_decimals(fmt)
595
+ return FormatConfig(type=FormatType.PERCENT, decimals=decimals)
596
+
597
+ # Detect scientific notation
598
+ if re.search(r"\{[^}]*[eE]\}", fmt):
599
+ decimals = FormatConfigBuilder._extract_decimals(fmt)
600
+ return FormatConfig(type=FormatType.SCIENTIFIC, decimals=decimals)
601
+
602
+ # Detect number format with comma separators (must check before fixed-point)
603
+ # This matches formats like {x:,.2f} or {x:,}
604
+ if re.search(r"\{[^}]*,", fmt):
605
+ decimals = FormatConfigBuilder._extract_decimals(fmt)
606
+ return FormatConfig(type=FormatType.NUMBER, decimals=decimals)
607
+
608
+ # Detect fixed-point format (no comma separator)
609
+ match = re.search(r"\{[^}]*\.(\d+)f\}", fmt)
610
+ if match:
611
+ decimals = int(match.group(1))
612
+ return FormatConfig(type=FormatType.FIXED, decimals=decimals)
613
+
614
+ return None
615
+
616
+ @staticmethod
617
+ def _extract_decimals(fmt: str) -> Optional[int]:
618
+ """
619
+ Extract the number of decimal places from a format string.
620
+
621
+ Parameters
622
+ ----------
623
+ fmt : str
624
+ A Python format string.
625
+
626
+ Returns
627
+ -------
628
+ int or None
629
+ The number of decimal places, or None if not specified.
630
+ """
631
+ # Match patterns like .2f, .1%, .3e
632
+ match = re.search(r"\.(\d+)[fFeE%]", fmt)
633
+ if match:
634
+ return int(match.group(1))
635
+ return None
636
+
637
+
638
+ def extract_axis_format(ax: Optional[Axes]) -> Dict[str, Dict[str, Any]]:
639
+ """
640
+ Extract format configurations from both axes of a plot.
641
+
642
+ Parameters
643
+ ----------
644
+ ax : Axes or None
645
+ The matplotlib Axes object to extract formats from, or None.
646
+
647
+ Returns
648
+ -------
649
+ Dict[str, Dict[str, Any]]
650
+ Dictionary with 'x' and/or 'y' keys containing format configurations.
651
+ Only includes axes where a format could be detected.
652
+
653
+ Examples
654
+ --------
655
+ >>> from matplotlib import pyplot as plt
656
+ >>> fig, ax = plt.subplots()
657
+ >>> ax.yaxis.set_major_formatter('${x:,.2f}')
658
+ >>> formats = extract_axis_format(ax)
659
+ >>> formats
660
+ {'y': {'type': 'currency', 'decimals': 2, 'currency': 'USD'}}
661
+ """
662
+ if ax is None:
663
+ return {}
664
+
665
+ result: Dict[str, Dict[str, Any]] = {}
666
+
667
+ # Extract X-axis format
668
+ x_formatter = ax.xaxis.get_major_formatter()
669
+ x_config = FormatConfigBuilder.from_formatter(x_formatter)
670
+ if x_config is not None:
671
+ result["x"] = x_config.to_dict()
672
+
673
+ # Extract Y-axis format
674
+ y_formatter = ax.yaxis.get_major_formatter()
675
+ y_config = FormatConfigBuilder.from_formatter(y_formatter)
676
+ if y_config is not None:
677
+ result["y"] = y_config.to_dict()
678
+
679
+ return result
@@ -5,4 +5,15 @@ from .extractor_mixin import (
5
5
  LineExtractorMixin,
6
6
  ScalarMappableExtractorMixin,
7
7
  )
8
+ from .format_extractor_mixin import FormatExtractorMixin
8
9
  from .merger_mixin import DictMergerMixin
10
+
11
+ __all__ = [
12
+ "CollectionExtractorMixin",
13
+ "ContainerExtractorMixin",
14
+ "LevelExtractorMixin",
15
+ "LineExtractorMixin",
16
+ "ScalarMappableExtractorMixin",
17
+ "FormatExtractorMixin",
18
+ "DictMergerMixin",
19
+ ]