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.
- maidr/__init__.py +1 -1
- maidr/core/enum/maidr_key.py +1 -0
- maidr/core/plot/candlestick.py +73 -104
- maidr/core/plot/maidr_plot.py +11 -2
- maidr/core/plot/mplfinance_barplot.py +5 -1
- maidr/core/plot/mplfinance_lineplot.py +5 -1
- maidr/util/datetime_conversion.py +8 -18
- maidr/util/format_config.py +679 -0
- maidr/util/mixin/__init__.py +11 -0
- maidr/util/mixin/format_extractor_mixin.py +83 -0
- maidr/util/mplfinance_utils.py +5 -303
- {maidr-1.9.0.dist-info → maidr-1.11.0.dist-info}/METADATA +1 -1
- {maidr-1.9.0.dist-info → maidr-1.11.0.dist-info}/RECORD +15 -13
- {maidr-1.9.0.dist-info → maidr-1.11.0.dist-info}/WHEEL +1 -1
- {maidr-1.9.0.dist-info → maidr-1.11.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
maidr/util/mixin/__init__.py
CHANGED
|
@@ -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
|
+
]
|