6 Commits

15 changed files with 444 additions and 402 deletions

View File

@@ -36,7 +36,7 @@ markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
myauth==0.2.0
myutils==0.1.0
myutils==0.4.0
nh3==0.3.1
oauthlib==3.3.1
packaging==25.0

View File

@@ -30,8 +30,7 @@ class mk:
@staticmethod
def manage_command(ft, command: Command):
if command:
htmx = command.get_htmx_params()
ft.attrs |= htmx
ft = command.bind_ft(ft)
return ft

View File

@@ -3,11 +3,12 @@ import uuid
from enum import Enum
from typing import Optional, Any
from fasthtml.components import Option
from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values, unbind
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select
from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select, is_datalist
bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings")
@@ -17,6 +18,7 @@ class UpdateMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
DatalistListChange = "DatalistListChange"
class DetectionMode(Enum):
@@ -104,6 +106,13 @@ class SelectValueChangeFtUpdate(FtUpdate):
return ft
class DatalistListChangeFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
new_to_use = converter.convert(new) if converter else new
ft.children = tuple([Option(value=v) for v in new_to_use])
return ft
class AttributePresenceFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
# attribute presence mode, toggle the attribute (add or remove it)
@@ -134,6 +143,20 @@ class BooleanConverter(DataConverter):
return False
class ListConverter(DataConverter):
def convert(self, data):
if data is None:
return []
if isinstance(data, str):
return data.split("\n")
if isinstance(data, (list, set, tuple)):
return data
return [data]
class RadioConverter(DataConverter):
def __init__(self, radio_value):
self.radio_value = radio_value
@@ -223,6 +246,10 @@ class Binding:
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.SelectValueChange
default_update_mode = UpdateMode.SelectValueChange
elif is_datalist(ft):
default_data_converter = self.data_converter or ListConverter()
default_detection_mode = DetectionMode.SelectValueChange
default_update_mode = UpdateMode.DatalistListChange
else:
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.ValueChange
@@ -377,6 +404,9 @@ class Binding:
elif mode == UpdateMode.SelectValueChange:
return SelectValueChangeFtUpdate()
elif mode == UpdateMode.DatalistListChange:
return DatalistListChangeFtUpdate()
else:
raise ValueError(f"Invalid detection mode: {mode}")

View File

@@ -1,6 +1,8 @@
import uuid
from typing import Optional
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
from myfasthtml.core.constants import Routes, ROUTE_ROOT
@@ -25,13 +27,14 @@ class BaseCommand:
self.id = uuid.uuid4()
self.name = name
self.description = description
self.htmx_extra = {}
self._htmx_extra = {}
self._bindings = []
# register the command
CommandsManager.register(self)
def get_htmx_params(self):
return self.htmx_extra | {
return self._htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}',
}
@@ -39,9 +42,46 @@ class BaseCommand:
def execute(self):
raise NotImplementedError
def htmx(self, target=None):
if target:
self.htmx_extra["hx-target"] = target
def htmx(self, target="this", swap="innerHTML"):
if target is None:
self._htmx_extra["hx-swap"] = "none"
elif target != "this":
self._htmx_extra["hx-target"] = target
if swap is None:
self._htmx_extra["hx-swap"] = "none"
elif swap != "innerHTML":
self._htmx_extra["hx-swap"] = swap
return self
def bind_ft(self, ft):
"""
Update the FT with the command's HTMX parameters.
:param ft:
:return:
"""
htmx = self.get_htmx_params()
ft.attrs |= htmx
return ft
def bind(self, data):
"""
Attach a binding to the command.
When done, if a binding is triggered during the execution of the command,
the results of the binding will be passed to the command's execute() method.
:param data:
:return:
"""
if not hasattr(data, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
self._bindings.append(data)
# by default, remove the swap on the attached element when binding is used
self._htmx_extra["hx-swap"] = "none"
return self
@@ -71,7 +111,26 @@ class Command(BaseCommand):
self.kwargs = kwargs
def execute(self):
return self.callback(*self.args, **self.kwargs)
ret_from_bindings = []
def binding_result_callback(attr, old, new, results):
ret_from_bindings.extend(results)
for data in self._bindings:
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
ret = self.callback(*self.args, **self.kwargs)
for data in self._bindings:
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
if not ret_from_bindings:
return ret
if isinstance(ret, list):
return ret + ret_from_bindings
else:
return [ret] + ret_from_bindings
def __str__(self):
return f"Command({self.name})"

View File

@@ -134,6 +134,15 @@ def is_select(elt):
return False
def is_datalist(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "datalist"
elif isinstance(elt, Tag):
return elt.name == "datalist"
else:
return False
def quoted_str(s):
if s is None:
return "None"

View File

@@ -0,0 +1,66 @@
import logging
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
def add_suggestion():
nb = len(data.value)
data.value = data.value + [f"suggestion{nb}"]
def remove_suggestion():
if len(data.value) > 0:
data.value = data.value[:-1]
data = Data(["suggestion0", "suggestion1", "suggestion2"])
@rt("/")
def get():
datalist = Datalist(
id="suggestions"
)
input_elt = Input(name="input_name", list="suggestions")
label_elt = Label()
mk.manage_binding(datalist, Binding(data))
mk.manage_binding(label_elt, Binding(data))
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion).bind(data))
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
return Div(
add_button,
remove_button,
input_elt,
datalist,
label_elt
)
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,40 @@
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
data = Data(50)
@rt("/")
def get():
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
label_elt = Label()
mk.manage_binding(range_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return range_elt, label_elt
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -129,7 +129,7 @@ class ErrorOutput:
return item, None, None
def __str__(self):
self.compute()
return f"ErrorOutput({self.output})"
def compute(self):
# first render the path hierarchy
@@ -156,27 +156,26 @@ class ErrorOutput:
self.indent += " "
element_index = 0
for expected_child in expected_children:
if hasattr(expected_child, "tag"):
if element_index < len(self.element.children):
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors in children
child_error_str = self._detect_error(element_child, expected_child)
if child_error_str:
self._add_to_output(child_error_str)
element_index += 1
else:
# When there are fewer children than expected, we display a placeholder
child_str = "! ** MISSING ** !"
self._add_to_output(child_str)
if element_index >= len(self.element.children):
# When there are fewer children than expected, we display a placeholder
child_str = "! ** MISSING ** !"
self._add_to_output(child_str)
element_index += 1
continue
else:
if expected_child in self.element.children:
self._add_to_output(expected_child)
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors (only when the expected is a FT element
if hasattr(expected_child, "tag"):
child_error_str = self._detect_error(element_child, expected_child)
if child_error_str:
self._add_to_output(child_error_str)
# continue
element_index += 1
self.indent = self.indent[:-2]
self._add_to_output(")")

View File

@@ -18,7 +18,7 @@ from typing import Any
import pytest
from fasthtml.components import (
Input, Label, Textarea, Select, Option, Button, Datalist
Input, Label, Textarea, Select, Option, Datalist
)
from fasthtml.fastapp import fast_app
@@ -468,7 +468,59 @@ class TestBindingSelectMultiple:
class TestBindingRange:
"""Tests for binding Range (slider) components."""
def test_i_can_bind_range(self, user, rt):
def test_i_can_bind_range(self):
data = Data(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
binding = Binding(data)
updated = mk.manage_binding(range_elt, binding)
expected = Input(
AttributeForbidden("hx_swap_oob"),
type="range",
name="range_name",
min="0",
max="100",
value=50,
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
id=AnyValue(),
)
assert matches(updated, expected)
def test_i_can_update_range(self):
data = Data(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
binding = Binding(data)
mk.manage_binding(range_elt, binding)
res = binding.update({"range_name": 25})
expected = [Input(
type="range",
name="range_name",
min="0",
max="100",
value=25,
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
id=AnyValue(),
hx_swap_oob="true"
)]
assert matches(res, expected)
def test_i_can_bind_range_with_label(self, user, rt):
"""
Range input should bind with numeric data.
Changing the slider should update the label.
@@ -642,138 +694,23 @@ class TestBindingRadio:
user.should_see("option2")
class TestBindingButton:
"""Tests for binding Button components."""
def test_i_can_click_button_with_binding(self, user, rt):
"""
Clicking a button with HTMX should trigger binding updates.
"""
@rt("/")
def index():
data = Data("initial")
button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}')
label_elt = Label()
mk.manage_binding(button_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return button_elt, label_elt
@rt("/update")
def update(action: str):
data = Data("button clicked")
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
return label_elt
user.open("/")
user.should_see("initial")
testable_button = user.find_element("button")
testable_button.click()
user.should_see("button clicked")
def test_button_without_htmx_does_nothing(self, user, rt):
"""
Button without HTMX should not trigger updates.
"""
@rt("/")
def index():
data = Data("initial")
button_elt = Button("Plain button") # No HTMX
label_elt = Label()
mk.manage_binding(button_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return button_elt, label_elt
user.open("/")
user.should_see("initial")
testable_button = user.find_element("button")
result = testable_button.click()
assert result is None # No HTMX, no response
class TestBindingDatalist:
"""Tests for binding Input with Datalist (combobox)."""
def test_i_can_bind_input_with_datalist(self, user, rt):
"""
Input with datalist should allow both free text and suggestions.
"""
def test_i_can_bind_datalist(self):
data = Data(["suggestion2"])
datalist = Datalist(
Option(value="suggestion1"),
id="suggestions"
)
@rt("/")
def index():
data = Data("")
datalist = Datalist(
Option(value="suggestion1"),
Option(value="suggestion2"),
Option(value="suggestion3"),
id="suggestions"
)
input_elt = Input(
name="input_name",
list="suggestions"
)
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, datalist, label_elt
updated = mk.manage_binding(datalist, Binding(data))
expected = Datalist(
Option(value="suggestion2"),
id="suggestions"
)
user.open("/")
user.should_see("")
testable_input = user.find_element("input[list='suggestions']")
# Can type free text
testable_input.send("custom value")
user.should_see("custom value")
# Can select from suggestions
testable_input.select_suggestion("suggestion2")
user.should_see("suggestion2")
def test_datalist_suggestions_are_available(self, user, rt):
"""
Datalist suggestions should be accessible for validation.
"""
@rt("/")
def index():
data = Data("")
datalist = Datalist(
Option(value="apple"),
Option(value="banana"),
Option(value="cherry"),
id="fruits"
)
input_elt = Input(
name="input_name",
list="fruits"
)
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, datalist, label_elt
user.open("/")
testable_input = user.find_element("input[list='fruits']")
# Check that suggestions are available
suggestions = testable_input.suggestions
assert "apple" in suggestions
assert "banana" in suggestions
assert "cherry" in suggestions
assert matches(updated, expected)
class TestBindingEdgeCases:
@@ -900,7 +837,7 @@ class TestCheckBox:
binding = Binding(data)
updated = mk.manage_binding(check_box, binding)
expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox", hx_swap_oob="true")
expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox")
assert matches(updated, expected)
def test_checkbox_initial_state_true(self):
@@ -910,7 +847,7 @@ class TestCheckBox:
binding = Binding(data)
updated = mk.manage_binding(check_box, binding)
expected = Input(name="checkbox_name", type="checkbox", hx_swap_oob="true", checked="true")
expected = Input(name="checkbox_name", type="checkbox", checked="true")
assert matches(updated, expected)
def test_i_can_bind_checkbox_and_label_without_converter(self, user, rt):

View File

@@ -1,6 +1,18 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import Button
from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.constants import ROUTE_ROOT, Routes
from myfasthtml.test.matcher import matches
@dataclass
class Data:
value: Any
def callback():
@@ -23,3 +35,61 @@ def test_i_can_create_a_command_with_no_params():
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command
def test_i_can_bind_a_command_to_an_element():
command = Command('test', 'Command description', callback)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
assert matches(updated, expected)
def test_i_can_suppress_swapping_with_target_attr():
command = Command('test', 'Command description', callback).htmx(target=None)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
assert matches(updated, expected)
def test_i_can_bind_a_command_to_an_observable():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return "another callback result"
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another callback result", ("hello", "new value")]
def test_i_can_bind_a_command_to_an_observable_2():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return ["another 1", "another 2"]
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another 1", "another 2", ("hello", "new value")]

View File

@@ -1,104 +0,0 @@
"""
Comprehensive binding tests for all bindable FastHTML components.
This test suite covers:
- Input (text) - already tested
- Checkbox - already tested
- Textarea
- Select (single)
- Select (multiple)
- Range (slider)
- Radio buttons
- Button
- Input with Datalist (combobox)
"""
from dataclasses import dataclass
from fasthtml.components import (
Label, Button
)
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
@dataclass
class Data:
value: str = "hello world"
@dataclass
class NumericData:
value: int = 50
@dataclass
class BoolData:
value: bool = True
@dataclass
class ListData:
value: list = None
def __post_init__(self):
if self.value is None:
self.value = []
class TestBindingButton:
"""Tests for binding Button components."""
def test_i_can_click_button_with_binding(self, user, rt):
"""
Clicking a button with HTMX should trigger binding updates.
"""
@rt("/")
def index():
data = Data("initial")
button_elt = Button("Click me", hx_post="/update", hx_vals='{"action": "clicked"}')
label_elt = Label()
mk.manage_binding(button_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return button_elt, label_elt
@rt("/update")
def update(action: str):
data = Data("button clicked")
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
return label_elt
user.open("/")
user.should_see("initial")
testable_button = user.find_element("button")
testable_button.click()
user.should_see("button clicked")
def test_button_without_htmx_does_nothing(self, user, rt):
"""
Button without HTMX should not trigger updates.
"""
@rt("/")
def index():
data = Data("initial")
button_elt = Button("Plain button") # No HTMX
label_elt = Label()
mk.manage_binding(button_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return button_elt, label_elt
user.open("/")
user.should_see("initial")
testable_button = user.find_element("button")
result = testable_button.click()
assert result is None # No HTMX, no response

View File

@@ -1,124 +0,0 @@
"""
Comprehensive binding tests for all bindable FastHTML components.
This test suite covers:
- Input (text) - already tested
- Checkbox - already tested
- Textarea
- Select (single)
- Select (multiple)
- Range (slider)
- Radio buttons
- Button
- Input with Datalist (combobox)
"""
from dataclasses import dataclass
from fasthtml.components import (
Input, Label, Option, Datalist
)
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
@dataclass
class Data:
value: str = "hello world"
@dataclass
class NumericData:
value: int = 50
@dataclass
class BoolData:
value: bool = True
@dataclass
class ListData:
value: list = None
def __post_init__(self):
if self.value is None:
self.value = []
class TestBindingDatalist:
"""Tests for binding Input with Datalist (combobox)."""
def test_i_can_bind_input_with_datalist(self, user, rt):
"""
Input with datalist should allow both free text and suggestions.
"""
@rt("/")
def index():
data = Data("")
datalist = Datalist(
Option(value="suggestion1"),
Option(value="suggestion2"),
Option(value="suggestion3"),
id="suggestions"
)
input_elt = Input(
name="input_name",
list="suggestions"
)
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, datalist, label_elt
user.open("/")
user.should_see("")
testable_input = user.find_element("input[list='suggestions']")
# Can type free text
testable_input.send("custom value")
user.should_see("custom value")
# Can select from suggestions
testable_input.select_suggestion("suggestion2")
user.should_see("suggestion2")
def test_datalist_suggestions_are_available(self, user, rt):
"""
Datalist suggestions should be accessible for validation.
"""
@rt("/")
def index():
data = Data("")
datalist = Datalist(
Option(value="apple"),
Option(value="banana"),
Option(value="cherry"),
id="fruits"
)
input_elt = Input(
name="input_name",
list="fruits"
)
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, datalist, label_elt
user.open("/")
testable_input = user.find_element("input[list='fruits']")
# Check that suggestions are available
suggestions = testable_input.suggestions
assert "apple" in suggestions
assert "banana" in suggestions
assert "cherry" in suggestions

View File

@@ -268,8 +268,7 @@ class TestableFormUpdateFieldValues:
'''
form = TestableForm(mock_client, html)
assert "size" not in form.fields, \
f"Expected 'size' not in fields, got {form.fields}"
assert form.fields == {"size": None}, f"Expected 'size' not in fields, got {form.fields}"
def test_i_can_handle_number_input_with_integer(self, mock_client):
"""

View File

@@ -0,0 +1,72 @@
from dataclasses import dataclass
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableRange
@dataclass
class Data:
value: str = "hello world"
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_range(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
assert input_elt.name == "range_name"
assert input_elt.value == 50
assert input_elt.min_value == 0
assert input_elt.max_value == 100
assert input_elt.step == 10
@pytest.mark.parametrize("value, expected", [
(30, 30),
(24, 20), # step 10
(-10, 0), # min 0
(110, 100), # max 100
])
def test_i_can_set_value(test_client, value, expected):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.set(value)
assert input_elt.value == expected
def test_i_can_increase_value(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.increase()
assert input_elt.value == 60
def test_i_can_decrease_value(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.decrease()
assert input_elt.value == 40

View File

@@ -34,13 +34,3 @@ def test_i_can_read_input(test_client):
assert input_elt.name == "textarea_name"
assert input_elt.value == "Lorem ipsum"
@pytest.mark.skip("To update later")
def test_i_can_read_input_with_label(test_client):
html = '''<label for="uid">Text Area</label><textarea id="uid" name="textarea_name">Lorem ipsum</textarea>'''
input_elt = TestableTextarea(test_client, html)
assert input_elt.fields_mapping == {"Text Area": "textarea_name"}
assert input_elt.name == "textarea_name"
assert input_elt.value == "Lorem ipsum"