Skip to content

Conversation

@gazpachoking
Copy link
Contributor

@gazpachoking gazpachoking commented May 20, 2025

Adds a fluent helper for constructing data-* attributes in the Python SDK.

  • Type hints everything to allow for IDE autocompletion and static type checking
  • Includes docstrings for all attributes and modifiers for quick reference from IDE
  • Only allows appropriate modifiers on each attribute
  • Automatic case conversion and case modifier addition for appropriate attributes. i.e. data.computed("FooBar", "$foo") -> data-computed-foo-bar__case.pascal='$foo' It doesn't (and can't) save you from identifiers that are unrepresentable, e.g. Caps_And_Underscores. (Should it error at runtime? Or just let you fail?)
  • Allows keyword argument specification for data-signals, data-attr, data-class, and data-computed. (data.class_(foo="$bar")) If you need kabab case or anything unrepresentable with a python identifier you must use the dict form. (data.class_({'foo-bar': '$baz'}))
  • Escapes input when rendering to a string
  • Usable with aliased builds by instantiating the generator yourself. ds = AttributeGenerator(alias='data-star-')

This is most useful when using a tool to construct html in python, like htpy or Fasthtml. These libraries allow defining tags with attributes using a function with keyword arguments, but this method doesn't work for attributes that aren't valid python identifiers, like data-* attributes which have hyphens. Both of these frameworks allow falling back to providing a dictionary of attributes, but the dx isn't quite as nice. This helper allows constructing an attribute dict for datastar attributes in a nicer way.

# htpy
button(data.on("click", "console.log('clicked')").debounce(1000).stop)["My Button"]
# FastHTML
Button("My Button", **data.on("click", "console.log('clicked')").debounce(1000).stop)
# After next release of FastHTML you don't have to unpack the datastar helpers e.g.
Button("My Button", data.on("click", "console.log('clicked')").debounce(1000).stop)
# f-strings
f"<button {data.on("click", "console.log('clicked')").stop}>My Button</button>"

These two issues would make compatibility with fasthtml and htpy even better:

It also works in a templating language like jinja but I don't recommend that. Jinja doesn't have the same issues (it's just text, you can already type attributes with -) nor does it get the advantages (it's untyped, so no IDE completion or type checking) It does mean errors would happen on server side when rendering the html rather than on client side if you have some invalid datastar syntax, (using non-existent modifiers for example,) so there could still be some value.

<button {{ data.on("click", "console.log('clicked')").debounce(1000).stop }}>My Button</button>

My main questions are about how this should be named and imported. Should it be called data to mirror most closely regular usage? data.persist == data-persist That feels a bit generic though, maybe ds? ds.bind('mysignal') Either of these don't feel very descriptive when importing from the library though, so my current top contender is to provide it as a name like attribute_generator and let the user alias it to whatever they want. Examples would probably be shown with it imported as data

# attribute_generator might be the most descriptive
from datastar_py import attribute_generator as data
data.bind('blah')

TODO:

  • figure best name/location for importing
  • Add some docstrings for easy in-editor reference
  • Take out 'case' mod completely? It shouldn't be needed. All the attributes that allow specifying names in the value are used where possible, and the case mods are applied automatically for data-computed and data-on

Some examples:

>>> import htpy
>>> from fasthtml.ft import Button
>>> 
>>> from datastar_py import attribute_generator as data
>>> from datastar_py.attributes import AttributeGenerator
>>> 
>>> # Usage with htpy
>>> htpy.button(data.on("click", "console.log();").debounce(100))["My Button"]
<button data-on-click__debounce.100="console.log();">My Button</button>
>>> # Usage with fasthtml
>>> Button("My Button", **data.on("click", "console.log();").debounce(100))
<button data-on-click__debounce.100="console.log();">My Button</button>
>>> # Usage with f-strings
>>> f"<button {data.on('click', 'console.log();').debounce(100)}>My Button</button>"
<button data-on-click__debounce.100="console.log();">My Button</button>
>>> # Sove valid calls
>>> data.persist
data-persist
>>> data.persist("mysignal").session
data-persist__session="mysignal"
>>> data.bind("mysignal")
data-bind="mysignal"
>>> data.signals(foo="bar", foo_bar="baz")
data-signals="{&#34;foo&#34;: &#34;bar&#34;, &#34;foo_bar&#34;: &#34;baz&#34;}"
>>> # Use a dict if you need non snake_case keys
>>> data.signals({"foo-bar": "baz"})
data-signals="{&#34;foo-bar&#34;: &#34;baz&#34;}"
>>> # Namespaced signals
>>> data.signals({'namespace': {'sig1': 'foo'}})
data-signals="{&#34;namespace&#34;: {&#34;sig1&#34;: &#34;foo&#34;}}"
>>> data.signals(namespace={'sig1': 'blah'})
data-signals="{&#34;namespace&#34;: {&#34;sig1&#34;: &#34;blah&#34;}}"
>>> # If you want expressions you can specify the expressions_ argument
>>> data.signals(foo="Date.now()", expressions_=True)
data-signals="{&#34;foo&#34;: Date.now()}"
>>> data.computed({"foo": "$bar * $baz"})
data-computed-foo="$bar * $baz"
>>> data.on("load", "console.log('loaded')").delay(100)
data-on-load__delay.100="console.log(&#39;loaded&#39;)"
>>> data.on("click", "console.log('clicked')").window.debounce(100)
data-on-click__window__debounce.100="console.log(&#39;clicked&#39;)"
>>> # timings are ms if numbers, but you can use strings
>>> data.on("raf", "console.log('raf')").debounce("1s")
data-on-raf__debounce.1s="console.log(&#39;raf&#39;)"
>>> # attr and class_ don't use json, so that the values become expressions rather than strings
>>> data.attr({"checked": "$mysignal"})
data-attr="{&#34;checked&#34;: $mysignal}"
>>> data.class_({"coolclass": "$mysignal"})
data-class="{&#34;coolclass&#34;: $mysignal}"
>>> # on and computed automatically apply the needed case modifier
>>> # This follows the same syntax as the other attributes which can take a dictionary or kwargs
>>> # but will be split into multiple attributes due to how data-comptued works
>>> data.computed({"FooBar": "$b", "fooBar": "$b", "foo-bar": "$b", "foo_bar": "$b", "foobar": "$b"})
data-computed-foo-bar__case.pascal="$b" data-computed-foo-bar__case.camel="$b" data-computed-foo-bar__case.kebab="$b" data-computed-foo-bar__case.snake="$b" data-computed-foobar="$b"
>>> # If you don't need kebab-case, you can use the simpler kwarg format
>>> data.computed(foo_bar="$bar * $baz", another="$two_at_once")
data-computed-foo-bar__case.snake="$bar * $baz" data-computed-another="$two_at_once"
>>> # Dunno if it's a good idea, but since some things are properties, and some are methods
>>> # it always allows calling so you don't have to remember the difference
>>> # if there are no arguments though, you don't need the parentheses
>>> data.persist.session
data-persist__session
>>> data.persist().session()
data-persist__session
>>> # If you are using an aliased build, you can instantiate the generator yourself
>>> data_star = AttributeGenerator(alias="data-star-")
>>> data_star.on("click", "console.log('clicked')").debounce(100)
data-star-on-click__debounce.100="console.log(&#39;clicked&#39;)"
>>> data.computed({"FHHHH": "3"})
data-computed-f-h-h-h-h__case.pascal="3"
>>> data.computed({"form.fooBar.blah": "3"})
data-computed-form.foo-bar.blah__case.camel="3"

To test this out before it gets merged, if you are using uv to install your dependencies:

[tool.uv.sources]
datastar-py = { git = "https://github.com/gazpachoking/datastar", subdirectory = "sdk/python", branch="attribute-helper"}

@Kvit
Copy link

Kvit commented May 20, 2025

@gazpachoking my vote is is use data, which is closest to the original and easy for AI coders to understand with ds you are running bigger risk of conflicts. in fact this name is already used by another library ft_datastar https://github.com/banditburai/ft-datastar

@gazpachoking
Copy link
Contributor Author

I think I'm leaning toward attribute_generator, but have the idiomatic usage be from datastar_py import attribute_generator as data

@gazpachoking
Copy link
Contributor Author

Hmm. I sorta like the way keyword arguments are used in ft_datastar. I might try those out for data-signals and a few others.

@Kvit
Copy link

Kvit commented May 21, 2025

Hmm. I sorta like the way keyword arguments are used in ft_datastar. I might try those out for data-signals and a few others.

I like this library, good example, works well

Add docstrings for all attributes.
@gazpachoking
Copy link
Contributor Author

I added support for keyword arguments to data-signals, data-class, data-computed, and data-attr. I decided not to do any case mangling there, if you need to deal with identifiers that aren't representable as python identifiers you need to use the dict format.
Also didn't add keyword support for data-on because we couldn't type the return properly then. Typing the return means you get autocompletion only for the supported modifiers on each of the different data-on possibilities.

@gazpachoking
Copy link
Contributor Author

Added proper escaping when rendering to text.

@gazpachoking
Copy link
Contributor Author

gazpachoking commented May 21, 2025

Hmm. data-computed has a bit different interface to other similar attributes, i.e. data.computed(key, value) Should I change that to take a dict or keyword arguments like data-signals, data-attr and data-class? Or should those other ones also allow the simple form of (key, value)?

Double hmm, data-on also has the (key, value) form, but I don't see quite as nice a way to change that one. Since it has modifiers, allowing dict/kwargs form gets awkward because it could create multiple data-on attributes and there wouldn't be a nice way to add modifiers then. Especially since different -on events allow different modifiers.

@gazpachoking
Copy link
Contributor Author

Made data.computed consistent with attrs and class. It takes a dict for string form, or kwargs.

@gazpachoking gazpachoking marked this pull request as ready for review May 21, 2025 18:56
@gazpachoking
Copy link
Contributor Author

Feeling quite good about this now.

@CiberNin
Copy link

CiberNin commented Jun 2, 2025

I use python. This seems pretty useful to me.

@lllama
Copy link
Contributor

lllama commented Jun 2, 2025

This looks good. Just want to double check that the signals method supports namespaced signals (didn't see an example) - I'm assuming it does using a dictionary argument.

Otherwise LGTM - don't have an issue with this being added to the SDK.

Let expressions_ mode work for nested signals
@gazpachoking
Copy link
Contributor Author

Just want to double check that the signals method supports namespaced signals (didn't see an example) - I'm assuming it does using a dictionary argument.

Yep, nested dicts, pretty much the same as you'd do it with the js objects. data.signals({'thenamespace': {'signal1': 'foo', 'signal2': 'bar'}}) or data.signals(thenamespace={'signal1': 'foo'})

I did realize that the typing wasn't quite complete, and a bit wrong for the keyword form of data.signals. Fixed that up. And expressions mode wouldn't have work on namespaced signals, fixed that as well now.

@gazpachoking
Copy link
Contributor Author

gazpachoking commented Jun 2, 2025

One thing the signals helper doesn't support is setting signal values as objects. Do we think that's important? I think it's already a bit confusing how it's supported in datastar, where data-signals-foo="'bar'" is the same as data-signals="{foo: 'bar'}" but data-signals-baz="{foo: 'bar'}" (the only way to set a signal value to an object) is not the same as data-signals="{baz: {foo: 'bar'}}" (creating a namespace baz with a foo signal in)

Because of this ambiguity, I don't know how you'd specify using the helper that you'd like an object as a signal value rather than a namespaced signal.

@bencroker
Copy link
Collaborator

One conflict needs resolving before I can merge.

# Conflicts:
#	sdk/python/src/datastar_py/__init__.py
@jmoppel
Copy link
Contributor

jmoppel commented Jun 3, 2025

First off, outstanding work. This is terrific for everyone. Even Jinja/Django users can use these generators to create template functions/template tags and get a lot of the benefits you mentioned.

I agree that that just importing data as it is now a little mysterious. In general, I would try to model things after the Python stdlib and use attr (i.e. the stdlib has hasattr, getattr, delattr, xml.dom.Attr.*, etc) to supplement data. Here are a couple of suggested approaches:

  1. Put data in a datastar_py.attr module or even datastar_py.html.attr (similar to the stdlib's xml module). Then you can from datastar_py import attr, from datastar_py.attr import data or even ... html.attr.data if we think we would want to do anything with the HTML elements themselves. I personally favor this approach since it allows folks to use attr.data or just data if they like a more terse style.
  2. Rename data to data_attr and leave it in the current module.

@gazpachoking
Copy link
Contributor Author

I agree that that just importing data as it is now a little mysterious.

I got pretty comfortable with the way it's working right now and shown in the example from datastar_py import attribute_generator as data Do you think there's an issue with this? It gets a nice descriptive name at import time, but examples, (and presumably users who like the style of the examples,) can import it as data so as to make it look more like data-* attributes.

@bencroker bencroker added the sdk SDK related issues label Jun 3, 2025
@jmoppel
Copy link
Contributor

jmoppel commented Jun 3, 2025

I got pretty comfortable with the way it's working right now and shown in the example from datastar_py import attribute_generator as data Do you think there's an issue with this? It gets a nice descriptive name at import time, but examples, (and presumably users who like the style of the examples,) can import it as data so as to make it look more like data-* attributes.

Agreed, I really like the idea of using data as well! I thought it might be a bit more clear if we mimiced the stdlb's namespace (i.e., xml.*) concerning what kind of attribute we're generating (i.e., an html attribute). It's not a huge deal as long as we comment with docstrings at some point after the fact. I'm willing to help with that for sure if needed/wanted.

Are you thinking that we would recommend folks use the as data import style you mentioned?

@gazpachoking
Copy link
Contributor Author

Are you thinking that we would recommend folks use the as data import style you mentioned?

Yep. Just like shown in the code snippets in the top post. (People can of course import it however they want, that's just how I would do it and show it in examples.)

@jmoppel
Copy link
Contributor

jmoppel commented Jun 4, 2025

Is the thought that we/users might use attribute_generator for anything other than data? If not, just export data for them?

from typing import Any

from .sse import SSE_HEADERS, ServerSentEventGenerator
from .attributes import attribute_generator


data =  attribute_generator #new

__all__ = ["attribute_generator", "data", "ServerSentEventGenerator", "SSE_HEADERS"]  #changed

If that sounds good, what do you think about namespacing data like the stdlib's xml module? That would to make it more familiar and give data some namespacing context. ... What kind of data is it? .. oh an html.el.attrib.data (or something like that).

@gazpachoking
Copy link
Contributor Author

You mean just exposing it with 2 names, both attribute_generator and data? I don't quite get what you mean. I just think providing it directly as 'data' is super ambiguous, even if it is a nice way to use it

@jmoppel
Copy link
Contributor

jmoppel commented Jun 4, 2025

You mean just exposing it with 2 names, both attribute_generator and data? I don't quite get what you mean. I just think providing it directly as 'data' is super ambiguous, even if it is a nice way to use it

Agree, that's why my ideal import statement would be something like from datastar_py.html.attrib import data or even from datastar_py.html.el.attrib import data, but the last one while trying to be explicit is perhaps overly verbose. What do you think?

@gazpachoking
Copy link
Contributor Author

Agree, that's why my ideal import statement would be something like from datastar_py.html.attrib import data or even from datastar_py.html.el.attrib import data, but the last one while trying to be explicit is perhaps overly verbose. What do you think?

I'm unconvinced. Adding extra packages just to namespace that single import seems a bit overkill to me (and I'm not even sure I like it better) from datastar_py import attribute_generator as data seems pretty fine. I added a section in the readme now to mention the attribute generator showing it that way.

@gazpachoking gazpachoking force-pushed the attribute-helper branch 4 times, most recently from 9d4831d to 3587991 Compare June 5, 2025 02:17
@bencroker
Copy link
Collaborator

Let me know when this is ready to be merged.

@gazpachoking
Copy link
Contributor Author

I think we are good to go. :shipit:

@bencroker bencroker merged commit 5057c10 into starfederation:develop Jun 6, 2025
1 check passed
@bencroker
Copy link
Collaborator

Merged 🚀

@ndendic
Copy link

ndendic commented Jun 7, 2025

Hey @lllama - are these changes pulled into your datastar-py lib that can be installed through pip or the direct github install is the only way now?

@gazpachoking
Copy link
Contributor Author

The pypi version does not have these changes yet, they will be included next time we do a release there

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk SDK related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants