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.
@@ -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}"