diff --git a/.gitignore b/.gitignore index 7fae7a4..2cfd859 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ app.egg-info htmlcov .cache .venv +src/main.py tests/settings_from_unit_testing.json tests/TestDBEngineRoot tests/*.png diff --git a/README.md b/README.md index 1324e7e..13bbf6f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # MyFastHtml A utility library designed to simplify the development of FastHtml applications by providing: + - Predefined pages for common functionalities (e.g., authentication, user management). - A command management system to facilitate client-server interactions. - Helpers to create interactive controls more easily. @@ -10,10 +11,12 @@ A utility library designed to simplify the development of FastHtml applications ## Features -- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like `/commands`. +- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like + `/commands`. - **Command management**: Write server-side logic in Python while abstracting the complexities of HTMX. +- **Binding management**: Mechanism to bind two html element together. - **Control helpers**: Easily create reusable components like buttons. -- **Predefined Pages (Roadmap)**: Include common pages like login, user management, and customizable dashboards. +- **Login Pages**: Include common pages for login, user management, and customizable dashboards. > _**Note:** Support for state persistence is currently under construction._ @@ -31,28 +34,59 @@ pip install myfasthtml ## Quick Start -Here’s a simple example of creating an **interactive button** linked to a command: +### FastHtml Application -### Example: Button with a Command +To create a simple FastHtml application, you can use the `create_app` function: ```python -from fasthtml.fastapp import fast_app +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.myfastapp import create_app + +app, rt = create_app(protect_routes=False) + + +@rt("/") +def get_homepage(): + return Div("Hello, FastHtml!") + + +if __name__ == "__main__": + serve(port=5002) + + +``` + +### Use Commands + +```python +from fasthtml import serve + +from myfasthtml.controls.helpers import mk_button from myfasthtml.core.commands import Command -from myfasthtml.controls.button import mk_button +from myfasthtml.myfastapp import create_app + # Define a simple command action def say_hello(): - return "Hello, FastHtml!" + return "Hello, FastHtml!" + # Create the command hello_command = Command("say_hello", "Responds with a greeting", say_hello) -# Create the app and define a route with a button -app, rt = fast_app(default_hdrs=False) +# Create the app +app, rt = create_app(protect_routes=False) + @rt("/") def get_homepage(): - return mk_button("Click Me!", command=hello_command) + return mk_button("Click Me!", command=hello_command) + + +if __name__ == "__main__": + serve(port=5002) ``` - When the button is clicked, the `say_hello` command will be executed, and the server will return the response. @@ -60,34 +94,60 @@ def get_homepage(): --- +### Bind components + +```python +@dataclass +class Data: + value: str = "Hello World" + checked: bool = False + +# Binds an Input with a label +mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")), +mk.mk(Label("Text"), binding=Binding(data, attr="value")), + +# Binds a checkbox with a labl +mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")), +mk.mk(Label("Text"), binding=Binding(data, attr="checked")), +``` + ## Planned Features (Roadmap) ### Predefined Pages + The library will include predefined pages for: + - **Authentication**: Login, signup, password reset. - **User Management**: User profile and administration pages. - **Dashboard Templates**: Fully customizable dashboard components. - **Error Pages**: Detailed and styled error messages (e.g., 404, 500). ### State Persistence -Controls will have their state automatically synchronized between the client and the server. This feature is currently under construction. + +Controls will have their state automatically synchronized between the client and the server. This feature is currently +under construction. --- ## Advanced Features ### Command Management System -Commands allow you to simplify frontend/backend interaction. Instead of writing HTMX attributes manually, you can define Python methods and handle them as commands. + +Commands allow you to simplify frontend/backend interaction. Instead of writing HTMX attributes manually, you can define +Python methods and handle them as commands. #### Example + Here’s how `Command` simplifies dynamic interaction: ```python from myfasthtml.core.commands import Command + # Define a command def custom_action(data): - return f"Received: {data}" + return f"Received: {data}" + my_command = Command("custom", "Handles custom logic", custom_action) @@ -106,9 +166,659 @@ Use the `get_htmx_params()` method to directly integrate commands into HTML comp --- +## Testing + +### TestableElements + +#### TestableTextarea + +**Use case:** Multi-line text input + +**Methods:** + +- `send(value)` - Set the textarea value +- `append(text)` - Append text to current value +- `clear()` - Clear the textarea + +**Example:** + +```python +def test_textarea_binding(user, rt): + @rt("/") + def index(): + data = Data("Initial text") + textarea = Textarea(name="message") + label = Label() + + mk.manage_binding(textarea, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return textarea, label + + user.open("/") + textarea = user.find_element("textarea") + + textarea.send("New message") + user.should_see("New message") + + textarea.append("\nMore text") + user.should_see("New message\nMore text") + + textarea.clear() + user.should_see("") +``` + +#### TestableSelect + +**Use case:** Dropdown selection + +**Properties:** + +- `is_multiple` - Check if multiple selection is enabled +- `options` - List of available options + +**Methods:** + +- `select(value)` - Select option by value +- `select_by_text(text)` - Select option by visible text +- `deselect(value)` - Deselect option (multiple select only) + +**Example (Single Select):** + +```python +def test_select_binding(user, rt): + @rt("/") + def index(): + data = Data("option1") + select = Select( + Option("First", value="option1"), + Option("Second", value="option2"), + Option("Third", value="option3"), + name="choice" + ) + label = Label() + + mk.manage_binding(select, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return select, label + + user.open("/") + select_elt = user.find_element("select") + + select_elt.select("option2") + user.should_see("option2") + + select_elt.select_by_text("Third") + user.should_see("option3") +``` + +**Example (Multiple Select):** + +```python +def test_multiple_select_binding(user, rt): + @rt("/") + def index(): + data = ListData(["option1"]) + select = Select( + Option("First", value="option1"), + Option("Second", value="option2"), + Option("Third", value="option3"), + name="choices", + multiple=True + ) + label = Label() + + mk.manage_binding(select, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return select, label + + user.open("/") + select_elt = user.find_element("select") + + select_elt.select("option2") + user.should_see("['option1', 'option2']") + + select_elt.deselect("option1") + user.should_see("['option2']") +``` + +#### TestableRange + +**Use case:** Slider input + +**Properties:** + +- `min_value` - Minimum value +- `max_value` - Maximum value +- `step` - Step increment + +**Methods:** + +- `set(value)` - Set slider to specific value (auto-clamped) +- `increase()` - Increase by one step +- `decrease()` - Decrease by one step + +**Example:** + +```python +def test_range_binding(user, rt): + @rt("/") + def index(): + data = NumericData(50) + range_input = Input( + type="range", + name="volume", + min="0", + max="100", + step="10", + value="50" + ) + label = Label() + + mk.manage_binding(range_input, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return range_input, label + + user.open("/") + slider = user.find_element("input[type='range']") + + slider.set(75) + user.should_see("75") + + slider.increase() + user.should_see("85") + + slider.decrease() + user.should_see("75") +``` + +#### TestableRadio + +**Use case:** Radio button (mutually exclusive options) + +**Properties:** + +- `radio_value` - The value attribute of this radio +- `is_checked` - Check if this radio is selected + +**Methods:** + +- `select()` - Select this radio button + +**Example:** + +```python +def test_radio_binding(user, rt): + @rt("/") + def index(): + data = Data("option1") + + radio1 = Input(type="radio", name="choice", value="option1", checked=True) + radio2 = Input(type="radio", name="choice", value="option2") + radio3 = Input(type="radio", name="choice", value="option3") + label = Label() + + mk.manage_binding(radio1, Binding(data)) + mk.manage_binding(radio2, Binding(data)) + mk.manage_binding(radio3, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return radio1, radio2, radio3, label + + user.open("/") + + radio2 = user.find_element("input[value='option2']") + radio2.select() + user.should_see("option2") + + radio3 = user.find_element("input[value='option3']") + radio3.select() + user.should_see("option3") +``` + +#### TestableButton + +**Use case:** Clickable button with HTMX + +**Properties:** + +- `text` - Visible text of the button + +**Methods:** + +- `click()` - Click the button (triggers HTMX if configured) + +**Example:** + +```python +def test_button_binding(user, rt): + @rt("/") + def index(): + data = Data("initial") + button = Button( + "Click me", + hx_post="/update", + hx_vals='{"action": "clicked"}' + ) + label = Label() + + mk.manage_binding(button, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return button, label + + @rt("/update") + def update(action: str): + data = Data("updated") + label = Label() + mk.manage_binding(label, Binding(data)) + return label + + user.open("/") + + button = user.find_element("button") + button.click() + user.should_see("updated") +``` + +#### TestableDatalist + +**Use case:** Input with autocomplete suggestions (combobox) + +**Properties:** + +- `suggestions` - List of available suggestions + +**Methods:** + +- `send(value)` - Set input value (any value, not restricted to suggestions) +- `select_suggestion(value)` - Select a value from suggestions + +**Example:** + +```python +def test_datalist_binding(user, rt): + @rt("/") + def index(): + data = Data("") + + datalist = Datalist( + Option(value="apple"), + Option(value="banana"), + Option(value="cherry"), + id="fruits" + ) + input_elt = Input(name="fruit", list="fruits") + label = Label() + + mk.manage_binding(input_elt, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return input_elt, datalist, label + + user.open("/") + + input_with_list = user.find_element("input[list='fruits']") + + # Free text input + input_with_list.send("mango") + user.should_see("mango") + + # Select from suggestions + input_with_list.select_suggestion("banana") + user.should_see("banana") +``` + +## CSS Selectors for Finding Elements + +When using `user.find_element()`, use these selectors: + +| Component | Selector Example | +|----------------|--------------------------------------------------------| +| Input (text) | `"input[name='field_name']"` or `"input[type='text']"` | +| Checkbox | `"input[type='checkbox']"` | +| Radio | `"input[type='radio']"` or `"input[value='option1']"` | +| Range | `"input[type='range']"` | +| Textarea | `"textarea"` or `"textarea[name='field_name']"` | +| Select | `"select"` or `"select[name='field_name']"` | +| Button | `"button"` or `"button.primary"` | +| Datalist Input | `"input[list='datalist_id']"` | + +## Binding + +### Overview + +This package contains everything needed to implement a complete binding system for FastHTML components. + +### Fully Supported Components Summary + +| Component | Testable Class | Binding Support | +|-------------------|------------------|-----------------| +| Input (text) | TestableInput | ✅ | +| Checkbox | TestableCheckbox | ✅ | +| Textarea | TestableTextarea | ✅ | +| Select (single) | TestableSelect | ✅ | +| Select (multiple) | TestableSelect | ✅ | +| Range (slider) | TestableRange | ✅ | +| Radio buttons | TestableRadio | ✅ | +| Button | TestableButton | ✅ | +| Input + Datalist | TestableDatalist | ✅ | + +### Supported Components + +#### 1. Input (Text) + +```python +# Methods +input.send(value) + +# Binding modes +- ValueChange(default) +- Text +updates +trigger +data +changes +``` + +#### 2. Checkbox + +```python +# Methods +checkbox.check() +checkbox.uncheck() +checkbox.toggle() + +# Binding modes +- AttributePresence +- Boolean +data +binding +``` + +#### 3. Textarea + +```python +# Methods +textarea.send(value) +textarea.append(text) +textarea.clear() + +# Binding modes +- ValueChange +- Multi - line +text +support +``` + +#### 4. Select (Single) + +```python +# Methods +select.select(value) +select.select_by_text(text) + +# Properties +select.options # List of available options +select.is_multiple # False for single select + +# Binding modes +- ValueChange +- String +value +binding +``` + +#### 5. Select (Multiple) + +```python +# Methods +select.select(value) +select.deselect(value) +select.select_by_text(text) + +# Properties +select.options +select.is_multiple # True for multiple select + +# Binding modes +- ValueChange +- List +data +binding +``` + +#### 6. Range (Slider) + +```python +# Methods +range.set(value) # Auto-clamps to min/max +range.increase() +range.decrease() + +# Properties +range.min_value +range.max_value +range.step + +# Binding modes +- ValueChange +- Numeric +data +binding +``` + +#### 7. Radio Buttons + +```python +# Methods +radio.select() + +# Properties +radio.radio_value # Value attribute +radio.is_checked + +# Binding modes +- ValueChange +- String +value +binding +- Mutually +exclusive +group +behavior +``` + +#### 8. Button + +```python +# Methods +button.click() + +# Properties +button.text # Visible button text + +# Binding modes +- Triggers +HTMX +requests +- Can +update +bindings +via +server +response +``` + +#### 9. Input + Datalist (Combobox) + +```python +# Methods +datalist.send(value) # Any value +datalist.select_suggestion(value) # From suggestions + +# Properties +datalist.suggestions # Available options + +# Binding modes +- ValueChange +- Hybrid: free +text + suggestions +``` + +### Architecture Overview + +#### Three-Phase Binding Lifecycle + +```python +# Phase 1: Create (inactive) +binding = Binding(data, "value") + +# Phase 2: Configure + Activate +binding.bind_ft(element, name="input", attr="value") + +# Phase 3: Deactivate (cleanup) +binding.deactivate() +``` + +#### Data Flow + +``` +User Input → HTMX Component → HTMX Request → Binding.update() + ↓ + setattr(data, attr, value) + ↓ + Observable triggers + ↓ + Binding.notify() + ↓ + Update all bound UI elements +``` + +### Quick Reference + +#### Creating a Binding + +```python +# Simple binding +binding = Binding(data, "value").bind_ft( + Input(name="input"), + name="input", + attr="value" +) + +# With detection and update modes +binding = Binding(data, "checked").bind_ft( + Input(type="checkbox", name="check"), + name="check", + attr="checked", + detection_mode=DetectionMode.AttributePresence, + update_mode=UpdateMode.AttributePresence +) + +# With data converter +binding = Binding(data, "value").bind_ft( + Input(type="checkbox", name="check"), + name="check", + attr="checked", + data_converter=BooleanConverter() +) +``` + +#### Testing a Component + +```python +def test_component_binding(user, rt): + @rt("/") + def index(): + data = Data("initial") + component = Component(name="field") + label = Label() + + mk.manage_binding(component, Binding(data)) + mk.manage_binding(label, Binding(data)) + + return component, label + + user.open("/") + user.should_see("initial") + + testable = user.find_element("selector") + testable.method("new value") + user.should_see("new value") +``` + +#### Managing Binding Lifecycle + +```python +# Create +binding = Binding(data, "value") + +# Activate (via bind_ft) +binding.bind_ft(element, name="field") + +# Deactivate +binding.deactivate() + +# Reactivate with new element +binding.bind_ft(new_element, name="field") +``` + +### Pattern 1: Bidirectional Binding + +All components support bidirectional binding: + +- UI changes update the data object +- Data object changes update the UI (via Label or other bound components) + +```python +input_elt = Input(name="field") +label_elt = Label() + +mk.manage_binding(input_elt, Binding(data)) +mk.manage_binding(label_elt, Binding(data)) + +# Change via UI +testable_input.send("new value") +# Label automatically updates to show "new value" +``` + +### Pattern 2: Multiple Components, Same Data + +Multiple different components can bind to the same data: + +```python +input_elt = Input(name="input") +textarea_elt = Textarea(name="textarea") +label_elt = Label() + +# All bind to the same data object +mk.manage_binding(input_elt, Binding(data)) +mk.manage_binding(textarea_elt, Binding(data)) +mk.manage_binding(label_elt, Binding(data)) + +# Changing any component updates all others +``` + +### Pattern 3: Component Without Name + +Components without a name attribute won't trigger updates but won't crash: + +```python +input_elt = Input() # No name attribute +label_elt = Label() + +mk.manage_binding(label_elt, Binding(data)) +# Input won't trigger updates, but label will still display data +``` + ## Contributing We welcome contributions! To get started: + 1. Fork the repository. 2. Create a feature branch. 3. Submit a pull request with clear descriptions of your changes. @@ -144,26 +854,32 @@ MyFastHtml ### Notable Classes and Methods #### 1. `Command` + Represents a backend action with server communication. + - **Attributes:** - - `id`: Unique identifier for the command. - - `name`: Command name (e.g., `say_hello`). - - `description`: Description of the command. + - `id`: Unique identifier for the command. + - `name`: Command name (e.g., `say_hello`). + - `description`: Description of the command. - **Method:** `get_htmx_params()` generates HTMX attributes. #### 2. `mk_button` + Simplifies the creation of interactive buttons linked to commands. + - **Arguments:** - - `element` (str): The label for the button. - - `command` (Command): Command associated with the button. - - `kwargs`: Additional button attributes. + - `element` (str): The label for the button. + - `command` (Command): Command associated with the button. + - `kwargs`: Additional button attributes. #### 3. `LoginPage` + Predefined login page that provides a UI template ready for integration. + - **Constructor Parameters:** - - `settings_manager`: Configuration/settings object. - - `error_message`: Optional error message to display. - - `success_message`: Optional success message to display. + - `settings_manager`: Configuration/settings object. + - `error_message`: Optional error message to display. + - `success_message`: Optional success message to display. --- @@ -177,6 +893,40 @@ Predefined login page that provides a UI template ready for integration. No custom exceptions defined yet. (Placeholder for future use.) +## Troubleshooting + +### Issue: "No element found matching selector" + +**Cause:** Incorrect CSS selector or element not in DOM + +**Solution:** Check the HTML output and adjust selector + +```python +# Debug: Print the HTML +print(user.get_content()) + +# Try different selectors +user.find_element("textarea") +user.find_element("textarea[name='message']") +``` + +### Issue: TestableControl has no attribute 'send' + +**Cause:** Wrong testable class returned by factory + +**Solution:** Verify factory method is updated correctly + +### Issue: AttributeError on TestableTextarea + +**Cause:** Class not properly inheriting from TestableControl + +**Solution:** Check class hierarchy and imports + +### Issue: Select options not found + +**Cause:** `_update_fields()` not parsing select correctly + +**Solution:** Verify TestableElement properly parses select/option tags ## Relase History diff --git a/requirements.txt b/requirements.txt index 8205133..e4dd7a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,11 +6,14 @@ apswutils==0.1.0 argon2-cffi==25.1.0 argon2-cffi-bindings==25.1.0 beautifulsoup4==4.14.2 +build==1.3.0 certifi==2025.10.5 cffi==2.0.0 +charset-normalizer==3.4.4 click==8.3.0 cryptography==46.0.3 dnspython==2.8.0 +docutils==0.22.2 ecdsa==0.19.1 email-validator==2.3.0 fastapi==0.120.0 @@ -20,13 +23,25 @@ h11==0.16.0 httpcore==1.0.9 httptools==0.7.1 httpx==0.28.1 +id==1.5.0 idna==3.11 iniconfig==2.3.0 itsdangerous==2.2.0 --e git+ssh://git@sheerka.synology.me:1010/kodjo/MyAuth.git@0138ac247a4a53dc555b94ec13119eba16e1db68#egg=myauth +jaraco.classes==3.4.0 +jaraco.context==6.0.1 +jaraco.functools==4.3.0 +jeepney==0.9.0 +keyring==25.6.0 +markdown-it-py==4.0.0 +mdurl==0.1.2 +more-itertools==10.8.0 +myauth==0.2.0 +myutils==0.4.0 +nh3==0.3.1 oauthlib==3.3.1 packaging==25.0 passlib==1.7.4 +pipdeptree==2.29.0 pluggy==1.6.0 pyasn1==0.6.1 pycparser==2.23 @@ -34,6 +49,7 @@ pydantic==2.12.3 pydantic-settings==2.11.0 pydantic_core==2.41.4 Pygments==2.19.2 +pyproject_hooks==1.2.0 pytest==8.4.2 python-dateutil==2.9.0.post0 python-dotenv==1.1.1 @@ -41,13 +57,21 @@ python-fasthtml==0.12.30 python-jose==3.5.0 python-multipart==0.0.20 PyYAML==6.0.3 +readme_renderer==44.0 +requests==2.32.5 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==14.2.0 rsa==4.9.1 +SecretStorage==3.4.0 six==1.17.0 sniffio==1.3.1 soupsieve==2.8 starlette==0.48.0 +twine==6.2.0 typing-inspection==0.4.2 typing_extensions==4.15.0 +urllib3==2.5.0 uvicorn==0.38.0 uvloop==0.22.1 watchfiles==1.1.1 diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css new file mode 100644 index 0000000..6d60374 --- /dev/null +++ b/src/myfasthtml/assets/myfasthtml.css @@ -0,0 +1,15 @@ +.mf-icon-20 { + width: 20px; + min-width: 20px; + height: 20px; + margin-top: auto; + margin-bottom: auto; +} + +.mf-icon-16 { + width: 16px; + min-width: 16px; + height: 16px; + margin-top: auto; + margin-bottom: 4px; +} \ No newline at end of file diff --git a/src/myfasthtml/controls/button.py b/src/myfasthtml/controls/button.py deleted file mode 100644 index bbcc933..0000000 --- a/src/myfasthtml/controls/button.py +++ /dev/null @@ -1,11 +0,0 @@ -from fasthtml.components import * - -from myfasthtml.core.commands import Command - - -def mk_button(element, command: Command = None, **kwargs): - if command is None: - return Button(element, **kwargs) - - htmx = command.get_htmx_params() - return Button(element, **htmx, **kwargs) diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py new file mode 100644 index 0000000..4af75c4 --- /dev/null +++ b/src/myfasthtml/controls/helpers.py @@ -0,0 +1,55 @@ +from fasthtml.components import * + +from myfasthtml.core.bindings import Binding +from myfasthtml.core.commands import Command +from myfasthtml.core.utils import merge_classes + + +class mk: + + @staticmethod + def button(element, command: Command = None, binding: Binding = None, **kwargs): + return mk.mk(Button(element, **kwargs), command=command, binding=binding) + + @staticmethod + def icon(icon, size=20, + can_select=True, + can_hover=False, + cls='', + command: Command = None, + binding: Binding = None, + **kwargs): + merged_cls = merge_classes(f"mf-icon-{size}", + 'icon-btn' if can_select else '', + 'mmt-btn' if can_hover else '', + cls, + kwargs) + + return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding) + + @staticmethod + def manage_command(ft, command: Command): + if command: + ft = command.bind_ft(ft) + + return ft + + @staticmethod + def manage_binding(ft, binding: Binding, ft_attr=None, init_binding=True): + if not binding: + return ft + + binding.bind_ft(ft, ft_attr) + if init_binding: + binding.init() + # as it is the first binding, remove the hx-swap-oob + if "hx-swap-oob" in ft.attrs: + del ft.attrs["hx-swap-oob"] + + return ft + + @staticmethod + def mk(ft, command: Command = None, binding: Binding = None, init_binding=True): + ft = mk.manage_command(ft, command) + ft = mk.manage_binding(ft, binding, init_binding=init_binding) + return ft diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py new file mode 100644 index 0000000..dca0be0 --- /dev/null +++ b/src/myfasthtml/core/bindings.py @@ -0,0 +1,462 @@ +import logging +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, is_datalist + +bindings_app, bindings_rt = fast_app() +logger = logging.getLogger("Bindings") + + +class UpdateMode(Enum): + ValueChange = "ValueChange" + AttributePresence = "AttributePresence" + SelectValueChange = "SelectValueChange" + DatalistListChange = "DatalistListChange" + + +class DetectionMode(Enum): + ValueChange = "ValueChange" + AttributePresence = "AttributePresence" + SelectValueChange = "SelectValueChange" + + +class AttrChangedDetection: + """ + Base class for detecting changes in an attribute of a data object. + The when a modification is triggered we can + * Search for the attribute that is modified (usual case) + * Look if the attribute is present in the data object (for example when a checkbox is toggled) + """ + + def __init__(self, attr): + self.attr = attr + + def matches(self, values): + pass + + +class ValueChangedDetection(AttrChangedDetection): + """ + Search for the attribute that is modified. + """ + + def matches(self, values): + for key, value in values.items(): + if key == self.attr: + return True, value + + return False, None + + +class SelectValueChangedDetection(AttrChangedDetection): + """ + Search for the attribute that is modified. + """ + + def matches(self, values): + for key, value in values.items(): + if key == self.attr: + return True, value + + return True, [] + + +class AttrPresentDetection(AttrChangedDetection): + """ + Search if the attribute is present in the data object. + """ + + def matches(self, values): + return True, values.get(self.attr, None) + + +class FtUpdate: + def update(self, ft, ft_name, ft_attr, old, new, converter): + pass + + +class ValueChangeFtUpdate(FtUpdate): + def update(self, ft, ft_name, ft_attr, old, new, converter): + # simple mode, just update the text or the attribute + new_to_use = converter.convert(new) if converter else new + if ft_attr is None: + ft.children = (new_to_use,) + else: + ft.attrs[ft_attr] = new_to_use + return ft + + +class SelectValueChangeFtUpdate(FtUpdate): + def update(self, ft, ft_name, ft_attr, old, new, converter): + # simple mode, just update the text or the attribute + new_to_use = converter.convert(new) if converter else new + new_to_use = [new_to_use] if not isinstance(new_to_use, list) else new_to_use + for child in [c for c in ft.children if c.tag == "option"]: + if child.attrs.get("value", None) in new_to_use: + child.attrs["selected"] = "true" + else: + child.attrs.pop("selected", None) + 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) + new_to_use = converter.convert(new) if converter else new + if ft_attr is None: + ft.children = (bool(new_to_use),) + else: + ft.attrs[ft_attr] = "true" if new_to_use else None # FastHtml auto remove None attributes + return ft + + +class DataConverter: + def convert(self, data): + pass + + +class BooleanConverter(DataConverter): + def convert(self, data): + if data is None: + return False + + if isinstance(data, int): + return data != 0 + + if str(data).lower() in ("true", "yes", "on"): + return True + + 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 + + def convert(self, data): + return data == self.radio_value + + +class Binding: + def __init__(self, data: Any, attr: str = None, converter: DataConverter = None): + """ + Creates a new binding object between a data object and an HTML element. + The binding is not active until bind_ft() is called. + + Args: + data: Object used as a pivot + attr: Attribute of the data object to bind + """ + self.id = uuid.uuid4() + self.htmx_extra = {} + self.data = data + self.data_attr = attr or get_default_attr(data) + self.data_converter = converter + + # UI-related attributes (configured later via bind_ft) + self.ft = None + self.ft_name = None + self.ft_attr = None + self.detection_mode = DetectionMode.ValueChange + self.update_mode = UpdateMode.ValueChange + + # Strategy objects (configured later) + self._detection = None + self._update = None + + # Activation state + self._is_active = False + + def bind_ft(self, + ft, + attr=None, + name=None, + data_converter: DataConverter = None, + detection_mode: DetectionMode = None, + update_mode: UpdateMode = None): + """ + Configure the UI element and activate the binding. + + Args: + ft: HTML element to bind to + name: Name of the HTML element (sent by the form) + attr: Attribute of the HTML element to bind to + data_converter: Optional converter for data transformation + detection_mode: How to detect changes from UI + update_mode: How to update the UI element + + Returns: + self for method chaining + """ + # Deactivate if already active + if self._is_active: + self.deactivate() + + if ft.tag in ["input", "textarea", "select"]: + # I must not force the htmx + if {"hx-post", "hx_post"} & set(ft.attrs.keys()): + raise ValueError(f"Binding '{self.id}': htmx post already set on input.") + + # update the component to post on the correct route input and forms only + htmx = self.get_htmx_params() + ft.attrs |= htmx + + # Configure UI elements + self.ft = self._safe_ft(ft) + self.ft_name = name or ft.attrs.get("name") + self.ft_attr = attr or get_default_ft_attr(ft) + + if is_checkbox(ft): + default_data_converter = self.data_converter or BooleanConverter() + default_detection_mode = DetectionMode.AttributePresence + default_update_mode = UpdateMode.AttributePresence + elif is_radio(ft): + default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"]) + default_detection_mode = DetectionMode.ValueChange + default_update_mode = UpdateMode.AttributePresence + elif is_select(ft): + 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 + default_update_mode = UpdateMode.ValueChange + + # Update optional parameters if provided + self.data_converter = data_converter or default_data_converter + self.detection_mode = detection_mode or default_detection_mode + self.update_mode = update_mode or default_update_mode + + # Create strategy objects + self._detection = self._factory(self.detection_mode) + self._update = self._factory(self.update_mode) + + # Activate the binding + self.activate() + + return self + + def get_htmx_params(self): + return self.htmx_extra | { + "hx-post": f"{ROUTE_ROOT}{Routes.Bindings}", + "hx-vals": f'{{"b_id": "{self.id}"}}', + } + + def init(self): + """ + Initialise the UI element with the value of the data + :return: + """ + old_value = None # to complicated to retrieve as it depends on the nature of self.ft + new_value = getattr(self.data, self.data_attr) + self.notify(old_value, new_value) + return self + + def notify(self, old, new): + """ + Callback when the data attribute changes. + Updates the UI element accordingly. + + Args: + old: Previous value + new: New value + + Returns: + Updated ft element + """ + if not self._is_active: + logger.warning(f"Binding '{self.id}' received notification but is not active") + return None + + logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'") + self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new, self.data_converter) + + self.ft.attrs["hx-swap-oob"] = "true" + return self.ft + + def update(self, values: dict): + """ + Called by the FastHTML router when a request is received. + :param values: + :return: the list of updated elements (all elements that are bound to this binding) + """ + logger.debug(f"Binding '{self.id}': Updating with {values=}.") + matches, value = self._detection.matches(values) + if matches: + setattr(self.data, self.data_attr, value) + res = collect_return_values(self.data) + return res + + else: + logger.debug(f"Nothing to trigger in {values}.") + return None + + def activate(self): + """ + Activate the binding by setting up observers and registering it. + Should only be called after the binding is fully configured. + + Raises: + ValueError: If the binding is not fully configured + """ + if self._is_active: + logger.warning(f"Binding '{self.id}' is already active") + return + + # Validate configuration + self._validate_configuration() + + # Setup observable + make_observable(self.data) + bind(self.data, self.data_attr, self.notify) + + # Register in manager + BindingsManager.register(self) + + # Mark as active + self._is_active = True + + logger.debug(f"Binding '{self.id}' activated for {self.data_attr}") + + def deactivate(self): + """ + Deactivate the binding by removing observers and unregistering it. + Can be called multiple times safely. + """ + if not self._is_active: + logger.debug(f"Binding '{self.id}' is not active, nothing to deactivate") + return + + # Remove observer + unbind(self.data, self.data_attr, self.notify) + + # Unregister from manager + BindingsManager.unregister(self.id) + + # Mark as inactive + self._is_active = False + + logger.debug(f"Binding '{self.id}' deactivated") + + @staticmethod + def _safe_ft(ft): + """ + Make sure the ft has an id. + :param ft: + :return: + """ + if ft is None: + return None + + if ft.attrs.get("id", None) is None: + ft.attrs["id"] = str(uuid.uuid4()) + return ft + + def _factory(self, mode): + if mode == DetectionMode.ValueChange: + return ValueChangedDetection(self.ft_name) + + elif mode == DetectionMode.AttributePresence: + return AttrPresentDetection(self.ft_name) + + elif mode == DetectionMode.SelectValueChange: + return SelectValueChangedDetection(self.ft_name) + + elif mode == UpdateMode.ValueChange: + return ValueChangeFtUpdate() + + elif mode == UpdateMode.AttributePresence: + return AttributePresenceFtUpdate() + + elif mode == UpdateMode.SelectValueChange: + return SelectValueChangeFtUpdate() + + elif mode == UpdateMode.DatalistListChange: + return DatalistListChangeFtUpdate() + + else: + raise ValueError(f"Invalid detection mode: {mode}") + + def _validate_configuration(self): + """ + Validate that the binding is fully configured before activation. + + Raises: + ValueError: If required configuration is missing + """ + if self.ft is None: + raise ValueError(f"Binding '{self.id}': ft element is required") + + # if self.ft_name is None: + # raise ValueError(f"Binding '{self.id}': ft_name is required") + + if self._detection is None: + raise ValueError(f"Binding '{self.id}': detection strategy not initialized") + + if self._update is None: + raise ValueError(f"Binding '{self.id}': update strategy not initialized") + + def htmx(self, trigger=None): + if trigger: + self.htmx_extra["hx-trigger"] = trigger + return self + + +class BindingsManager: + bindings = {} + + @staticmethod + def register(binding: Binding): + BindingsManager.bindings[str(binding.id)] = binding + + @staticmethod + def unregister(binding_id: str): + """ + Unregister a binding from the manager. + + Args: + binding_id: ID of the binding to unregister + """ + if str(binding_id) in BindingsManager.bindings: + del BindingsManager.bindings[str(binding_id)] + + @staticmethod + def get_binding(binding_id: str) -> Optional[Binding]: + return BindingsManager.bindings.get(str(binding_id)) + + @staticmethod + def reset(): + return BindingsManager.bindings.clear() diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 79254dc..57ec103 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -1,14 +1,9 @@ -import logging import uuid from typing import Optional -from fasthtml.fastapp import fast_app +from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener from myfasthtml.core.constants import Routes, ROUTE_ROOT -from myfasthtml.core.utils import mount_if_not_exists - -commands_app, commands_rt = fast_app() -logger = logging.getLogger("Commands") class BaseCommand: @@ -32,18 +27,62 @@ class BaseCommand: self.id = uuid.uuid4() self.name = name self.description = description + self._htmx_extra = {} + self._bindings = [] # register the command CommandsManager.register(self) def get_htmx_params(self): - return { + return self._htmx_extra | { "hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-vals": f'{{"c_id": "{self.id}"}}', } def execute(self): raise NotImplementedError + + 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 class Command(BaseCommand): @@ -72,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})" @@ -92,31 +150,3 @@ class CommandsManager: @staticmethod def reset(): return CommandsManager.commands.clear() - - -@commands_rt(Routes.Commands) -def post(session: str, c_id: str): - """ - Default routes for all commands. - :param session: - :param c_id: - :return: - """ - logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}") - command = CommandsManager.get_command(c_id) - if command: - return command.execute() - - raise ValueError(f"Command with ID '{c_id}' not found.") - - -def mount_commands(app): - """ - Mounts the commands_app to the given application instance if the route does not already exist. - - :param app: The application instance to which the commands_app will be mounted. - :type app: Any - :return: Returns the result of the mount operation performed by mount_if_not_exists. - :rtype: Any - """ - return mount_if_not_exists(app, ROUTE_ROOT, commands_app) diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py index 415d59c..3d3bfdf 100644 --- a/src/myfasthtml/core/constants.py +++ b/src/myfasthtml/core/constants.py @@ -1,4 +1,5 @@ ROUTE_ROOT = "/myfasthtml" class Routes: - Commands = "/commands" \ No newline at end of file + Commands = "/commands" + Bindings = "/bindings" \ No newline at end of file diff --git a/src/myfasthtml/core/testclient.py b/src/myfasthtml/core/testclient.py deleted file mode 100644 index 9d94a0c..0000000 --- a/src/myfasthtml/core/testclient.py +++ /dev/null @@ -1,852 +0,0 @@ -import dataclasses -import json -import uuid -from dataclasses import dataclass -from typing import Self - -from bs4 import BeautifulSoup, Tag -from fastcore.xml import FT, to_xml -from fasthtml.common import FastHTML -from starlette.responses import Response -from starlette.testclient import TestClient - -from myfasthtml.core.commands import mount_commands - - -@dataclass -class MyFT: - tag: str - attrs: dict - children: list['MyFT'] = dataclasses.field(default_factory=list) - text: str | None = None - - -class TestableElement: - """ - Represents an HTML element that can be interacted with in tests. - - This class will be used for future interactions like clicking elements - or verifying element properties. - """ - - def __init__(self, client, source): - """ - Initialize a testable element. - - Args: - client: The MyTestClient instance. - ft: The FastHTML element representation. - """ - self.client = client - if isinstance(source, str): - self.html_fragment = source - tag = BeautifulSoup(source, 'html.parser').find() - self.ft = MyFT(tag.name, tag.attrs) - elif isinstance(source, Tag): - self.html_fragment = str(source) - self.ft = MyFT(source.name, source.attrs) - elif isinstance(source, FT): - self.ft = source - self.html_fragment = to_xml(source).strip() - else: - raise ValueError(f"Invalid source '{source}' for TestableElement.") - - def click(self): - """Click the element (to be implemented).""" - return self._send_htmx_request() - - def matches(self, ft): - """Check if element matches given FastHTML element (to be implemented).""" - pass - - def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response: - """ - Simulates an HTMX request in Python for unit testing. - - This function reads the 'hx-*' attributes from the FastHTML object - to determine the HTTP method, URL, headers, and body of the request, - then executes it via the TestClient. - - Args: - data: (Optional) A dict for form data - (sends as 'application/x-www-form-urlencoded'). - json_data: (Optional) A dict for JSON data - (sends as 'application/json'). - Takes precedence over 'hx_vals'. - - Returns: - The Response object from the simulated request. - """ - - # The essential header for FastHTML (and HTMX) to identify the request - headers = {"HX-Request": "true"} - method = "GET" # HTMX defaults to GET if not specified - url = None - - verbs = { - 'hx_get': 'GET', - 'hx_post': 'POST', - 'hx_put': 'PUT', - 'hx_delete': 'DELETE', - 'hx_patch': 'PATCH', - } - - # .props contains the kwargs passed to the object (e.g., hx_post="/url") - element_attrs = self.ft.attrs or {} - - # Build the attributes - for key, value in element_attrs.items(): - - # sanitize the key - key = key.lower().strip() - if key.startswith('hx-'): - key = 'hx_' + key[3:] - - if key in verbs: - # Verb attribute: defines the method and URL - method = verbs[key] - url = str(value) - - elif key == 'hx_vals': - # hx_vals defines the JSON body, if not already provided by the test - if json_data is None: - if isinstance(value, str): - json_data = json.loads(value) - elif isinstance(value, dict): - json_data = value - - elif key.startswith('hx_'): - # Any other hx_* attribute is converted to an HTTP header - # e.g.: 'hx_target' -> 'HX-Target' - header_name = '-'.join(p.capitalize() for p in key.split('_')) - headers[header_name] = str(value) - - # Sanity check - if url is None: - raise ValueError( - f"The <{self.ft.tag}> element has no HTMX verb attribute " - "(e.g., hx_get, hx_post) to define a URL." - ) - - # Send the request - return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data) - - def _support_htmx(self): - """Check if the element supports HTMX.""" - return ('hx_get' in self.ft.attrs or - 'hx-get' in self.ft.attrs or - 'hx_post' in self.ft.attrs or - 'hx-post' in self.ft.attrs) - - -class TestableForm(TestableElement): - """ - Represents an HTML form that can be filled and submitted in tests. - """ - - def __init__(self, client, source): - """ - Initialize a testable form. - - Args: - client: The MyTestClient instance. - source: The source HTML string containing a form. - """ - super().__init__(client, source) - self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form') - self.fields_mapping = {} # link between the input label and the input name - self.fields = {} # field name; field value - self.select_fields = {} # list of possible options for 'select' input fields - - self._update_fields_mapping() - self.update_fields() - - def update_fields(self): - """ - Update the fields dictionary with current form values and their proper types. - - This method processes all input and select elements in the form: - - Determines the appropriate Python type (str, int, float, bool) based on - the HTML input type attribute and/or the value itself - - For select elements, populates self.select_fields with available options - - Stores the final typed values in self.fields - - Type conversion priority: - 1. HTML type attribute (checkbox → bool, number → int/float, etc.) - 2. Value analysis fallback for ambiguous types (text/hidden/absent type) - """ - self.fields = {} - self.select_fields = {} - - # Process input fields - for input_field in self.form.find_all('input'): - name = input_field.get('name') - if not name: - continue - - input_type = input_field.get('type', 'text').lower() - raw_value = input_field.get('value', '') - - # Type conversion based on input type - if input_type == 'checkbox': - # Checkbox: bool based on 'checked' attribute - self.fields[name] = input_field.has_attr('checked') - - elif input_type == 'radio': - # Radio: str value (only if checked) - if input_field.has_attr('checked'): - self.fields[name] = raw_value - elif name not in self.fields: - # If no radio is checked yet, don't set a default - pass - - elif input_type == 'number': - # Number: int or float based on value - self.fields[name] = self._convert_number(raw_value) - - else: - # Other types (text, hidden, email, password, etc.): analyze value - self.fields[name] = self._convert_value(raw_value) - - # Process select fields - for select_field in self.form.find_all('select'): - name = select_field.get('name') - if not name: - continue - - # Extract all options - options = [] - selected_value = None - - for option in select_field.find_all('option'): - option_value = option.get('value', option.get_text(strip=True)) - option_text = option.get_text(strip=True) - - options.append({ - 'value': option_value, - 'text': option_text - }) - - # Track selected option - if option.has_attr('selected'): - selected_value = option_value - - # Store options list - self.select_fields[name] = options - - # Store selected value (or first option if none selected) - if selected_value is not None: - self.fields[name] = selected_value - elif options: - self.fields[name] = options[0]['value'] - - def fill(self, **kwargs): - """ - Fill the form with the given data. - - Args: - **kwargs: Field names and their values to fill in the form. - """ - for name, value in kwargs.items(): - field_name = self.translate(name) - if field_name not in self.fields: - raise ValueError(f"Invalid field name '{name}'.") - self.fields[self.translate(name)] = value - - def submit(self): - """ - Submit the form. - - This method handles both HTMX-enabled forms and classic HTML form submissions: - - If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request - - Otherwise, simulates a classic browser form submission using the form's - action and method attributes - - Returns: - The response from the form submission. - - Raises: - ValueError: If the form has no action attribute for classic submission. - """ - # Check if the form supports HTMX - if self._support_htmx(): - return self._send_htmx_request(data=self.fields) - - # Classic form submission - action = self.form.get('action') - if not action or action.strip() == '': - raise ValueError( - "The form has no 'action' attribute. " - "Cannot submit a classic form without a target URL." - ) - - method = self.form.get('method', 'post').upper() - - # Prepare headers for classic form submission - headers = { - "Content-Type": "application/x-www-form-urlencoded" - } - - # Send the request via the client - return self.client.send_request( - method=method, - url=action, - headers=headers, - data=self.fields - ) - - def translate(self, field): - return self.fields_mapping.get(field, field) - - def _update_fields_mapping(self): - """ - Build a mapping between label text and input field names. - - This method finds all labels in the form and associates them with their - corresponding input fields using the following priority order: - 1. Explicit association via 'for' attribute matching input 'id' - 2. Implicit association (label contains the input) - 3. Parent-level association with 'for'/'id' - 4. Proximity association (siblings in same parent) - 5. No label (use input name as key) - - The mapping is stored in self.fields_mapping as {label_text: input_name}. - For inputs without a name, the id is used. If neither exists, a generic - key like "unnamed_0" is generated. - """ - self.fields_mapping = {} - processed_inputs = set() - unnamed_counter = 0 - - # Get all inputs in the form - all_inputs = self.form.find_all('input') - - # Priority 1 & 2: Explicit association (for/id) and implicit (nested) - for label in self.form.find_all('label'): - label_text = label.get_text(strip=True) - - # Check for explicit association via 'for' attribute - label_for = label.get('for') - if label_for: - input_field = self.form.find('input', id=label_for) - if input_field: - input_name = self._get_input_identifier(input_field, unnamed_counter) - if input_name.startswith('unnamed_'): - unnamed_counter += 1 - self.fields_mapping[label_text] = input_name - processed_inputs.add(id(input_field)) - continue - - # Check for implicit association (label contains input) - input_field = label.find('input') - if input_field: - input_name = self._get_input_identifier(input_field, unnamed_counter) - if input_name.startswith('unnamed_'): - unnamed_counter += 1 - self.fields_mapping[label_text] = input_name - processed_inputs.add(id(input_field)) - continue - - # Priority 3 & 4: Parent-level associations - for label in self.form.find_all('label'): - label_text = label.get_text(strip=True) - - # Skip if this label was already processed - if label_text in self.fields_mapping: - continue - - parent = label.parent - if parent: - input_found = False - - # Priority 3: Look for sibling input with matching for/id - label_for = label.get('for') - if label_for: - for sibling in parent.find_all('input'): - if sibling.get('id') == label_for and id(sibling) not in processed_inputs: - input_name = self._get_input_identifier(sibling, unnamed_counter) - if input_name.startswith('unnamed_'): - unnamed_counter += 1 - self.fields_mapping[label_text] = input_name - processed_inputs.add(id(sibling)) - input_found = True - break - - # Priority 4: Fallback to proximity if no input found yet - if not input_found: - for sibling in parent.find_all('input'): - if id(sibling) not in processed_inputs: - input_name = self._get_input_identifier(sibling, unnamed_counter) - if input_name.startswith('unnamed_'): - unnamed_counter += 1 - self.fields_mapping[label_text] = input_name - processed_inputs.add(id(sibling)) - break - - # Priority 5: Inputs without labels - for input_field in all_inputs: - if id(input_field) not in processed_inputs: - input_name = self._get_input_identifier(input_field, unnamed_counter) - if input_name.startswith('unnamed_'): - unnamed_counter += 1 - self.fields_mapping[input_name] = input_name - - @staticmethod - def _get_input_identifier(input_field, counter): - """ - Get the identifier for an input field. - - Args: - input_field: The BeautifulSoup Tag object representing the input. - counter: Current counter for unnamed inputs. - - Returns: - The input name, id, or a generated "unnamed_X" identifier. - """ - if input_field.get('name'): - return input_field['name'] - elif input_field.get('id'): - return input_field['id'] - else: - return f"unnamed_{counter}" - - @staticmethod - def _convert_number(value): - """ - Convert a string value to int or float. - - Args: - value: String value to convert. - - Returns: - int, float, or empty string if conversion fails. - """ - if not value or value.strip() == '': - return '' - - try: - # Try float first to detect decimal numbers - if '.' in value or 'e' in value.lower(): - return float(value) - else: - return int(value) - except ValueError: - return value - - @staticmethod - def _convert_value(value): - """ - Analyze and convert a value to its appropriate type. - - Conversion priority: - 1. Boolean keywords (true/false) - 2. Float (contains decimal point) - 3. Int (numeric) - 4. Empty string - 5. String (default) - - Args: - value: String value to convert. - - Returns: - Converted value with appropriate type (bool, float, int, or str). - """ - if not value or value.strip() == '': - return '' - - value_lower = value.lower().strip() - - # Check for boolean - if value_lower in ('true', 'false'): - return value_lower == 'true' - - # Check for numeric values - try: - # Check for float (has decimal point or scientific notation) - if '.' in value or 'e' in value_lower: - return float(value) - # Try int - else: - return int(value) - except ValueError: - pass - - # Default to string - return value - - -class MyTestClient: - """ - A test client helper for FastHTML applications that provides - a more user-friendly API for testing HTML responses. - - This class wraps Starlette's TestClient and provides methods - to verify page content in a way similar to NiceGui's test fixtures. - """ - - def __init__(self, app: FastHTML, parent_levels: int = 1): - """ - Initialize the test client. - - Args: - app: The FastHTML application to test. - parent_levels: Number of parent levels to show in error messages (default: 1). - """ - self.app = app - self.client = TestClient(app) - self._content = None - self._soup = None - self._session = str(uuid.uuid4()) - self.parent_levels = parent_levels - - # make sure that the commands are mounted - mount_commands(self.app) - - def open(self, path: str) -> Self: - """ - Open a page and store its content for subsequent assertions. - - Args: - path: The URL path to request (e.g., '/home', '/api/users'). - - Returns: - self: Returns the client instance for method chaining. - - Raises: - AssertionError: If the response status code is not 200. - """ - - res = self.client.get(path) - assert res.status_code == 200, ( - f"Failed to open '{path}'. " - f"status code={res.status_code} : reason='{res.text}'" - ) - - self.set_content(res.text) - return self - - def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None): - if json_data is not None: - json_data['session'] = self._session - - res = self.client.request( - method, - url, - headers=headers, - data=data, # For form data - json=json_data # For JSON bodies (e.g., from hx_vals) - ) - - assert res.status_code == 200, ( - f"Failed to send request '{method=}', {url=}. " - f"status code={res.status_code} : reason='{res.text}'" - ) - - self.set_content(res.text) - return self - - def should_see(self, text: str) -> Self: - """ - Assert that the given text is present in the visible page content. - - This method parses the HTML and searches only in the visible text, - ignoring HTML tags and attributes. - - Args: - text: The text string to search for (case-sensitive). - - Returns: - self: Returns the client instance for method chaining. - - Raises: - AssertionError: If the text is not found in the page content. - ValueError: If no page has been opened yet. - """ - - def clean_text(txt): - return "\n".join(line for line in txt.splitlines() if line.strip()) - - - if self._content is None: - raise ValueError( - "No page content available. Call open() before should_see()." - ) - - visible_text = self._soup.get_text() - - if text not in visible_text: - # Provide a snippet of the actual content for debugging - snippet_length = 200 - content_snippet = clean_text( - visible_text[:snippet_length] + "..." - if len(visible_text) > snippet_length - else visible_text - ) - raise AssertionError( - f"Expected to see '{text}' in page content but it was not found.\n" - f"Visible content (first {snippet_length} chars): {content_snippet}" - ) - - return self - - def should_not_see(self, text: str) -> Self: - """ - Assert that the given text is NOT present in the visible page content. - - This method parses the HTML and searches only in the visible text, - ignoring HTML tags and attributes. - - Args: - text: The text string that should not be present (case-sensitive). - - Returns: - self: Returns the client instance for method chaining. - - Raises: - AssertionError: If the text is found in the page content. - ValueError: If no page has been opened yet. - """ - if self._content is None: - raise ValueError( - "No page content available. Call open() before should_not_see()." - ) - - visible_text = self._soup.get_text() - - if text in visible_text: - element = self._find_visible_text_element(self._soup, text) - - if element: - context = self._format_element_with_context(element, self.parent_levels) - error_msg = ( - f"Expected NOT to see '{text}' in page content but it was found.\n" - f"Found in:\n{context}" - ) - else: - error_msg = ( - f"Expected NOT to see '{text}' in page content but it was found.\n" - f"Unable to locate the element containing this text." - ) - - raise AssertionError(error_msg) - - return self - - def find_element(self, selector: str) -> TestableElement: - """ - Find a single HTML element using a CSS selector. - - This method searches for elements matching the given CSS selector. - It expects to find exactly one matching element. - - Args: - selector: A CSS selector string (e.g., '#my-id', '.my-class', 'button.primary'). - - Returns: - TestableElement: A testable element wrapping the HTML fragment. - - Raises: - ValueError: If no page has been opened yet. - AssertionError: If no element or multiple elements match the selector. - - Examples: - element = client.open('/').find_element('#login-button') - element = client.find_element('button.primary') - """ - if self._content is None: - raise ValueError( - "No page content available. Call open() before find_element()." - ) - - results = self._soup.select(selector) - - if len(results) == 0: - raise AssertionError( - f"No element found matching selector '{selector}'." - ) - elif len(results) == 1: - return TestableElement(self, results[0]) - else: - raise AssertionError( - f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1." - ) - - def find_form(self, fields: list = None) -> TestableForm: - """ - Find a form element in the page content. - Can provide title of the fields to ease the search - :param fields: - :return: - """ - if self._content is None: - raise ValueError( - "No page content available. Call open() before find_form()." - ) - - results = self._soup.select("form") - if len(results) == 0: - raise AssertionError( - f"No form found." - ) - - if fields is None: - remaining = [TestableForm(self, form) for form in results] - else: - remaining = [] - for form in results: - testable_form = TestableForm(self, form) - if all(testable_form.translate(field) in testable_form.fields for field in fields): - remaining.append(testable_form) - - if len(remaining) == 1: - return remaining[0] - else: - raise AssertionError( - f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1." - ) - - def get_content(self) -> str: - """ - Get the raw HTML content of the last opened page. - - Returns: - The HTML content as a string, or None if no page has been opened. - """ - return self._content - - def set_content(self, content: str) -> Self: - """ - Set the HTML content and parse it with BeautifulSoup. - - Args: - content: The HTML content string to set. - """ - self._content = content - self._soup = BeautifulSoup(content, 'html.parser') - return self - - @staticmethod - def _find_visible_text_element(soup, text: str): - """ - Find the first element containing the visible text. - - This method traverses the BeautifulSoup tree to find the first element - whose visible text content (including descendants) contains the search text. - - Args: - soup: BeautifulSoup object representing the parsed HTML. - text: The text to search for. - - Returns: - BeautifulSoup element containing the text, or None if not found. - """ - # Traverse all elements in the document - for element in soup.descendants: - # Skip NavigableString nodes, we want Tag elements - if not isinstance(element, Tag): - continue - - # Get visible text of this element and its descendants - element_text = element.get_text() - - # Check if our search text is in this element's visible text - if text in element_text: - # Found it! But we want the smallest element containing the text - # So let's check if any of its children also contain the text - found_in_child = False - - for child in element.children: - if isinstance(child, Tag) and text in child.get_text(): - found_in_child = True - break - - # If no child contains the text, this is our target element - if not found_in_child: - return element - - return None - - @staticmethod - def _indent_html(html_str: str, indent: int = 2): - """ - Add indentation to HTML string. - - Args: - html_str: HTML string to indent. - indent: Number of spaces for indentation. - - Returns: - str: Indented HTML string. - """ - lines = html_str.split('\n') - indented_lines = [' ' * indent + line for line in lines if line.strip()] - return '\n'.join(indented_lines) - - def _format_element_with_context(self, element, parent_levels: int): - """ - Format an element with its parent context for display. - - Args: - element: BeautifulSoup element to format. - parent_levels: Number of parent levels to include. - - Returns: - str: Formatted HTML string with indentation. - """ - # Collect the element and its parents - elements_to_show = [element] - current = element - - for _ in range(parent_levels): - if current.parent and current.parent.name: # Skip NavigableString parents - elements_to_show.insert(0, current.parent) - current = current.parent - else: - break - - # Format the top-level element with proper indentation - if len(elements_to_show) == 1: - return self._indent_html(str(element), indent=2) - - # Build the nested structure - result = self._build_nested_context(elements_to_show, element) - return self._indent_html(result, indent=2) - - def _build_nested_context(self, elements_chain, target_element): - """ - Build nested HTML context showing parents and target element. - - Args: - elements_chain: List of elements from outermost parent to target. - target_element: The element that contains the searched text. - - Returns: - str: Nested HTML structure. - """ - if len(elements_chain) == 1: - return str(target_element) - - # Get the outermost element - outer = elements_chain[0] - - # Start with opening tag - result = f"<{outer.name}" - if outer.attrs: - attrs = ' '.join(f'{k}="{v}"' if not isinstance(v, list) else f'{k}="{" ".join(v)}"' - for k, v in outer.attrs.items()) - result += f" {attrs}" - result += ">\n" - - # Add nested content - if len(elements_chain) == 2: - # This is the target element - result += self._indent_html(str(target_element), indent=2) + "\n" - else: - # Recursive call for deeper nesting - nested = self._build_nested_context(elements_chain[1:], target_element) - result += self._indent_html(nested, indent=2) + "\n" - - # Closing tag - result += f"{outer.name}>" - - return result diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index e2ef70f..bae9259 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -1,5 +1,16 @@ +import logging + +from bs4 import Tag +from fastcore.xml import FT +from fasthtml.fastapp import fast_app from starlette.routing import Mount +from myfasthtml.core.constants import Routes, ROUTE_ROOT +from myfasthtml.test.MyFT import MyFT + +utils_app, utils_rt = fast_app() +logger = logging.getLogger("Commands") + def mount_if_not_exists(app, path: str, sub_app): """ @@ -17,3 +28,166 @@ def mount_if_not_exists(app, path: str, sub_app): if not is_mounted: app.mount(path, app=sub_app) + + +def merge_classes(*args): + all_elements = [] + for element in args: + if element is None or element == '': + continue + + if isinstance(element, (tuple, list, set)): + all_elements.extend(element) + + elif isinstance(element, dict): + if "cls" in element: + all_elements.append(element.pop("cls")) + elif "class" in element: + all_elements.append(element.pop("class")) + + elif isinstance(element, str): + all_elements.append(element) + + else: + raise ValueError(f"Cannot merge {element} of type {type(element)}") + + if all_elements: + # Remove duplicates while preserving order + unique_elements = list(dict.fromkeys(all_elements)) + return " ".join(unique_elements) + else: + return None + + +def debug_routes(app): + def _debug_routes(_app, _route, prefix=""): + if isinstance(_route, Mount): + for sub_route in _route.app.router.routes: + _debug_routes(_app, sub_route, prefix=_route.path) + else: + print(f"path={prefix}{_route.path}, methods={_route.methods}, endpoint={_route.endpoint}") + + for route in app.router.routes: + _debug_routes(app, route) + + +def mount_utils(app): + """ + Mounts the commands_app to the given application instance if the route does not already exist. + + :param app: The application instance to which the commands_app will be mounted. + :type app: Any + :return: Returns the result of the mount operation performed by mount_if_not_exists. + :rtype: Any + """ + return mount_if_not_exists(app, ROUTE_ROOT, utils_app) + + +def get_default_ft_attr(ft): + """ + for every type of HTML element (ft) gives the default attribute to use for binding + :param ft: + :return: + """ + if ft.tag == "input": + if ft.attrs.get("type") == "checkbox": + return "checked" + elif ft.attrs.get("type") == "radio": + return "checked" + elif ft.attrs.get("type") == "file": + return "files" + else: + return "value" + else: + return None # indicate that the content of the FT should be updated + + +def get_default_attr(data): + all_attrs = data.__dict__.keys() + return next(iter(all_attrs)) + + +def is_checkbox(elt): + if isinstance(elt, (FT, MyFT)): + return elt.tag == "input" and elt.attrs.get("type", None) == "checkbox" + elif isinstance(elt, Tag): + return elt.name == "input" and elt.attrs.get("type", None) == "checkbox" + else: + return False + + +def is_radio(elt): + if isinstance(elt, (FT, MyFT)): + return elt.tag == "input" and elt.attrs.get("type", None) == "radio" + elif isinstance(elt, Tag): + return elt.name == "input" and elt.attrs.get("type", None) == "radio" + else: + return False + + +def is_select(elt): + if isinstance(elt, (FT, MyFT)): + return elt.tag == "select" + elif isinstance(elt, Tag): + return elt.name == "select" + else: + 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" + + if isinstance(s, str): + if "'" in s and '"' in s: + return f'"{s.replace('"', '\\"')}"' + elif '"' in s: + return f"'{s}'" + else: + return f'"{s}"' + + return str(s) + + +@utils_rt(Routes.Commands) +def post(session, c_id: str): + """ + Default routes for all commands. + :param session: + :param c_id: + :return: + """ + logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}") + from myfasthtml.core.commands import CommandsManager + command = CommandsManager.get_command(c_id) + if command: + return command.execute() + + raise ValueError(f"Command with ID '{c_id}' not found.") + + +@utils_rt(Routes.Bindings) +def post(session, b_id: str, values: dict): + """ + Default routes for all bindings. + :param session: + :param b_id: + :param values: + :return: + """ + logger.debug(f"Entering {Routes.Bindings} with {session=}, {b_id=}, {values=}") + from myfasthtml.core.bindings import BindingsManager + binding = BindingsManager.get_binding(b_id) + if binding: + return binding.update(values) + + raise ValueError(f"Binding with ID '{b_id}' not found.") diff --git a/src/myfasthtml/examples/__init__.py b/src/myfasthtml/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/myfasthtml/examples/binding_checkbox.py b/src/myfasthtml/examples/binding_checkbox.py new file mode 100644 index 0000000..608724c --- /dev/null +++ b/src/myfasthtml/examples/binding_checkbox.py @@ -0,0 +1,51 @@ +import logging +from dataclasses import dataclass + +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.bindings import Binding, BooleanConverter +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: str = "Hello World" + checked: bool = False + + +data = Data() + + +@rt("/set_checkbox") +def post(check_box_name: str = None): + print(check_box_name) + + +@rt("/") +def index(): + return Div( + mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")), + mk.mk(Label("Text"), binding=Binding(data, attr="checked", converter=BooleanConverter())), + ) + + +@rt("/test_checkbox_htmx") +def get(): + check_box = Input(type="checkbox", name="check_box_name", hx_post="/set_checkbox") + return check_box + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_datalist.py b/src/myfasthtml/examples/binding_datalist.py new file mode 100644 index 0000000..70289f5 --- /dev/null +++ b/src/myfasthtml/examples/binding_datalist.py @@ -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) diff --git a/src/myfasthtml/examples/binding_input.py b/src/myfasthtml/examples/binding_input.py new file mode 100644 index 0000000..50f6b87 --- /dev/null +++ b/src/myfasthtml/examples/binding_input.py @@ -0,0 +1,33 @@ +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() + + +@rt("/") +def get(): + return Div( + mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")), + mk.mk(Label("Text"), binding=Binding(data, attr="value")) + ) + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_radio.py b/src/myfasthtml/examples/binding_radio.py new file mode 100644 index 0000000..0998280 --- /dev/null +++ b/src/myfasthtml/examples/binding_radio.py @@ -0,0 +1,47 @@ +import logging +from dataclasses import dataclass + +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 + +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: str = "Hello World" + checked: bool = False + + +data = Data() + + +@rt("/") +def get(): + radio1 = Input(type="radio", name="radio_name", value="option1") + radio2 = Input(type="radio", name="radio_name", value="option2", checked=True) + radio3 = Input(type="radio", name="radio_name", value="option3") + label_elt = Label("hi hi hi !") + + mk.manage_binding(radio1, Binding(data)) + mk.manage_binding(radio2, Binding(data)) + mk.manage_binding(radio3, Binding(data)) + mk.manage_binding(label_elt, Binding(data)) + + return radio1, radio2, radio3, label_elt + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_range.py b/src/myfasthtml/examples/binding_range.py new file mode 100644 index 0000000..89bdf2d --- /dev/null +++ b/src/myfasthtml/examples/binding_range.py @@ -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) diff --git a/src/myfasthtml/examples/binding_select.py b/src/myfasthtml/examples/binding_select.py new file mode 100644 index 0000000..a150b63 --- /dev/null +++ b/src/myfasthtml/examples/binding_select.py @@ -0,0 +1,46 @@ +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.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" + + +data = Data() + + +@rt("/") +def get(): + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name" + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data), init_binding=False) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_select_multiple.py b/src/myfasthtml/examples/binding_select_multiple.py new file mode 100644 index 0000000..2d733e7 --- /dev/null +++ b/src/myfasthtml/examples/binding_select_multiple.py @@ -0,0 +1,47 @@ +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.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" + + +data = Data() + + +@rt("/") +def get(): + select_elt = Select( + Option("Option 1", value="option1"), + Option("Option 2", value="option2"), + Option("Option 3", value="option3"), + name="select_name", + multiple=True + ) + label_elt = Label() + mk.manage_binding(select_elt, Binding(data), init_binding=False) + mk.manage_binding(label_elt, Binding(data)) + return select_elt, label_elt + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/binding_textarea.py b/src/myfasthtml/examples/binding_textarea.py new file mode 100644 index 0000000..1aa8022 --- /dev/null +++ b/src/myfasthtml/examples/binding_textarea.py @@ -0,0 +1,33 @@ +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() + + +@rt("/") +def get(): + return Div( + mk.mk(Textarea(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")), + mk.mk(Label("Text"), binding=Binding(data, attr="value")) + ) + + +if __name__ == "__main__": + debug_routes(app) + serve(port=5002) diff --git a/src/myfasthtml/examples/clickme.py b/src/myfasthtml/examples/clickme.py new file mode 100644 index 0000000..5c4ca88 --- /dev/null +++ b/src/myfasthtml/examples/clickme.py @@ -0,0 +1,26 @@ +from fasthtml import serve + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.myfastapp import create_app + + +# Define a simple command action +def say_hello(): + return "Hello, FastHtml!" + + +# Create the command +hello_command = Command("say_hello", "Responds with a greeting", say_hello) + +# Create the app +app, rt = create_app(protect_routes=False) + + +@rt("/") +def get_homepage(): + return mk.button("Click Me!", command=hello_command) + + +if __name__ == "__main__": + serve(port=5002) diff --git a/src/myfasthtml/examples/command_with_htmx_params.py b/src/myfasthtml/examples/command_with_htmx_params.py new file mode 100644 index 0000000..3ab2747 --- /dev/null +++ b/src/myfasthtml/examples/command_with_htmx_params.py @@ -0,0 +1,25 @@ +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.icons.fa import icon_home +from myfasthtml.myfastapp import create_app + +app, rt = create_app(protect_routes=False) + + +def change_text(): + return "New text" + + +command = Command("change_text", "change the text", change_text).htmx(target="#text") + + +@rt("/") +def index(): + return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command) + + +if __name__ == "__main__": + serve(port=5002) diff --git a/src/myfasthtml/examples/helloworld.py b/src/myfasthtml/examples/helloworld.py new file mode 100644 index 0000000..c65f0ff --- /dev/null +++ b/src/myfasthtml/examples/helloworld.py @@ -0,0 +1,15 @@ +from fasthtml import serve +from fasthtml.components import * + +from myfasthtml.myfastapp import create_app + +app, rt = create_app(protect_routes=False) + + +@rt("/") +def get_homepage(): + return Div("Hello, FastHtml!") + + +if __name__ == "__main__": + serve(port=5002) diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index cccf3a1..d76eded 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -1,3 +1,4 @@ +import logging from importlib.resources import files from pathlib import Path from typing import Optional, Any @@ -8,6 +9,9 @@ from starlette.responses import Response from myfasthtml.auth.routes import setup_auth_routes from myfasthtml.auth.utils import create_auth_beforeware +from myfasthtml.core.utils import utils_app + +logger = logging.getLogger("MyFastHtml") def get_asset_path(filename): @@ -25,7 +29,7 @@ def get_asset_content(filename): return get_asset_path(filename).read_text() -def create_app(daisyui: Optional[bool] = False, +def create_app(daisyui: Optional[bool] = True, protect_routes: Optional[bool] = True, mount_auth_app: Optional[bool] = False, **kwargs) -> Any: @@ -50,10 +54,10 @@ def create_app(daisyui: Optional[bool] = False, :return: A tuple containing the FastHtml application instance and the associated router. :rtype: Any """ - hdrs = [] + hdrs = [Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css")] if daisyui: - hdrs = [ + hdrs += [ Link(href="/myfasthtml/daisyui-5.css", rel="stylesheet", type="text/css"), Link(href="/myfasthtml/daisyui-5-themes.css", rel="stylesheet", type="text/css"), Script(src="/myfasthtml/tailwindcss-browser@4.js"), @@ -84,6 +88,9 @@ def create_app(daisyui: Optional[bool] = False, # and put it back after the myfasthtml static files routes app.routes.append(static_route_exts_get) + # route the commands and the bindings + app.mount("/myfasthtml", utils_app) + if mount_auth_app: # Setup authentication routes setup_auth_routes(app, rt) diff --git a/src/myfasthtml/test/MyFT.py b/src/myfasthtml/test/MyFT.py new file mode 100644 index 0000000..036858a --- /dev/null +++ b/src/myfasthtml/test/MyFT.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass, field + + +@dataclass +class MyFT: + tag: str + attrs: dict + children: list['MyFT'] = field(default_factory=list) + text: str | None = None diff --git a/src/myfasthtml/test/__init__.py b/src/myfasthtml/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/myfasthtml/core/matcher.py b/src/myfasthtml/test/matcher.py similarity index 67% rename from src/myfasthtml/core/matcher.py rename to src/myfasthtml/test/matcher.py index 67ce003..bb7ee78 100644 --- a/src/myfasthtml/core/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -3,7 +3,8 @@ from dataclasses import dataclass from fastcore.basics import NotStr -from myfasthtml.core.testclient import MyFT +from myfasthtml.core.utils import quoted_str +from myfasthtml.test.testclient import MyFT class Predicate: @@ -14,10 +15,13 @@ class Predicate: raise NotImplementedError def __str__(self): - return f"{self.__class__.__name__}({self.value})" + return f"{self.__class__.__name__}({self.value if self.value is not None else ''})" + + def __repr__(self): + return f"{self.__class__.__name__}({self.value if self.value is not None else ''})" def __eq__(self, other): - if not isinstance(other, Predicate): + if type(self) is not type(other): return False return self.value == other.value @@ -25,7 +29,15 @@ class Predicate: return hash(self.value) -class StartsWith(Predicate): +class AttrPredicate(Predicate): + """ + Predicate that validates an attribute value. + It's given as a value of an attribute. + """ + pass + + +class StartsWith(AttrPredicate): def __init__(self, value): super().__init__(value) @@ -33,7 +45,7 @@ class StartsWith(Predicate): return actual.startswith(self.value) -class Contains(Predicate): +class Contains(AttrPredicate): def __init__(self, value): super().__init__(value) @@ -41,7 +53,7 @@ class Contains(Predicate): return self.value in actual -class DoesNotContain(Predicate): +class DoesNotContain(AttrPredicate): def __init__(self, value): super().__init__(value) @@ -49,16 +61,56 @@ class DoesNotContain(Predicate): return self.value not in actual +class AnyValue(AttrPredicate): + """ + True is the attribute is present and the value is not None. + """ + + def __init__(self): + super().__init__(None) + + def validate(self, actual): + return actual is not None + + +class ChildrenPredicate(Predicate): + """ + Predicate given as a child of an element. + """ + + def to_debug(self, element): + return element + + +class Empty(ChildrenPredicate): + def __init__(self): + super().__init__(None) + + def validate(self, actual): + return len(actual.children) == 0 and len(actual.attrs) == 0 + + +class AttributeForbidden(ChildrenPredicate): + """ + To validate that an attribute is not present in an element. + """ + + def __init__(self, value): + super().__init__(value) + + def validate(self, actual): + return self.value not in actual.attrs or actual.attrs[self.value] is None + + def to_debug(self, element): + element.attrs[self.value] = "** NOT ALLOWED **" + return element + + @dataclass class DoNotCheck: desc: str = None -@dataclass -class Empty: - desc: str = None - - class ErrorOutput: def __init__(self, path, element, expected): self.path = path @@ -77,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 @@ -99,30 +151,31 @@ class ErrorOutput: self._add_to_output(error_str) # render the children - if len(self.expected.children) > 0: + expected_children = [c for c in self.expected.children if not isinstance(c, ChildrenPredicate)] + if len(expected_children) > 0: self.indent += " " element_index = 0 - for expected_child in self.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) + for expected_child in expected_children: + 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: - 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(")") @@ -142,24 +195,27 @@ class ErrorOutput: if expected is None: expected = element - # the attributes are compared to the expected element - elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in - [attr_name for attr_name in expected.attrs if attr_name is not None]} - elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) + if hasattr(element, "tag"): + # the attributes are compared to the expected element + elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in + [attr_name for attr_name in expected.attrs if attr_name is not None]} + + elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) + tag_str = f"({element.tag} {elt_attrs_str}" + + # manage the closing tag + if keep_open is False: + tag_str += " ...)" if len(element.children) > 0 else ")" + elif keep_open is True: + tag_str += "..." if elt_attrs_str == "" else " ..." + else: + # close the tag if there are no children + not_special_children = [c for c in element.children if not isinstance(c, Predicate)] + if len(not_special_children) == 0: tag_str += ")" + return tag_str - # - tag_str = f"({element.tag} {elt_attrs_str}" - - # manage the closing tag - if keep_open is False: - tag_str += " ...)" if len(element.children) > 0 else ")" - elif keep_open is True: - tag_str += "..." if elt_attrs_str == "" else " ..." else: - # close the tag if there are no children - if len(element.children) == 0: tag_str += ")" - - return tag_str + return quoted_str(element) def _detect_error(self, element, expected): if hasattr(expected, "tag") and hasattr(element, "tag"): @@ -307,16 +363,18 @@ def matches(actual, expected, path=""): _actual=actual.tag, _expected=expected.tag) - # special case when the expected element is empty - if len(expected.children) > 0 and isinstance(expected.children[0], Empty): - assert len(actual.children) == 0, _error_msg("Actual is not empty:", _actual=actual) - assert len(actual.attrs) == 0, _error_msg("Actual is not empty:", _actual=actual) - return True + # special conditions + for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]: + assert predicate.validate(actual), \ + _error_msg(f"The condition '{predicate}' is not satisfied.", + _actual=actual, + _expected=predicate.to_debug(expected)) # compare the attributes for expected_attr, expected_value in expected.attrs.items(): assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.", - _actual=actual.attrs) + _actual=actual, + _expected=expected) if isinstance(expected_value, Predicate): assert expected_value.validate(actual.attrs[expected_attr]), \ @@ -327,14 +385,15 @@ def matches(actual, expected, path=""): else: assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \ _error_msg(f"The values are different for '{expected_attr}': ", - _actual=actual.attrs[expected_attr], - _expected=expected.attrs[expected_attr]) + _actual=actual, + _expected=expected) # compare the children - if len(actual.children) < len(expected.children): + expected_children = [c for c in expected.children if not isinstance(c, Predicate)] + if len(actual.children) < len(expected_children): _assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected) - for actual_child, expected_child in zip(actual.children, expected.children): + for actual_child, expected_child in zip(actual.children, expected_children): assert matches(actual_child, expected_child, path=path) else: diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py new file mode 100644 index 0000000..c2fee2f --- /dev/null +++ b/src/myfasthtml/test/testclient.py @@ -0,0 +1,1595 @@ +import json +import uuid +from typing import Self + +from bs4 import BeautifulSoup, Tag +from fastcore.xml import FT, to_xml +from fasthtml.common import FastHTML +from starlette.responses import Response +from starlette.testclient import TestClient + +from myfasthtml.core.utils import mount_utils +from myfasthtml.test.MyFT import MyFT + +verbs = { + 'hx_get': 'GET', + 'hx_post': 'POST', + 'hx_put': 'PUT', + 'hx_delete': 'DELETE', + 'hx_patch': 'PATCH', +} + + +class DoNotSendCls: + pass + + +DoNotSend = DoNotSendCls() + + +class TestableElement: + """ + Represents an HTML element that can be interacted with in tests. + + This class will be used for future interactions like clicking elements + or verifying element properties. + """ + + def __init__(self, client, source, tag=None): + """ + Initialize a testable element. + + Args: + client: The MyTestClient instance. + ft: The FastHTML element representation. + """ + self.client = client + if isinstance(source, str): + self.html_fragment = source.strip() + elif isinstance(source, Tag): + self.html_fragment = str(source).strip() + elif isinstance(source, FT): + self.html_fragment = to_xml(source).strip() + else: + raise ValueError(f"Invalid source '{source}' for TestableElement.") + + self.tag, self.element, self.my_ft = self._parse(tag, self.html_fragment) + self.fields_mapping = {} # link between the input label and the input name + self.fields = {} # Values of the fields {name: value} + self.select_fields = {} # list of possible options for 'select' input fields + + self._update_fields_mapping() + self._update_fields() + + def fill(self, **kwargs): + """ + Fill the form with the given data. + + Args: + **kwargs: Field names and their values to fill in the form. + """ + for name, value in kwargs.items(): + field_name = self._translate(name) + if field_name not in self.fields: + raise ValueError(f"Invalid field name '{name}'.") + self.fields[self._translate(name)] = value + + def click(self): + """Click the element (to be implemented).""" + return self._send_htmx_request() + + def matches(self, ft): + """Check if element matches given FastHTML element (to be implemented).""" + pass + + def _translate(self, field): + """ + Translate a given field using a predefined mapping. If the field is not found + in the mapping, the original field is returned unmodified. + + :param field: The field name to be translated. + :type field: str + :return: The translated field name if present in the mapping, or the original + field name if no mapping exists for it. + :rtype: str + """ + return self.fields_mapping.get(field, field) + + def _support_htmx(self): + """Check if the element supports HTMX.""" + return ('hx_get' in self.my_ft.attrs or + 'hx-get' in self.my_ft.attrs or + 'hx_post' in self.my_ft.attrs or + 'hx-post' in self.my_ft.attrs) + + def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response: + """ + Simulates an HTMX request in Python for unit testing. + + This function reads the 'hx-*' attributes from the FastHTML object + to determine the HTTP method, URL, headers, and body of the request, + then executes it via the TestClient. + + Args: + data: (Optional) A dict for form data + (sends as 'application/x-www-form-urlencoded'). + json_data: (Optional) A dict for JSON data + (sends as 'application/json'). + Takes precedence over 'hx_vals'. + + Returns: + The Response object from the simulated request. + """ + + # The essential header for FastHTML (and HTMX) to identify the request + headers = {"HX-Request": "true"} + method = "GET" # HTMX defaults to GET if not specified + url = None + + if data is not None: + headers['Content-Type'] = 'application/x-www-form-urlencoded' + bag_to_use = data + elif json_data is not None: + headers['Content-Type'] = 'application/json' + bag_to_use = json_data + else: + # default to json_data + headers['Content-Type'] = 'application/json' + json_data = {} + bag_to_use = json_data + + # .props contains the kwargs passed to the object (e.g., hx_post="/url") + element_attrs = self.my_ft.attrs or {} + + # Build the attributes + for key, value in element_attrs.items(): + + # sanitize the key + key = key.lower().strip() + if key.startswith('hx-'): + key = 'hx_' + key[3:] + + if key in verbs: + # Verb attribute: defines the method and URL + method = verbs[key] + url = str(value) + + elif key == 'hx_vals': + # hx_vals defines the JSON body, if not already provided by the test + if isinstance(value, str): + bag_to_use |= json.loads(value) + elif isinstance(value, dict): + bag_to_use |= value + + elif key.startswith('hx_'): + # Any other hx_* attribute is converted to an HTTP header + # e.g.: 'hx_target' -> 'HX-Target' + header_name = '-'.join(p.capitalize() for p in key.split('_')) + headers[header_name] = str(value) + + # Sanity check + if url is None: + raise ValueError( + f"The <{self.my_ft.tag}> element has no HTMX verb attribute " + "(e.g., hx_get, hx_post) to define a URL." + ) + + # Send the request + return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data) + + def _update_fields_mapping(self): + """ + Build a mapping between label text and input field names. + + This method finds all labels in the form and associates them with their + corresponding input fields using the following priority order: + 1. Explicit association via 'for' attribute matching input 'id' + 2. Implicit association (label contains the input) + 3. Parent-level association with 'for'/'id' + 4. Proximity association (siblings in same parent) + 5. No label (use input name as key) + + The mapping is stored in self.fields_mapping as {label_text: input_name}. + For inputs without a name, the id is used. If neither exists, a generic + key like "unnamed_0" is generated. + """ + self.fields_mapping = {} + processed_inputs = set() + unnamed_counter = 0 + + # Get all inputs in the form + all_inputs = self.element.find_all('input') + + # Priority 1 & 2: Explicit association (for/id) and implicit (nested) + for label in self.element.find_all('label'): + label_text = label.get_text(strip=True) + + # Check for explicit association via 'for' attribute + label_for = label.get('for') + if label_for: + input_field = self.element.find(id=label_for) + if input_field: + input_name = self._get_input_identifier(input_field, unnamed_counter) + if input_name.startswith('unnamed_'): + unnamed_counter += 1 + self.fields_mapping[label_text] = input_name + processed_inputs.add(id(input_field)) + continue + + # Check for implicit association (label contains input) + input_field = label.find('input') + if input_field: + input_name = self._get_input_identifier(input_field, unnamed_counter) + if input_name.startswith('unnamed_'): + unnamed_counter += 1 + self.fields_mapping[label_text] = input_name + processed_inputs.add(id(input_field)) + continue + + # Priority 3 & 4: Parent-level associations + for label in self.element.find_all('label'): + label_text = label.get_text(strip=True) + + # Skip if this label was already processed + if label_text in self.fields_mapping: + continue + + parent = label.parent + if parent: + input_found = False + + # Priority 3: Look for sibling input with matching for/id + label_for = label.get('for') + if label_for: + for sibling in parent.find_all('input'): + if sibling.get('id') == label_for and id(sibling) not in processed_inputs: + input_name = self._get_input_identifier(sibling, unnamed_counter) + if input_name.startswith('unnamed_'): + unnamed_counter += 1 + self.fields_mapping[label_text] = input_name + processed_inputs.add(id(sibling)) + input_found = True + break + + # Priority 4: Fallback to proximity if no input found yet + if not input_found: + for sibling in parent.find_all('input'): + if id(sibling) not in processed_inputs: + input_name = self._get_input_identifier(sibling, unnamed_counter) + if input_name.startswith('unnamed_'): + unnamed_counter += 1 + self.fields_mapping[label_text] = input_name + processed_inputs.add(id(sibling)) + break + + # Priority 5: Inputs without labels + for input_field in all_inputs: + if id(input_field) not in processed_inputs: + input_name = self._get_input_identifier(input_field, unnamed_counter) + if input_name.startswith('unnamed_'): + unnamed_counter += 1 + self.fields_mapping[input_name] = input_name + + def _update_fields(self): + """ + Update the fields dictionary with current form values and their proper types. + + This method processes all input and select elements in the form: + - Determines the appropriate Python type (str, int, float, bool) based on + the HTML input type attribute and/or the value itself + - For select elements, populates self.select_fields with available options + - Stores the final typed values in self.fields + + Type conversion priority: + 1. HTML type attribute (checkbox → bool, number → int/float, etc.) + 2. Value analysis fallback for ambiguous types (text/hidden/absent type) + """ + self.fields = {} + self.select_fields = {} + + # Process input fields + for input_field in self.element.find_all('input'): + name = input_field.get('name') + if not name: + continue + + input_type = input_field.get('type', 'text').lower() + raw_value = input_field.get('value', '') + + # Type conversion based on input type + if input_type == 'checkbox': + # Checkbox: bool based on 'checked' attribute + self.fields[name] = input_field.has_attr('checked') + + elif input_type == 'radio': + # Radio: str value (only if checked) + if input_field.has_attr('checked'): + self.fields[name] = raw_value + elif name not in self.fields: + # If no radio is checked yet, don't set a default + self.fields[name] = None + + elif input_type == 'number': + # Number: int or float based on value + self.fields[name] = self._convert_number(raw_value) + + else: + # Other types (text, hidden, email, password, etc.): analyze value + self.fields[name] = self._convert_value(raw_value) + + # Process select fields + for select_field in self.element.find_all('select'): + name = select_field.get('name') + if not name: + continue + + # Extract all options + options = [] + selected_value = [] + + for option in select_field.find_all('option'): + option_value = option.get('value', option.get_text(strip=True)) + option_text = option.get_text(strip=True) + + options.append({ + 'value': option_value, + 'text': option_text + }) + + # Track selected option + if option.has_attr('selected'): + selected_value.append(option_value) + + # Store options list + self.select_fields[name] = options + + # Store selected value (or first option if none selected) + is_multiple = select_field.has_attr('multiple') + if is_multiple: + self.fields[name] = selected_value + else: + if len(selected_value) > 0: + self.fields[name] = selected_value[-1] + elif options: + self.fields[name] = options[0]['value'] + + # Process textarea fields + for textarea_field in self.element.find_all('textarea'): + name = textarea_field.get('name') + if not name: + continue + + self.fields[name] = textarea_field.get_text(strip=True) + + @staticmethod + def _get_input_identifier(input_field, counter): + """ + Get the identifier for an input field. + + Args: + input_field: The BeautifulSoup Tag object representing the input. + counter: Current counter for unnamed inputs. + + Returns: + The input name, id, or a generated "unnamed_X" identifier. + """ + if input_field.get('name'): + return input_field['name'] + elif input_field.get('id'): + return input_field['id'] + else: + return f"unnamed_{counter}" + + @staticmethod + def _convert_number(value): + """ + Convert a string value to int or float. + + Args: + value: String value to convert. + + Returns: + int, float, or empty string if conversion fails. + """ + if not value or value.strip() == '': + return '' + + try: + # Try float first to detect decimal numbers + if '.' in value or 'e' in value.lower(): + return float(value) + else: + return int(value) + except ValueError: + return value + + @staticmethod + def _convert_value(value): + """ + Analyze and convert a value to its appropriate type. + + Conversion priority: + 1. Boolean keywords (true/false) + 2. Float (contains decimal point) + 3. Int (numeric) + 4. Empty string + 5. String (default) + + Args: + value: String value to convert. + + Returns: + Converted value with appropriate type (bool, float, int, or str). + """ + if not value or value.strip() == '': + return '' + + value_lower = value.lower().strip() + + # Check for boolean + if value_lower in ('true', 'false'): + return value_lower == 'true' + + # Check for numeric values + try: + # Check for float (has decimal point or scientific notation) + if '.' in value or 'e' in value_lower: + return float(value) + # Try int + else: + return int(value) + except ValueError: + pass + + # Default to string + return value + + @staticmethod + def _parse(tag, html_fragment: str): + elt = BeautifulSoup(html_fragment, 'html.parser') + + if len(elt) == 0: + raise ValueError(f"No HTML element found in: {html_fragment}") + + if len(elt) == 1: + elt = elt.find() + elt_tag = elt.name + if tag is not None and tag != elt_tag: + raise ValueError(f"Tag '{tag}' does not match with '{html_fragment}'.") + my_ft = MyFT(elt_tag, elt.attrs) + if elt_tag != "form": + elt = BeautifulSoup(f"
", 'html.parser') + return elt_tag, elt, my_ft + + else: + if tag is None: + raise ValueError(f"Multiple elements found in {html_fragment}. Please specify a tag.") + + elt = BeautifulSoup(f"", 'html.parser') + _inner = elt.find(tag) + my_ft = MyFT(_inner.name, _inner.attrs) + return tag, elt.find(), my_ft + + +class TestableForm(TestableElement): + """ + Represents an HTML form that can be filled and submitted in tests. + """ + + def __init__(self, client, source): + """ + Initialize a testable form. + + Args: + client: The MyTestClient instance. + source: The source HTML string containing a form. + """ + super().__init__(client, source, "form") + # self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form') + # self.fields_mapping = {} # link between the input label and the input name + # self.fields = {} # field name; field value + # self.select_fields = {} # list of possible options for 'select' input fields + # + # self._update_fields_mapping() + # self.update_fields() + + # def update_fields(self): + # """ + # Update the fields dictionary with current form values and their proper types. + # + # This method processes all input and select elements in the form: + # - Determines the appropriate Python type (str, int, float, bool) based on + # the HTML input type attribute and/or the value itself + # - For select elements, populates self.select_fields with available options + # - Stores the final typed values in self.fields + # + # Type conversion priority: + # 1. HTML type attribute (checkbox → bool, number → int/float, etc.) + # 2. Value analysis fallback for ambiguous types (text/hidden/absent type) + # """ + # self.fields = {} + # self.select_fields = {} + # + # # Process input fields + # for input_field in self.form.find_all('input'): + # name = input_field.get('name') + # if not name: + # continue + # + # input_type = input_field.get('type', 'text').lower() + # raw_value = input_field.get('value', '') + # + # # Type conversion based on input type + # if input_type == 'checkbox': + # # Checkbox: bool based on 'checked' attribute + # self.fields[name] = input_field.has_attr('checked') + # + # elif input_type == 'radio': + # # Radio: str value (only if checked) + # if input_field.has_attr('checked'): + # self.fields[name] = raw_value + # elif name not in self.fields: + # # If no radio is checked yet, don't set a default + # pass + # + # elif input_type == 'number': + # # Number: int or float based on value + # self.fields[name] = self._convert_number(raw_value) + # + # else: + # # Other types (text, hidden, email, password, etc.): analyze value + # self.fields[name] = self._convert_value(raw_value) + # + # # Process select fields + # for select_field in self.form.find_all('select'): + # name = select_field.get('name') + # if not name: + # continue + # + # # Extract all options + # options = [] + # selected_value = None + # + # for option in select_field.find_all('option'): + # option_value = option.get('value', option.get_text(strip=True)) + # option_text = option.get_text(strip=True) + # + # options.append({ + # 'value': option_value, + # 'text': option_text + # }) + # + # # Track selected option + # if option.has_attr('selected'): + # selected_value = option_value + # + # # Store options list + # self.select_fields[name] = options + # + # # Store selected value (or first option if none selected) + # if selected_value is not None: + # self.fields[name] = selected_value + # elif options: + # self.fields[name] = options[0]['value'] + # + + def submit(self): + """ + Submit the form. + + This method handles both HTMX-enabled forms and classic HTML form submissions: + - If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request + - Otherwise, simulates a classic browser form submission using the form's + action and method attributes + + Returns: + The response from the form submission. + + Raises: + ValueError: If the form has no action attribute for classic submission. + """ + # Check if the form supports HTMX + if self._support_htmx(): + return self._send_htmx_request(data=self.fields) + + # Classic form submission + action = self.element.get('action') + if not action or action.strip() == '': + raise ValueError( + "The form has no 'action' attribute. " + "Cannot submit a classic form without a target URL." + ) + + method = self.element.get('method', 'post').upper() + + # Prepare headers for classic form submission + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + # Send the request via the client + return self.client.send_request( + method=method, + url=action, + headers=headers, + data=self.fields + ) + + +class TestableControl(TestableElement): + def __init__(self, client, source, tag): + super().__init__(client, source, tag) + assert len(self.fields) == 1 + self._input_name = next(iter(self.fields)) + + @property + def name(self): + return self._input_name + + @property + def value(self): + return self.fields[self._input_name] + + def _send_value(self): + if self._input_name and self._support_htmx(): + value = {} if self.value is DoNotSend else {self._input_name: self.value} + return self._send_htmx_request(data=value) + return None + + +class TestableInput(TestableControl): + def __init__(self, client, source): + super().__init__(client, source, "input") + + def send(self, value): + self.fields[self.name] = value + return self._send_value() + + +class TestableCheckbox(TestableControl): + def __init__(self, client, source): + super().__init__(client, source, "input") + + @property + def is_checked(self): + return self.fields[self._input_name] == True + + def check(self): + self.fields[self._input_name] = "on" + return self._send_value() + + def uncheck(self): + self.fields[self._input_name] = DoNotSend + return self._send_value() + + def toggle(self): + if self.fields[self._input_name] == "on": + return self.uncheck() + else: + return self.check() + + +class TestableTextarea(TestableControl): + """ + Represents a textarea element that can be interacted with in tests. + + Textareas are similar to text inputs but support multi-line text. + """ + + def __init__(self, client, source): + """ + Initialize a testable textarea. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + # Parse as textarea element + super().__init__(client, source, "textarea") + + def send(self, value): + """ + Set the textarea value and trigger HTMX update if configured. + + Args: + value: The text value to set (can be multi-line string). + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = value + return self._send_value() + + def append(self, text): + """ + Append text to the current textarea value. + + Args: + text: Text to append. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + current_value = self.fields.get(self.name, '') + self.fields[self.name] = current_value + text + return self._send_value() + + def clear(self): + """ + Clear the textarea content. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = '' + return self._send_value() + + +class TestableSelect(TestableControl): + """ + Represents a select dropdown element that can be interacted with in tests. + + Supports both single and multiple selection modes. + """ + + def __init__(self, client, source): + """ + Initialize a testable select element. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + # Parse as select element + super().__init__(client, source, "select") + self._is_multiple = self.my_ft.attrs.get('multiple') is not None + + @property + def is_multiple(self): + """Check if this is a multiple selection dropdown.""" + return self._is_multiple + + @property + def options(self): + """ + Get all available options for this select. + + Returns: + List of dicts with 'value' and 'text' keys. + """ + return self.select_fields.get(self.name, []) + + def select(self, value): + """ + Select an option by value. + + Args: + value: The value of the option to select (not the text). + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If the value is not in the available options. + """ + # Validate the value exists in options + available_values = [opt['value'] for opt in self.options] + if value not in available_values: + raise ValueError( + f"Value '{value}' not found in select options. " + f"Available values: {available_values}" + ) + + if self.is_multiple: + # For multiple select, value should be a list + current = self.fields.get(self.name, []) + if not isinstance(current, list): + current = [current] if current else [] + if value not in current: + current.append(value) + self.fields[self.name] = current[0] if len(current) == 1 else current # it's not a list when only one is selected + else: + # For single select, just set the value + self.fields[self.name] = value + + return self._send_value() + + def select_by_text(self, text): + """ + Select an option by its visible text. + + Args: + text: The visible text of the option to select. + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If the text is not found in options. + """ + # Find the value corresponding to this text + for option in self.options: + if option['text'] == text: + return self.select(option['value']) + + raise ValueError( + f"Option with text '{text}' not found. " + f"Available texts: {[opt['text'] for opt in self.options]}" + ) + + def deselect(self, value): + """ + Deselect an option (only for multiple selects). + + Args: + value: The value of the option to deselect. + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If called on a non-multiple select. + """ + if not self.is_multiple: + raise ValueError("Cannot deselect on a single-select dropdown") + + current = self.fields.get(self.name, []) + if not isinstance(current, list): + current = [current] if current else [] + + if value in current: + current.remove(value) + self.fields[self.name] = current[0] if len(current) == 1 else current + return self._send_value() + + return None + + +class TestableRange(TestableControl): + """ + Represents a range input (slider) that can be interacted with in tests. + """ + + def __init__(self, client, source): + """ + Initialize a testable range input. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "input") + + # Extract min, max, step from attributes + self._min = float(self.my_ft.attrs.get('min', 0)) + self._max = float(self.my_ft.attrs.get('max', 100)) + self._step = float(self.my_ft.attrs.get('step', 1)) + + @property + def min_value(self): + """Get the minimum value of the range.""" + return self._min + + @property + def max_value(self): + """Get the maximum value of the range.""" + return self._max + + @property + def step(self): + """Get the step increment of the range.""" + return self._step + + def set(self, value): + """ + Set the range value. + + Args: + value: Numeric value to set (will be clamped to min/max). + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + # Clamp value to valid range + value = max(self._min, min(self._max, float(value))) + + # Round to nearest step + value = round((value - self._min) / self._step) * self._step + self._min + + self.fields[self.name] = value + return self._send_value() + + def increase(self): + """ + Increase the range value by one step. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + current = float(self.fields.get(self.name, self._min)) + return self.set(current + self._step) + + def decrease(self): + """ + Decrease the range value by one step. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + current = float(self.fields.get(self.name, self._min)) + return self.set(current - self._step) + + +class TestableRadio(TestableControl): + """ + Represents a radio button input that can be interacted with in tests. + + Note: Radio buttons with the same name form a group where only one + can be selected at a time. + """ + + def __init__(self, client, source): + """ + Initialize a testable radio button. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "input") + nb_radio_buttons = len(self.element.find_all("input", type="radio")) + assert nb_radio_buttons > 0, "No radio buttons found." + assert nb_radio_buttons < 2, "Only one radio button per name is supported." + self._radio_value = self.my_ft.attrs.get('value', '') + + @property + def radio_value(self): + """Get the value attribute of this radio button.""" + return self._radio_value + + @property + def is_checked(self): + """Check if this radio button is currently selected.""" + return self.fields.get(self.name) == self._radio_value + + def select(self): + """ + Select this radio button. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = self._radio_value + return self._send_value() + + +class TestableButton(TestableElement): + """ + Represents a button element that can be clicked in tests. + + Buttons can trigger HTMX requests or form submissions. + """ + + def __init__(self, client, source): + """ + Initialize a testable button. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "button") + + @property + def text(self): + """Get the visible text of the button.""" + return self.element.get_text(strip=True) + + def click(self): + """ + Click the button and trigger any associated HTMX request. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + if self._support_htmx(): + return self._send_htmx_request() + return None + + +class TestableDatalist(TestableControl): + """ + Represents an input with datalist (autocomplete/combobox) that can be + interacted with in tests. + + This is essentially an input that can show suggestions from a datalist. + """ + + def __init__(self, client, source): + """ + Initialize a testable input with datalist. + + Args: + client: The MyTestClient instance. + source: The source HTML or BeautifulSoup Tag. + """ + super().__init__(client, source, "input") + + # Find associated datalist + list_id = self.my_ft.attrs.get('list') + self._datalist_options = [] + + if list_id: + # Parse the full HTML to find the datalist + soup = BeautifulSoup(self.html_fragment, 'html.parser') + datalist = soup.find('datalist', id=list_id) + + if datalist: + for option in datalist.find_all('option'): + option_value = option.get('value', option.get_text(strip=True)) + self._datalist_options.append(option_value) + + @property + def suggestions(self): + """ + Get all available suggestions from the datalist. + + Returns: + List of suggestion values. + """ + return self._datalist_options + + def send(self, value): + """ + Set the input value (can be any value, not restricted to suggestions). + + Args: + value: The value to set. + + Returns: + Response from HTMX request if applicable, None otherwise. + """ + self.fields[self.name] = value + return self._send_value() + + def select_suggestion(self, value): + """ + Select a value from the datalist suggestions. + + Args: + value: The suggestion value to select. + + Returns: + Response from HTMX request if applicable, None otherwise. + + Raises: + ValueError: If the value is not in the suggestions. + """ + if value not in self._datalist_options: + raise ValueError( + f"Value '{value}' not found in datalist suggestions. " + f"Available: {self._datalist_options}" + ) + + return self.send(value) + + +# Update the TestableElement factory method +# This should be added to the MyTestClient._testable_element_factory method + +def _testable_element_factory_extended(client, elt): + """ + Extended factory method for creating appropriate Testable* instances. + + This should replace or extend the existing _testable_element_factory method + in MyTestClient. + + Args: + client: The MyTestClient instance. + elt: BeautifulSoup Tag element. + + Returns: + Appropriate Testable* instance based on element type. + """ + if elt.name == "input": + input_type = elt.get("type", "text").lower() + + if input_type == "checkbox": + return TestableCheckbox(client, elt) + elif input_type == "radio": + return TestableRadio(client, elt) + elif input_type == "range": + return TestableRange(client, elt) + elif elt.get("list"): # Input with datalist + return TestableDatalist(client, elt) + else: + return TestableInput(client, elt) + + elif elt.name == "textarea": + return TestableTextarea(client, elt) + + elif elt.name == "select": + return TestableSelect(client, elt) + + elif elt.name == "button": + return TestableButton(client, elt) + + else: + return TestableElement(client, elt, elt.name) + + +# def get_value(tag): +# """Return the current user-facing value of an HTML input-like element.""" +# if tag.name == 'input': +# t = tag.get('type', 'text').lower() +# if t in ('checkbox', 'radio'): +# # For checkbox/radio: return True/False if checked, else value if defined +# return tag.has_attr('checked') +# return tag.get('value', '') +# +# elif tag.name == 'textarea': +# # Textarea content is its text, not an attribute +# return tag.text or '' +# +# elif tag.name == 'select': +# # For