Working version of tabsearch. Added subsequent and fuzzy matching. Need to fix the unit tests

This commit is contained in:
2025-11-16 11:52:22 +01:00
parent 09c4217cb6
commit c38a012c74
9 changed files with 290 additions and 47 deletions

View File

@@ -25,3 +25,4 @@ clean: clean-build clean-tests
clean-all : clean clean-all : clean
rm -rf src/.sesskey rm -rf src/.sesskey
rm -rf src/Users.db rm -rf src/Users.db
rm -rf src/.myFastHtmlDb

View File

@@ -383,10 +383,6 @@
flex: 1; flex: 1;
overflow: auto; overflow: auto;
height: 100%; height: 100%;
border: 2px solid black;
/*background-color: var(--color-base-100);*/
/*padding: 1rem;*/
/*border-top: 1px solid var(--color-border-primary);*/
} }
.mf-tab-content-wrapper { .mf-tab-content-wrapper {
@@ -410,4 +406,10 @@
.mf-vis { .mf-vis {
width: 100%; width: 100%;
height: 100%; height: 100%;
}
.mf-search-results {
margin-top: 0.5rem;
max-height: 200px;
overflow: auto;
} }

View File

@@ -41,7 +41,7 @@ Note: `python-jose` may already be installed if you have FastAPI.
### 1. Update API configuration in `auth/utils.py` ### 1. Update API configuration in `auth/utils.py`
```python ```python
API_BASE_URL = "http://localhost:5001" # Your FastAPI backend URL API_BASE_URL = "http://localhost:5003" # Your FastAPI backend URL
JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret
``` ```

View File

@@ -1,4 +1,5 @@
import logging import logging
from typing import Callable, Any
from fasthtml.components import * from fasthtml.components import *
@@ -6,43 +7,78 @@ from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
logger = logging.getLogger("Search") logger = logging.getLogger("Search")
class Commands(BaseCommands): class Commands(BaseCommands):
def search(self): def search(self):
return (Command("Search", f"Search {self._owner.items_names}", self._owner.search). return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
htmx(trigger="keyup changed delay:300ms")) htmx(target=f"#{self._owner.get_id()}-results",
trigger="keyup changed delay:300ms",
swap="innerHTML"))
class Search(MultipleInstance): class Search(MultipleInstance):
def __init__(self, session, items_names=None, template=None, _id=None): def __init__(self,
session,
_id=None,
items_names=None, # what is the name of the items to filter
items=None, # first set of items to filter
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
template: Callable[[Any], Any] = None): # once filtered, what to render ?
"""
Represents a component for managing and filtering a list of items based on specific criteria.
This class initializes with a session, an optional identifier, a list of item names,
a callable for extracting a string value from items, and a template callable for rendering
the filtered items. It provides functionality to handle and organize item-based operations.
:param session: The session object to maintain state or context across operations.
:param _id: Optional identifier for the component.
:param items: An optional list of names for the items to be filtered.
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
function that returns the item as is.
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
"""
super().__init__(session, Ids.Search, _id=_id) super().__init__(session, Ids.Search, _id=_id)
self.items_names = items_names self.items_names = items_names or ''
self.items = [] self.items = items or []
self.filtered = [] self.filtered = self.items.copy()
self.get_attr = get_attr or (lambda x: x)
self.template = template or Div self.template = template or Div
self.commands = Commands(self) self.commands = Commands(self)
def set_items(self, items): def set_items(self, items):
self.items = items self.items = items
self.filtered = self.items self.filtered = self.items.copy()
return self return self
def on_search(self, query):
logger.debug(f"on_search {query=}")
self.search(query)
return tuple(self._mk_search_results())
def search(self, query): def search(self, query):
logger.debug(f"search {query=}") logger.debug(f"search {query=}")
pass if query is None or query.strip() == "":
self.filtered = self.items.copy()
else:
res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr)
res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr)
self.filtered = res_seq + res_fuzzy
return self.filtered
def _mk_search_results(self): def _mk_search_results(self):
return [self.template(item) for item in self.filtered] return [self.template(item) for item in self.filtered]
def render(self): def render(self):
return Div( return Div(
# mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"), mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"),
# command=self.commands.search()), command=self.commands.search()),
mk.mk(Input(name="query", id=f"{self._id}-search", type="search", placeholder="Search...", cls="input input-xs"),
),
Div( Div(
*self._mk_search_results(), *self._mk_search_results(),
id=f"{self._id}-results", id=f"{self._id}-results",

View File

@@ -77,11 +77,6 @@ class Commands(BaseCommands):
return Command(f"{self._prefix}UpdateBoundaries", return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries", "Update component boundaries",
self._owner.update_boundaries).htmx(target=None) self._owner.update_boundaries).htmx(target=None)
def search_tab(self, query: str):
return Command(f"{self._prefix}SearchTab",
"Search for a tab by name",
self._owner.search_tab, query).htmx(target=f"#{self._id}-dropdown-content")
class TabsManager(MultipleInstance): class TabsManager(MultipleInstance):
@@ -97,7 +92,10 @@ class TabsManager(MultipleInstance):
self._state = TabsManagerState(self) self._state = TabsManagerState(self)
self.commands = Commands(self) self.commands = Commands(self)
self._boundaries = Boundaries() self._boundaries = Boundaries()
self._search = Search(self._session).set_items(self._get_tabs_labels()) self._search = Search(self._session,
items=self._get_tab_list(),
get_attr=lambda x: x["label"],
template=self._mk_tab_button)
logger.debug(f"TabsManager created with id: {self._id}") logger.debug(f"TabsManager created with id: {self._id}")
logger.debug(f" tabs : {self._get_ordered_tabs()}") logger.debug(f" tabs : {self._get_ordered_tabs()}")
logger.debug(f" active tab : {self._state.active_tab}") logger.debug(f" active tab : {self._state.active_tab}")
@@ -176,6 +174,7 @@ class TabsManager(MultipleInstance):
# Add tab metadata to state # Add tab metadata to state
state.tabs[tab_id] = { state.tabs[tab_id] = {
'id': tab_id,
'label': label, 'label': label,
'component_type': component_type, 'component_type': component_type,
'component_id': component_id 'component_id': component_id
@@ -193,7 +192,7 @@ class TabsManager(MultipleInstance):
# finally, update the state # finally, update the state
self._state.update(state) self._state.update(state)
self._search.set_items(self._get_tabs_labels()) self._search.set_items(self._get_tab_list())
return tab_id return tab_id
@@ -251,23 +250,10 @@ class TabsManager(MultipleInstance):
# Update state # Update state
self._state.update(state) self._state.update(state)
self._search.set_items(self._get_tabs_labels()) self._search.set_items(self._get_tab_list())
return self return self
def search_tab(self, query: str):
"""
Search tabs by name (for dropdown search).
Args:
query: Search query
Returns:
Dropdown content with filtered tabs
"""
# This will be implemented later for search functionality
pass
def add_tab_btn(self): def add_tab_btn(self):
return mk.icon(tab_add24_regular, return mk.icon(tab_add24_regular,
id=f"{self._id}-add-tab-btn", id=f"{self._id}-add-tab-btn",
@@ -285,7 +271,7 @@ class TabsManager(MultipleInstance):
def _mk_tabs_header(self, oob=False): def _mk_tabs_header(self, oob=False):
# Create visible tab buttons # Create visible tab buttons
visible_tab_buttons = [ visible_tab_buttons = [
self._mk_tab_button(tab_id, self._state.tabs[tab_id]) self._mk_tab_button(self._state.tabs[tab_id])
for tab_id in self._state.tabs_order for tab_id in self._state.tabs_order
if tab_id in self._state.tabs if tab_id in self._state.tabs
] ]
@@ -300,7 +286,7 @@ class TabsManager(MultipleInstance):
hx_swap_oob="true" if oob else None hx_swap_oob="true" if oob else None
) )
def _mk_tab_button(self, tab_id: str, tab_data: dict, in_dropdown: bool = False): def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
""" """
Create a single tab button with its label and close button. Create a single tab button with its label and close button.
@@ -312,6 +298,7 @@ class TabsManager(MultipleInstance):
Returns: Returns:
Button element representing the tab Button element representing the tab
""" """
tab_id = tab_data["id"]
is_active = tab_id == self._state.active_tab is_active = tab_id == self._state.active_tab
close_btn = mk.mk( close_btn = mk.mk(
@@ -382,8 +369,8 @@ class TabsManager(MultipleInstance):
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper", hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
) )
def _get_tabs_labels(self): def _get_tab_list(self):
return [tab["label"] for tab in self._state.tabs.values()] return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
def update_boundaries(self): def update_boundaries(self):
return Script(f"updateBoundaries('{self._id}');") return Script(f"updateBoundaries('{self._id}');")

View File

@@ -44,7 +44,8 @@ class BaseCommand:
def execute(self, client_response: dict = None): def execute(self, client_response: dict = None):
raise NotImplementedError raise NotImplementedError
def htmx(self, target="this", swap="innerHTML", trigger=None): def htmx(self, target="this", swap="outerHTML", trigger=None):
# Note that the default value is the same than in get_htmx_params()
if target is None: if target is None:
self._htmx_extra["hx-swap"] = "none" self._htmx_extra["hx-swap"] = "none"
elif target != "this": elif target != "this":
@@ -52,7 +53,7 @@ class BaseCommand:
if swap is None: if swap is None:
self._htmx_extra["hx-swap"] = "none" self._htmx_extra["hx-swap"] = "none"
elif swap != "innerHTML": elif swap != "outerHTML":
self._htmx_extra["hx-swap"] = swap self._htmx_extra["hx-swap"] = swap
if trigger is not None: if trigger is not None:
@@ -162,7 +163,7 @@ class Command(BaseCommand):
# Set the hx-swap-oob attribute on all elements returned by the callback # Set the hx-swap-oob attribute on all elements returned by the callback
if isinstance(ret, (list, tuple)): if isinstance(ret, (list, tuple)):
for r in ret[1:]: for r in ret[1:]:
if hasattr(r, 'attrs'): if hasattr(r, 'attrs') and r.get("id", None) is not None:
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true") r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
if not ret_from_bindings: if not ret_from_bindings:

View File

@@ -0,0 +1,85 @@
from difflib import SequenceMatcher
from typing import Any
def _is_subsequence(query: str, target: str) -> tuple[bool, float]:
"""
Determines if a query string is a subsequence of a target string and calculates
a score based on the compactness of the match. The match is case-insensitive.
The function iterates through each character of the query and checks if it
exists in the target string while maintaining the order. If all characters of
the query are found in order, it calculates a score based on the smallest
window in the target that contains all the matched characters.
:param query: The query string to check as a subsequence.
:param target: The target string in which to find the subsequence.
:return: A tuple where the first value is a boolean indicating if a valid
subsequence exists, and the second value is a float representing the
compactness score of the match.
:rtype: tuple[bool, float]
"""
query = query.lower()
target = target.lower()
positions = []
idx = 0
for char in query:
idx = target.find(char, idx)
if idx == -1:
return False, 0.0
positions.append(idx)
idx += 1
# Smallest window containing all matched chars
window_size = positions[-1] - positions[0] + 1
# Score: ratio of query length vs window size (compactness)
score = len(query) / window_size
return True, score
def fuzzy_matching(query: str, choices: list[Any], similarity_threshold: float = 0.7, get_attr=None):
"""
Perform fuzzy matching on a list of items to find the items that are similar
to the given query based on a similarity threshold.
:param query: The search query to be matched, provided as a string.
:param choices: A list of strings representing the items to be compared against the query.
:param similarity_threshold: A float value representing the minimum similarity score
(between 0 and 1) an item needs to achieve to be considered a match. Defaults to 0.7.
:param get_attr: When choice is a object, give the property to use
:return: A list of strings containing the items from the input list that meet or exceed
the similarity threshold, sorted in descending order of similarity.
"""
get_attr = get_attr or (lambda x: x)
matches = []
for file_doc in choices:
# Calculate similarity between search term and filename
similarity = SequenceMatcher(None, query.lower(), get_attr(file_doc).lower()).ratio()
if similarity >= similarity_threshold:
matches.append((file_doc, similarity))
# Sort by similarity score (highest first)
matches.sort(key=lambda x: x[1], reverse=True)
# Return only the FileDocument objects
return [match[0] for match in matches]
def subsequence_matching(query: str, choices: list[Any], get_attr=None):
get_attr = get_attr or (lambda x: x)
matches = []
for item in choices:
matched, score = _is_subsequence(query, get_attr(item))
if matched:
matches.append((item, score))
# Sort by score (highest first)
matches.sort(key=lambda x: x[1], reverse=True)
# Return only the FileDocument objects
return [match[0] for match in matches]

View File

@@ -104,8 +104,8 @@ class TestCommandBind:
assert matches(updated, expected) assert matches(updated, expected)
@pytest.mark.parametrize("return_values", [ @pytest.mark.parametrize("return_values", [
[Div(), Div(), "hello", Div()], # list [Div(), Div(id="id1"), "hello", Div(id="id2")], # list
(Div(), Div(), "hello", Div()) # tuple (Div(), Div(id="id1"), "hello", Div(id="id2")) # tuple
]) ])
def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values): def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values):
"""Test that hx-swap-oob is automatically set, but not for the first.""" """Test that hx-swap-oob is automatically set, but not for the first."""
@@ -157,3 +157,29 @@ class TestCommandExecute:
command = Command('test', 'Command description', callback_with_param) command = Command('test', 'Command description', callback_with_param)
command.execute(client_response={"number": "10"}) command.execute(client_response={"number": "10"})
def test_swap_oob_is_added_when_multiple_elements_are_returned(self):
"""Test that hx-swap-oob is automatically set, but not for the first."""
def another_callback():
return Div(id="first"), Div(id="second"), "hello", Div(id="third")
command = Command('test', 'Command description', another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
assert res[1].attrs["hx-swap-oob"] == "true"
assert res[3].attrs["hx-swap-oob"] == "true"
def test_swap_oob_is_not_added_when_there_no_id(self):
"""Test that hx-swap-oob is automatically set, but not for the first."""
def another_callback():
return Div(id="first"), Div(), "hello", Div()
command = Command('test', 'Command description', another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
assert "hx-swap-oob" not in res[1].attrs
assert "hx-swap-oob" not in res[3].attrs

View File

@@ -0,0 +1,105 @@
from dataclasses import dataclass
from myfasthtml.core.matching_utils import fuzzy_matching, subsequence_matching
class TestFuzzyMatching:
def test_i_can_find_exact_match_with_fuzzy(self):
# Exact match should always pass
choices = ["hello"]
result = fuzzy_matching("hello", choices)
assert len(result) == 1
assert result[0] == "hello"
def test_i_can_find_close_match_with_fuzzy(self):
# "helo.txt" should match "hello.txt" with high similarity
choices = ["hello"]
result = fuzzy_matching("helo", choices, similarity_threshold=0.7)
assert len(result) == 1
assert result[0] == "hello"
def test_i_cannot_find_dissimilar_match_with_fuzzy(self):
# "world.txt" should not match "hello.txt"
choices = ["hello"]
result = fuzzy_matching("world", choices, similarity_threshold=0.7)
assert len(result) == 0
def test_i_can_sort_by_similarity_in_fuzzy(self):
# hello has a higher similarity than helo
choices = [
"hello",
"helo",
]
result = fuzzy_matching("hello", choices, similarity_threshold=0.7)
assert result == ["hello", "helo"]
def test_i_can_find_on_object(self):
@dataclass
class DummyObject:
value: str
id: str
choices = [
DummyObject("helo", "1"),
DummyObject("hello", "2"),
DummyObject("xyz", "3"),
]
result = fuzzy_matching("hello", choices, get_attr=lambda x: x.value)
assert len(result) == 2
assert result == [DummyObject("hello", "2"), DummyObject("helo", "1")]
class TestSubsequenceMatching:
def test_i_can_match_subsequence_simple(self):
# "abg" should match "AlphaBetaGamma"
choices = ["AlphaBetaGamma"]
result = subsequence_matching("abg", choices)
assert len(result) == 1
assert result[0] == "AlphaBetaGamma"
def test_i_can_match_subsequence_simple_case_insensitive(self):
# "abg" should match "alphabetagamma"
choices = ["alphabetagamma"]
result = subsequence_matching("abg", choices)
assert len(result) == 1
assert result[0] == "alphabetagamma"
def test_i_cannot_match_wrong_order_subsequence(self):
# the order is wrong
choices = ["AlphaBetaGamma"]
result = subsequence_matching("gba", choices)
assert len(result) == 0
def test_i_can_match_multiple_documents_subsequence(self):
# "abg" should match both filenames, but "AlphaBetaGamma" has a higher score
choices = [
"AlphaBetaGamma",
"HalleBerryIsGone",
]
result = subsequence_matching("abg", choices)
assert len(result) == 2
assert result[0] == "AlphaBetaGamma"
assert result[1] == "HalleBerryIsGone"
def test_i_cannot_match_unrelated_subsequence(self):
# "xyz" should not match any file
choices = ["AlphaBetaGamma"]
result = subsequence_matching("xyz", choices)
assert len(result) == 0
def test_i_can_match_on_object(self):
@dataclass
class DummyObject:
value: str
id: str
choices = [
DummyObject("HalleBerryIsGone", "1"),
DummyObject("AlphaBetaGamma", "2"),
DummyObject("xyz", "3"),
]
result = subsequence_matching("abg", choices, get_attr=lambda x: x.value)
assert len(result) == 2
assert result == [DummyObject("AlphaBetaGamma", "2"), DummyObject("HalleBerryIsGone", "1")]