3 Commits

25 changed files with 1172 additions and 550 deletions

View File

@@ -48,12 +48,14 @@ Rules are indented (Python-style) under their scope.
Scopes define which cells a rule applies to:
| Scope | Syntax | Applies To |
|-------|--------|------------|
| **Column** | `column <name>:` | All cells in the column |
| **Row** | `row <index>:` | All cells in the row |
| **Cell (coordinates)** | `cell (<col>, <row>):` | Single cell by position |
| **Cell (ID)** | `cell <cell_id>:` | Single cell by ID |
| Scope | Syntax | Applies To | Specificity |
|-------|--------|------------|-------------|
| **Cell (coordinates)** | `cell (<col>, <row>):` | Single cell by position | Highest (1) |
| **Cell (ID)** | `cell <cell_id>:` | Single cell by ID | Highest (1) |
| **Row** | `row <index>:` | All cells in the row | High (2) |
| **Column** | `column <name>:` | All cells in the column | Medium (3) |
| **Table** | `table "<name>":` | All cells in a specific table | Low (4) |
| **Tables** | `tables:` | All cells in all tables (global) | Lowest (5) |
**Column scope:**
@@ -97,6 +99,24 @@ cell tcell_grid1-3-2:
style(background_color="yellow")
```
**Table scope:**
```python
# Table by name (must match DataGrid _settings.name)
table "products":
style("neutral")
format.number(precision=2)
```
**Tables scope (global):**
```python
# All tables in the application
tables:
style(color="#333")
format.date(format="%Y-%m-%d")
```
### Rules
A rule consists of optional **style**, optional **format**, and optional **condition**:
@@ -355,10 +375,12 @@ program : scope+
// Scopes
scope : scope_header NEWLINE INDENT rule+ DEDENT
scope_header : column_scope | row_scope | cell_scope
scope_header : column_scope | row_scope | cell_scope | table_scope | tables_scope
column_scope : "column" column_name ":"
row_scope : "row" INTEGER ":"
cell_scope : "cell" cell_ref ":"
table_scope : "table" QUOTED_STRING ":"
tables_scope : "tables" ":"
column_name : NAME | QUOTED_STRING
cell_ref : "(" column_name "," INTEGER ")" | CELL_ID
@@ -452,6 +474,21 @@ row 0:
style("neutral", bold=True)
```
**Apply default style to all cells in a table:**
```python
table "products":
style("neutral")
format.number(precision=2)
```
**Apply global styles to all tables:**
```python
tables:
style(font_size="14px")
```
### Advanced Examples
**Compare with another column:**
@@ -496,9 +533,17 @@ column status:
format.enum(source={"draft": "Brouillon", "pending": "En attente", "approved": "Approuvé"}, default="Inconnu")
```
**Complete example - Financial report:**
**Complete example - Financial report with hierarchy:**
```python
# Global styling for all tables
tables:
style(font_size="14px", color="#333")
# Table-specific defaults
table "financial_report":
format.number(precision=2)
# Header styling
row 0:
style("neutral", bold=True)
@@ -533,6 +578,13 @@ cell (amount, 10):
style("accent", bold=True)
```
**Note on hierarchy:** In the example above, for the cell `(amount, 10)`, the styles are applied in this order:
1. Cell-specific rule wins (accent, bold)
2. If no cell rule, column rules apply (amount formatting + conditional styles)
3. If no column rule, row rules apply (header bold)
4. If no row rule, table rules apply (financial_report precision)
5. If no table rule, global rules apply (tables font size and color)
---
## Autocompletion
@@ -579,8 +631,9 @@ The DSL editor provides context-aware autocompletion to help users write rules e
| Context | Trigger | Suggestions |
|---------|---------|-------------|
| **Scope keyword** | Start of line | `column`, `row`, `cell` |
| **Scope keyword** | Start of line | `column`, `row`, `cell`, `table`, `tables` |
| **Column name** | After `column ` | Column names from DataGrid |
| **Table name** | After `table ` | Table name from current DataGrid (_settings.name) |
| **Style preset** | Inside `style("` | Style presets |
| **Style parameter** | Inside `style(... , ` | `bold`, `italic`, `color`, etc. |
| **Format preset** | Inside `format("` | Format presets |
@@ -609,6 +662,13 @@ column amount: │ (new line, indent)
style("error", bold=True) if │ value, col., row., not
style("error", bold=True) if value │ ==, !=, <, >, in, ...
style("error", bold=True) if value < │ [number input]
tab │ table, tables
table │ [table name from current grid]
table "products": │ (new line, indent)
tables │ tables
tables: │ (new line, indent)
```
---
@@ -1166,12 +1226,14 @@ class DatagridMetadataProvider(Protocol):
| Context | Trigger | Suggestions |
|---------|---------|-------------|
| `SCOPE_KEYWORD` | Start of non-indented line | `column`, `row`, `cell` |
| `SCOPE_KEYWORD` | Start of non-indented line | `column`, `row`, `cell`, `table`, `tables` |
| `COLUMN_NAME` | After `column ` | Column names from DataGrid |
| `ROW_INDEX` | After `row ` | First 10 indices + last index |
| `CELL_START` | After `cell ` | `(` |
| `CELL_COLUMN` | After `cell (` | Column names |
| `CELL_ROW` | After `cell (col, ` | First 10 indices + last index |
| `TABLE_NAME` | After `table ` | Table name from current DataGrid (_settings.name) |
| `TABLES_SCOPE` | After `tables` | `:` |
| `RULE_START` | Start of indented line (after scope) | `style(`, `format(`, `format.` |
| `STYLE_ARGS` | After `style(` (no quote) | Presets with quotes + named params (`bold=`, `color=`, ...) |
| `STYLE_PRESET` | Inside `style("` | Style presets |
@@ -1313,16 +1375,18 @@ The following features are excluded from autocompletion for simplicity:
| DSL Grammar (lark) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/grammar.py` |
| DSL Parser | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/parser.py` |
| DSL Transformer | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/transformer.py` |
| Scope Dataclasses | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/scopes.py` |
| Scope Dataclasses (Column, Row, Cell) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/scopes.py` |
| Scope Dataclasses (Table, Tables) | :o: To implement | `src/myfasthtml/core/formatting/dsl/scopes.py` |
| Exceptions | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/exceptions.py` |
| Public API (`parse_dsl()`) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/__init__.py` |
| Unit Tests (Parser) | :white_check_mark: ~35 tests | `tests/core/formatting/dsl/test_dsl_parser.py` |
| **Autocompletion** | | |
| DatagridMetadataProvider | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/provider.py` |
| Scope Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Context Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Suggestions Generator | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/suggestions.py` |
| Completion Engine | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/engine.py` |
| Scope Detector (Column, Row, Cell) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Scope Detector (Table, Tables) | :o: To implement | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Context Detector | :white_check_mark: Implemented | `FormattingCompletionEngine.py` |
| Suggestions Generator | :white_check_mark: Implemented | `FormattingCompletionEngine.py` |
| Completion Engine | :white_check_mark: Implemented | `FormattingCompletionEngine.py` |
| Presets | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/presets.py` |
| Unit Tests (Completion) | :white_check_mark: ~50 tests | `tests/core/formatting/dsl/test_completion.py` |
| REST Endpoint | :white_check_mark: Implemented | `src/myfasthtml/core/utils.py``/myfasthtml/completions` |

View File

@@ -16,6 +16,12 @@
| `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 | |
| **Scope Levels** | | |
| Cell scope | :white_check_mark: Implemented | |
| Row scope | :white_check_mark: Implemented | |
| Column scope | :white_check_mark: Implemented | |
| Table scope | :o: To implement | |
| Tables scope (global) | :o: To implement | |
| **DataGrid Integration** | | |
| Integration in `mk_body_cell_content()` | :white_check_mark: Implemented | `DataGrid.py` |
| DataGridFormattingEditor | :white_check_mark: Implemented | `DataGridFormattingEditor.py` |
@@ -32,13 +38,15 @@
This document describes the formatting capabilities for the DataGrid component.
**Formatting applies at three levels:**
**Formatting applies at five levels:**
| Level | Cells Targeted | Condition Evaluated On |
|------------|-------------------------|----------------------------------------------|
| **Cell** | 1 specific cell | The cell value |
| **Row** | All cells in the row | Each cell value (or fixed column with `col`) |
| **Column** | All cells in the column | Each cell value (or fixed row with `row`) |
| Level | Cells Targeted | Condition Evaluated On | Specificity |
|------------|-----------------------------------|----------------------------------------------|-------------|
| **Cell** | 1 specific cell | The cell value | Highest (1) |
| **Row** | All cells in the row | Each cell value (or fixed column with `col`) | High (2) |
| **Column** | All cells in the column | Each cell value (or fixed row with `row`) | Medium (3) |
| **Table** | All cells in a specific table | Each cell value | Low (4) |
| **Tables** | All cells in all tables (global) | Each cell value | Lowest (5) |
---
@@ -467,11 +475,13 @@ formatter_presets = {
### Format Storage Location
| Level | Storage | Key | Status |
|------------|------------------------------|---------|--------|
| **Column** | `DataGridColumnState.format` | - | Structure exists |
| **Row** | `DataGridRowState.format` | - | Structure exists |
| **Cell** | `DatagridState.cell_formats` | Cell ID | Structure exists |
| Level | Storage | Key | Status |
|------------|--------------------------------|---------|--------|
| **Cell** | `DatagridState.cell_formats` | Cell ID | Structure exists |
| **Row** | `DataGridRowState.format` | - | Structure exists |
| **Column** | `DataGridColumnState.format` | - | Structure exists |
| **Table** | `DatagridState.table_format` | - | :o: To implement |
| **Tables** | `DataGridsManager.all_tables_formats` | - | :o: To implement |
### Cell ID Format

View File

@@ -24,7 +24,10 @@ from myfasthtml.controls.helpers import mk, icons
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formatting.dsl.completion.FormattingCompletionEngine import FormattingCompletionEngine
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.dsl.parser import DSLParser
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv
@@ -75,6 +78,7 @@ class DatagridState(DbObject):
self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState()
self.cell_formats: dict = {}
self.table_format: list = []
self.ne_df = None
self.ns_fast_access = None
@@ -97,6 +101,7 @@ class DatagridSettings(DbObject):
self.open_file_visible: bool = True
self.open_settings_visible: bool = True
self.text_size: str = "sm"
self.enable_formatting: bool = True
class Commands(BaseCommands):
@@ -206,12 +211,20 @@ class DataGrid(MultipleInstance):
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
editor_conf = DslEditorConf()
self._formatting_editor = DataGridFormattingEditor(self,
conf=editor_conf,
dsl=FormattingDSL(),
save_state=self._settings.save_state,
_id="-formatting_editor")
if self._settings.enable_formatting:
completion_engine = FormattingCompletionEngine(self._parent, self.get_table_name())
editor_conf = DslEditorConf(engine_id=completion_engine.get_id())
dsl = FormattingDSL()
self._formatting_editor = DataGridFormattingEditor(self,
conf=editor_conf,
dsl=dsl,
save_state=self._settings.save_state,
_id="-formatting_editor")
# register the auto-completion for the formatter DSL
DslsManager.register(completion_engine,
DSLParser())
else:
self._formatting_editor = None
# other definitions
self._mouse_support = {
@@ -220,7 +233,7 @@ class DataGrid(MultipleInstance):
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
}
logger.debug(f"DataGrid '{self._get_full_name()}' with id='{self._id}' created.")
logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.")
@property
def _df(self):
@@ -262,7 +275,7 @@ class DataGrid(MultipleInstance):
def _get_filtered_df(self):
if self._df is None:
return DataFrame()
return None
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
@@ -298,9 +311,6 @@ class DataGrid(MultipleInstance):
self._state.selection.selected = pos
self._state.save()
def _get_full_name(self):
return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name
def init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype):
@@ -387,6 +397,8 @@ class DataGrid(MultipleInstance):
1. Cell-level: self._state.cell_formats[cell_id]
2. Row-level: row_state.format (if row has specific state)
3. Column-level: col_def.format
4. Table-level: self._state.table_format
5. Tables-level (global): manager.all_tables_formats
Args:
col_pos: Column position index
@@ -410,7 +422,11 @@ class DataGrid(MultipleInstance):
if col_def.format:
return col_def.format
return None
if self._state.table_format:
return self._state.table_format
# Get global tables formatting from manager
return self._parent.all_tables_formats
def set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS."""
@@ -497,6 +513,9 @@ class DataGrid(MultipleInstance):
def get_settings(self):
return self._settings
def get_table_name(self):
return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column()
@@ -617,6 +636,9 @@ class DataGrid(MultipleInstance):
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
"""
df = self._get_filtered_df()
if df is None:
return []
start = page_index * DATAGRID_PAGE_SIZE
end = start + DATAGRID_PAGE_SIZE
if self._state.ns_total_rows > end:

View File

@@ -2,7 +2,8 @@ import logging
from collections import defaultdict
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope, TableScope, TablesScope
from myfasthtml.core.instances import InstancesManager
logger = logging.getLogger("DataGridFormattingEditor")
@@ -62,11 +63,13 @@ class DataGridFormattingEditor(DslEditor):
columns_rules = defaultdict(list) # key = column name
rows_rules = defaultdict(list) # key = row index
cells_rules = defaultdict(list) # key = cell_id
table_rules = [] # rules for this table
tables_rules = [] # global rules for all tables
for scoped_rule in scoped_rules:
scope = scoped_rule.scope
rule = scoped_rule.rule
if isinstance(scope, ColumnScope):
columns_rules[scope.column].append(rule)
elif isinstance(scope, RowScope):
@@ -75,6 +78,14 @@ class DataGridFormattingEditor(DslEditor):
cell_id = self._get_cell_id(scope)
if cell_id:
cells_rules[cell_id].append(rule)
elif isinstance(scope, TableScope):
# Validate table name matches current grid
if scope.table == self._parent._settings.name:
table_rules.append(rule)
else:
logger.warning(f"Table name '{scope.table}' does not match grid name '{self._parent._settings.name}', skipping rules")
elif isinstance(scope, TablesScope):
tables_rules.append(rule)
# Step 3: Copy state for atomic update
state = self._parent.get_state().copy()
@@ -85,6 +96,7 @@ class DataGridFormattingEditor(DslEditor):
for row in state.rows:
row.format = None
state.cell_formats.clear()
state.table_format = []
# Step 5: Apply grouped rules on the copy
for column_name, rules in columns_rules.items():
@@ -103,10 +115,21 @@ class DataGridFormattingEditor(DslEditor):
for cell_id, rules in cells_rules.items():
state.cell_formats[cell_id] = rules
# Apply table-level rules
if table_rules:
state.table_format = table_rules
# Apply global tables-level rules to manager
if tables_rules:
from myfasthtml.controls.DataGridsManager import DataGridsManager
manager = InstancesManager.get_by_type(self._session, DataGridsManager)
if manager:
manager.all_tables_formats = tables_rules
# Step 6: Update state atomically
self._parent.get_state().update(state)
# Step 7: Refresh the DataGrid
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells")
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells, table: {len(table_rules)}, tables: {len(tables_rules)}")
return self._parent.render_partial("body")

View File

@@ -14,11 +14,7 @@ from myfasthtml.controls.helpers import mk
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formatting.dsl.completion.engine import FormattingCompletionEngine
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.dsl.parser import DSLParser
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
from myfasthtml.core.instances import InstancesManager, SingleInstance
from myfasthtml.icons.fluent_p1 import table_add20_regular
@@ -88,17 +84,13 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._state = DataGridsState(self)
self._tree = self._mk_tree()
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, None)
self._registry = DataGridsRegistry(parent)
# Global presets shared across all DataGrids
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
# register the auto-completion for the formatter DSL
DslsManager.register(FormattingDSL().get_id(),
FormattingCompletionEngine(self),
DSLParser())
self.all_tables_formats: list = []
def upload_from_source(self):
file_upload = FileUpload(self)
@@ -112,7 +104,7 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
namespace = file_upload.get_file_basename()
name = file_upload.get_sheet_name()
dg_conf = DatagridConf(namespace=namespace, name=name)
dg = DataGrid(self._tabs_manager, conf=dg_conf, save_state=True) # first time the Datagrid is created
dg = DataGrid(self, conf=dg_conf, save_state=True) # first time the Datagrid is created
dg.init_from_dataframe(df)
self._registry.put(namespace, name, dg.get_id())
document = DocumentDefinition(
@@ -133,7 +125,7 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
document_id = self._tree.get_bag(node_id)
try:
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
except StopIteration:
# the selected node is not a document (it's a folder)
@@ -157,7 +149,7 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
raise ValueError(f"No document found for tab {tab_id}")
# Recreate the DataGrid with its saved state
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
return dg
def clear_tree(self):

View File

@@ -32,6 +32,7 @@ class DslEditorConf:
linting: bool = True
placeholder: str = ""
readonly: bool = False
engine_id: str = None # id of the DSL engine to use for autocompletion
class DslEditorState(DbObject):
@@ -98,7 +99,7 @@ class DslEditor(MultipleInstance):
self._dsl = dsl
self.conf = conf or DslEditorConf()
self._state = DslEditorState(self, name=conf.name, save_state=save_state)
self._state = DslEditorState(self, name=self.conf.name, save_state=save_state)
self.commands = Commands(self)
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")
@@ -141,7 +142,7 @@ class DslEditor(MultipleInstance):
simple_mode_config = None
if hasattr(self._dsl, 'simple_mode_config'):
simple_mode_config = self._dsl.simple_mode_config
config = {
"elementId": str(self._id),
"textareaId": f"ta_{self._id}",
@@ -151,7 +152,7 @@ class DslEditor(MultipleInstance):
"placeholder": self.conf.placeholder,
"readonly": self.conf.readonly,
"updateCommandId": str(self.commands.update_content().id),
"dslId": self._dsl.get_id(),
"dslId": self.conf.engine_id,
"dsl": {
"name": self._dsl.name,
"completions": self._dsl.completions,

View File

@@ -33,6 +33,7 @@ class BaseCompletionEngine(ABC):
provider: Metadata provider for context-aware suggestions
"""
self.provider = provider
self._id = type(self).__name__
def get_completions(self, text: str, cursor: Position) -> CompletionResult:
"""
@@ -169,4 +170,4 @@ class BaseCompletionEngine(ABC):
)
def get_id(self):
return type(self).__name__
return self._id

View File

@@ -19,7 +19,7 @@ class BaseMetadataProvider(Protocol):
can extend this with additional methods.
"""
def get_style_presets(self) -> list[str]:
def list_style_presets(self) -> list[str]:
"""
Return the list of available style preset names.
@@ -28,7 +28,7 @@ class BaseMetadataProvider(Protocol):
"""
...
def get_format_presets(self) -> list[str]:
def list_format_presets(self) -> list[str]:
"""
Return the list of available format preset names.

View File

@@ -14,9 +14,9 @@ class DslsManager:
dsls: dict[str, DslDefinition] = {}
@staticmethod
def register(dsl_id: str, completion: BaseCompletionEngine, validation: DSLParser):
def register(completion: BaseCompletionEngine, validation: DSLParser):
# then engine_id is actually the DSL id
DslsManager.dsls[dsl_id] = DslDefinition(completion, validation)
DslsManager.dsls[completion.get_id()] = DslDefinition(completion, validation)
@staticmethod
def get_completion_engine(engine_id) -> BaseCompletionEngine:

View File

@@ -23,7 +23,7 @@ Example:
"""
from .parser import get_parser
from .transformer import DSLTransformer
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from .scopes import ColumnScope, RowScope, CellScope, TableScope, TablesScope, ScopedRule
from .exceptions import DSLError, DSLSyntaxError, DSLValidationError
@@ -61,6 +61,8 @@ __all__ = [
"ColumnScope",
"RowScope",
"CellScope",
"TableScope",
"TablesScope",
"ScopedRule",
# Exceptions
"DSLError",

View File

@@ -0,0 +1,397 @@
"""
Completion engine for the formatting DSL.
Implements the BaseCompletionEngine for DataGrid formatting rules.
"""
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
from myfasthtml.core.utils import make_safe_id
from . import presets
from .contexts import Context, DetectedScope, detect_scope, detect_context
from .provider import DatagridMetadataProvider
class FormattingCompletionEngine(BaseCompletionEngine):
"""
Autocompletion engine for the DataGrid Formatting DSL.
Provides context-aware suggestions for:
- Scope definitions (column, row, cell)
- Style expressions with presets and parameters
- Format expressions with presets and types
- Conditions with operators and values
"""
def __init__(self, provider: DatagridMetadataProvider, table_name: str):
"""
Initialize the completion engine.
Args:
provider: DataGrid metadata provider for dynamic suggestions
"""
super().__init__(provider)
self.provider: DatagridMetadataProvider = provider
self.table_name: str = table_name # current table name
self._id = "formatting_completion_engine#" + make_safe_id(table_name)
def detect_scope(self, text: str, current_line: int) -> DetectedScope:
"""
Detect the current scope by scanning previous lines.
Looks for the most recent scope declaration (column/row/cell).
Args:
text: The full document text
current_line: Current line number (0-based)
Returns:
DetectedScope with scope information
"""
return detect_scope(text, current_line)
def detect_context(
self, text: str, cursor: Position, scope: DetectedScope
) -> Context:
"""
Detect the completion context at the cursor position.
Args:
text: The full document text
cursor: Cursor position
scope: The detected scope
Returns:
Context enum value
"""
return detect_context(text, cursor, scope)
def get_suggestions(
self, context: Context, scope: DetectedScope, prefix: str
) -> list[Suggestion]:
"""
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
prefix: The current word prefix (not used here, filtering done in base)
Returns:
List of suggestions
"""
match context:
# =================================================================
# Scope-level contexts
# =================================================================
case Context.NONE:
return []
case Context.SCOPE_KEYWORD:
return presets.SCOPE_KEYWORDS
case Context.COLUMN_NAME:
return self._get_column_suggestions()
case Context.ROW_INDEX:
return self._get_row_index_suggestions()
case Context.CELL_START:
return [Suggestion("(", "Start cell coordinates", "syntax")]
case Context.CELL_COLUMN:
return self._get_column_suggestions()
case Context.CELL_ROW:
return self._get_row_index_suggestions()
case Context.TABLE_NAME:
return self._get_table_name_suggestion()
case Context.TABLES_SCOPE:
return [Suggestion(":", "Define global rules for all tables", "syntax")]
# =================================================================
# Rule-level contexts
# =================================================================
case Context.RULE_START:
return presets.RULE_START
# =================================================================
# Style contexts
# =================================================================
case Context.STYLE_ARGS:
# Presets (with quotes) + params
style_presets = self._get_style_preset_suggestions_quoted()
return style_presets + presets.STYLE_PARAMS
case Context.STYLE_PRESET:
return self._get_style_preset_suggestions()
case Context.STYLE_PARAM:
return presets.STYLE_PARAMS
# =================================================================
# Format contexts
# =================================================================
case Context.FORMAT_PRESET:
return self._get_format_preset_suggestions()
case Context.FORMAT_TYPE:
return presets.FORMAT_TYPES
case Context.FORMAT_PARAM_DATE:
return presets.FORMAT_PARAMS_DATE
case Context.FORMAT_PARAM_TEXT:
return presets.FORMAT_PARAMS_TEXT
# =================================================================
# After style/format
# =================================================================
case Context.AFTER_STYLE_OR_FORMAT:
return presets.AFTER_STYLE_OR_FORMAT
# =================================================================
# Condition contexts
# =================================================================
case Context.CONDITION_START:
return presets.CONDITION_START
case Context.CONDITION_AFTER_NOT:
return presets.CONDITION_AFTER_NOT
case Context.COLUMN_REF:
return self._get_column_suggestions()
case Context.COLUMN_REF_QUOTED:
return self._get_column_suggestions_with_closing_quote()
# =================================================================
# Operator contexts
# =================================================================
case Context.OPERATOR:
return presets.COMPARISON_OPERATORS
case Context.OPERATOR_VALUE | Context.BETWEEN_VALUE:
# col., True, False + column values
base = presets.OPERATOR_VALUE_BASE.copy()
base.extend(self._get_column_value_suggestions(scope))
return base
case Context.BETWEEN_AND:
return presets.BETWEEN_AND
case Context.IN_LIST_START:
return presets.IN_LIST_START
case Context.IN_LIST_VALUE:
return self._get_column_value_suggestions(scope)
# =================================================================
# Value contexts
# =================================================================
case Context.BOOLEAN_VALUE:
return presets.BOOLEAN_VALUES
case Context.COLOR_VALUE:
return presets.ALL_COLORS
case Context.DATE_FORMAT_VALUE:
return presets.DATE_PATTERNS
case Context.TRANSFORM_VALUE:
return presets.TEXT_TRANSFORMS
case _:
return []
def _get_column_suggestions(self) -> list[Suggestion]:
"""Get column name suggestions from provider."""
try:
# Try to get columns from the first available table
tables = self.provider.list_tables()
if tables:
columns = self.provider.list_columns(self.table_name)
return [Suggestion(col, "Column", "column") for col in columns]
except Exception:
pass
return []
def _get_column_suggestions_with_closing_quote(self) -> list[Suggestion]:
"""Get column name suggestions with closing quote."""
try:
tables = self.provider.list_tables()
if tables:
columns = self.provider.list_columns(self.table_name)
return [Suggestion(f'{col}"', "Column", "column") for col in columns]
except Exception:
pass
return []
def _get_table_name_suggestion(self) -> list[Suggestion]:
"""Get table name suggestion (current table only)."""
if self.table_name:
return [Suggestion(f'"{self.table_name}"', f"Current table: {self.table_name}", "table")]
return []
def _get_style_preset_suggestions(self) -> list[Suggestion]:
"""Get style preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
try:
custom_presets = self.provider.list_style_presets()
for preset in custom_presets:
# Check if it's already in default presets
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets (just the name, no quotes - we're inside quotes)
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_style_preset_suggestions_quoted(self) -> list[Suggestion]:
"""Get style preset suggestions with quotes."""
suggestions = []
# Add provider presets if available
try:
custom_presets = self.provider.list_style_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
except Exception:
pass
# Add default presets with quotes
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
return suggestions
def _get_format_preset_suggestions(self) -> list[Suggestion]:
"""Get format preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
try:
custom_presets = self.provider.list_format_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.FORMAT_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets
for preset in presets.FORMAT_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_row_index_suggestions(self) -> list[Suggestion]:
"""Get row index suggestions (first 10 + last)."""
suggestions = []
try:
tables = self.provider.list_tables()
if tables:
row_count = self.provider.get_row_count(self.table_name)
if row_count > 0:
# First 10 rows
for i in range(min(10, row_count)):
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
# Last row if not already included
last_index = row_count - 1
if last_index >= 10:
suggestions.append(
Suggestion(str(last_index), f"Last row ({row_count} total)", "index")
)
except Exception:
pass
# Fallback if no provider data
if not suggestions:
for i in range(10):
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
return suggestions
def _get_column_value_suggestions(self, scope: DetectedScope) -> list[Suggestion]:
"""Get column value suggestions based on the current scope."""
if not scope.column_name:
return []
try:
# Use table_name from scope, or empty string as fallback
table_name = scope.table_name or ""
values = self.provider.list_column_values(table_name, scope.column_name)
suggestions = []
for value in values:
if value is None:
continue
# Format value appropriately
if isinstance(value, str):
label = f'"{value}"'
detail = "Text value"
elif isinstance(value, bool):
label = str(value)
detail = "Boolean value"
elif isinstance(value, (int, float)):
label = str(value)
detail = "Numeric value"
else:
label = f'"{value}"'
detail = "Value"
suggestions.append(Suggestion(label, detail, "value"))
return suggestions
except Exception:
return []
def get_completions(
text: str,
cursor: Position,
provider: DatagridMetadataProvider,
table_name: str,
) -> CompletionResult:
"""
Get autocompletion suggestions for the formatting DSL.
This is the main entry point for the autocompletion API.
Args:
text: The full DSL document text
cursor: Cursor position (line and ch are 0-based)
provider: DataGrid metadata provider
table_name: Table name for completions
Returns:
CompletionResult with suggestions and replacement range
Example:
result = get_completions(
text='column amount:\\n style("err',
cursor=Position(line=1, ch=15),
provider=my_provider,
table_name="app.orders"
)
# result.suggestions contains ["error"] filtered by prefix "err"
"""
engine = FormattingCompletionEngine(provider, table_name)
return engine.get_completions(text, cursor)

View File

@@ -25,12 +25,14 @@ class Context(Enum):
NONE = auto()
# Scope-level contexts
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell, table, tables
COLUMN_NAME = auto() # After "column ": column names
ROW_INDEX = auto() # After "row ": row indices
CELL_START = auto() # After "cell ": (
CELL_COLUMN = auto() # After "cell (": column names
CELL_ROW = auto() # After "cell (col, ": row indices
TABLE_NAME = auto() # After "table ": table name
TABLES_SCOPE = auto() # After "tables": colon
# Rule-level contexts
RULE_START = auto() # Start of indented line: style(, format(, format.
@@ -76,10 +78,10 @@ class DetectedScope:
Represents the detected scope from scanning previous lines.
Attributes:
scope_type: "column", "row", "cell", or None
scope_type: "column", "row", "cell", "table", "tables", or None
column_name: Column name (for column and cell scopes)
row_index: Row index (for row and cell scopes)
table_name: DataGrid name (if determinable)
table_name: Table name (for table scope) or DataGrid name
"""
scope_type: str | None = None
@@ -92,7 +94,7 @@ def detect_scope(text: str, current_line: int) -> DetectedScope:
"""
Detect the current scope by scanning backwards from the cursor line.
Looks for the most recent scope declaration (column/row/cell)
Looks for the most recent scope declaration (column/row/cell/table/tables)
that is not indented.
Args:
@@ -138,6 +140,17 @@ def detect_scope(text: str, current_line: int) -> DetectedScope:
return DetectedScope(
scope_type="cell", column_name=column_name, row_index=row_index
)
# Check for table scope
match = re.match(r'^table\s+"([^"]+)"\s*:', line)
if match:
table_name = match.group(1)
return DetectedScope(scope_type="table", table_name=table_name)
# Check for tables scope
match = re.match(r"^tables\s*:", line)
if match:
return DetectedScope(scope_type="tables")
return DetectedScope()
@@ -192,6 +205,14 @@ def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context
if re.match(r'^cell\s+\(\s*(?:"[^"]*"|[a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*$', line_to_cursor):
return Context.CELL_ROW
# After "table "
if re.match(r"^table\s+", line_to_cursor) and not line_to_cursor.rstrip().endswith(":"):
return Context.TABLE_NAME
# After "tables"
if re.match(r"^tables\s*$", line_to_cursor):
return Context.TABLES_SCOPE
# Start of line or partial keyword
return Context.SCOPE_KEYWORD

View File

@@ -1,109 +0,0 @@
"""
Completion engine for the formatting DSL.
Implements the BaseCompletionEngine for DataGrid formatting rules.
"""
from myfasthtml.core.dsl.base_completion import BaseCompletionEngine
from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult
from . import suggestions as suggestions_module
from .contexts import Context, DetectedScope, detect_scope, detect_context
from .provider import DatagridMetadataProvider
class FormattingCompletionEngine(BaseCompletionEngine):
"""
Autocompletion engine for the DataGrid Formatting DSL.
Provides context-aware suggestions for:
- Scope definitions (column, row, cell)
- Style expressions with presets and parameters
- Format expressions with presets and types
- Conditions with operators and values
"""
def __init__(self, provider: DatagridMetadataProvider):
"""
Initialize the completion engine.
Args:
provider: DataGrid metadata provider for dynamic suggestions
"""
super().__init__(provider)
self.provider: DatagridMetadataProvider = provider
def detect_scope(self, text: str, current_line: int) -> DetectedScope:
"""
Detect the current scope by scanning previous lines.
Looks for the most recent scope declaration (column/row/cell).
Args:
text: The full document text
current_line: Current line number (0-based)
Returns:
DetectedScope with scope information
"""
return detect_scope(text, current_line)
def detect_context(
self, text: str, cursor: Position, scope: DetectedScope
) -> Context:
"""
Detect the completion context at the cursor position.
Args:
text: The full document text
cursor: Cursor position
scope: The detected scope
Returns:
Context enum value
"""
return detect_context(text, cursor, scope)
def get_suggestions(
self, context: Context, scope: DetectedScope, prefix: str
) -> list[Suggestion]:
"""
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
prefix: The current word prefix (not used here, filtering done in base)
Returns:
List of suggestions
"""
return suggestions_module.get_suggestions(context, scope, self.provider)
def get_completions(
text: str,
cursor: Position,
provider: DatagridMetadataProvider,
) -> CompletionResult:
"""
Get autocompletion suggestions for the formatting DSL.
This is the main entry point for the autocompletion API.
Args:
text: The full DSL document text
cursor: Cursor position (line and ch are 0-based)
provider: DataGrid metadata provider
Returns:
CompletionResult with suggestions and replacement range
Example:
result = get_completions(
text='column amount:\\n style("err',
cursor=Position(line=1, ch=15),
provider=my_provider
)
# result.suggestions contains ["error"] filtered by prefix "err"
"""
engine = FormattingCompletionEngine(provider)
return engine.get_completions(text, cursor)

View File

@@ -120,6 +120,8 @@ SCOPE_KEYWORDS: list[Suggestion] = [
Suggestion("column", "Define column scope", "keyword"),
Suggestion("row", "Define row scope", "keyword"),
Suggestion("cell", "Define cell scope", "keyword"),
Suggestion("table", "Define table scope", "keyword"),
Suggestion("tables", "Define global scope for all tables", "keyword"),
]
# =============================================================================

View File

@@ -5,10 +5,12 @@ Provides access to DataGrid metadata (columns, values, row counts)
for context-aware autocompletion.
"""
from typing import Protocol, Any
from typing import Any
from myfasthtml.core.dsl.base_provider import BaseMetadataProvider
class DatagridMetadataProvider(Protocol):
class DatagridMetadataProvider(BaseMetadataProvider):
"""
Protocol for providing DataGrid metadata to the autocompletion engine.
@@ -70,25 +72,3 @@ class DatagridMetadataProvider(Protocol):
Number of rows
"""
...
def list_style_presets(self) -> list[str]:
"""
Return the list of available style preset names.
Includes default presets (primary, error, etc.) and custom presets.
Returns:
List of style preset names
"""
...
def list_format_presets(self) -> list[str]:
"""
Return the list of available format preset names.
Includes default presets (EUR, USD, etc.) and custom presets.
Returns:
List of format preset names
"""
...

View File

@@ -1,313 +0,0 @@
"""
Suggestions generation for the formatting DSL.
Provides functions to generate appropriate suggestions
based on the detected context and scope.
"""
from myfasthtml.core.dsl.types import Suggestion
from . import presets
from .contexts import Context, DetectedScope
from .provider import DatagridMetadataProvider
def get_suggestions(
context: Context,
scope: DetectedScope,
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""
Generate suggestions for the given context.
Args:
context: The detected completion context
scope: The detected scope
provider: Metadata provider for dynamic data
Returns:
List of suggestions
"""
match context:
# =================================================================
# Scope-level contexts
# =================================================================
case Context.NONE:
return []
case Context.SCOPE_KEYWORD:
return presets.SCOPE_KEYWORDS
case Context.COLUMN_NAME:
return _get_column_suggestions(provider)
case Context.ROW_INDEX:
return _get_row_index_suggestions(provider)
case Context.CELL_START:
return [Suggestion("(", "Start cell coordinates", "syntax")]
case Context.CELL_COLUMN:
return _get_column_suggestions(provider)
case Context.CELL_ROW:
return _get_row_index_suggestions(provider)
# =================================================================
# Rule-level contexts
# =================================================================
case Context.RULE_START:
return presets.RULE_START
# =================================================================
# Style contexts
# =================================================================
case Context.STYLE_ARGS:
# Presets (with quotes) + params
style_presets = _get_style_preset_suggestions_quoted(provider)
return style_presets + presets.STYLE_PARAMS
case Context.STYLE_PRESET:
return _get_style_preset_suggestions(provider)
case Context.STYLE_PARAM:
return presets.STYLE_PARAMS
# =================================================================
# Format contexts
# =================================================================
case Context.FORMAT_PRESET:
return _get_format_preset_suggestions(provider)
case Context.FORMAT_TYPE:
return presets.FORMAT_TYPES
case Context.FORMAT_PARAM_DATE:
return presets.FORMAT_PARAMS_DATE
case Context.FORMAT_PARAM_TEXT:
return presets.FORMAT_PARAMS_TEXT
# =================================================================
# After style/format
# =================================================================
case Context.AFTER_STYLE_OR_FORMAT:
return presets.AFTER_STYLE_OR_FORMAT
# =================================================================
# Condition contexts
# =================================================================
case Context.CONDITION_START:
return presets.CONDITION_START
case Context.CONDITION_AFTER_NOT:
return presets.CONDITION_AFTER_NOT
case Context.COLUMN_REF:
return _get_column_suggestions(provider)
case Context.COLUMN_REF_QUOTED:
return _get_column_suggestions_with_closing_quote(provider)
# =================================================================
# Operator contexts
# =================================================================
case Context.OPERATOR:
return presets.COMPARISON_OPERATORS
case Context.OPERATOR_VALUE | Context.BETWEEN_VALUE:
# col., True, False + column values
base = presets.OPERATOR_VALUE_BASE.copy()
base.extend(_get_column_value_suggestions(scope, provider))
return base
case Context.BETWEEN_AND:
return presets.BETWEEN_AND
case Context.IN_LIST_START:
return presets.IN_LIST_START
case Context.IN_LIST_VALUE:
return _get_column_value_suggestions(scope, provider)
# =================================================================
# Value contexts
# =================================================================
case Context.BOOLEAN_VALUE:
return presets.BOOLEAN_VALUES
case Context.COLOR_VALUE:
return presets.ALL_COLORS
case Context.DATE_FORMAT_VALUE:
return presets.DATE_PATTERNS
case Context.TRANSFORM_VALUE:
return presets.TEXT_TRANSFORMS
case _:
return []
def _get_column_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get column name suggestions from provider."""
try:
# Try to get columns from the first available table
tables = provider.list_tables()
if tables:
columns = provider.list_columns(tables[0])
return [Suggestion(col, "Column", "column") for col in columns]
except Exception:
pass
return []
def _get_column_suggestions_with_closing_quote(
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""Get column name suggestions with closing quote."""
try:
tables = provider.list_tables()
if tables:
columns = provider.list_columns(tables[0])
return [Suggestion(f'{col}"', "Column", "column") for col in columns]
except Exception:
pass
return []
def _get_style_preset_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get style preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
try:
custom_presets = provider.list_style_presets()
for preset in custom_presets:
# Check if it's already in default presets
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets (just the name, no quotes - we're inside quotes)
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_style_preset_suggestions_quoted(
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""Get style preset suggestions with quotes."""
suggestions = []
# Add provider presets if available
try:
custom_presets = provider.list_style_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.STYLE_PRESETS):
suggestions.append(Suggestion(f'"{preset}"', "Custom preset", "preset"))
except Exception:
pass
# Add default presets with quotes
for preset in presets.STYLE_PRESETS:
suggestions.append(Suggestion(f'"{preset.label}"', preset.detail, preset.kind))
return suggestions
def _get_format_preset_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get format preset suggestions (without quotes)."""
suggestions = []
# Add provider presets if available
try:
custom_presets = provider.list_format_presets()
for preset in custom_presets:
if not any(s.label == preset for s in presets.FORMAT_PRESETS):
suggestions.append(Suggestion(preset, "Custom preset", "preset"))
except Exception:
pass
# Add default presets
for preset in presets.FORMAT_PRESETS:
suggestions.append(Suggestion(preset.label, preset.detail, preset.kind))
return suggestions
def _get_row_index_suggestions(provider: DatagridMetadataProvider) -> list[Suggestion]:
"""Get row index suggestions (first 10 + last)."""
suggestions = []
try:
tables = provider.list_tables()
if tables:
row_count = provider.get_row_count(tables[0])
if row_count > 0:
# First 10 rows
for i in range(min(10, row_count)):
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
# Last row if not already included
last_index = row_count - 1
if last_index >= 10:
suggestions.append(
Suggestion(str(last_index), f"Last row ({row_count} total)", "index")
)
except Exception:
pass
# Fallback if no provider data
if not suggestions:
for i in range(10):
suggestions.append(Suggestion(str(i), f"Row {i}", "index"))
return suggestions
def _get_column_value_suggestions(
scope: DetectedScope,
provider: DatagridMetadataProvider,
) -> list[Suggestion]:
"""Get column value suggestions based on the current scope."""
if not scope.column_name:
return []
try:
# Use table_name from scope, or empty string as fallback
table_name = scope.table_name or ""
values = provider.list_column_values(table_name, scope.column_name)
suggestions = []
for value in values:
if value is None:
continue
# Format value appropriately
if isinstance(value, str):
label = f'"{value}"'
detail = "Text value"
elif isinstance(value, bool):
label = str(value)
detail = "Boolean value"
elif isinstance(value, (int, float)):
label = str(value)
detail = "Numeric value"
else:
label = f'"{value}"'
detail = "Value"
suggestions.append(Suggestion(label, detail, "value"))
return suggestions
except Exception:
return []

View File

@@ -16,10 +16,14 @@ GRAMMAR = r"""
scope_header: column_scope
| row_scope
| cell_scope
| table_scope
| tables_scope
column_scope: "column" column_name
row_scope: "row" INTEGER
cell_scope: "cell" cell_ref
table_scope: "table" QUOTED_STRING
tables_scope: "tables"
column_name: NAME -> name
| QUOTED_STRING -> quoted_name

View File

@@ -64,12 +64,18 @@ class DSLParser:
lines = text.split("\n")
lines = ["" if line.strip().startswith("#") else line for line in lines]
text = "\n".join(lines)
# Strip leading whitespace/newlines and ensure text ends with newline
text = text.strip()
if text and not text.endswith("\n"):
# Handle empty text (return empty tree that will transform to empty list)
if not text:
from lark import Tree
return Tree('start', [])
if not text.endswith("\n"):
text += "\n"
try:
return self._parser.parse(text)
except UnexpectedInput as e:

View File

@@ -32,6 +32,18 @@ class CellScope:
cell_id: str = None
@dataclass
class TableScope:
"""Scope targeting a specific table by name."""
table: str
@dataclass
class TablesScope:
"""Scope targeting all tables (global)."""
pass
@dataclass
class ScopedRule:
"""
@@ -40,8 +52,8 @@ class ScopedRule:
The DSL parser returns a list of ScopedRule objects.
Attributes:
scope: Where the rule applies (ColumnScope, RowScope, or CellScope)
scope: Where the rule applies (ColumnScope, RowScope, CellScope, TableScope, or TablesScope)
rule: The FormatRule (condition + style + formatter)
"""
scope: ColumnScope | RowScope | CellScope
scope: ColumnScope | RowScope | CellScope | TableScope | TablesScope
rule: FormatRule

View File

@@ -6,7 +6,7 @@ Converts lark AST into FormatRule and ScopedRule objects.
from lark import Transformer
from .exceptions import DSLValidationError
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from .scopes import ColumnScope, RowScope, CellScope, TableScope, TablesScope, ScopedRule
from ..dataclasses import (
Condition,
Style,
@@ -67,7 +67,14 @@ class DSLTransformer(Transformer):
def cell_id(self, items):
cell_id = str(items[0])
return CellScope(cell_id=cell_id)
def table_scope(self, items):
table_name = self._unquote(items[0])
return TableScope(table=table_name)
def tables_scope(self, items):
return TablesScope()
def name(self, items):
return str(items[0])

View File

@@ -202,12 +202,17 @@ class InstancesManager:
return default
@staticmethod
def get_by_type(session: dict, cls: type):
def get_by_type(session: dict, cls: type, default=NO_DEFAULT_VALUE):
session_id = InstancesManager.get_session_id(session)
res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0]
try:
assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0]
except AssertionError:
if default is NO_DEFAULT_VALUE:
raise
return default
@staticmethod
def dynamic_get(session, component_parent: tuple, component: tuple):

View File

@@ -375,7 +375,7 @@ def post(session, b_id: str, values: dict):
:param values:
:return:
"""
logger.debug(f"Entering {Routes.Bindings} with {session=}, {b_id=}, {values=}")
logger.debug(f"Entering {Routes.Bindings} with session='{debug_session(session)}', {b_id=}, {values=}")
from myfasthtml.core.bindings import BindingsManager
binding = BindingsManager.get_binding(b_id)
if binding:
@@ -396,7 +396,8 @@ def get(session, e_id: str, text: str, line: int, ch: int):
:param ch:
:return:
"""
logger.debug(f"Entering {Routes.Completions} with {session=}, {e_id=}, {text=}, {line=}, {ch}")
logger.debug(
f"Entering {Routes.Completions} with session='{debug_session(session)}', {e_id=}, text={len(text)} char(s), {line=}, {ch=}")
completion = DslsManager.get_completion_engine(e_id)
result = completion.get_completions(text, Position(line, ch))
return result.to_dict()
@@ -413,7 +414,8 @@ def get(session, e_id: str, text: str, line: int, ch: int):
:param ch:
:return:
"""
logger.debug(f"Entering {Routes.Validations} with {session=}, {e_id=}, {text=}, {line=}, {ch}")
logger.debug(
f"Entering {Routes.Validations} with session='{debug_session(session)}', {e_id=}, text={len(text)} char(s), {line=}, {ch=}")
validation = DslsManager.get_validation_parser(e_id)
try:
validation.parse(text)

View File

@@ -0,0 +1,279 @@
"""
Tests for DataGrid formatting integration with table/tables scopes.
Tests the complete formatting flow: DSL → Storage → Application.
"""
import pytest
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.formatting.dataclasses import FormatRule, Style
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.instances import InstancesManager
@pytest.fixture
def manager(root_instance):
"""Create a DataGridsManager instance."""
mgr = DataGridsManager(root_instance, _id="test-manager")
yield mgr
InstancesManager.reset()
@pytest.fixture
def datagrid(manager):
"""Create a DataGrid instance."""
from myfasthtml.controls.DataGrid import DatagridConf
conf = DatagridConf(namespace="app", name="products", id="test-grid")
grid = DataGrid(manager, conf=conf, save_state=False, _id="test-datagrid")
# Add some columns
grid._state.columns = [
DataGridColumnState(col_id="amount", col_index=0, title="Amount", type=ColumnType.Number, visible=True),
DataGridColumnState(col_id="status", col_index=1, title="Status", type=ColumnType.Text, visible=True),
]
# Add some rows
grid._state.rows = [
DataGridRowState(0),
DataGridRowState(1),
]
yield grid
InstancesManager.reset()
@pytest.fixture
def editor(datagrid):
return DataGridFormattingEditor(datagrid, FormattingDSL())
# =============================================================================
# _get_format_rules() Hierarchy Tests
# =============================================================================
class TestFormatRulesHierarchy:
"""Tests for format rules hierarchy (cell > row > column > table > tables)."""
def test_i_can_get_cell_level_rules(self, datagrid):
"""Test that cell-level rules have highest priority."""
# Setup rules at different levels
cell_rules = [FormatRule(style=Style(preset="error"))]
column_rules = [FormatRule(style=Style(preset="success"))]
table_rules = [FormatRule(style=Style(preset="info"))]
datagrid._state.cell_formats["tcell_test-datagrid-0-0"] = cell_rules
datagrid._state.columns[0].format = column_rules
datagrid._state.table_format = table_rules
# Get rules for cell (0, 0)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return cell rules (highest priority)
assert rules == cell_rules
def test_i_can_get_row_level_rules(self, datagrid):
"""Test that row-level rules have second priority."""
# Setup rules at different levels
row_rules = [FormatRule(style=Style(preset="warning"))]
column_rules = [FormatRule(style=Style(preset="success"))]
table_rules = [FormatRule(style=Style(preset="info"))]
datagrid._state.rows[0].format = row_rules
datagrid._state.columns[0].format = column_rules
datagrid._state.table_format = table_rules
# Get rules for row 0 (no cell-level rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return row rules
assert rules == row_rules
def test_i_can_get_column_level_rules(self, datagrid):
"""Test that column-level rules have third priority."""
# Setup rules at different levels
column_rules = [FormatRule(style=Style(preset="success"))]
table_rules = [FormatRule(style=Style(preset="info"))]
datagrid._state.columns[0].format = column_rules
datagrid._state.table_format = table_rules
# Get rules for column (no cell or row rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return column rules
assert rules == column_rules
def test_i_can_get_table_level_rules(self, datagrid, manager):
"""Test that table-level rules have fourth priority."""
# Setup rules at different levels
table_rules = [FormatRule(style=Style(preset="info"))]
tables_rules = [FormatRule(style=Style(preset="neutral"))]
datagrid._state.table_format = table_rules
manager.all_tables_formats = tables_rules
# Get rules for cell (no higher level rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return table rules
assert rules == table_rules
def test_i_can_get_tables_level_rules(self, datagrid, manager):
"""Test that tables-level rules have lowest priority."""
# Setup global rules
tables_rules = [FormatRule(style=Style(preset="neutral"))]
manager.all_tables_formats = tables_rules
# Get rules for cell (no other rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return global tables rules
assert rules == tables_rules
def test_i_get_none_when_no_rules(self, datagrid):
"""Test that None is returned when no rules are defined."""
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
assert rules == []
@pytest.mark.parametrize("level,setup_func,expected_preset", [
("cell", lambda dg: dg._state.cell_formats.__setitem__("tcell_test-datagrid-0-0",
[FormatRule(style=Style(preset="error"))]), "error"),
("row", lambda dg: setattr(dg._state.rows[0], "format",
[FormatRule(style=Style(preset="warning"))]), "warning"),
("column", lambda dg: setattr(dg._state.columns[0], "format",
[FormatRule(style=Style(preset="success"))]), "success"),
("table", lambda dg: setattr(dg._state, "table_format",
[FormatRule(style=Style(preset="info"))]), "info"),
])
def test_hierarchy_priority(self, datagrid, level, setup_func, expected_preset):
"""Test that each level has correct priority in the hierarchy."""
setup_func(datagrid)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
assert rules is not None
assert len(rules) == 1
assert rules[0].style.preset == expected_preset
# =============================================================================
# DataGridFormattingEditor Integration Tests
# =============================================================================
class TestFormattingEditorIntegration:
"""Tests for DataGridFormattingEditor with table/tables scopes."""
def test_i_can_dispatch_table_rules(self, datagrid, editor):
"""Test that table rules are dispatched to DatagridState.table_format."""
dsl = '''
table "products":
style("info")
'''
editor.set_content(dsl)
editor.on_content_changed()
# Check that table_format is populated
assert len(datagrid._state.table_format) == 1
assert datagrid._state.table_format[0].style.preset == "info"
def test_i_cannot_use_wrong_table_name(self, datagrid, editor):
"""Test that wrong table name is rejected."""
dsl = '''
table "wrong_name":
style("error")
'''
editor.set_content(dsl)
editor.on_content_changed()
# Rules should not be applied (wrong table name)
assert len(datagrid._state.table_format) == 0
def test_i_can_dispatch_tables_rules(self, manager, datagrid, editor):
"""Test that tables rules are dispatched to DataGridsManager."""
dsl = '''
tables:
style("neutral")
format.number(precision=2)
'''
editor.set_content(dsl)
editor.on_content_changed()
# Check that manager.all_tables_formats is populated
assert len(manager.all_tables_formats) == 2
assert manager.all_tables_formats[0].style.preset == "neutral"
assert manager.all_tables_formats[1].formatter.precision == 2
def test_i_can_combine_all_scope_types(self, manager, datagrid, editor):
"""Test that all 5 scope types can be used together."""
dsl = '''
tables:
style(font_size="14px")
table "products":
format.number(precision=2)
column amount:
style("success") if value > 0
row 0:
style("neutral", bold=True)
cell (amount, 1):
style("error")
'''
editor.set_content(dsl)
editor.on_content_changed()
# Check all levels are populated
assert len(manager.all_tables_formats) == 1
assert len(datagrid._state.table_format) == 1
assert len(datagrid._state.columns[0].format) == 1
assert len(datagrid._state.rows[0].format) == 1
assert len(datagrid._state.cell_formats) == 1
def test_i_can_clear_table_format(self, datagrid, editor):
"""Test that table_format is cleared when DSL changes."""
# First set table rules
dsl = '''
table "products":
style("info")
'''
editor.set_content(dsl)
editor.on_content_changed()
assert len(datagrid._state.table_format) == 1
# Then remove them
editor.set_content("")
editor.on_content_changed()
assert len(datagrid._state.table_format) == 0
@pytest.mark.parametrize("table_name,should_apply", [
("products", True), # Correct name
("PRODUCTS", False), # Case sensitive
("product", False), # Partial match not allowed
("app.products", False), # Namespace not included
("other", False), # Completely wrong
])
def test_table_name_validation(self, datagrid, editor, table_name, should_apply):
"""Test that table name validation is case-sensitive and exact."""
dsl = f'''
table "{table_name}":
style("info")
'''
editor.set_content(dsl)
editor.on_content_changed()
if should_apply:
assert len(datagrid._state.table_format) == 1
else:
assert len(datagrid._state.table_format) == 0

View File

@@ -14,8 +14,7 @@ from myfasthtml.core.formatting.dsl.completion.contexts import (
detect_scope,
detect_context,
)
from myfasthtml.core.formatting.dsl.completion.suggestions import get_suggestions
from myfasthtml.core.formatting.dsl.completion.engine import (
from myfasthtml.core.formatting.dsl.completion.FormattingCompletionEngine import (
FormattingCompletionEngine,
get_completions,
)
@@ -143,6 +142,49 @@ def test_i_can_detect_scope_with_multiple_declarations():
assert scope.column_name == "amount"
def test_i_can_detect_table_scope():
"""Test detection of table scope."""
text = 'table "products":\n style()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "table"
assert scope.table_name == "products"
def test_i_can_detect_table_scope_with_spaces():
"""Test detection of table scope with spaces in name."""
text = 'table "financial report":\n format()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "table"
assert scope.table_name == "financial report"
def test_i_can_detect_tables_scope():
"""Test detection of global tables scope."""
text = "tables:\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "tables"
assert scope.table_name is None
@pytest.mark.parametrize("scope_def,expected_type,expected_attrs", [
('column amount:\n style()', "column", {"column_name": "amount"}),
('row 5:\n style()', "row", {"row_index": 5}),
('cell (amount, 3):\n style()', "cell", {"column_name": "amount", "row_index": 3}),
('table "products":\n style()', "table", {"table_name": "products"}),
('tables:\n style()', "tables", {}),
])
def test_i_can_detect_all_scope_types(scope_def, expected_type, expected_attrs):
"""Test detection of all 5 scope types."""
scope = detect_scope(scope_def, current_line=1)
assert scope.scope_type == expected_type
for attr, value in expected_attrs.items():
assert getattr(scope, attr) == value
# =============================================================================
# Context Detection - Scope Contexts
# =============================================================================
@@ -228,6 +270,42 @@ def test_context_cell_row_after_comma_quoted():
assert context == Context.CELL_ROW
def test_context_table_name_after_table():
"""Test TABLE_NAME context after 'table '."""
text = "table "
cursor = Position(line=0, ch=6)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.TABLE_NAME
def test_context_tables_scope_after_tables():
"""Test TABLES_SCOPE context after 'tables'."""
text = "tables"
cursor = Position(line=0, ch=6)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.TABLES_SCOPE
@pytest.mark.parametrize("text,cursor_ch,expected_context", [
("column ", 7, Context.COLUMN_NAME),
("row ", 4, Context.ROW_INDEX),
("cell ", 5, Context.CELL_START),
("table ", 6, Context.TABLE_NAME),
("tables", 6, Context.TABLES_SCOPE),
])
def test_i_can_detect_all_scope_contexts(text, cursor_ch, expected_context):
"""Test detection of all scope-related contexts."""
cursor = Position(line=0, ch=cursor_ch)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == expected_context
# =============================================================================
# Context Detection - Rule Contexts
# =============================================================================
@@ -545,21 +623,49 @@ def test_context_none_in_comment():
def test_suggestions_scope_keyword(provider):
"""Test suggestions for SCOPE_KEYWORD context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope()
suggestions = get_suggestions(Context.SCOPE_KEYWORD, scope, provider)
suggestions = engine.get_suggestions(Context.SCOPE_KEYWORD, scope, "")
labels = [s.label for s in suggestions]
assert "column" in labels
assert "row" in labels
assert "cell" in labels
assert "table" in labels
assert "tables" in labels
def test_suggestions_table_name(provider):
"""Test suggestions for TABLE_NAME context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope()
suggestions = engine.get_suggestions(Context.TABLE_NAME, scope, "")
labels = [s.label for s in suggestions]
# Should suggest the current table name in quotes
assert '"app.orders"' in labels
def test_suggestions_tables_scope(provider):
"""Test suggestions for TABLES_SCOPE context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope()
suggestions = engine.get_suggestions(Context.TABLES_SCOPE, scope, "")
labels = [s.label for s in suggestions]
# Should suggest colon to complete the scope
assert ":" in labels
def test_suggestions_style_preset(provider):
"""Test suggestions for STYLE_PRESET context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.STYLE_PRESET, scope, provider)
suggestions = engine.get_suggestions(Context.STYLE_PRESET, scope, "")
labels = [s.label for s in suggestions]
assert "primary" in labels
@@ -570,9 +676,10 @@ def test_suggestions_style_preset(provider):
def test_suggestions_format_type(provider):
"""Test suggestions for FORMAT_TYPE context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.FORMAT_TYPE, scope, provider)
suggestions = engine.get_suggestions(Context.FORMAT_TYPE, scope, "")
labels = [s.label for s in suggestions]
assert "number" in labels
@@ -584,9 +691,10 @@ def test_suggestions_format_type(provider):
def test_suggestions_operators(provider):
"""Test suggestions for OPERATOR context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.OPERATOR, scope, provider)
suggestions = engine.get_suggestions(Context.OPERATOR, scope, "")
labels = [s.label for s in suggestions]
assert "==" in labels
@@ -598,9 +706,10 @@ def test_suggestions_operators(provider):
def test_suggestions_boolean_value(provider):
"""Test suggestions for BOOLEAN_VALUE context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.BOOLEAN_VALUE, scope, provider)
suggestions = engine.get_suggestions(Context.BOOLEAN_VALUE, scope, "")
labels = [s.label for s in suggestions]
assert "True" in labels
@@ -609,9 +718,10 @@ def test_suggestions_boolean_value(provider):
def test_suggestions_color_value(provider):
"""Test suggestions for COLOR_VALUE context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.COLOR_VALUE, scope, provider)
suggestions = engine.get_suggestions(Context.COLOR_VALUE, scope, "")
labels = [s.label for s in suggestions]
assert "red" in labels
@@ -621,9 +731,10 @@ def test_suggestions_color_value(provider):
def test_suggestions_column_values(provider):
"""Test suggestions for OPERATOR_VALUE context with column scope."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope(scope_type="column", column_name="status")
suggestions = get_suggestions(Context.OPERATOR_VALUE, scope, provider)
suggestions = engine.get_suggestions(Context.OPERATOR_VALUE, scope, "")
labels = [s.label for s in suggestions]
# Base suggestions
@@ -639,9 +750,10 @@ def test_suggestions_column_values(provider):
def test_suggestions_rule_start(provider):
"""Test suggestions for RULE_START context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope(scope_type="column", column_name="amount")
suggestions = get_suggestions(Context.RULE_START, scope, provider)
suggestions = engine.get_suggestions(Context.RULE_START, scope, "")
labels = [s.label for s in suggestions]
assert "style(" in labels
@@ -651,9 +763,10 @@ def test_suggestions_rule_start(provider):
def test_suggestions_none_context(provider):
"""Test that NONE context returns empty suggestions."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope()
suggestions = get_suggestions(Context.NONE, scope, provider)
suggestions = engine.get_suggestions(Context.NONE, scope, "")
assert suggestions == []
@@ -668,7 +781,7 @@ def test_i_can_get_completions_for_style_preset(provider):
text = 'column amount:\n style("'
cursor = Position(line=1, ch=11)
result = get_completions(text, cursor, provider)
result = get_completions(text, cursor, provider, "app.orders")
assert not result.is_empty
labels = [s.label for s in result.suggestions]
@@ -681,7 +794,7 @@ def test_i_can_get_completions_filters_by_prefix(provider):
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14)
result = get_completions(text, cursor, provider)
result = get_completions(text, cursor, provider, "app.orders")
labels = [s.label for s in result.suggestions]
assert "error" in labels
@@ -693,7 +806,7 @@ def test_i_can_get_completions_returns_correct_positions(provider):
text = 'column amount:\n style("err'
cursor = Position(line=1, ch=14) # After "err"
result = get_completions(text, cursor, provider)
result = get_completions(text, cursor, provider, "app.orders")
# from_pos should be at start of "err"
assert result.from_pos.line == 1
@@ -709,7 +822,7 @@ def test_i_can_get_completions_at_scope_start(provider):
text = ""
cursor = Position(line=0, ch=0)
result = get_completions(text, cursor, provider)
result = get_completions(text, cursor, provider, "app.orders")
labels = [s.label for s in result.suggestions]
assert "column" in labels
@@ -722,7 +835,7 @@ def test_i_can_get_completions_for_column_names(provider):
text = "column "
cursor = Position(line=0, ch=7)
result = get_completions(text, cursor, provider)
result = get_completions(text, cursor, provider, "app.orders")
labels = [s.label for s in result.suggestions]
assert "id" in labels
@@ -735,21 +848,22 @@ def test_i_can_get_completions_in_comment_returns_empty(provider):
text = "column amount:\n # comment"
cursor = Position(line=1, ch=15)
result = get_completions(text, cursor, provider)
result = get_completions(text, cursor, provider, "app.orders")
assert result.is_empty
def test_i_can_create_formatting_completion_engine(provider):
"""Test that FormattingCompletionEngine can be instantiated."""
engine = FormattingCompletionEngine(provider)
engine = FormattingCompletionEngine(provider, "app.orders")
assert engine.provider == provider
assert engine.table_name == "app.orders"
def test_i_can_use_engine_detect_scope(provider):
"""Test engine's detect_scope method."""
engine = FormattingCompletionEngine(provider)
engine = FormattingCompletionEngine(provider, "app.orders")
text = "column amount:\n style()"
scope = engine.detect_scope(text, current_line=1)
@@ -760,7 +874,7 @@ def test_i_can_use_engine_detect_scope(provider):
def test_i_can_use_engine_detect_context(provider):
"""Test engine's detect_context method."""
engine = FormattingCompletionEngine(provider)
engine = FormattingCompletionEngine(provider, "app.orders")
text = "column amount:\n style("
cursor = Position(line=1, ch=10)
scope = DetectedScope(scope_type="column", column_name="amount")

View File

@@ -17,6 +17,8 @@ from myfasthtml.core.formatting.dsl import (
ColumnScope,
RowScope,
CellScope,
TableScope,
TablesScope,
DSLSyntaxError,
)
@@ -125,6 +127,62 @@ cell tcell_grid1-3-2:
assert rules[0].scope.row is None
class TestTableScope:
"""Tests for table scope parsing."""
def test_i_can_parse_table_scope(self):
"""Test parsing a table scope."""
dsl = """
table "products":
style("neutral")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, TableScope)
assert rules[0].scope.table == "products"
def test_i_can_parse_table_scope_with_spaces(self):
"""Test parsing a table scope with spaces in name."""
dsl = """
table "financial report":
style("info")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, TableScope)
assert rules[0].scope.table == "financial report"
class TestTablesScope:
"""Tests for tables scope (global) parsing."""
def test_i_can_parse_tables_scope(self):
"""Test parsing the global tables scope."""
dsl = """
tables:
style("neutral")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, TablesScope)
def test_i_can_parse_tables_scope_with_multiple_rules(self):
"""Test parsing tables scope with multiple rules."""
dsl = """
tables:
style("neutral")
format.number(precision=2)
"""
rules = parse_dsl(dsl)
assert len(rules) == 2
assert isinstance(rules[0].scope, TablesScope)
assert isinstance(rules[1].scope, TablesScope)
# =============================================================================
# Style Tests
# =============================================================================
@@ -496,7 +554,49 @@ row 0:
# Third rule: row 0
assert isinstance(rules[2].scope, RowScope)
assert rules[2].scope.row == 0
def test_i_can_parse_all_scope_types(self):
"""Test parsing all 5 scope types together."""
dsl = """
tables:
style(font_size="14px")
table "products":
format.number(precision=2)
column amount:
format("EUR")
row 0:
style("neutral", bold=True)
cell (amount, 5):
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 5
# First rule: tables (global)
assert isinstance(rules[0].scope, TablesScope)
# Second rule: table "products"
assert isinstance(rules[1].scope, TableScope)
assert rules[1].scope.table == "products"
# Third rule: column amount
assert isinstance(rules[2].scope, ColumnScope)
assert rules[2].scope.column == "amount"
# Fourth rule: row 0
assert isinstance(rules[3].scope, RowScope)
assert rules[3].scope.row == 0
# Fifth rule: cell (amount, 5)
assert isinstance(rules[4].scope, CellScope)
assert rules[4].scope.column == "amount"
assert rules[4].scope.row == 5
def test_i_can_parse_style_and_format_combined(self):
"""Test parsing style and format on same line."""
dsl = """