Added classes to support formatting
This commit is contained in:
@@ -1,5 +1,32 @@
|
|||||||
# DataGrid Formatting
|
# DataGrid Formatting
|
||||||
|
|
||||||
|
## Implementation Status
|
||||||
|
|
||||||
|
| Component | Status | Location |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| **Core Module** | | `src/myfasthtml/core/formatting/` |
|
||||||
|
| Dataclasses (Condition, Style, Formatter, FormatRule) | :white_check_mark: Implemented | `dataclasses.py` |
|
||||||
|
| Style Presets (DaisyUI 5) | :white_check_mark: Implemented | `presets.py` |
|
||||||
|
| Formatter Presets (EUR, USD, etc.) | :white_check_mark: Implemented | `presets.py` |
|
||||||
|
| ConditionEvaluator (12 operators) | :white_check_mark: Implemented | `condition_evaluator.py` |
|
||||||
|
| StyleResolver | :white_check_mark: Implemented | `style_resolver.py` |
|
||||||
|
| FormatterResolver (Number, Date, Boolean, Text, Enum) | :white_check_mark: Implemented | `formatter_resolver.py` |
|
||||||
|
| FormattingEngine (facade + conflict resolution) | :white_check_mark: Implemented | `engine.py` |
|
||||||
|
| **Condition Features** | | |
|
||||||
|
| `col` parameter (row-level conditions) | :white_check_mark: Implemented | |
|
||||||
|
| `row` parameter (column-level conditions) | :x: Not implemented | |
|
||||||
|
| Column reference in value `{"col": "..."}` | :white_check_mark: Implemented | |
|
||||||
|
| **DataGrid Integration** | | |
|
||||||
|
| Integration in `mk_body_cell_content()` | :x: Not implemented | |
|
||||||
|
| DataGridsManager (global presets) | :white_check_mark: Implemented | `DataGridsManager.py` |
|
||||||
|
| **Tests** | | `tests/core/formatting/` |
|
||||||
|
| test_condition_evaluator.py | :white_check_mark: ~45 test cases | |
|
||||||
|
| test_style_resolver.py | :white_check_mark: ~12 test cases | |
|
||||||
|
| test_formatter_resolver.py | :white_check_mark: ~40 test cases | |
|
||||||
|
| test_engine.py | :white_check_mark: ~18 test cases | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This document describes the formatting capabilities for the DataGrid component.
|
This document describes the formatting capabilities for the DataGrid component.
|
||||||
@@ -79,17 +106,19 @@ For `value = -5`: Rule 3 wins (same specificity as rule 2, but defined later).
|
|||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
| Field | Type | Default | Required | Description |
|
| Field | Type | Default | Required | Description | Status |
|
||||||
|------------------|------------------------|---------|----------|---------------------------------------------|
|
|------------------|------------------------|---------|----------|---------------------------------------------|--------|
|
||||||
| `operator` | string | - | Yes | Comparison operator |
|
| `operator` | string | - | Yes | Comparison operator | :white_check_mark: |
|
||||||
| `value` | scalar / list / object | - | Depends | Value to compare against |
|
| `value` | scalar / list / object | - | Depends | Value to compare against | :white_check_mark: |
|
||||||
| `not` | bool | `false` | No | Inverts the condition result |
|
| `not` | bool | `false` | No | Inverts the condition result | :white_check_mark: (as `negate`) |
|
||||||
| `case_sensitive` | bool | `false` | No | Case-sensitive string comparison |
|
| `case_sensitive` | bool | `false` | No | Case-sensitive string comparison | :white_check_mark: |
|
||||||
| `col` | string | - | No | Reference column (for row-level conditions) |
|
| `col` | string | - | No | Reference column (for row-level conditions) | :white_check_mark: |
|
||||||
| `row` | int | - | No | Reference row (for column-level conditions) |
|
| `row` | int | - | No | Reference row (for column-level conditions) | :x: Not implemented |
|
||||||
|
|
||||||
### Operators
|
### Operators
|
||||||
|
|
||||||
|
All operators are :white_check_mark: **implemented**.
|
||||||
|
|
||||||
| Operator | Description | Value Required |
|
| Operator | Description | Value Required |
|
||||||
|--------------|--------------------------|------------------|
|
|--------------|--------------------------|------------------|
|
||||||
| `==` | Equal | Yes |
|
| `==` | Equal | Yes |
|
||||||
@@ -217,6 +246,8 @@ String comparisons are **case-insensitive by default**.
|
|||||||
|
|
||||||
## Style Structure
|
## Style Structure
|
||||||
|
|
||||||
|
:white_check_mark: **Fully implemented** in `style_resolver.py`
|
||||||
|
|
||||||
### Fields
|
### Fields
|
||||||
|
|
||||||
| Field | Type | Default | Description |
|
| Field | Type | Default | Description |
|
||||||
@@ -292,6 +323,8 @@ If formatting fails (e.g., non-numeric value for `number` formatter), display `"
|
|||||||
|
|
||||||
## Formatter Types
|
## Formatter Types
|
||||||
|
|
||||||
|
All formatter types are :white_check_mark: **implemented** in `formatter_resolver.py`.
|
||||||
|
|
||||||
### `number`
|
### `number`
|
||||||
|
|
||||||
For numbers, currencies, and percentages.
|
For numbers, currencies, and percentages.
|
||||||
@@ -347,7 +380,7 @@ For mapping values to display labels. Also used for Select dropdowns.
|
|||||||
|
|
||||||
#### Source Types
|
#### Source Types
|
||||||
|
|
||||||
**Static mapping:**
|
**Static mapping:** :white_check_mark: Implemented
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -364,7 +397,7 @@ For mapping values to display labels. Also used for Select dropdowns.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**From another DataGrid:**
|
**From another DataGrid:** :white_check_mark: Implemented (requires `lookup_resolver` injection)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -429,13 +462,15 @@ formatter_presets = {
|
|||||||
|
|
||||||
## Storage Architecture
|
## Storage Architecture
|
||||||
|
|
||||||
|
:warning: **Structures exist but integration with formatting engine not implemented**
|
||||||
|
|
||||||
### Format Storage Location
|
### Format Storage Location
|
||||||
|
|
||||||
| Level | Storage | Key |
|
| Level | Storage | Key | Status |
|
||||||
|------------|------------------------------|---------|
|
|------------|------------------------------|---------|--------|
|
||||||
| **Column** | `DataGridColumnState.format` | - |
|
| **Column** | `DataGridColumnState.format` | - | Structure exists |
|
||||||
| **Row** | `DataGridRowState.format` | - |
|
| **Row** | `DataGridRowState.format` | - | Structure exists |
|
||||||
| **Cell** | `DatagridState.cell_formats` | Cell ID |
|
| **Cell** | `DatagridState.cell_formats` | Cell ID | Structure exists |
|
||||||
|
|
||||||
### Cell ID Format
|
### Cell ID Format
|
||||||
|
|
||||||
@@ -447,18 +482,42 @@ tcell_{datagrid_id}-{row_index}-{col_index}
|
|||||||
|
|
||||||
## DataGridsManager
|
## DataGridsManager
|
||||||
|
|
||||||
Global settings stored in `DataGridsManager`:
|
:white_check_mark: **Implemented** in `src/myfasthtml/controls/DataGridsManager.py`
|
||||||
|
|
||||||
| Property | Type | Description |
|
Global presets stored as instance attributes:
|
||||||
|---------------------|--------|-------------------------------------------|
|
|
||||||
| `style_presets` | dict | Style presets (primary, success, etc.) |
|
| Property | Type | Description | Status |
|
||||||
| `formatter_presets` | dict | Formatter presets (EUR, percentage, etc.) |
|
|---------------------|--------|-------------------------------------------|--------|
|
||||||
| `default_locale` | string | Default locale for number/date formatting |
|
| `style_presets` | dict | Style presets (primary, success, etc.) | :white_check_mark: |
|
||||||
|
| `formatter_presets` | dict | Formatter presets (EUR, percentage, etc.) | :white_check_mark: |
|
||||||
|
| `default_locale` | string | Default locale for number/date formatting | :x: Not implemented |
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `get_style_presets()` | Get the global style presets |
|
||||||
|
| `get_formatter_presets()` | Get the global formatter presets |
|
||||||
|
| `add_style_preset(name, preset)` | Add or update a style preset |
|
||||||
|
| `add_formatter_preset(name, preset)` | Add or update a formatter preset |
|
||||||
|
| `remove_style_preset(name)` | Remove a style preset |
|
||||||
|
| `remove_formatter_preset(name)` | Remove a formatter preset |
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add custom presets
|
||||||
|
manager.add_style_preset("highlight", {"background-color": "yellow", "color": "black"})
|
||||||
|
manager.add_formatter_preset("CHF", {"type": "number", "prefix": "CHF ", "precision": 2})
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Considerations
|
## Future Considerations
|
||||||
|
|
||||||
|
All items below are :x: **not implemented**.
|
||||||
|
|
||||||
|
- **`row` parameter for column-level conditions**: Evaluate condition on a specific row
|
||||||
- **AND/OR conditions**: Add explicit `and`/`or` operators if `between`/`in` prove insufficient
|
- **AND/OR conditions**: Add explicit `and`/`or` operators if `between`/`in` prove insufficient
|
||||||
- **Cell references**: Extend to `{"col": "x", "row": 0}` for specific cell and `{"col": "x", "row_offset": -1}` for
|
- **Cell references**: Extend to `{"col": "x", "row": 0}` for specific cell and `{"col": "x", "row_offset": -1}` for
|
||||||
relative references
|
relative references
|
||||||
@@ -478,3 +537,4 @@ Global settings stored in `DataGridsManager`:
|
|||||||
- **API source for enum**: `{"type": "api", "value": "https://...", ...}`
|
- **API source for enum**: `{"type": "api", "value": "https://...", ...}`
|
||||||
- **Searchable enum**: For large option lists
|
- **Searchable enum**: For large option lists
|
||||||
- **Formatter chaining**: Apply multiple formatters in sequence
|
- **Formatter chaining**: Apply multiple formatters in sequence
|
||||||
|
- **DataGrid integration**: Connect `FormattingEngine` to `DataGrid.mk_body_cell_content()`
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from myfasthtml.controls.TreeView import TreeView, TreeNode
|
|||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
|
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
|
||||||
from myfasthtml.core.instances import MultipleInstance, InstancesManager
|
from myfasthtml.core.instances import MultipleInstance, InstancesManager
|
||||||
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
||||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||||
@@ -71,6 +72,7 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class DataGridsManager(MultipleInstance):
|
class DataGridsManager(MultipleInstance):
|
||||||
|
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
if not getattr(self, "_is_new_instance", False):
|
if not getattr(self, "_is_new_instance", False):
|
||||||
# Skip __init__ if instance already existed
|
# Skip __init__ if instance already existed
|
||||||
@@ -81,6 +83,10 @@ class DataGridsManager(MultipleInstance):
|
|||||||
self._tree = self._mk_tree()
|
self._tree = self._mk_tree()
|
||||||
self._tree.bind_command("SelectNode", self.commands.show_document())
|
self._tree.bind_command("SelectNode", self.commands.show_document())
|
||||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||||
|
|
||||||
|
# Global presets shared across all DataGrids
|
||||||
|
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
|
||||||
|
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
|
||||||
|
|
||||||
def upload_from_source(self):
|
def upload_from_source(self):
|
||||||
file_upload = FileUpload(self)
|
file_upload = FileUpload(self)
|
||||||
@@ -143,6 +149,46 @@ class DataGridsManager(MultipleInstance):
|
|||||||
self._tree.clear()
|
self._tree.clear()
|
||||||
return self._tree
|
return self._tree
|
||||||
|
|
||||||
|
# === Presets Management ===
|
||||||
|
|
||||||
|
def get_style_presets(self) -> dict:
|
||||||
|
"""Get the global style presets."""
|
||||||
|
return self.style_presets
|
||||||
|
|
||||||
|
def get_formatter_presets(self) -> dict:
|
||||||
|
"""Get the global formatter presets."""
|
||||||
|
return self.formatter_presets
|
||||||
|
|
||||||
|
def add_style_preset(self, name: str, preset: dict):
|
||||||
|
"""
|
||||||
|
Add or update a style preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Preset name (e.g., "custom_highlight")
|
||||||
|
preset: Dict with CSS properties (e.g., {"background-color": "yellow", "color": "black"})
|
||||||
|
"""
|
||||||
|
self.style_presets[name] = preset
|
||||||
|
|
||||||
|
def add_formatter_preset(self, name: str, preset: dict):
|
||||||
|
"""
|
||||||
|
Add or update a formatter preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Preset name (e.g., "custom_currency")
|
||||||
|
preset: Dict with formatter config (e.g., {"type": "number", "prefix": "CHF ", "precision": 2})
|
||||||
|
"""
|
||||||
|
self.formatter_presets[name] = preset
|
||||||
|
|
||||||
|
def remove_style_preset(self, name: str):
|
||||||
|
"""Remove a style preset."""
|
||||||
|
if name in self.style_presets:
|
||||||
|
del self.style_presets[name]
|
||||||
|
|
||||||
|
def remove_formatter_preset(self, name: str):
|
||||||
|
"""Remove a formatter preset."""
|
||||||
|
if name in self.formatter_presets:
|
||||||
|
del self.formatter_presets[name]
|
||||||
|
|
||||||
def mk_main_icons(self):
|
def mk_main_icons(self):
|
||||||
return Div(
|
return Div(
|
||||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||||
|
|||||||
1
src/myfasthtml/core/formatting/__init__.py
Normal file
1
src/myfasthtml/core/formatting/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Formatting module for DataGrid
|
||||||
191
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
191
src/myfasthtml/core/formatting/condition_evaluator.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.dataclasses import Condition
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionEvaluator:
|
||||||
|
"""Evaluates conditions against cell values."""
|
||||||
|
|
||||||
|
def evaluate(self, condition: Condition, cell_value: Any, row_data: dict = None) -> bool:
|
||||||
|
"""
|
||||||
|
Evaluate a condition against a cell value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
condition: The condition to evaluate
|
||||||
|
cell_value: The value of the current cell
|
||||||
|
row_data: Dict of {col_id: value} for column references (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if condition is met, False otherwise
|
||||||
|
"""
|
||||||
|
# If col parameter is set, use that column's value instead of cell_value
|
||||||
|
if condition.col is not None:
|
||||||
|
if row_data is None or condition.col not in row_data:
|
||||||
|
return self._apply_negate(False, condition.negate)
|
||||||
|
cell_value = row_data[condition.col]
|
||||||
|
|
||||||
|
# Handle isempty/isnotempty first (they work with None)
|
||||||
|
if condition.operator == "isempty":
|
||||||
|
result = self._is_empty(cell_value)
|
||||||
|
return self._apply_negate(result, condition.negate)
|
||||||
|
|
||||||
|
if condition.operator == "isnotempty":
|
||||||
|
result = not self._is_empty(cell_value)
|
||||||
|
return self._apply_negate(result, condition.negate)
|
||||||
|
|
||||||
|
# For all other operators, None cell_value returns False
|
||||||
|
if cell_value is None:
|
||||||
|
return self._apply_negate(False, condition.negate)
|
||||||
|
|
||||||
|
# Resolve the comparison value (might be a column reference)
|
||||||
|
compare_value = self._resolve_value(condition.value, row_data)
|
||||||
|
|
||||||
|
# If reference resolved to None, return False
|
||||||
|
if compare_value is None and isinstance(condition.value, dict):
|
||||||
|
return self._apply_negate(False, condition.negate)
|
||||||
|
|
||||||
|
# Evaluate based on operator
|
||||||
|
result = self._evaluate_operator(
|
||||||
|
condition.operator,
|
||||||
|
cell_value,
|
||||||
|
compare_value,
|
||||||
|
condition.case_sensitive
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._apply_negate(result, condition.negate)
|
||||||
|
|
||||||
|
def _resolve_value(self, value: Any, row_data: dict) -> Any:
|
||||||
|
"""Resolve a value, handling column references."""
|
||||||
|
if isinstance(value, dict) and "col" in value:
|
||||||
|
if row_data is None:
|
||||||
|
return None
|
||||||
|
col_id = value["col"]
|
||||||
|
return row_data.get(col_id)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _is_empty(self, value: Any) -> bool:
|
||||||
|
"""Check if a value is empty (None or empty string)."""
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if isinstance(value, str) and value == "":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _apply_negate(self, result: bool, negate: bool) -> bool:
|
||||||
|
"""Apply negation if needed."""
|
||||||
|
return not result if negate else result
|
||||||
|
|
||||||
|
def _evaluate_operator(
|
||||||
|
self,
|
||||||
|
operator: str,
|
||||||
|
cell_value: Any,
|
||||||
|
compare_value: Any,
|
||||||
|
case_sensitive: bool
|
||||||
|
) -> bool:
|
||||||
|
"""Evaluate a specific operator."""
|
||||||
|
try:
|
||||||
|
if operator == "==":
|
||||||
|
return self._equals(cell_value, compare_value, case_sensitive)
|
||||||
|
elif operator == "!=":
|
||||||
|
return not self._equals(cell_value, compare_value, case_sensitive)
|
||||||
|
elif operator == "<":
|
||||||
|
return self._less_than(cell_value, compare_value)
|
||||||
|
elif operator == "<=":
|
||||||
|
return self._less_than_or_equal(cell_value, compare_value)
|
||||||
|
elif operator == ">":
|
||||||
|
return self._greater_than(cell_value, compare_value)
|
||||||
|
elif operator == ">=":
|
||||||
|
return self._greater_than_or_equal(cell_value, compare_value)
|
||||||
|
elif operator == "contains":
|
||||||
|
return self._contains(cell_value, compare_value, case_sensitive)
|
||||||
|
elif operator == "startswith":
|
||||||
|
return self._startswith(cell_value, compare_value, case_sensitive)
|
||||||
|
elif operator == "endswith":
|
||||||
|
return self._endswith(cell_value, compare_value, case_sensitive)
|
||||||
|
elif operator == "in":
|
||||||
|
return self._in_list(cell_value, compare_value)
|
||||||
|
elif operator == "between":
|
||||||
|
return self._between(cell_value, compare_value)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Type mismatch or invalid operation
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _equals(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||||
|
"""Check equality, with optional case-insensitive string comparison."""
|
||||||
|
if isinstance(cell_value, str) and isinstance(compare_value, str):
|
||||||
|
if case_sensitive:
|
||||||
|
return cell_value == compare_value
|
||||||
|
return cell_value.lower() == compare_value.lower()
|
||||||
|
return cell_value == compare_value
|
||||||
|
|
||||||
|
def _less_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||||
|
"""Check if cell_value < compare_value."""
|
||||||
|
if type(cell_value) != type(compare_value):
|
||||||
|
# Allow int/float comparison
|
||||||
|
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||||
|
return cell_value < compare_value
|
||||||
|
raise TypeError("Type mismatch")
|
||||||
|
return cell_value < compare_value
|
||||||
|
|
||||||
|
def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||||
|
"""Check if cell_value <= compare_value."""
|
||||||
|
if type(cell_value) != type(compare_value):
|
||||||
|
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||||
|
return cell_value <= compare_value
|
||||||
|
raise TypeError("Type mismatch")
|
||||||
|
return cell_value <= compare_value
|
||||||
|
|
||||||
|
def _greater_than(self, cell_value: Any, compare_value: Any) -> bool:
|
||||||
|
"""Check if cell_value > compare_value."""
|
||||||
|
if type(cell_value) != type(compare_value):
|
||||||
|
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||||
|
return cell_value > compare_value
|
||||||
|
raise TypeError("Type mismatch")
|
||||||
|
return cell_value > compare_value
|
||||||
|
|
||||||
|
def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool:
|
||||||
|
"""Check if cell_value >= compare_value."""
|
||||||
|
if type(cell_value) != type(compare_value):
|
||||||
|
if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)):
|
||||||
|
return cell_value >= compare_value
|
||||||
|
raise TypeError("Type mismatch")
|
||||||
|
return cell_value >= compare_value
|
||||||
|
|
||||||
|
def _contains(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||||
|
"""Check if cell_value contains compare_value (string)."""
|
||||||
|
cell_str = str(cell_value)
|
||||||
|
compare_str = str(compare_value)
|
||||||
|
if case_sensitive:
|
||||||
|
return compare_str in cell_str
|
||||||
|
return compare_str.lower() in cell_str.lower()
|
||||||
|
|
||||||
|
def _startswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||||
|
"""Check if cell_value starts with compare_value (string)."""
|
||||||
|
cell_str = str(cell_value)
|
||||||
|
compare_str = str(compare_value)
|
||||||
|
if case_sensitive:
|
||||||
|
return cell_str.startswith(compare_str)
|
||||||
|
return cell_str.lower().startswith(compare_str.lower())
|
||||||
|
|
||||||
|
def _endswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool:
|
||||||
|
"""Check if cell_value ends with compare_value (string)."""
|
||||||
|
cell_str = str(cell_value)
|
||||||
|
compare_str = str(compare_value)
|
||||||
|
if case_sensitive:
|
||||||
|
return cell_str.endswith(compare_str)
|
||||||
|
return cell_str.lower().endswith(compare_str.lower())
|
||||||
|
|
||||||
|
def _in_list(self, cell_value: Any, compare_value: list) -> bool:
|
||||||
|
"""Check if cell_value is in the list."""
|
||||||
|
if not isinstance(compare_value, list):
|
||||||
|
raise TypeError("'in' operator requires a list")
|
||||||
|
return cell_value in compare_value
|
||||||
|
|
||||||
|
def _between(self, cell_value: Any, compare_value: list) -> bool:
|
||||||
|
"""Check if cell_value is between min and max (inclusive)."""
|
||||||
|
if not isinstance(compare_value, list) or len(compare_value) != 2:
|
||||||
|
raise TypeError("'between' operator requires a list of [min, max]")
|
||||||
|
min_val, max_val = compare_value
|
||||||
|
return min_val <= cell_value <= max_val
|
||||||
165
src/myfasthtml/core/formatting/dataclasses.py
Normal file
165
src/myfasthtml/core/formatting/dataclasses.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# === Condition ===
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Condition:
|
||||||
|
"""
|
||||||
|
Represents a condition for conditional formatting.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=",
|
||||||
|
"contains", "startswith", "endswith", "in", "between",
|
||||||
|
"isempty", "isnotempty")
|
||||||
|
value: Value to compare against (literal, list, or {"col": "..."} for reference)
|
||||||
|
negate: If True, inverts the condition result
|
||||||
|
case_sensitive: If True, string comparisons are case-sensitive (default False)
|
||||||
|
col: Column ID for row-level conditions (evaluate this column instead of current cell)
|
||||||
|
row: Row index for column-level conditions (evaluate this row instead of current cell)
|
||||||
|
"""
|
||||||
|
operator: str
|
||||||
|
value: Any = None
|
||||||
|
negate: bool = False
|
||||||
|
case_sensitive: bool = False
|
||||||
|
col: str = None
|
||||||
|
row: int = None
|
||||||
|
|
||||||
|
|
||||||
|
# === Style ===
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Style:
|
||||||
|
"""
|
||||||
|
Represents style properties for cell formatting.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
preset: Name of a style preset ("primary", "success", "error", etc.)
|
||||||
|
background_color: Background color (hex, CSS name, or CSS variable)
|
||||||
|
color: Text color
|
||||||
|
font_weight: "normal" or "bold"
|
||||||
|
font_style: "normal" or "italic"
|
||||||
|
font_size: Font size ("12px", "0.9em")
|
||||||
|
text_decoration: "none", "underline", or "line-through"
|
||||||
|
"""
|
||||||
|
preset: str = None
|
||||||
|
background_color: str = None
|
||||||
|
color: str = None
|
||||||
|
font_weight: str = None
|
||||||
|
font_style: str = None
|
||||||
|
font_size: str = None
|
||||||
|
text_decoration: str = None
|
||||||
|
|
||||||
|
|
||||||
|
# === Formatters ===
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Formatter:
|
||||||
|
"""Base class for all formatters."""
|
||||||
|
preset: str = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NumberFormatter(Formatter):
|
||||||
|
"""
|
||||||
|
Formatter for numbers, currencies, and percentages.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
prefix: Text before value (e.g., "$")
|
||||||
|
suffix: Text after value (e.g., " EUR")
|
||||||
|
thousands_sep: Thousands separator (e.g., ",", " ")
|
||||||
|
decimal_sep: Decimal separator (e.g., ".", ",")
|
||||||
|
precision: Number of decimal places
|
||||||
|
multiplier: Multiply value before display (e.g., 100 for percentage)
|
||||||
|
"""
|
||||||
|
prefix: str = ""
|
||||||
|
suffix: str = ""
|
||||||
|
thousands_sep: str = ""
|
||||||
|
decimal_sep: str = "."
|
||||||
|
precision: int = 0
|
||||||
|
multiplier: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DateFormatter(Formatter):
|
||||||
|
"""
|
||||||
|
Formatter for dates and datetimes.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
format: strftime format pattern (default: "%Y-%m-%d")
|
||||||
|
"""
|
||||||
|
format: str = "%Y-%m-%d"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BooleanFormatter(Formatter):
|
||||||
|
"""
|
||||||
|
Formatter for boolean values.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
true_value: Display string for True
|
||||||
|
false_value: Display string for False
|
||||||
|
null_value: Display string for None/null
|
||||||
|
"""
|
||||||
|
true_value: str = "true"
|
||||||
|
false_value: str = "false"
|
||||||
|
null_value: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TextFormatter(Formatter):
|
||||||
|
"""
|
||||||
|
Formatter for text transformations.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
transform: Text transformation ("uppercase", "lowercase", "capitalize")
|
||||||
|
max_length: Maximum length before truncation
|
||||||
|
ellipsis: Suffix when truncated (default: "...")
|
||||||
|
"""
|
||||||
|
transform: str = None
|
||||||
|
max_length: int = None
|
||||||
|
ellipsis: str = "..."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnumFormatter(Formatter):
|
||||||
|
"""
|
||||||
|
Formatter for mapping values to display labels.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
source: Data source dict with "type" and "value" keys
|
||||||
|
- {"type": "mapping", "value": {"key": "label", ...}}
|
||||||
|
- {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"}
|
||||||
|
default: Label for unknown values
|
||||||
|
allow_empty: Show empty option in Select dropdowns
|
||||||
|
empty_label: Label for empty option
|
||||||
|
order_by: Sort order ("source", "display", "value")
|
||||||
|
"""
|
||||||
|
source: dict = field(default_factory=dict)
|
||||||
|
default: str = ""
|
||||||
|
allow_empty: bool = True
|
||||||
|
empty_label: str = "-- Select --"
|
||||||
|
order_by: str = "source"
|
||||||
|
|
||||||
|
|
||||||
|
# === Format Rule ===
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FormatRule:
|
||||||
|
"""
|
||||||
|
A formatting rule combining condition, style, and formatter.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- style and formatter can appear alone (unconditional formatting)
|
||||||
|
- condition cannot appear alone - must be paired with style and/or formatter
|
||||||
|
- If condition is present, style/formatter is applied only if condition is met
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
condition: Optional condition for conditional formatting
|
||||||
|
style: Optional style to apply
|
||||||
|
formatter: Optional formatter to apply
|
||||||
|
"""
|
||||||
|
condition: Condition = None
|
||||||
|
style: Style = None
|
||||||
|
formatter: Formatter = None
|
||||||
152
src/myfasthtml/core/formatting/engine.py
Normal file
152
src/myfasthtml/core/formatting/engine.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
|
||||||
|
from myfasthtml.core.formatting.dataclasses import FormatRule
|
||||||
|
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver
|
||||||
|
from myfasthtml.core.formatting.style_resolver import StyleResolver
|
||||||
|
|
||||||
|
|
||||||
|
class FormattingEngine:
|
||||||
|
"""
|
||||||
|
Main facade for the formatting system.
|
||||||
|
|
||||||
|
Combines:
|
||||||
|
- ConditionEvaluator: evaluates conditions
|
||||||
|
- StyleResolver: resolves styles to CSS
|
||||||
|
- FormatterResolver: formats values for display
|
||||||
|
- Conflict resolution: handles multiple matching rules
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(style=Style(preset="error"), condition=Condition(operator="<", value=0)),
|
||||||
|
FormatRule(formatter=NumberFormatter(preset="EUR")),
|
||||||
|
]
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=-5.0)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
style_presets: dict = None,
|
||||||
|
formatter_presets: dict = None,
|
||||||
|
lookup_resolver: Callable[[str, str, str], dict] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the FormattingEngine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
style_presets: Custom style presets. If None, uses defaults.
|
||||||
|
formatter_presets: Custom formatter presets. If None, uses defaults.
|
||||||
|
lookup_resolver: Function for resolving enum datagrid sources.
|
||||||
|
"""
|
||||||
|
self._condition_evaluator = ConditionEvaluator()
|
||||||
|
self._style_resolver = StyleResolver(style_presets)
|
||||||
|
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
|
||||||
|
|
||||||
|
def apply_format(
|
||||||
|
self,
|
||||||
|
rules: list[FormatRule],
|
||||||
|
cell_value: Any,
|
||||||
|
row_data: dict = None
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""
|
||||||
|
Apply format rules to a cell value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rules: List of FormatRule to evaluate
|
||||||
|
cell_value: The cell value to format
|
||||||
|
row_data: Dict of {col_id: value} for column references
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (css_string, formatted_value):
|
||||||
|
- css_string: CSS inline style string, or None if no style
|
||||||
|
- formatted_value: Formatted string, or None if no formatter
|
||||||
|
"""
|
||||||
|
if not rules:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Find all matching rules
|
||||||
|
matching_rules = self._get_matching_rules(rules, cell_value, row_data)
|
||||||
|
|
||||||
|
if not matching_rules:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Resolve conflicts to get the winning rule
|
||||||
|
winning_rule = self._resolve_conflicts(matching_rules)
|
||||||
|
|
||||||
|
if winning_rule is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
# Apply style
|
||||||
|
css_string = None
|
||||||
|
if winning_rule.style:
|
||||||
|
css_string = self._style_resolver.to_css_string(winning_rule.style)
|
||||||
|
if css_string == "":
|
||||||
|
css_string = None
|
||||||
|
|
||||||
|
# Apply formatter
|
||||||
|
formatted_value = None
|
||||||
|
if winning_rule.formatter:
|
||||||
|
formatted_value = self._formatter_resolver.resolve(winning_rule.formatter, cell_value)
|
||||||
|
|
||||||
|
return css_string, formatted_value
|
||||||
|
|
||||||
|
def _get_matching_rules(
|
||||||
|
self,
|
||||||
|
rules: list[FormatRule],
|
||||||
|
cell_value: Any,
|
||||||
|
row_data: dict = None
|
||||||
|
) -> list[FormatRule]:
|
||||||
|
"""
|
||||||
|
Get all rules that match the current cell.
|
||||||
|
|
||||||
|
A rule matches if:
|
||||||
|
- It has no condition (unconditional)
|
||||||
|
- Its condition evaluates to True
|
||||||
|
"""
|
||||||
|
matching = []
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
if rule.condition is None:
|
||||||
|
# Unconditional rule always matches
|
||||||
|
matching.append(rule)
|
||||||
|
elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data):
|
||||||
|
# Conditional rule matches
|
||||||
|
matching.append(rule)
|
||||||
|
|
||||||
|
return matching
|
||||||
|
|
||||||
|
def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None:
|
||||||
|
"""
|
||||||
|
Resolve conflicts when multiple rules match.
|
||||||
|
|
||||||
|
Resolution logic:
|
||||||
|
1. Specificity = 1 if rule has condition, 0 otherwise
|
||||||
|
2. Higher specificity wins
|
||||||
|
3. At equal specificity, last rule wins entirely (no fusion)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
matching_rules: List of rules that matched
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The winning FormatRule, or None if no rules
|
||||||
|
"""
|
||||||
|
if not matching_rules:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(matching_rules) == 1:
|
||||||
|
return matching_rules[0]
|
||||||
|
|
||||||
|
# Calculate specificity for each rule
|
||||||
|
# Specificity = 1 if has condition, 0 otherwise
|
||||||
|
def get_specificity(rule: FormatRule) -> int:
|
||||||
|
return 1 if rule.condition is not None else 0
|
||||||
|
|
||||||
|
# Find the maximum specificity
|
||||||
|
max_specificity = max(get_specificity(rule) for rule in matching_rules)
|
||||||
|
|
||||||
|
# Filter to rules with max specificity
|
||||||
|
top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity]
|
||||||
|
|
||||||
|
# Last rule wins among equal specificity
|
||||||
|
return top_rules[-1]
|
||||||
339
src/myfasthtml/core/formatting/formatter_resolver.py
Normal file
339
src/myfasthtml/core/formatting/formatter_resolver.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.dataclasses import (
|
||||||
|
Formatter,
|
||||||
|
NumberFormatter,
|
||||||
|
DateFormatter,
|
||||||
|
BooleanFormatter,
|
||||||
|
TextFormatter,
|
||||||
|
EnumFormatter,
|
||||||
|
)
|
||||||
|
from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS
|
||||||
|
|
||||||
|
|
||||||
|
# Error indicator when formatting fails
|
||||||
|
FORMAT_ERROR = "\u26a0" # ⚠
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFormatterResolver(ABC):
|
||||||
|
"""Base class for all formatter resolvers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def resolve(self, formatter: Formatter, value: Any) -> str:
|
||||||
|
"""Format a value using the given formatter."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def apply_preset(self, formatter: Formatter, presets: dict) -> Formatter:
|
||||||
|
"""Apply preset values to create a fully configured formatter."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class NumberFormatterResolver(BaseFormatterResolver):
|
||||||
|
"""Resolver for NumberFormatter."""
|
||||||
|
|
||||||
|
def resolve(self, formatter: NumberFormatter, value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Convert to float and apply multiplier
|
||||||
|
num = float(value) * formatter.multiplier
|
||||||
|
|
||||||
|
# Round to precision
|
||||||
|
if formatter.precision > 0:
|
||||||
|
num = round(num, formatter.precision)
|
||||||
|
else:
|
||||||
|
num = int(round(num))
|
||||||
|
|
||||||
|
# Format with decimal separator
|
||||||
|
if formatter.precision > 0:
|
||||||
|
int_part = int(num)
|
||||||
|
dec_part = abs(num - int_part)
|
||||||
|
dec_str = f"{dec_part:.{formatter.precision}f}"[2:] # Remove "0."
|
||||||
|
else:
|
||||||
|
int_part = int(num)
|
||||||
|
dec_str = ""
|
||||||
|
|
||||||
|
# Format integer part with thousands separator
|
||||||
|
int_str = self._format_with_thousands(abs(int_part), formatter.thousands_sep)
|
||||||
|
|
||||||
|
# Handle negative
|
||||||
|
if num < 0:
|
||||||
|
int_str = "-" + int_str
|
||||||
|
|
||||||
|
# Combine parts
|
||||||
|
if dec_str:
|
||||||
|
result = f"{int_str}{formatter.decimal_sep}{dec_str}"
|
||||||
|
else:
|
||||||
|
result = int_str
|
||||||
|
|
||||||
|
return f"{formatter.prefix}{result}{formatter.suffix}"
|
||||||
|
|
||||||
|
def _format_with_thousands(self, num: int, sep: str) -> str:
|
||||||
|
"""Format an integer with thousands separator."""
|
||||||
|
if not sep:
|
||||||
|
return str(num)
|
||||||
|
|
||||||
|
result = ""
|
||||||
|
s = str(num)
|
||||||
|
for i, digit in enumerate(reversed(s)):
|
||||||
|
if i > 0 and i % 3 == 0:
|
||||||
|
result = sep + result
|
||||||
|
result = digit + result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def apply_preset(self, formatter: Formatter, presets: dict) -> NumberFormatter:
|
||||||
|
preset = presets.get(formatter.preset, {})
|
||||||
|
|
||||||
|
if isinstance(formatter, NumberFormatter):
|
||||||
|
return NumberFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
prefix=formatter.prefix if formatter.prefix != "" else preset.get("prefix", ""),
|
||||||
|
suffix=formatter.suffix if formatter.suffix != "" else preset.get("suffix", ""),
|
||||||
|
thousands_sep=formatter.thousands_sep if formatter.thousands_sep != "" else preset.get("thousands_sep", ""),
|
||||||
|
decimal_sep=formatter.decimal_sep if formatter.decimal_sep != "." else preset.get("decimal_sep", "."),
|
||||||
|
precision=formatter.precision if formatter.precision != 0 else preset.get("precision", 0),
|
||||||
|
multiplier=formatter.multiplier if formatter.multiplier != 1.0 else preset.get("multiplier", 1.0),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return NumberFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
prefix=preset.get("prefix", ""),
|
||||||
|
suffix=preset.get("suffix", ""),
|
||||||
|
thousands_sep=preset.get("thousands_sep", ""),
|
||||||
|
decimal_sep=preset.get("decimal_sep", "."),
|
||||||
|
precision=preset.get("precision", 0),
|
||||||
|
multiplier=preset.get("multiplier", 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DateFormatterResolver(BaseFormatterResolver):
|
||||||
|
"""Resolver for DateFormatter."""
|
||||||
|
|
||||||
|
def resolve(self, formatter: DateFormatter, value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.strftime(formatter.format)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
dt = datetime.fromisoformat(value)
|
||||||
|
return dt.strftime(formatter.format)
|
||||||
|
else:
|
||||||
|
raise TypeError(f"Cannot format {type(value)} as date")
|
||||||
|
|
||||||
|
def apply_preset(self, formatter: Formatter, presets: dict) -> DateFormatter:
|
||||||
|
preset = presets.get(formatter.preset, {})
|
||||||
|
|
||||||
|
if isinstance(formatter, DateFormatter):
|
||||||
|
return DateFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
format=formatter.format if formatter.format != "%Y-%m-%d" else preset.get("format", "%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return DateFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
format=preset.get("format", "%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BooleanFormatterResolver(BaseFormatterResolver):
|
||||||
|
"""Resolver for BooleanFormatter."""
|
||||||
|
|
||||||
|
def resolve(self, formatter: BooleanFormatter, value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return formatter.null_value
|
||||||
|
if value is True or value == 1:
|
||||||
|
return formatter.true_value
|
||||||
|
if value is False or value == 0:
|
||||||
|
return formatter.false_value
|
||||||
|
return formatter.null_value
|
||||||
|
|
||||||
|
def apply_preset(self, formatter: Formatter, presets: dict) -> BooleanFormatter:
|
||||||
|
preset = presets.get(formatter.preset, {})
|
||||||
|
|
||||||
|
if isinstance(formatter, BooleanFormatter):
|
||||||
|
return BooleanFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
true_value=formatter.true_value if formatter.true_value != "true" else preset.get("true_value", "true"),
|
||||||
|
false_value=formatter.false_value if formatter.false_value != "false" else preset.get("false_value", "false"),
|
||||||
|
null_value=formatter.null_value if formatter.null_value != "" else preset.get("null_value", ""),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return BooleanFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
true_value=preset.get("true_value", "true"),
|
||||||
|
false_value=preset.get("false_value", "false"),
|
||||||
|
null_value=preset.get("null_value", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TextFormatterResolver(BaseFormatterResolver):
|
||||||
|
"""Resolver for TextFormatter."""
|
||||||
|
|
||||||
|
def resolve(self, formatter: TextFormatter, value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = str(value)
|
||||||
|
|
||||||
|
# Apply transformation
|
||||||
|
if formatter.transform == "uppercase":
|
||||||
|
text = text.upper()
|
||||||
|
elif formatter.transform == "lowercase":
|
||||||
|
text = text.lower()
|
||||||
|
elif formatter.transform == "capitalize":
|
||||||
|
text = text.capitalize()
|
||||||
|
|
||||||
|
# Apply truncation
|
||||||
|
if formatter.max_length and len(text) > formatter.max_length:
|
||||||
|
text = text[:formatter.max_length] + formatter.ellipsis
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def apply_preset(self, formatter: Formatter, presets: dict) -> TextFormatter:
|
||||||
|
preset = presets.get(formatter.preset, {})
|
||||||
|
|
||||||
|
if isinstance(formatter, TextFormatter):
|
||||||
|
return TextFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
transform=formatter.transform or preset.get("transform"),
|
||||||
|
max_length=formatter.max_length or preset.get("max_length"),
|
||||||
|
ellipsis=formatter.ellipsis if formatter.ellipsis != "..." else preset.get("ellipsis", "..."),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return TextFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
transform=preset.get("transform"),
|
||||||
|
max_length=preset.get("max_length"),
|
||||||
|
ellipsis=preset.get("ellipsis", "..."),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnumFormatterResolver(BaseFormatterResolver):
|
||||||
|
"""Resolver for EnumFormatter."""
|
||||||
|
|
||||||
|
def __init__(self, lookup_resolver: Callable[[str, str, str], dict] = None):
|
||||||
|
self.lookup_resolver = lookup_resolver
|
||||||
|
|
||||||
|
def resolve(self, formatter: EnumFormatter, value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return formatter.default
|
||||||
|
|
||||||
|
source = formatter.source
|
||||||
|
if not source:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
source_type = source.get("type")
|
||||||
|
|
||||||
|
if source_type == "mapping":
|
||||||
|
mapping = source.get("value", {})
|
||||||
|
return mapping.get(value, formatter.default)
|
||||||
|
|
||||||
|
elif source_type == "datagrid":
|
||||||
|
if not self.lookup_resolver:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
grid_id = source.get("value")
|
||||||
|
value_col = source.get("value_column")
|
||||||
|
display_col = source.get("display_column")
|
||||||
|
|
||||||
|
mapping = self.lookup_resolver(grid_id, value_col, display_col)
|
||||||
|
return mapping.get(value, formatter.default)
|
||||||
|
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def apply_preset(self, formatter: Formatter, presets: dict) -> EnumFormatter:
|
||||||
|
preset = presets.get(formatter.preset, {})
|
||||||
|
|
||||||
|
if isinstance(formatter, EnumFormatter):
|
||||||
|
return formatter
|
||||||
|
else:
|
||||||
|
return EnumFormatter(
|
||||||
|
preset=formatter.preset,
|
||||||
|
source=preset.get("source", {}),
|
||||||
|
default=preset.get("default", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FormatterResolver:
|
||||||
|
"""
|
||||||
|
Main resolver that dispatches to the appropriate formatter resolver.
|
||||||
|
|
||||||
|
This is a facade that delegates formatting to specialized resolvers
|
||||||
|
based on the formatter type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
formatter_presets: dict = None,
|
||||||
|
lookup_resolver: Callable[[str, str, str], dict] = None
|
||||||
|
):
|
||||||
|
self.formatter_presets = formatter_presets or DEFAULT_FORMATTER_PRESETS
|
||||||
|
self.lookup_resolver = lookup_resolver
|
||||||
|
|
||||||
|
# Registry of resolvers by formatter type
|
||||||
|
self._resolvers: dict[type, BaseFormatterResolver] = {
|
||||||
|
NumberFormatter: NumberFormatterResolver(),
|
||||||
|
DateFormatter: DateFormatterResolver(),
|
||||||
|
BooleanFormatter: BooleanFormatterResolver(),
|
||||||
|
TextFormatter: TextFormatterResolver(),
|
||||||
|
EnumFormatter: EnumFormatterResolver(lookup_resolver),
|
||||||
|
}
|
||||||
|
|
||||||
|
def resolve(self, formatter: Formatter, value: Any) -> str:
|
||||||
|
"""
|
||||||
|
Apply formatter to a value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
formatter: The Formatter to apply
|
||||||
|
value: The value to format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string for display, or FORMAT_ERROR on failure
|
||||||
|
"""
|
||||||
|
if formatter is None:
|
||||||
|
return str(value) if value is not None else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get the appropriate resolver
|
||||||
|
resolver = self._get_resolver(formatter)
|
||||||
|
if resolver is None:
|
||||||
|
return str(value) if value is not None else ""
|
||||||
|
|
||||||
|
# Apply preset if specified
|
||||||
|
formatter = resolver.apply_preset(formatter, self.formatter_presets)
|
||||||
|
|
||||||
|
# Resolve the value
|
||||||
|
return resolver.resolve(formatter, value)
|
||||||
|
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return FORMAT_ERROR
|
||||||
|
|
||||||
|
def _get_resolver(self, formatter: Formatter) -> BaseFormatterResolver | None:
|
||||||
|
"""Get the appropriate resolver for a formatter."""
|
||||||
|
# Direct type match
|
||||||
|
formatter_type = type(formatter)
|
||||||
|
if formatter_type in self._resolvers:
|
||||||
|
return self._resolvers[formatter_type]
|
||||||
|
|
||||||
|
# Base Formatter with preset - determine type from preset
|
||||||
|
if formatter_type == Formatter and formatter.preset:
|
||||||
|
preset = self.formatter_presets.get(formatter.preset, {})
|
||||||
|
preset_type = preset.get("type")
|
||||||
|
|
||||||
|
type_mapping = {
|
||||||
|
"number": NumberFormatter,
|
||||||
|
"date": DateFormatter,
|
||||||
|
"boolean": BooleanFormatter,
|
||||||
|
"text": TextFormatter,
|
||||||
|
"enum": EnumFormatter,
|
||||||
|
}
|
||||||
|
|
||||||
|
target_type = type_mapping.get(preset_type)
|
||||||
|
if target_type:
|
||||||
|
return self._resolvers.get(target_type)
|
||||||
|
|
||||||
|
return None
|
||||||
76
src/myfasthtml/core/formatting/presets.py
Normal file
76
src/myfasthtml/core/formatting/presets.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# === Style Presets (DaisyUI 5) ===
|
||||||
|
# Keys use CSS property names (with hyphens)
|
||||||
|
|
||||||
|
DEFAULT_STYLE_PRESETS = {
|
||||||
|
"primary": {
|
||||||
|
"background-color": "var(--color-primary)",
|
||||||
|
"color": "var(--color-primary-content)",
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"background-color": "var(--color-secondary)",
|
||||||
|
"color": "var(--color-secondary-content)",
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"background-color": "var(--color-accent)",
|
||||||
|
"color": "var(--color-accent-content)",
|
||||||
|
},
|
||||||
|
"neutral": {
|
||||||
|
"background-color": "var(--color-neutral)",
|
||||||
|
"color": "var(--color-neutral-content)",
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"background-color": "var(--color-info)",
|
||||||
|
"color": "var(--color-info-content)",
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"background-color": "var(--color-success)",
|
||||||
|
"color": "var(--color-success-content)",
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"background-color": "var(--color-warning)",
|
||||||
|
"color": "var(--color-warning-content)",
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"background-color": "var(--color-error)",
|
||||||
|
"color": "var(--color-error-content)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# === Formatter Presets ===
|
||||||
|
|
||||||
|
DEFAULT_FORMATTER_PRESETS = {
|
||||||
|
"EUR": {
|
||||||
|
"type": "number",
|
||||||
|
"suffix": " €",
|
||||||
|
"thousands_sep": " ",
|
||||||
|
"decimal_sep": ",",
|
||||||
|
"precision": 2,
|
||||||
|
},
|
||||||
|
"USD": {
|
||||||
|
"type": "number",
|
||||||
|
"prefix": "$",
|
||||||
|
"thousands_sep": ",",
|
||||||
|
"decimal_sep": ".",
|
||||||
|
"precision": 2,
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"type": "number",
|
||||||
|
"suffix": "%",
|
||||||
|
"precision": 1,
|
||||||
|
"multiplier": 100,
|
||||||
|
},
|
||||||
|
"short_date": {
|
||||||
|
"type": "date",
|
||||||
|
"format": "%d/%m/%Y",
|
||||||
|
},
|
||||||
|
"iso_date": {
|
||||||
|
"type": "date",
|
||||||
|
"format": "%Y-%m-%d",
|
||||||
|
},
|
||||||
|
"yes_no": {
|
||||||
|
"type": "boolean",
|
||||||
|
"true_value": "Yes",
|
||||||
|
"false_value": "No",
|
||||||
|
},
|
||||||
|
}
|
||||||
75
src/myfasthtml/core/formatting/style_resolver.py
Normal file
75
src/myfasthtml/core/formatting/style_resolver.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from myfasthtml.core.formatting.dataclasses import Style
|
||||||
|
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping from Python attribute names to CSS property names
|
||||||
|
PROPERTY_NAME_MAP = {
|
||||||
|
"background_color": "background-color",
|
||||||
|
"color": "color",
|
||||||
|
"font_weight": "font-weight",
|
||||||
|
"font_style": "font-style",
|
||||||
|
"font_size": "font-size",
|
||||||
|
"text_decoration": "text-decoration",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StyleResolver:
|
||||||
|
"""Resolves styles by applying presets and explicit properties."""
|
||||||
|
|
||||||
|
def __init__(self, style_presets: dict = None):
|
||||||
|
"""
|
||||||
|
Initialize the StyleResolver.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS.
|
||||||
|
"""
|
||||||
|
self.style_presets = style_presets or DEFAULT_STYLE_PRESETS
|
||||||
|
|
||||||
|
def resolve(self, style: Style) -> dict:
|
||||||
|
"""
|
||||||
|
Resolve a Style to CSS properties dict.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
1. If preset is defined, load preset properties
|
||||||
|
2. Override with explicit properties (non-None values)
|
||||||
|
3. Convert Python names to CSS names
|
||||||
|
|
||||||
|
Args:
|
||||||
|
style: The Style object to resolve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict of CSS properties, e.g. {"background-color": "red", "color": "white"}
|
||||||
|
"""
|
||||||
|
if style is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Apply preset first
|
||||||
|
if style.preset and style.preset in self.style_presets:
|
||||||
|
preset_props = self.style_presets[style.preset]
|
||||||
|
for css_name, value in preset_props.items():
|
||||||
|
result[css_name] = value
|
||||||
|
|
||||||
|
# Override with explicit properties
|
||||||
|
for py_name, css_name in PROPERTY_NAME_MAP.items():
|
||||||
|
value = getattr(style, py_name, None)
|
||||||
|
if value is not None:
|
||||||
|
result[css_name] = value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def to_css_string(self, style: Style) -> str:
|
||||||
|
"""
|
||||||
|
Resolve a Style to a CSS inline string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
style: The Style object to resolve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CSS string, e.g. "background-color: red; color: white;"
|
||||||
|
"""
|
||||||
|
props = self.resolve(style)
|
||||||
|
if not props:
|
||||||
|
return ""
|
||||||
|
return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";"
|
||||||
1
tests/core/formatting/__init__.py
Normal file
1
tests/core/formatting/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Tests for formatting module
|
||||||
233
tests/core/formatting/test_condition_evaluator.py
Normal file
233
tests/core/formatting/test_condition_evaluator.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator
|
||||||
|
from myfasthtml.core.formatting.dataclasses import Condition
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparisonOperators:
|
||||||
|
@pytest.mark.parametrize("operator,cell_value,compare_value,expected", [
|
||||||
|
# ==
|
||||||
|
("==", 5, 5, True),
|
||||||
|
("==", 5, 10, False),
|
||||||
|
("==", "hello", "hello", True),
|
||||||
|
("==", "hello", "world", False),
|
||||||
|
("==", "Hello", "hello", True), # case insensitive by default
|
||||||
|
# !=
|
||||||
|
("!=", 5, 10, True),
|
||||||
|
("!=", 5, 5, False),
|
||||||
|
# <
|
||||||
|
("<", 5, 10, True),
|
||||||
|
("<", 10, 5, False),
|
||||||
|
("<", 5, 5, False),
|
||||||
|
("<", 5.0, 10, True), # int/float mix
|
||||||
|
# <=
|
||||||
|
("<=", 5, 10, True),
|
||||||
|
("<=", 5, 5, True),
|
||||||
|
("<=", 10, 5, False),
|
||||||
|
# >
|
||||||
|
(">", 10, 5, True),
|
||||||
|
(">", 5, 10, False),
|
||||||
|
(">", 5, 5, False),
|
||||||
|
# >=
|
||||||
|
(">=", 10, 5, True),
|
||||||
|
(">=", 5, 5, True),
|
||||||
|
(">=", 5, 10, False),
|
||||||
|
# type mismatch returns False
|
||||||
|
(">=", 10, "5", False),
|
||||||
|
])
|
||||||
|
def test_comparison_operators(self, operator, cell_value, compare_value, expected):
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator=operator, value=compare_value)
|
||||||
|
assert evaluator.evaluate(condition, cell_value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestStringOperators:
|
||||||
|
@pytest.mark.parametrize("operator,cell_value,compare_value,case_sensitive,expected", [
|
||||||
|
# contains
|
||||||
|
("contains", "Hello World", "World", False, True),
|
||||||
|
("contains", "Hello World", "world", False, True),
|
||||||
|
("contains", "Hello World", "world", True, False),
|
||||||
|
("contains", "Hello World", "xyz", False, False),
|
||||||
|
# startswith
|
||||||
|
("startswith", "Hello World", "Hello", False, True),
|
||||||
|
("startswith", "Hello World", "hello", False, True),
|
||||||
|
("startswith", "Hello World", "hello", True, False),
|
||||||
|
("startswith", "Hello World", "World", False, False),
|
||||||
|
# endswith
|
||||||
|
("endswith", "Hello World", "World", False, True),
|
||||||
|
("endswith", "Hello World", "world", False, True),
|
||||||
|
("endswith", "Hello World", "world", True, False),
|
||||||
|
("endswith", "Hello World", "Hello", False, False),
|
||||||
|
])
|
||||||
|
def test_string_operators(self, operator, cell_value, compare_value, case_sensitive, expected):
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator=operator, value=compare_value, case_sensitive=case_sensitive)
|
||||||
|
assert evaluator.evaluate(condition, cell_value) == expected
|
||||||
|
|
||||||
|
def test_string_operators_convert_non_strings(self):
|
||||||
|
"""String operators should convert non-string values to strings."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="contains", value="23")
|
||||||
|
assert evaluator.evaluate(condition, 123) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectionOperators:
|
||||||
|
@pytest.mark.parametrize("operator,cell_value,compare_value,expected", [
|
||||||
|
# in
|
||||||
|
("in", "A", ["A", "B", "C"], True),
|
||||||
|
("in", "D", ["A", "B", "C"], False),
|
||||||
|
("in", 1, [1, 2, 3], True),
|
||||||
|
("in", 4, [1, 2, 3], False),
|
||||||
|
# between (inclusive)
|
||||||
|
("between", 5, [1, 10], True),
|
||||||
|
("between", 1, [1, 10], True),
|
||||||
|
("between", 10, [1, 10], True),
|
||||||
|
("between", 0, [1, 10], False),
|
||||||
|
("between", 11, [1, 10], False),
|
||||||
|
("between", 5.5, [1, 10], True),
|
||||||
|
])
|
||||||
|
def test_collection_operators(self, operator, cell_value, compare_value, expected):
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator=operator, value=compare_value)
|
||||||
|
assert evaluator.evaluate(condition, cell_value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestNullOperators:
|
||||||
|
@pytest.mark.parametrize("operator,cell_value,expected", [
|
||||||
|
# isempty
|
||||||
|
("isempty", None, True),
|
||||||
|
("isempty", "", True),
|
||||||
|
("isempty", "hello", False),
|
||||||
|
("isempty", 0, False),
|
||||||
|
("isempty", False, False),
|
||||||
|
# isnotempty
|
||||||
|
("isnotempty", "hello", True),
|
||||||
|
("isnotempty", 0, True),
|
||||||
|
("isnotempty", False, True),
|
||||||
|
("isnotempty", None, False),
|
||||||
|
("isnotempty", "", False),
|
||||||
|
])
|
||||||
|
def test_null_operators(self, operator, cell_value, expected):
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator=operator)
|
||||||
|
assert evaluator.evaluate(condition, cell_value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestNegation:
|
||||||
|
@pytest.mark.parametrize("operator,cell_value,compare_value,negate,expected", [
|
||||||
|
("==", 5, 5, False, True),
|
||||||
|
("==", 5, 5, True, False),
|
||||||
|
("==", 5, 10, True, True),
|
||||||
|
("in", "A", ["A", "B"], False, True),
|
||||||
|
("in", "A", ["A", "B"], True, False),
|
||||||
|
("in", "C", ["A", "B"], True, True),
|
||||||
|
("contains", "hello", "ell", False, True),
|
||||||
|
("contains", "hello", "ell", True, False),
|
||||||
|
("isempty", None, None, False, True),
|
||||||
|
("isempty", None, None, True, False),
|
||||||
|
])
|
||||||
|
def test_negation(self, operator, cell_value, compare_value, negate, expected):
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator=operator, value=compare_value, negate=negate)
|
||||||
|
assert evaluator.evaluate(condition, cell_value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestColumnReference:
|
||||||
|
def test_i_can_evaluate_with_column_reference_in_value(self):
|
||||||
|
"""Compare cell value with another column's value using value={"col": ...}."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator=">", value={"col": "budget"})
|
||||||
|
row_data = {"budget": 100, "actual": 150}
|
||||||
|
assert evaluator.evaluate(condition, cell_value=150, row_data=row_data) is True
|
||||||
|
assert evaluator.evaluate(condition, cell_value=50, row_data=row_data) is False
|
||||||
|
|
||||||
|
def test_i_can_evaluate_with_col_parameter(self):
|
||||||
|
"""Row-level condition: evaluate based on specific column using col parameter."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value="error", col="status")
|
||||||
|
row_data = {"status": "error", "name": "Task 1"}
|
||||||
|
# cell_value is ignored when col is set
|
||||||
|
assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is True
|
||||||
|
|
||||||
|
def test_i_can_evaluate_col_parameter_with_non_matching_value(self):
|
||||||
|
"""Row-level condition returns False when column value doesn't match."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value="error", col="status")
|
||||||
|
row_data = {"status": "ok", "name": "Task 1"}
|
||||||
|
assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is False
|
||||||
|
|
||||||
|
def test_col_parameter_missing_column_returns_false(self):
|
||||||
|
"""When col parameter references non-existent column, return False."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value="error", col="missing")
|
||||||
|
row_data = {"status": "error"}
|
||||||
|
assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is False
|
||||||
|
|
||||||
|
def test_col_parameter_without_row_data_returns_false(self):
|
||||||
|
"""When col parameter is set but no row_data provided, return False."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value="error", col="status")
|
||||||
|
assert evaluator.evaluate(condition, cell_value="anything", row_data=None) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
def test_null_cell_value_returns_false(self):
|
||||||
|
"""None cell value returns False for comparison operators."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value=5)
|
||||||
|
assert evaluator.evaluate(condition, cell_value=None) is False
|
||||||
|
|
||||||
|
def test_null_reference_returns_false(self):
|
||||||
|
"""Column reference resolving to None returns False."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value={"col": "other"})
|
||||||
|
row_data = {"other": None}
|
||||||
|
assert evaluator.evaluate(condition, cell_value=5, row_data=row_data) is False
|
||||||
|
|
||||||
|
def test_missing_reference_column_returns_false(self):
|
||||||
|
"""Missing column in row_data returns False."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value={"col": "missing"})
|
||||||
|
row_data = {"other": 10}
|
||||||
|
assert evaluator.evaluate(condition, cell_value=5, row_data=row_data) is False
|
||||||
|
|
||||||
|
def test_no_row_data_for_reference_returns_false(self):
|
||||||
|
"""Column reference without row_data returns False."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value={"col": "other"})
|
||||||
|
assert evaluator.evaluate(condition, cell_value=5, row_data=None) is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cell_value,compare_value", [
|
||||||
|
("abc", 5),
|
||||||
|
(5, "abc"),
|
||||||
|
([1, 2], 5),
|
||||||
|
({"a": 1}, 5),
|
||||||
|
])
|
||||||
|
def test_type_mismatch_returns_false(self, cell_value, compare_value):
|
||||||
|
"""Type mismatch in comparison returns False."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="<", value=compare_value)
|
||||||
|
assert evaluator.evaluate(condition, cell_value) is False
|
||||||
|
|
||||||
|
def test_unknown_operator_returns_false(self):
|
||||||
|
"""Unknown operator returns False."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="unknown", value=5)
|
||||||
|
assert evaluator.evaluate(condition, cell_value=5) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseSensitiveEquals:
|
||||||
|
def test_case_sensitive_string_equals(self):
|
||||||
|
"""Case sensitive string comparison with ==."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value="Hello", case_sensitive=True)
|
||||||
|
assert evaluator.evaluate(condition, "Hello") is True
|
||||||
|
assert evaluator.evaluate(condition, "hello") is False
|
||||||
|
|
||||||
|
def test_case_insensitive_string_equals_default(self):
|
||||||
|
"""Case insensitive string comparison is default."""
|
||||||
|
evaluator = ConditionEvaluator()
|
||||||
|
condition = Condition(operator="==", value="Hello", case_sensitive=False)
|
||||||
|
assert evaluator.evaluate(condition, "Hello") is True
|
||||||
|
assert evaluator.evaluate(condition, "hello") is True
|
||||||
|
assert evaluator.evaluate(condition, "HELLO") is True
|
||||||
282
tests/core/formatting/test_engine.py
Normal file
282
tests/core/formatting/test_engine.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.dataclasses import (
|
||||||
|
Condition,
|
||||||
|
Style,
|
||||||
|
Formatter,
|
||||||
|
NumberFormatter,
|
||||||
|
FormatRule,
|
||||||
|
)
|
||||||
|
from myfasthtml.core.formatting.engine import FormattingEngine
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyFormat:
|
||||||
|
def test_apply_format_with_style_only(self):
|
||||||
|
"""Rule with style only returns CSS string."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [FormatRule(style=Style(background_color="red", color="white"))]
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=42)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert "background-color: red" in css
|
||||||
|
assert "color: white" in css
|
||||||
|
assert formatted is None
|
||||||
|
|
||||||
|
def test_apply_format_with_formatter_only(self):
|
||||||
|
"""Rule with formatter only returns formatted value."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [FormatRule(formatter=NumberFormatter(precision=2, suffix=" €"))]
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=1234.5)
|
||||||
|
|
||||||
|
assert css is None
|
||||||
|
assert formatted == "1234.50 €"
|
||||||
|
|
||||||
|
def test_apply_format_with_style_and_formatter(self):
|
||||||
|
"""Rule with both style and formatter returns both."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(
|
||||||
|
style=Style(color="green"),
|
||||||
|
formatter=NumberFormatter(precision=2)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=42.567)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert "color: green" in css
|
||||||
|
assert formatted == "42.57"
|
||||||
|
|
||||||
|
def test_apply_format_condition_met(self):
|
||||||
|
"""Conditional rule applies when condition is met."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
style=Style(color="red")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=-5)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert "color: red" in css
|
||||||
|
|
||||||
|
def test_apply_format_condition_not_met(self):
|
||||||
|
"""Conditional rule does not apply when condition is not met."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
style=Style(color="red")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=5)
|
||||||
|
|
||||||
|
assert css is None
|
||||||
|
assert formatted is None
|
||||||
|
|
||||||
|
def test_empty_rules_returns_none(self):
|
||||||
|
"""Empty rules list returns (None, None)."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format([], cell_value=42)
|
||||||
|
|
||||||
|
assert css is None
|
||||||
|
assert formatted is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestConflictResolution:
|
||||||
|
def test_unconditional_rule_always_applies(self):
|
||||||
|
"""Unconditional rule (no condition) always applies."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [FormatRule(style=Style(color="gray"))]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value="anything")
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert "color: gray" in css
|
||||||
|
|
||||||
|
def test_multiple_unconditional_rules_last_wins(self):
|
||||||
|
"""Among unconditional rules, last one wins."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(style=Style(color="red")),
|
||||||
|
FormatRule(style=Style(color="blue")),
|
||||||
|
FormatRule(style=Style(color="green")),
|
||||||
|
]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=42)
|
||||||
|
|
||||||
|
assert "color: green" in css
|
||||||
|
assert "color: red" not in css
|
||||||
|
assert "color: blue" not in css
|
||||||
|
|
||||||
|
def test_conditional_beats_unconditional(self):
|
||||||
|
"""Conditional rule (higher specificity) beats unconditional."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(style=Style(color="gray")), # unconditional, specificity=0
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
style=Style(color="red") # conditional, specificity=1
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=-5)
|
||||||
|
|
||||||
|
assert "color: red" in css
|
||||||
|
assert "color: gray" not in css
|
||||||
|
|
||||||
|
def test_conditional_not_met_falls_back_to_unconditional(self):
|
||||||
|
"""When conditional doesn't match, unconditional applies."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(style=Style(color="gray")), # unconditional
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
style=Style(color="red") # doesn't match
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=5) # positive, condition not met
|
||||||
|
|
||||||
|
assert "color: gray" in css
|
||||||
|
|
||||||
|
def test_multiple_conditional_last_wins(self):
|
||||||
|
"""Among conditional rules with same specificity, last wins."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=10),
|
||||||
|
style=Style(color="red")
|
||||||
|
),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=10),
|
||||||
|
style=Style(color="blue")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=5)
|
||||||
|
|
||||||
|
assert "color: blue" in css
|
||||||
|
assert "color: red" not in css
|
||||||
|
|
||||||
|
def test_spec_example_value_minus_5(self):
|
||||||
|
"""
|
||||||
|
Example from spec: value=-5
|
||||||
|
Rule 1: unconditional gray
|
||||||
|
Rule 2: <0 -> red
|
||||||
|
Rule 3: ==-5 -> black
|
||||||
|
|
||||||
|
Both rule 2 and 3 match with same specificity (1).
|
||||||
|
Rule 3 is last, so black wins.
|
||||||
|
"""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(style=Style(color="gray")),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
style=Style(color="red")
|
||||||
|
),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="==", value=-5),
|
||||||
|
style=Style(color="black")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=-5)
|
||||||
|
|
||||||
|
assert "color: black" in css
|
||||||
|
|
||||||
|
def test_spec_example_value_minus_3(self):
|
||||||
|
"""
|
||||||
|
Same rules as above but value=-3.
|
||||||
|
Rule 3 (==-5) doesn't match.
|
||||||
|
Rule 2 (<0) matches and beats rule 1 (unconditional).
|
||||||
|
"""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(style=Style(color="gray")),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
style=Style(color="red")
|
||||||
|
),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="==", value=-5),
|
||||||
|
style=Style(color="black")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=-3)
|
||||||
|
|
||||||
|
assert "color: red" in css
|
||||||
|
|
||||||
|
|
||||||
|
class TestWithRowData:
|
||||||
|
def test_condition_with_column_reference(self):
|
||||||
|
"""Condition can reference another column."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator=">", value={"col": "budget"}),
|
||||||
|
style=Style(color="red")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
row_data = {"budget": 100, "actual": 150}
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=150, row_data=row_data)
|
||||||
|
|
||||||
|
assert "color: red" in css
|
||||||
|
|
||||||
|
def test_condition_with_col_parameter(self):
|
||||||
|
"""Row-level condition using col parameter."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="==", value="error", col="status"),
|
||||||
|
style=Style(preset="error")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
row_data = {"status": "error", "value": 42}
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=42, row_data=row_data)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert "background-color" in css
|
||||||
|
|
||||||
|
|
||||||
|
class TestPresets:
|
||||||
|
def test_style_preset(self):
|
||||||
|
"""Style preset is resolved correctly."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [FormatRule(style=Style(preset="success"))]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=42)
|
||||||
|
|
||||||
|
assert "var(--color-success)" in css
|
||||||
|
|
||||||
|
def test_formatter_preset(self):
|
||||||
|
"""Formatter preset is resolved correctly."""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [FormatRule(formatter=Formatter(preset="EUR"))]
|
||||||
|
|
||||||
|
_, formatted = engine.apply_format(rules, cell_value=1234.56)
|
||||||
|
|
||||||
|
assert formatted == "1 234,56 €"
|
||||||
|
|
||||||
|
def test_custom_presets(self):
|
||||||
|
"""Custom presets can be injected."""
|
||||||
|
custom_style_presets = {
|
||||||
|
"custom": {"background-color": "purple", "color": "yellow"}
|
||||||
|
}
|
||||||
|
engine = FormattingEngine(style_presets=custom_style_presets)
|
||||||
|
rules = [FormatRule(style=Style(preset="custom"))]
|
||||||
|
|
||||||
|
css, _ = engine.apply_format(rules, cell_value=42)
|
||||||
|
|
||||||
|
assert "background-color: purple" in css
|
||||||
|
assert "color: yellow" in css
|
||||||
274
tests/core/formatting/test_formatter_resolver.py
Normal file
274
tests/core/formatting/test_formatter_resolver.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.dataclasses import (
|
||||||
|
Formatter,
|
||||||
|
NumberFormatter,
|
||||||
|
DateFormatter,
|
||||||
|
BooleanFormatter,
|
||||||
|
TextFormatter,
|
||||||
|
EnumFormatter,
|
||||||
|
)
|
||||||
|
from myfasthtml.core.formatting.formatter_resolver import FormatterResolver, FORMAT_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
class TestNumberFormatter:
|
||||||
|
@pytest.mark.parametrize("value,precision,thousands_sep,decimal_sep,prefix,suffix,multiplier,expected", [
|
||||||
|
# Basic formatting
|
||||||
|
(1234.567, 2, "", ".", "", "", 1.0, "1234.57"),
|
||||||
|
(1234.5, 2, "", ".", "", "", 1.0, "1234.50"),
|
||||||
|
(1234, 0, "", ".", "", "", 1.0, "1234"),
|
||||||
|
# With thousands separator
|
||||||
|
(1234567.89, 2, " ", ",", "", "", 1.0, "1 234 567,89"),
|
||||||
|
(1234567, 0, ",", ".", "", "", 1.0, "1,234,567"),
|
||||||
|
# With prefix/suffix
|
||||||
|
(1234.56, 2, " ", ",", "", " €", 1.0, "1 234,56 €"),
|
||||||
|
(1234.56, 2, ",", ".", "$", "", 1.0, "$1,234.56"),
|
||||||
|
# With multiplier (percentage)
|
||||||
|
(0.156, 1, "", ".", "", "%", 100, "15.6%"),
|
||||||
|
(0.5, 0, "", ".", "", "%", 100, "50%"),
|
||||||
|
# Negative numbers
|
||||||
|
(-1234.56, 2, " ", ",", "", " €", 1.0, "-1 234,56 €"),
|
||||||
|
# Zero
|
||||||
|
(0, 2, "", ".", "", "", 1.0, "0.00"),
|
||||||
|
# Small numbers
|
||||||
|
(0.99, 2, "", ".", "", "", 1.0, "0.99"),
|
||||||
|
])
|
||||||
|
def test_number_formatting(self, value, precision, thousands_sep, decimal_sep, prefix, suffix, multiplier, expected):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = NumberFormatter(
|
||||||
|
precision=precision,
|
||||||
|
thousands_sep=thousands_sep,
|
||||||
|
decimal_sep=decimal_sep,
|
||||||
|
prefix=prefix,
|
||||||
|
suffix=suffix,
|
||||||
|
multiplier=multiplier,
|
||||||
|
)
|
||||||
|
assert resolver.resolve(formatter, value) == expected
|
||||||
|
|
||||||
|
def test_number_none_value(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = NumberFormatter(precision=2)
|
||||||
|
assert resolver.resolve(formatter, None) == ""
|
||||||
|
|
||||||
|
def test_number_invalid_value_returns_error(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = NumberFormatter(precision=2)
|
||||||
|
assert resolver.resolve(formatter, "not a number") == FORMAT_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
class TestDateFormatter:
|
||||||
|
@pytest.mark.parametrize("value,format,expected", [
|
||||||
|
(datetime(2024, 1, 15), "%Y-%m-%d", "2024-01-15"),
|
||||||
|
(datetime(2024, 1, 15), "%d/%m/%Y", "15/01/2024"),
|
||||||
|
(datetime(2024, 1, 15, 14, 30), "%Y-%m-%d %H:%M", "2024-01-15 14:30"),
|
||||||
|
(datetime(2024, 12, 31), "%d %b %Y", "31 Dec 2024"),
|
||||||
|
])
|
||||||
|
def test_date_formatting(self, value, format, expected):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = DateFormatter(format=format)
|
||||||
|
assert resolver.resolve(formatter, value) == expected
|
||||||
|
|
||||||
|
def test_date_from_iso_string(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = DateFormatter(format="%d/%m/%Y")
|
||||||
|
assert resolver.resolve(formatter, "2024-01-15") == "15/01/2024"
|
||||||
|
|
||||||
|
def test_date_none_value(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = DateFormatter()
|
||||||
|
assert resolver.resolve(formatter, None) == ""
|
||||||
|
|
||||||
|
def test_date_invalid_value_returns_error(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = DateFormatter()
|
||||||
|
assert resolver.resolve(formatter, "not a date") == FORMAT_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
class TestBooleanFormatter:
|
||||||
|
@pytest.mark.parametrize("value,true_val,false_val,null_val,expected", [
|
||||||
|
(True, "Yes", "No", "-", "Yes"),
|
||||||
|
(False, "Yes", "No", "-", "No"),
|
||||||
|
(None, "Yes", "No", "-", "-"),
|
||||||
|
(True, "Oui", "Non", "", "Oui"),
|
||||||
|
(False, "Oui", "Non", "", "Non"),
|
||||||
|
# Integer equivalents
|
||||||
|
(1, "Yes", "No", "-", "Yes"),
|
||||||
|
(0, "Yes", "No", "-", "No"),
|
||||||
|
])
|
||||||
|
def test_boolean_formatting(self, value, true_val, false_val, null_val, expected):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = BooleanFormatter(
|
||||||
|
true_value=true_val,
|
||||||
|
false_value=false_val,
|
||||||
|
null_value=null_val,
|
||||||
|
)
|
||||||
|
assert resolver.resolve(formatter, value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestTextFormatter:
|
||||||
|
@pytest.mark.parametrize("value,transform,max_length,ellipsis,expected", [
|
||||||
|
# Transform only
|
||||||
|
("hello", "uppercase", None, "...", "HELLO"),
|
||||||
|
("HELLO", "lowercase", None, "...", "hello"),
|
||||||
|
("hello world", "capitalize", None, "...", "Hello world"),
|
||||||
|
("hELLO", "capitalize", None, "...", "Hello"),
|
||||||
|
# Truncation only
|
||||||
|
("hello world", None, 5, "...", "hello..."),
|
||||||
|
("hello world", None, 5, "~", "hello~"),
|
||||||
|
("hi", None, 5, "...", "hi"), # No truncation needed
|
||||||
|
# Transform + truncation
|
||||||
|
("hello world", "uppercase", 5, "...", "HELLO..."),
|
||||||
|
# None value
|
||||||
|
(None, "uppercase", None, "...", ""),
|
||||||
|
])
|
||||||
|
def test_text_formatting(self, value, transform, max_length, ellipsis, expected):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = TextFormatter(
|
||||||
|
transform=transform,
|
||||||
|
max_length=max_length,
|
||||||
|
ellipsis=ellipsis,
|
||||||
|
)
|
||||||
|
assert resolver.resolve(formatter, value) == expected
|
||||||
|
|
||||||
|
def test_text_non_string_converted(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = TextFormatter(transform="uppercase")
|
||||||
|
assert resolver.resolve(formatter, 123) == "123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnumFormatter:
|
||||||
|
def test_enum_mapping(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = EnumFormatter(
|
||||||
|
source={
|
||||||
|
"type": "mapping",
|
||||||
|
"value": {
|
||||||
|
"draft": "Brouillon",
|
||||||
|
"pending": "En attente",
|
||||||
|
"approved": "Approuv\u00e9",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert resolver.resolve(formatter, "draft") == "Brouillon"
|
||||||
|
assert resolver.resolve(formatter, "pending") == "En attente"
|
||||||
|
assert resolver.resolve(formatter, "approved") == "Approuv\u00e9"
|
||||||
|
|
||||||
|
def test_enum_default_value(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = EnumFormatter(
|
||||||
|
source={
|
||||||
|
"type": "mapping",
|
||||||
|
"value": {"a": "A", "b": "B"}
|
||||||
|
},
|
||||||
|
default="Unknown"
|
||||||
|
)
|
||||||
|
assert resolver.resolve(formatter, "unknown_key") == "Unknown"
|
||||||
|
|
||||||
|
def test_enum_none_value(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = EnumFormatter(
|
||||||
|
source={"type": "mapping", "value": {"a": "A"}},
|
||||||
|
default="N/A"
|
||||||
|
)
|
||||||
|
assert resolver.resolve(formatter, None) == "N/A"
|
||||||
|
|
||||||
|
def test_enum_with_lookup_resolver(self):
|
||||||
|
def mock_lookup(grid_id, value_col, display_col):
|
||||||
|
if grid_id == "categories_grid":
|
||||||
|
return {1: "Electronics", 2: "Books", 3: "Clothing"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
resolver = FormatterResolver(lookup_resolver=mock_lookup)
|
||||||
|
formatter = EnumFormatter(
|
||||||
|
source={
|
||||||
|
"type": "datagrid",
|
||||||
|
"value": "categories_grid",
|
||||||
|
"value_column": "id",
|
||||||
|
"display_column": "name",
|
||||||
|
},
|
||||||
|
default="Unknown category"
|
||||||
|
)
|
||||||
|
assert resolver.resolve(formatter, 1) == "Electronics"
|
||||||
|
assert resolver.resolve(formatter, 2) == "Books"
|
||||||
|
assert resolver.resolve(formatter, 99) == "Unknown category"
|
||||||
|
|
||||||
|
def test_enum_datagrid_without_resolver(self):
|
||||||
|
resolver = FormatterResolver() # No lookup_resolver
|
||||||
|
formatter = EnumFormatter(
|
||||||
|
source={
|
||||||
|
"type": "datagrid",
|
||||||
|
"value": "some_grid",
|
||||||
|
"value_column": "id",
|
||||||
|
"display_column": "name",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Should fall back to string representation
|
||||||
|
assert resolver.resolve(formatter, 1) == "1"
|
||||||
|
|
||||||
|
def test_enum_empty_source(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = EnumFormatter(source={})
|
||||||
|
assert resolver.resolve(formatter, "value") == "value"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPresets:
|
||||||
|
def test_eur_preset(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = Formatter(preset="EUR")
|
||||||
|
assert resolver.resolve(formatter, 1234.56) == "1 234,56 \u20ac"
|
||||||
|
|
||||||
|
def test_usd_preset(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = Formatter(preset="USD")
|
||||||
|
assert resolver.resolve(formatter, 1234.56) == "$1,234.56"
|
||||||
|
|
||||||
|
def test_percentage_preset(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = Formatter(preset="percentage")
|
||||||
|
assert resolver.resolve(formatter, 0.156) == "15.6%"
|
||||||
|
|
||||||
|
def test_short_date_preset(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = Formatter(preset="short_date")
|
||||||
|
assert resolver.resolve(formatter, datetime(2024, 1, 15)) == "15/01/2024"
|
||||||
|
|
||||||
|
def test_iso_date_preset(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = Formatter(preset="iso_date")
|
||||||
|
assert resolver.resolve(formatter, datetime(2024, 1, 15)) == "2024-01-15"
|
||||||
|
|
||||||
|
def test_yes_no_preset(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = Formatter(preset="yes_no")
|
||||||
|
assert resolver.resolve(formatter, True) == "Yes"
|
||||||
|
assert resolver.resolve(formatter, False) == "No"
|
||||||
|
|
||||||
|
def test_unknown_preset_passthrough(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = Formatter(preset="unknown_preset")
|
||||||
|
assert resolver.resolve(formatter, "value") == "value"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPresetOverride:
|
||||||
|
def test_preset_with_custom_precision(self):
|
||||||
|
"""NumberFormatter with preset can override precision."""
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = NumberFormatter(preset="EUR", precision=3)
|
||||||
|
# EUR preset has precision=2, but we override to 3
|
||||||
|
assert resolver.resolve(formatter, 1234.5678) == "1 234,568 \u20ac"
|
||||||
|
|
||||||
|
def test_preset_with_custom_suffix(self):
|
||||||
|
"""NumberFormatter with preset can override suffix."""
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
formatter = NumberFormatter(preset="EUR", suffix=" euros")
|
||||||
|
assert resolver.resolve(formatter, 1234.56) == "1 234,56 euros"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoneFormatter:
|
||||||
|
def test_none_formatter_returns_string(self):
|
||||||
|
resolver = FormatterResolver()
|
||||||
|
assert resolver.resolve(None, "value") == "value"
|
||||||
|
assert resolver.resolve(None, 123) == "123"
|
||||||
|
assert resolver.resolve(None, None) == ""
|
||||||
143
tests/core/formatting/test_style_resolver.py
Normal file
143
tests/core/formatting/test_style_resolver.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.formatting.dataclasses import Style
|
||||||
|
from myfasthtml.core.formatting.style_resolver import StyleResolver
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolve:
|
||||||
|
def test_resolve_explicit_properties(self):
|
||||||
|
"""Style with explicit properties only."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style(background_color="red", color="white", font_weight="bold")
|
||||||
|
result = resolver.resolve(style)
|
||||||
|
|
||||||
|
assert result["background-color"] == "red"
|
||||||
|
assert result["color"] == "white"
|
||||||
|
assert result["font-weight"] == "bold"
|
||||||
|
|
||||||
|
def test_resolve_preset_with_override(self):
|
||||||
|
"""Preset properties can be overridden by explicit values."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
# "success" preset has background and color defined
|
||||||
|
style = Style(preset="success", color="black")
|
||||||
|
result = resolver.resolve(style)
|
||||||
|
|
||||||
|
# background comes from preset
|
||||||
|
assert result["background-color"] == "var(--color-success)"
|
||||||
|
# color is overridden
|
||||||
|
assert result["color"] == "black"
|
||||||
|
|
||||||
|
def test_resolve_unknown_preset_ignored(self):
|
||||||
|
"""Unknown preset is ignored, only explicit properties returned."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style(preset="unknown_preset", color="blue")
|
||||||
|
result = resolver.resolve(style)
|
||||||
|
|
||||||
|
assert "background-color" not in result
|
||||||
|
assert result["color"] == "blue"
|
||||||
|
|
||||||
|
def test_resolve_custom_presets(self):
|
||||||
|
"""Custom presets can be provided to the resolver."""
|
||||||
|
custom_presets = {
|
||||||
|
"custom": {
|
||||||
|
"background-color": "purple",
|
||||||
|
"color": "yellow",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolver = StyleResolver(style_presets=custom_presets)
|
||||||
|
style = Style(preset="custom")
|
||||||
|
result = resolver.resolve(style)
|
||||||
|
|
||||||
|
assert result["background-color"] == "purple"
|
||||||
|
assert result["color"] == "yellow"
|
||||||
|
|
||||||
|
def test_resolve_empty_style(self):
|
||||||
|
"""Empty style returns empty dict."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style()
|
||||||
|
result = resolver.resolve(style)
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_resolve_none_style(self):
|
||||||
|
"""None style returns empty dict."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
result = resolver.resolve(None)
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_resolve_converts_property_names(self):
|
||||||
|
"""Python attribute names are converted to CSS property names."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style(
|
||||||
|
background_color="red",
|
||||||
|
font_weight="bold",
|
||||||
|
font_style="italic",
|
||||||
|
font_size="14px",
|
||||||
|
text_decoration="underline"
|
||||||
|
)
|
||||||
|
result = resolver.resolve(style)
|
||||||
|
|
||||||
|
assert "background-color" in result
|
||||||
|
assert "font-weight" in result
|
||||||
|
assert "font-style" in result
|
||||||
|
assert "font-size" in result
|
||||||
|
assert "text-decoration" in result
|
||||||
|
# Python names should not be in result
|
||||||
|
assert "background_color" not in result
|
||||||
|
assert "font_weight" not in result
|
||||||
|
|
||||||
|
def test_resolve_all_properties(self):
|
||||||
|
"""All style properties are resolved correctly."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style(
|
||||||
|
background_color="#ff0000",
|
||||||
|
color="#ffffff",
|
||||||
|
font_weight="bold",
|
||||||
|
font_style="italic",
|
||||||
|
font_size="12px",
|
||||||
|
text_decoration="line-through"
|
||||||
|
)
|
||||||
|
result = resolver.resolve(style)
|
||||||
|
|
||||||
|
assert result["background-color"] == "#ff0000"
|
||||||
|
assert result["color"] == "#ffffff"
|
||||||
|
assert result["font-weight"] == "bold"
|
||||||
|
assert result["font-style"] == "italic"
|
||||||
|
assert result["font-size"] == "12px"
|
||||||
|
assert result["text-decoration"] == "line-through"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToCssString:
|
||||||
|
def test_to_css_string(self):
|
||||||
|
"""Convert resolved style to CSS inline string."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style(background_color="red", color="white")
|
||||||
|
result = resolver.to_css_string(style)
|
||||||
|
|
||||||
|
assert "background-color: red" in result
|
||||||
|
assert "color: white" in result
|
||||||
|
assert result.endswith(";")
|
||||||
|
|
||||||
|
def test_to_css_string_empty(self):
|
||||||
|
"""Empty style returns empty string."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style()
|
||||||
|
result = resolver.to_css_string(style)
|
||||||
|
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_to_css_string_none(self):
|
||||||
|
"""None style returns empty string."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
result = resolver.to_css_string(None)
|
||||||
|
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
def test_to_css_string_format(self):
|
||||||
|
"""CSS string has correct format with semicolons."""
|
||||||
|
resolver = StyleResolver()
|
||||||
|
style = Style(color="blue")
|
||||||
|
result = resolver.to_css_string(style)
|
||||||
|
|
||||||
|
assert result == "color: blue;"
|
||||||
Reference in New Issue
Block a user