Added classes to support formatting

This commit is contained in:
2026-01-26 21:32:26 +01:00
parent 3083f3b1fd
commit 9abb9dddfe
14 changed files with 2059 additions and 21 deletions

View File

@@ -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()`

View File

@@ -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()),

View File

@@ -0,0 +1 @@
# Formatting module for DataGrid

View 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

View 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

View 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]

View 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

View 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",
},
}

View 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()) + ";"

View File

@@ -0,0 +1 @@
# Tests for formatting module

View 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

View 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

View 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) == ""

View 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;"