prismiq 0.1.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.
- prismiq/__init__.py +543 -0
- prismiq/api.py +1889 -0
- prismiq/auth.py +108 -0
- prismiq/cache.py +527 -0
- prismiq/calculated_field_processor.py +231 -0
- prismiq/calculated_fields.py +819 -0
- prismiq/dashboard_store.py +1219 -0
- prismiq/dashboards.py +374 -0
- prismiq/dates.py +247 -0
- prismiq/engine.py +1315 -0
- prismiq/executor.py +345 -0
- prismiq/filter_merge.py +397 -0
- prismiq/formatting.py +298 -0
- prismiq/logging.py +489 -0
- prismiq/metrics.py +536 -0
- prismiq/middleware.py +346 -0
- prismiq/permissions.py +87 -0
- prismiq/persistence/__init__.py +45 -0
- prismiq/persistence/models.py +208 -0
- prismiq/persistence/postgres_store.py +1119 -0
- prismiq/persistence/saved_query_store.py +336 -0
- prismiq/persistence/schema.sql +95 -0
- prismiq/persistence/setup.py +222 -0
- prismiq/persistence/tables.py +76 -0
- prismiq/pins.py +72 -0
- prismiq/py.typed +0 -0
- prismiq/query.py +1233 -0
- prismiq/schema.py +333 -0
- prismiq/schema_config.py +354 -0
- prismiq/sql_utils.py +147 -0
- prismiq/sql_validator.py +219 -0
- prismiq/sqlalchemy_builder.py +577 -0
- prismiq/timeseries.py +410 -0
- prismiq/transforms.py +471 -0
- prismiq/trends.py +573 -0
- prismiq/types.py +688 -0
- prismiq-0.1.0.dist-info/METADATA +109 -0
- prismiq-0.1.0.dist-info/RECORD +39 -0
- prismiq-0.1.0.dist-info/WHEEL +4 -0
prismiq/filter_merge.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Filter merging utilities for Prismiq dashboards.
|
|
2
|
+
|
|
3
|
+
This module provides functions to merge dashboard filters with widget
|
|
4
|
+
queries, converting dashboard-level filters into query-level filters.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
from datetime import date
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from prismiq.dashboards import DashboardFilter, DashboardFilterType
|
|
16
|
+
from prismiq.dates import DatePreset, resolve_date_preset
|
|
17
|
+
from prismiq.types import DatabaseSchema, FilterDefinition, FilterOperator, QueryDefinition
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FilterValue(BaseModel):
|
|
21
|
+
"""Runtime value for a dashboard filter.
|
|
22
|
+
|
|
23
|
+
Represents the current value of a filter as set by the user in the
|
|
24
|
+
UI.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
model_config = ConfigDict(strict=True)
|
|
28
|
+
|
|
29
|
+
filter_id: str
|
|
30
|
+
"""ID of the dashboard filter this value applies to."""
|
|
31
|
+
|
|
32
|
+
value: Any
|
|
33
|
+
"""The filter value. Type depends on filter type:
|
|
34
|
+
- DATE_RANGE: dict with 'start' and 'end' date strings, or preset name
|
|
35
|
+
- SELECT: single string value
|
|
36
|
+
- MULTI_SELECT: list of string values
|
|
37
|
+
- TEXT: string value
|
|
38
|
+
- NUMBER_RANGE: dict with 'min' and/or 'max' numbers
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def merge_filters(
|
|
43
|
+
query: QueryDefinition,
|
|
44
|
+
dashboard_filters: list[DashboardFilter],
|
|
45
|
+
filter_values: list[FilterValue],
|
|
46
|
+
schema: DatabaseSchema,
|
|
47
|
+
) -> QueryDefinition:
|
|
48
|
+
"""Merge dashboard filter values into a widget query.
|
|
49
|
+
|
|
50
|
+
Creates a new QueryDefinition with additional filters from the dashboard.
|
|
51
|
+
Only applies filters whose fields exist in the query's tables.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
query: The widget's base query definition.
|
|
55
|
+
dashboard_filters: Dashboard filter definitions.
|
|
56
|
+
filter_values: Current filter values from the UI.
|
|
57
|
+
schema: Database schema for column lookup.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
New QueryDefinition with filters merged.
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> merged = merge_filters(
|
|
64
|
+
... query=widget.query,
|
|
65
|
+
... dashboard_filters=dashboard.filters,
|
|
66
|
+
... filter_values=[FilterValue(filter_id="f1", value="active")],
|
|
67
|
+
... schema=schema,
|
|
68
|
+
... )
|
|
69
|
+
"""
|
|
70
|
+
# Get applicable filters (those whose columns exist in the query)
|
|
71
|
+
applicable = get_applicable_filters(query, dashboard_filters, schema)
|
|
72
|
+
|
|
73
|
+
# Create filter value lookup
|
|
74
|
+
value_map = {fv.filter_id: fv for fv in filter_values}
|
|
75
|
+
|
|
76
|
+
# Convert dashboard filters to query filters
|
|
77
|
+
new_filters: list[FilterDefinition] = []
|
|
78
|
+
for dash_filter in applicable:
|
|
79
|
+
filter_value = value_map.get(dash_filter.id)
|
|
80
|
+
if filter_value is None:
|
|
81
|
+
# Use default value if no runtime value provided
|
|
82
|
+
if dash_filter.default_value is not None:
|
|
83
|
+
filter_value = FilterValue(
|
|
84
|
+
filter_id=dash_filter.id, value=dash_filter.default_value
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Convert to query filter(s)
|
|
90
|
+
query_filters = filter_to_query_filters(dash_filter, filter_value, query, schema)
|
|
91
|
+
new_filters.extend(query_filters)
|
|
92
|
+
|
|
93
|
+
if not new_filters:
|
|
94
|
+
return query
|
|
95
|
+
|
|
96
|
+
# Deep copy the query and add new filters
|
|
97
|
+
return QueryDefinition(
|
|
98
|
+
tables=copy.deepcopy(query.tables),
|
|
99
|
+
joins=copy.deepcopy(query.joins),
|
|
100
|
+
columns=copy.deepcopy(query.columns),
|
|
101
|
+
filters=[*copy.deepcopy(query.filters), *new_filters],
|
|
102
|
+
group_by=copy.deepcopy(query.group_by),
|
|
103
|
+
order_by=copy.deepcopy(query.order_by),
|
|
104
|
+
limit=query.limit,
|
|
105
|
+
offset=query.offset,
|
|
106
|
+
time_series=copy.deepcopy(query.time_series) if query.time_series else None,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def filter_to_query_filter(
|
|
111
|
+
dashboard_filter: DashboardFilter,
|
|
112
|
+
value: FilterValue,
|
|
113
|
+
) -> FilterDefinition | None:
|
|
114
|
+
"""Convert a dashboard filter to a single query filter.
|
|
115
|
+
|
|
116
|
+
This is a simplified version that doesn't resolve table IDs.
|
|
117
|
+
Use filter_to_query_filters for full functionality.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
dashboard_filter: The dashboard filter definition.
|
|
121
|
+
value: The runtime filter value.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A FilterDefinition, or None if filter shouldn't be applied.
|
|
125
|
+
"""
|
|
126
|
+
# Handle empty or "all" values
|
|
127
|
+
if value.value is None:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
if dashboard_filter.type == DashboardFilterType.SELECT:
|
|
131
|
+
if value.value == "" or value.value == "__all__":
|
|
132
|
+
return None
|
|
133
|
+
return FilterDefinition(
|
|
134
|
+
table_id="", # Must be resolved by caller
|
|
135
|
+
column=dashboard_filter.field,
|
|
136
|
+
operator=FilterOperator.EQ,
|
|
137
|
+
value=value.value,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if dashboard_filter.type == DashboardFilterType.MULTI_SELECT:
|
|
141
|
+
if not value.value or len(value.value) == 0:
|
|
142
|
+
return None
|
|
143
|
+
return FilterDefinition(
|
|
144
|
+
table_id="",
|
|
145
|
+
column=dashboard_filter.field,
|
|
146
|
+
operator=FilterOperator.IN,
|
|
147
|
+
value=value.value,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if dashboard_filter.type == DashboardFilterType.TEXT:
|
|
151
|
+
if not value.value or value.value == "":
|
|
152
|
+
return None
|
|
153
|
+
return FilterDefinition(
|
|
154
|
+
table_id="",
|
|
155
|
+
column=dashboard_filter.field,
|
|
156
|
+
operator=FilterOperator.ILIKE,
|
|
157
|
+
value=f"%{value.value}%",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Date and number ranges require multiple filters, handled elsewhere
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def filter_to_query_filters(
|
|
165
|
+
dashboard_filter: DashboardFilter,
|
|
166
|
+
value: FilterValue,
|
|
167
|
+
query: QueryDefinition,
|
|
168
|
+
schema: DatabaseSchema,
|
|
169
|
+
) -> list[FilterDefinition]:
|
|
170
|
+
"""Convert a dashboard filter to query filter(s).
|
|
171
|
+
|
|
172
|
+
Handles date ranges (which need two filters) and resolves table IDs.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
dashboard_filter: The dashboard filter definition.
|
|
176
|
+
value: The runtime filter value.
|
|
177
|
+
query: The query to merge into.
|
|
178
|
+
schema: Database schema for column lookup.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
List of FilterDefinition objects (may be empty, one, or two).
|
|
182
|
+
"""
|
|
183
|
+
# Find the table containing this column
|
|
184
|
+
table_id = _find_table_for_column(query, dashboard_filter.field, dashboard_filter.table, schema)
|
|
185
|
+
if table_id is None:
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
# Handle empty or null values
|
|
189
|
+
if value.value is None:
|
|
190
|
+
return []
|
|
191
|
+
|
|
192
|
+
filters: list[FilterDefinition] = []
|
|
193
|
+
|
|
194
|
+
if dashboard_filter.type == DashboardFilterType.DATE_RANGE:
|
|
195
|
+
date_range = resolve_date_filter(dashboard_filter, value)
|
|
196
|
+
if date_range:
|
|
197
|
+
start_date, end_date = date_range
|
|
198
|
+
filters.append(
|
|
199
|
+
FilterDefinition(
|
|
200
|
+
table_id=table_id,
|
|
201
|
+
column=dashboard_filter.field,
|
|
202
|
+
operator=FilterOperator.GTE,
|
|
203
|
+
value=start_date.isoformat(),
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
filters.append(
|
|
207
|
+
FilterDefinition(
|
|
208
|
+
table_id=table_id,
|
|
209
|
+
column=dashboard_filter.field,
|
|
210
|
+
operator=FilterOperator.LTE,
|
|
211
|
+
value=end_date.isoformat(),
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
elif dashboard_filter.type == DashboardFilterType.SELECT:
|
|
216
|
+
if value.value and value.value != "" and value.value != "__all__":
|
|
217
|
+
filters.append(
|
|
218
|
+
FilterDefinition(
|
|
219
|
+
table_id=table_id,
|
|
220
|
+
column=dashboard_filter.field,
|
|
221
|
+
operator=FilterOperator.EQ,
|
|
222
|
+
value=value.value,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
elif dashboard_filter.type == DashboardFilterType.MULTI_SELECT:
|
|
227
|
+
if value.value and len(value.value) > 0:
|
|
228
|
+
filters.append(
|
|
229
|
+
FilterDefinition(
|
|
230
|
+
table_id=table_id,
|
|
231
|
+
column=dashboard_filter.field,
|
|
232
|
+
operator=FilterOperator.IN,
|
|
233
|
+
value=value.value,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
elif dashboard_filter.type == DashboardFilterType.TEXT:
|
|
238
|
+
if value.value and value.value != "":
|
|
239
|
+
filters.append(
|
|
240
|
+
FilterDefinition(
|
|
241
|
+
table_id=table_id,
|
|
242
|
+
column=dashboard_filter.field,
|
|
243
|
+
operator=FilterOperator.ILIKE,
|
|
244
|
+
value=f"%{value.value}%",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
elif dashboard_filter.type == DashboardFilterType.NUMBER_RANGE:
|
|
249
|
+
number_range = value.value
|
|
250
|
+
if isinstance(number_range, dict):
|
|
251
|
+
if "min" in number_range and number_range["min"] is not None:
|
|
252
|
+
filters.append(
|
|
253
|
+
FilterDefinition(
|
|
254
|
+
table_id=table_id,
|
|
255
|
+
column=dashboard_filter.field,
|
|
256
|
+
operator=FilterOperator.GTE,
|
|
257
|
+
value=number_range["min"],
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
if "max" in number_range and number_range["max"] is not None:
|
|
261
|
+
filters.append(
|
|
262
|
+
FilterDefinition(
|
|
263
|
+
table_id=table_id,
|
|
264
|
+
column=dashboard_filter.field,
|
|
265
|
+
operator=FilterOperator.LTE,
|
|
266
|
+
value=number_range["max"],
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return filters
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_applicable_filters(
|
|
274
|
+
query: QueryDefinition,
|
|
275
|
+
dashboard_filters: list[DashboardFilter],
|
|
276
|
+
schema: DatabaseSchema,
|
|
277
|
+
) -> list[DashboardFilter]:
|
|
278
|
+
"""Get filters that apply to a specific query.
|
|
279
|
+
|
|
280
|
+
Only returns filters whose field exists in one of the query's tables.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
query: The widget's query definition.
|
|
284
|
+
dashboard_filters: Dashboard filter definitions.
|
|
285
|
+
schema: Database schema for column lookup.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
List of applicable DashboardFilter objects.
|
|
289
|
+
"""
|
|
290
|
+
applicable: list[DashboardFilter] = []
|
|
291
|
+
|
|
292
|
+
for dash_filter in dashboard_filters:
|
|
293
|
+
table_id = _find_table_for_column(query, dash_filter.field, dash_filter.table, schema)
|
|
294
|
+
if table_id is not None:
|
|
295
|
+
applicable.append(dash_filter)
|
|
296
|
+
|
|
297
|
+
return applicable
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def resolve_date_filter(
|
|
301
|
+
filter_def: DashboardFilter,
|
|
302
|
+
value: FilterValue,
|
|
303
|
+
) -> tuple[date, date] | None:
|
|
304
|
+
"""Resolve a date range filter value to concrete dates.
|
|
305
|
+
|
|
306
|
+
Handles both preset values (like "last_30_days") and explicit date ranges.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
filter_def: The dashboard filter definition.
|
|
310
|
+
value: The runtime filter value.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Tuple of (start_date, end_date), or None if cannot be resolved.
|
|
314
|
+
"""
|
|
315
|
+
filter_value = value.value
|
|
316
|
+
|
|
317
|
+
if filter_value is None:
|
|
318
|
+
# Try using the filter's date_preset default
|
|
319
|
+
if filter_def.date_preset:
|
|
320
|
+
preset = _str_to_date_preset(filter_def.date_preset)
|
|
321
|
+
if preset:
|
|
322
|
+
return resolve_date_preset(preset)
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
# Handle string preset values
|
|
326
|
+
if isinstance(filter_value, str):
|
|
327
|
+
preset = _str_to_date_preset(filter_value)
|
|
328
|
+
if preset:
|
|
329
|
+
return resolve_date_preset(preset)
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
# Handle dict with explicit start/end
|
|
333
|
+
if isinstance(filter_value, dict):
|
|
334
|
+
# Check for preset in dict
|
|
335
|
+
if "preset" in filter_value:
|
|
336
|
+
preset = _str_to_date_preset(filter_value["preset"])
|
|
337
|
+
if preset:
|
|
338
|
+
return resolve_date_preset(preset)
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
# Check for explicit start/end dates
|
|
342
|
+
start_str = filter_value.get("start")
|
|
343
|
+
end_str = filter_value.get("end")
|
|
344
|
+
|
|
345
|
+
if start_str and end_str:
|
|
346
|
+
try:
|
|
347
|
+
start_date = date.fromisoformat(str(start_str))
|
|
348
|
+
end_date = date.fromisoformat(str(end_str))
|
|
349
|
+
return (start_date, end_date)
|
|
350
|
+
except ValueError:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _str_to_date_preset(value: str) -> DatePreset | None:
|
|
357
|
+
"""Convert a string to a DatePreset enum value.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
value: The string value to convert.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The DatePreset enum value, or None if invalid.
|
|
364
|
+
"""
|
|
365
|
+
try:
|
|
366
|
+
return DatePreset(value)
|
|
367
|
+
except ValueError:
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _find_table_for_column(
|
|
372
|
+
query: QueryDefinition,
|
|
373
|
+
column_name: str,
|
|
374
|
+
table_hint: str | None,
|
|
375
|
+
schema: DatabaseSchema,
|
|
376
|
+
) -> str | None:
|
|
377
|
+
"""Find the table ID containing a specific column.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
query: The query to search in.
|
|
381
|
+
column_name: Name of the column to find.
|
|
382
|
+
table_hint: Optional table name hint from the filter.
|
|
383
|
+
schema: Database schema for column lookup.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
The table ID containing the column, or None if not found.
|
|
387
|
+
"""
|
|
388
|
+
for query_table in query.tables:
|
|
389
|
+
# If table hint is provided, only check that table
|
|
390
|
+
if table_hint and query_table.name != table_hint:
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
table_schema = schema.get_table(query_table.name)
|
|
394
|
+
if table_schema and table_schema.has_column(column_name):
|
|
395
|
+
return query_table.id
|
|
396
|
+
|
|
397
|
+
return None
|
prismiq/formatting.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Number formatting utilities for Prismiq analytics.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for formatting numbers in various styles
|
|
4
|
+
commonly used in dashboards and reports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NumberFormat(str, Enum):
|
|
15
|
+
"""Number formatting styles."""
|
|
16
|
+
|
|
17
|
+
PLAIN = "plain" # 1234567.89
|
|
18
|
+
COMPACT = "compact" # 1.2M
|
|
19
|
+
CURRENCY = "currency" # $1,234,567.89
|
|
20
|
+
PERCENT = "percent" # 12.34%
|
|
21
|
+
FIXED = "fixed" # 1234567.89 (with specified decimals)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_number(
|
|
25
|
+
value: int | float | Decimal | None,
|
|
26
|
+
format_type: NumberFormat = NumberFormat.PLAIN,
|
|
27
|
+
decimals: int = 2,
|
|
28
|
+
currency_symbol: str = "$",
|
|
29
|
+
compact_threshold: int = 1000,
|
|
30
|
+
locale: str = "en_US",
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Format a number according to the specified style.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
value: The number to format. None returns empty string.
|
|
36
|
+
format_type: The formatting style to use.
|
|
37
|
+
decimals: Number of decimal places (used by FIXED, CURRENCY, PERCENT).
|
|
38
|
+
currency_symbol: Symbol for currency formatting.
|
|
39
|
+
compact_threshold: Minimum value for compact notation.
|
|
40
|
+
locale: Locale for number formatting (currently supports en_US).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Formatted string representation.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> format_number(1234567.89, NumberFormat.CURRENCY)
|
|
47
|
+
'$1,234,567.89'
|
|
48
|
+
>>> format_number(1234567, NumberFormat.COMPACT)
|
|
49
|
+
'1.2M'
|
|
50
|
+
"""
|
|
51
|
+
if value is None:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
# Convert Decimal to float for calculations
|
|
55
|
+
num = float(value)
|
|
56
|
+
|
|
57
|
+
# Handle NaN
|
|
58
|
+
if math.isnan(num):
|
|
59
|
+
return "N/A"
|
|
60
|
+
|
|
61
|
+
# Handle infinity
|
|
62
|
+
if math.isinf(num):
|
|
63
|
+
return "Infinity" if num > 0 else "-Infinity"
|
|
64
|
+
|
|
65
|
+
if format_type == NumberFormat.PLAIN:
|
|
66
|
+
return _format_plain(num)
|
|
67
|
+
|
|
68
|
+
if format_type == NumberFormat.COMPACT:
|
|
69
|
+
if abs(num) < compact_threshold:
|
|
70
|
+
return _format_with_separators(num, 0 if num == int(num) else decimals, locale)
|
|
71
|
+
return format_compact(num, decimals=1)
|
|
72
|
+
|
|
73
|
+
if format_type == NumberFormat.CURRENCY:
|
|
74
|
+
return format_currency(num, symbol=currency_symbol, decimals=decimals, locale=locale)
|
|
75
|
+
|
|
76
|
+
if format_type == NumberFormat.PERCENT:
|
|
77
|
+
return format_percent(num, decimals=decimals)
|
|
78
|
+
|
|
79
|
+
if format_type == NumberFormat.FIXED:
|
|
80
|
+
return _format_with_separators(num, decimals, locale)
|
|
81
|
+
|
|
82
|
+
# Fallback
|
|
83
|
+
return str(num)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def format_compact(value: float, decimals: int = 1) -> str:
|
|
87
|
+
"""Format number in compact notation (K, M, B, T).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
value: The number to format.
|
|
91
|
+
decimals: Number of decimal places.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Compact string representation (e.g., "1.2M").
|
|
95
|
+
|
|
96
|
+
Example:
|
|
97
|
+
>>> format_compact(1234567)
|
|
98
|
+
'1.2M'
|
|
99
|
+
>>> format_compact(-1500)
|
|
100
|
+
'-1.5K'
|
|
101
|
+
"""
|
|
102
|
+
if math.isnan(value):
|
|
103
|
+
return "N/A"
|
|
104
|
+
|
|
105
|
+
if math.isinf(value):
|
|
106
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
107
|
+
|
|
108
|
+
abs_value = abs(value)
|
|
109
|
+
sign = "-" if value < 0 else ""
|
|
110
|
+
|
|
111
|
+
if abs_value < 1000:
|
|
112
|
+
if abs_value == int(abs_value):
|
|
113
|
+
return f"{sign}{int(abs_value)}"
|
|
114
|
+
return f"{sign}{abs_value:.{decimals}f}"
|
|
115
|
+
|
|
116
|
+
if abs_value < 1_000_000:
|
|
117
|
+
result = abs_value / 1000
|
|
118
|
+
return f"{sign}{result:.{decimals}f}K"
|
|
119
|
+
|
|
120
|
+
if abs_value < 1_000_000_000:
|
|
121
|
+
result = abs_value / 1_000_000
|
|
122
|
+
return f"{sign}{result:.{decimals}f}M"
|
|
123
|
+
|
|
124
|
+
if abs_value < 1_000_000_000_000:
|
|
125
|
+
result = abs_value / 1_000_000_000
|
|
126
|
+
return f"{sign}{result:.{decimals}f}B"
|
|
127
|
+
|
|
128
|
+
result = abs_value / 1_000_000_000_000
|
|
129
|
+
return f"{sign}{result:.{decimals}f}T"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_currency(
|
|
133
|
+
value: float,
|
|
134
|
+
symbol: str = "$",
|
|
135
|
+
decimals: int = 2,
|
|
136
|
+
locale: str = "en_US",
|
|
137
|
+
) -> str:
|
|
138
|
+
"""Format as currency with thousands separators.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
value: The number to format.
|
|
142
|
+
symbol: Currency symbol (e.g., "$", "EUR").
|
|
143
|
+
decimals: Number of decimal places.
|
|
144
|
+
locale: Locale for number formatting.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Currency formatted string (e.g., "$1,234.56").
|
|
148
|
+
|
|
149
|
+
Example:
|
|
150
|
+
>>> format_currency(1234.56)
|
|
151
|
+
'$1,234.56'
|
|
152
|
+
>>> format_currency(-1234.56, symbol="EUR")
|
|
153
|
+
'-EUR1,234.56'
|
|
154
|
+
"""
|
|
155
|
+
if math.isnan(value):
|
|
156
|
+
return "N/A"
|
|
157
|
+
|
|
158
|
+
if math.isinf(value):
|
|
159
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
160
|
+
|
|
161
|
+
sign = "-" if value < 0 else ""
|
|
162
|
+
formatted = _format_with_separators(abs(value), decimals, locale)
|
|
163
|
+
return f"{sign}{symbol}{formatted}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def format_percent(value: float, decimals: int = 2) -> str:
|
|
167
|
+
"""Format as percentage.
|
|
168
|
+
|
|
169
|
+
The value is expected to be a ratio (e.g., 0.1234 for 12.34%).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
value: The ratio to format (0.1234 becomes 12.34%).
|
|
173
|
+
decimals: Number of decimal places.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Percentage string (e.g., "12.34%").
|
|
177
|
+
|
|
178
|
+
Example:
|
|
179
|
+
>>> format_percent(0.1234)
|
|
180
|
+
'12.34%'
|
|
181
|
+
>>> format_percent(1.5)
|
|
182
|
+
'150.00%'
|
|
183
|
+
"""
|
|
184
|
+
if math.isnan(value):
|
|
185
|
+
return "N/A"
|
|
186
|
+
|
|
187
|
+
if math.isinf(value):
|
|
188
|
+
return "Infinity%" if value > 0 else "-Infinity%"
|
|
189
|
+
|
|
190
|
+
percent = value * 100
|
|
191
|
+
return f"{percent:.{decimals}f}%"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_number(value: str) -> float | None:
|
|
195
|
+
"""Parse a formatted number string back to float.
|
|
196
|
+
|
|
197
|
+
Handles common formats including currency symbols, percentage signs,
|
|
198
|
+
thousands separators, and compact notation.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
value: The formatted string to parse.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The numeric value, or None if parsing fails.
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
>>> parse_number("$1,234.56")
|
|
208
|
+
1234.56
|
|
209
|
+
>>> parse_number("1.5M")
|
|
210
|
+
1500000.0
|
|
211
|
+
>>> parse_number("12.34%")
|
|
212
|
+
0.1234
|
|
213
|
+
"""
|
|
214
|
+
if not value or value.strip() == "":
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
original = value.strip()
|
|
218
|
+
|
|
219
|
+
# Handle special values
|
|
220
|
+
if original.lower() in ("n/a", "nan"):
|
|
221
|
+
return float("nan")
|
|
222
|
+
if original.lower() == "infinity":
|
|
223
|
+
return float("inf")
|
|
224
|
+
if original.lower() == "-infinity":
|
|
225
|
+
return float("-inf")
|
|
226
|
+
|
|
227
|
+
# Check for negative
|
|
228
|
+
is_negative = original.startswith("-")
|
|
229
|
+
if is_negative:
|
|
230
|
+
original = original[1:]
|
|
231
|
+
|
|
232
|
+
# Handle percentage
|
|
233
|
+
if original.endswith("%"):
|
|
234
|
+
try:
|
|
235
|
+
num = float(original[:-1].replace(",", ""))
|
|
236
|
+
result = num / 100
|
|
237
|
+
return -result if is_negative else result
|
|
238
|
+
except ValueError:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
# Remove currency symbols (common ones)
|
|
242
|
+
currency_symbols = ["$", "EUR", "GBP", "JPY", "CNY"]
|
|
243
|
+
for sym in currency_symbols:
|
|
244
|
+
if original.startswith(sym):
|
|
245
|
+
original = original[len(sym) :]
|
|
246
|
+
break
|
|
247
|
+
# Handle symbol at end (some locales)
|
|
248
|
+
if original.endswith(sym):
|
|
249
|
+
original = original[: -len(sym)]
|
|
250
|
+
break
|
|
251
|
+
|
|
252
|
+
original = original.strip()
|
|
253
|
+
|
|
254
|
+
# Handle compact notation
|
|
255
|
+
compact_suffixes = {
|
|
256
|
+
"K": 1_000,
|
|
257
|
+
"M": 1_000_000,
|
|
258
|
+
"B": 1_000_000_000,
|
|
259
|
+
"T": 1_000_000_000_000,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for suffix, multiplier in compact_suffixes.items():
|
|
263
|
+
if original.upper().endswith(suffix):
|
|
264
|
+
try:
|
|
265
|
+
num = float(original[:-1].replace(",", "")) * multiplier
|
|
266
|
+
return -num if is_negative else num
|
|
267
|
+
except ValueError:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
# Remove thousands separators and parse
|
|
271
|
+
try:
|
|
272
|
+
clean = original.replace(",", "")
|
|
273
|
+
num = float(clean)
|
|
274
|
+
return -num if is_negative else num
|
|
275
|
+
except ValueError:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _format_plain(value: float) -> str:
|
|
280
|
+
"""Format as plain number without separators."""
|
|
281
|
+
if value == int(value):
|
|
282
|
+
return str(int(value))
|
|
283
|
+
return str(value)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _format_with_separators(value: float, decimals: int, locale: str) -> str:
|
|
287
|
+
"""Format number with thousands separators."""
|
|
288
|
+
# For en_US locale (and default), use comma as thousands separator
|
|
289
|
+
if locale in ("en_US", "en_GB", "en"):
|
|
290
|
+
if decimals == 0:
|
|
291
|
+
return f"{value:,.0f}"
|
|
292
|
+
return f"{value:,.{decimals}f}"
|
|
293
|
+
|
|
294
|
+
# For other locales, use simple formatting
|
|
295
|
+
# (Full locale support would require the locale module)
|
|
296
|
+
if decimals == 0:
|
|
297
|
+
return f"{value:,.0f}"
|
|
298
|
+
return f"{value:,.{decimals}f}"
|