Open k1mbl3 opened 1 month ago
Agreed. We haven't finalised on a design we like yet. I'll leave this issue open and come back to it once we have something implemented.
Agreed. We haven't finalised on a design we like yet. I'll leave this issue open and come back to it once we have something implemented.
It would also be useful not only to inject dependencies into the <head>
of the page but also to have the capability of injecting at any level of the tree. I can imagine an infinite number of components using, for instance, Alpine.js, like a form component that injects functions into the x-data
attribute of the parent component for validation, serialization, two-way binding, etc. This feature would make implementing a global pub/sub for intra-component communication, among other exciting tools, easily achievable. It would almost give magical superpowers to fasthtml.
You can do that already using oob swaps with HTMX, unless I'm misunderstanding. Here's an example of using Alpine with HTMX btw: https://django-htmx-alpine.nicholasmoen.com/
You can do that already using oob swaps with HTMX, unless I'm misunderstanding. Here's an example of using Alpine with HTMX btw: https://django-htmx-alpine.nicholasmoen.com/
I'm thinking about more advanced use cases where the states must stay on the client side. There is a barrier that every htmx user will hit, and it's those advanced use cases. The developers themselves recommend using their side project _hyperscript (or Alpine.js, which i prefer), when this happens. The idea I mentioned before was something like a form and field integration (note that this is more pseudocode than real code since I'm not yet fluent in FastHTML):
def TextField(name):
x_model = f'___form.{name}'
return Input(type="text", name=name, id=name, x_model=x_model)
def NumberField(name):
x_model = f'___form.{name}'
return Input(type="number", name=name, id=name, x_model=x_model)
In order for Alpine.js' two-way binding to work, they must be below a common tag with the x-data
attribute set (in addition to the library being added to the <head>
). The injection mechanism would inject it into the parent <form>
. From the moment x-data
exists, all the Alpine.js magic can be attached, such as a serialization function like serialize() { return $el._x_dataStack.reverse().reduce((a, b) => Object.assign(a, b), {}); }
, which traverses the entire x-data
tree looking for all the data client-side states. The global pub/sub would also work this way, adding the x-data
attribute to the <body>
tag with some global functions that can be called by any child component.
The use case i have in mind is a personal dashboard i wrote using only htmx. It was a form POST that retrieves data from the database, performs some manipulations with pandas, and returns a Plotly graph over the wire. However, it becomes utterly slow. Turns out i had to rewrite it using the Plotly.js library on the client side only.
I'm just looking at the library today. I came here to ask this question, though more for the reasons that FastAPI uses dependency injection. My endpoint functions typically take a database connection, and the calling user, as an injected parameter. While the latter can be taken from a session, I think it's cleaner to use the FastAPI Depends call.
It would also make porting my application over considerably easier!
I'm just looking at the library today. I came here to ask this question, though more for the reasons that FastAPI uses dependency injection. My endpoint functions typically take a database connection, and the calling user, as an injected parameter. While the latter can be taken from a session, I think it's cleaner to use the FastAPI Depends call.
It would also make porting my application over considerably easier!
Maybe it's better to rename the issue since 'dependency injection' is already a term used in another context within the FastAPI community?
That would seem good to me.
Here is a sketch of my idea for dependency injection that I brainstormed using Claude Sonnet.
I thought of 2 decorators: one to mark the components that need some dependency, and another to apply the dependencies to the final component returned by the routes:
import functools
import starlette.requests
INJECTED_ATTR_NAME = '___deps'
_valid_names = ('parent', 'head')
def register_dep(target, modifier_fn):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if not hasattr(result, INJECTED_ATTR_NAME):
setattr(result, INJECTED_ATTR_NAME, [])
getattr(result, INJECTED_ATTR_NAME).append({
'target': target,
'modifier_fn': modifier_fn
})
return result
return wrapper
if target not in _valid_names:
raise ValueError(f"Invalid target: {target}. Must be one of {_valid_names}")
return decorator
def apply_deps(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
def _extract_objects(args_to_test, *types):
ret = []
for obj_type in types:
found = False
for arg in args_to_test:
if isinstance(arg, obj_type):
ret.append(arg)
found = True
if not found:
ret.append(None)
return None if not ret else ret[0] if ret and len(ret) == 1 else ret
request = _extract_objects(args, starlette.requests.Request)
def traverse_and_apply(element, path=[]):
if isinstance(element, list) and len(element) == 3: # FT object
if hasattr(element, INJECTED_ATTR_NAME):
for dep in getattr(element, INJECTED_ATTR_NAME):
target = dep['target']
modifier_fn = dep['modifier_fn']
if target == 'head':
if not (request and hasattr(request, 'hdrs')):
raise Exception('Target "head" specified, but request.hdrs is not present')
modifier_fn(request.hdrs)
elif target == 'parent':
if path:
parent = path[-1]
modifier_fn(parent)
else:
print("Warning: 'parent' modification attempted on root element")
tag, cs, attrs = element
new_cs = []
for child in cs:
new_child = traverse_and_apply(child, path + [element])
new_cs.append(new_child)
element[1] = tuple(new_cs)
elif isinstance(element, (list, tuple)):
return type(element)(traverse_and_apply(item, path) for item in element)
return element
result = func(*args, **kwargs)
return traverse_and_apply(result)
return wrapper
First, you register the dependencies on the component definition:
import json
import fasthtml.common as fh
app, rt = fh.fast_app()
def GenericField(_type, name):
x_model_key = 'x-model'
x_model = f'___form.{name}'
if _type == 'number':
x_model_key += '.number'
return fh.Input(type=_type, placeholder=name, name=name, id=name, **{x_model_key: x_model})
def add_alpinejs_to_hrds(hrds):
for i in hrds:
if i[0] != 'script':
continue
if not hasattr(i, 'src'):
continue
if 'alpinejs' in i.src:
return
hrds.append(fh.Script(src='https://unpkg.com/alpinejs', defer=True))
def init_xdata(t):
jdata = t.attrs.get('x-data', '{}')
jdata = json.loads(jdata)
form = jdata.get('___form', {})
jdata['___form'] = form
t.attrs.update({'x-data': json.dumps(jdata)})
@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def TextField(name):
return fh.Div(GenericField('text', name))
@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def NumberField(name):
return GenericField('number', name)
Note that the modifier_fn
can be a simple lambda function, but complete function definitions would work better when avoiding repetitions or solving conflicts:
@register_dep('head', lambda hrds: hrds.append(fh.Script(src='https://unpkg.com/alpinejs', defer=True)))
@register_dep('parent', init_xdata)
def NumberField(name):
return GenericField('number', name)
Then, apply the modifications to the FT tree
@rt("/")
@apply_deps
def get(request):
return fh.Titled("FastHTML", fh.Div(
fh.Div(x_text="JSON.stringify(___form)"),
TextField('name'),
NumberField('age'),
))
fh.serve()
Here is the full draft:
import functools
import starlette.requests
INJECTED_ATTR_NAME = '___deps'
_valid_names = ('parent', 'head')
def register_dep(target, modifier_fn):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if not hasattr(result, INJECTED_ATTR_NAME):
setattr(result, INJECTED_ATTR_NAME, [])
getattr(result, INJECTED_ATTR_NAME).append({
'target': target,
'modifier_fn': modifier_fn
})
return result
return wrapper
if target not in _valid_names:
raise ValueError(f"Invalid target: {target}. Must be one of {_valid_names}")
return decorator
def apply_deps(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
def _extract_objects(args_to_test, *types):
ret = []
for obj_type in types:
found = False
for arg in args_to_test:
if isinstance(arg, obj_type):
ret.append(arg)
found = True
if not found:
ret.append(None)
return None if not ret else ret[0] if ret and len(ret) == 1 else ret
request = _extract_objects(args, starlette.requests.Request)
def traverse_and_apply(element, path=[]):
if isinstance(element, list) and len(element) == 3: # FT object
if hasattr(element, INJECTED_ATTR_NAME):
for dep in getattr(element, INJECTED_ATTR_NAME):
target = dep['target']
modifier_fn = dep['modifier_fn']
if target == 'head':
if not (request and hasattr(request, 'hdrs')):
raise Exception('Target "head" specified, but request.hdrs is not present')
modifier_fn(request.hdrs)
elif target == 'parent':
if path:
parent = path[-1]
modifier_fn(parent)
else:
print("Warning: 'parent' modification attempted on root element")
tag, cs, attrs = element
new_cs = []
for child in cs:
new_child = traverse_and_apply(child, path + [element])
new_cs.append(new_child)
element[1] = tuple(new_cs)
elif isinstance(element, (list, tuple)):
return type(element)(traverse_and_apply(item, path) for item in element)
return element
result = func(*args, **kwargs)
return traverse_and_apply(result)
return wrapper
###
import json
import fasthtml.common as fh
app, rt = fh.fast_app()
def GenericField(_type, name):
x_model_key = 'x-model'
x_model = f'___form.{name}'
if _type == 'number':
x_model_key += '.number'
return fh.Input(type=_type, placeholder=name, name=name, id=name, **{x_model_key: x_model})
def add_alpinejs_to_hrds(hrds):
for i in hrds:
if i[0] != 'script':
continue
if not hasattr(i, 'src'):
continue
if 'alpinejs' in i.src:
return
hrds.append(fh.Script(src='https://unpkg.com/alpinejs', defer=True))
def init_xdata(t):
jdata = t.attrs.get('x-data', '{}')
jdata = json.loads(jdata)
form = jdata.get('___form', {})
jdata['___form'] = form
t.attrs.update({'x-data': json.dumps(jdata)})
@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def TextField(name):
return fh.Div(GenericField('text', name))
@register_dep('head', add_alpinejs_to_hrds)
@register_dep('parent', init_xdata)
def NumberField(name):
return GenericField('number', name)
@rt("/")
@apply_deps
def get(request):
return fh.Titled("FastHTML", fh.Div(
fh.Div(x_text="JSON.stringify(___form)"),
TextField('name'),
NumberField('age'),
))
fh.serve()
Wow! You just took this project out of my mind! With all these 'pure Python webapp' libraries using WebSockets, I wondered when someone would leverage HTMX. Anyway, is there a way to inject dependencies into a parent component? For example, a generic snippet injecting the Alpine.js source into the
<head>
of the page?It would be very helpful for creating component libraries that also depend on JavaScript libraries without forcing the final user to worry about setting up those dependencies correctly.