diff --git a/examples/click_to_edit.py b/examples/click_to_edit.py index 7eac893..62b9ac0 100644 --- a/examples/click_to_edit.py +++ b/examples/click_to_edit.py @@ -72,7 +72,7 @@ async def put(cls, id: str, attrs: Parser[ContactAttrs]) -> Self: @override def render(self) -> Stack: return Stack( - Pairs(items=self.attrs.items()), + Pairs(items=self.attrs.items()), # type: ignore Cluster( Button( "Click To Edit", diff --git a/examples/click_to_load.py b/examples/click_to_load.py index 46c7d55..379244d 100644 --- a/examples/click_to_load.py +++ b/examples/click_to_load.py @@ -7,7 +7,7 @@ from ludic.catalog.quotes import Quote from ludic.catalog.tables import Table, TableHead, TableRow from ludic.html import td -from ludic.types import Attrs, Blank, Component, ComponentStrict +from ludic.types import Attrs, Blank, Component, ComponentStrict, URLType from ludic.web import Endpoint, LudicApp from ludic.web.datastructures import QueryParams @@ -26,7 +26,7 @@ class ContactsSliceAttrs(Attrs): class LoadMoreAttrs(Attrs): - url: str + url: URLType def load_contacts(page: int) -> list[ContactAttrs]: diff --git a/examples/delete_row.py b/examples/delete_row.py index 5eb7183..3fc59ca 100644 --- a/examples/delete_row.py +++ b/examples/delete_row.py @@ -2,7 +2,7 @@ from examples import Page, init_db -from ludic.attrs import Attrs, HtmxAttrs +from ludic.attrs import Attrs, GlobalAttrs from ludic.catalog.buttons import ButtonDanger from ludic.catalog.headers import H1, H2 from ludic.catalog.quotes import Quote @@ -78,7 +78,7 @@ def render(self) -> Table[TableHead, PersonRow]: return Table( TableHead("Name", "Email", "Active", ""), *(PersonRow(**person) for person in self.attrs["people"]), - body_attrs=HtmxAttrs( + body_attrs=GlobalAttrs( hx_confirm="Are you sure?", hx_target="closest tr", hx_swap="outerHTML swap:1s", diff --git a/examples/edit_row.py b/examples/edit_row.py index c8cedaa..0852b08 100644 --- a/examples/edit_row.py +++ b/examples/edit_row.py @@ -2,7 +2,7 @@ from examples import Page, init_db -from ludic.attrs import Attrs, HtmxAttrs +from ludic.attrs import Attrs, GlobalAttrs from ludic.catalog.buttons import ( ButtonPrimary, ButtonSecondary, @@ -87,7 +87,7 @@ def render(self) -> TableRow: self.attrs["email"], ButtonPrimary( "Edit", - hx_get=self.url_for(PersonForm), + hx_get=self.url_for(PersonForm).path, hx_trigger="edit", on_click=self.on_click_script, classes=["small"], @@ -140,6 +140,6 @@ def render(self) -> Table[TableHead, PersonRow]: return Table[TableHead, PersonRow]( TableHead("Name", "Email", "Action"), *(PersonRow(**person) for person in self.attrs["people"]), - body_attrs=HtmxAttrs(hx_target="closest tr", hx_swap="outerHTML"), + body_attrs=GlobalAttrs(hx_target="closest tr", hx_swap="outerHTML"), classes=["text-align-center"], ) diff --git a/ludic/attrs.py b/ludic/attrs.py index 01397ff..83f2944 100644 --- a/ludic/attrs.py +++ b/ludic/attrs.py @@ -1,10 +1,16 @@ -from typing import Annotated, Literal +from typing import Annotated, Literal, Protocol from .base import Attrs as Attrs from .base import NoAttrs as NoAttrs from .styles import CSSProperties +class URLType(Protocol): + """Protocol for URL-like types.""" + + def __str__(self) -> str: ... + + class Alias(str): """Alias type for attributes.""" @@ -40,11 +46,11 @@ class HtmxAttrs(Attrs, total=False): See: https://htmx.org/ """ - hx_get: Annotated[str, Alias("hx-get")] - hx_post: Annotated[str, Alias("hx-post")] - hx_put: Annotated[str, Alias("hx-put")] - hx_delete: Annotated[str, Alias("hx-delete")] - hx_patch: Annotated[str, Alias("hx-patch")] + hx_get: Annotated[URLType, Alias("hx-get")] + hx_post: Annotated[URLType, Alias("hx-post")] + hx_put: Annotated[URLType, Alias("hx-put")] + hx_delete: Annotated[URLType, Alias("hx-delete")] + hx_patch: Annotated[URLType, Alias("hx-patch")] hx_on: Annotated[str, Alias("hx-on")] hx_include: Annotated[str, Alias("hx-include")] @@ -84,7 +90,7 @@ class HtmxAttrs(Attrs, total=False): hx_params: Annotated[str, Alias("hx-params")] hx_preserve: Annotated[bool, Alias("hx-preserve")] hx_prompt: Annotated[str, Alias("hx-prompt")] - hx_replace_url: Annotated[str, Alias("hx-replace-url")] + hx_replace_url: Annotated[URLType, Alias("hx-replace-url")] hx_request: Annotated[str, Alias("hx-request")] hx_validate: Annotated[bool, Alias("hx-validate")] hx_ws: Annotated[str, Alias("hx-ws")] @@ -225,7 +231,8 @@ class ScriptAttrs(HtmlAttrs, total=False): class HeadLinkAttrs(HtmlAttrs, total=False): - rel: Literal["canonical", "alternate", "stylesheet"] + type: str + rel: Literal["canonical", "alternate", "stylesheet", "icon", "apple-touch-icon"] crossorigin: Literal["anonymous", "use-credentials"] hreflang: str href: str diff --git a/ludic/catalog/lists.py b/ludic/catalog/lists.py index a3ab5d8..22b6d48 100644 --- a/ludic/catalog/lists.py +++ b/ludic/catalog/lists.py @@ -39,7 +39,7 @@ def render(self) -> ul: children = tuple(map(Item, items)) else: children = self.children - return ul(*children, **self.attrs) + return ul(*children, **self.attrs_for(ul)) class NumberedList(Component[Item, ListAttrs]): @@ -59,4 +59,4 @@ def render(self) -> ol: children = tuple(map(Item, items)) else: children = self.children - return ol(*children, **self.attrs) + return ol(*children, **self.attrs_for(ol)) diff --git a/ludic/catalog/loaders.py b/ludic/catalog/loaders.py index e1a24fd..3678d70 100644 --- a/ludic/catalog/loaders.py +++ b/ludic/catalog/loaders.py @@ -2,7 +2,7 @@ from ludic.attrs import GlobalAttrs from ludic.html import div, style -from ludic.types import AnyChildren, Component +from ludic.types import AnyChildren, Component, URLType class Loading(Component[AnyChildren, GlobalAttrs]): @@ -79,7 +79,7 @@ def render(self) -> div: class LazyLoaderAttrs(GlobalAttrs): - load_url: str + load_url: URLType placeholder: NotRequired[AnyChildren] diff --git a/ludic/catalog/tables.py b/ludic/catalog/tables.py index 95ce03f..7eeb399 100644 --- a/ludic/catalog/tables.py +++ b/ludic/catalog/tables.py @@ -99,7 +99,7 @@ def render(self) -> tr: TRow = TypeVar("TRow", bound=BaseElement, default=TableRow) -class TableAttrs(GlobalAttrs): +class TableAttrs(GlobalAttrs, total=False): head_attrs: GlobalAttrs body_attrs: GlobalAttrs diff --git a/ludic/mypy_plugin.py b/ludic/mypy_plugin.py new file mode 100644 index 0000000..b3859c9 --- /dev/null +++ b/ludic/mypy_plugin.py @@ -0,0 +1,106 @@ +"""This module is designed specifically for use with the mypy plugin.""" + +from collections.abc import Callable +from typing import TypeVar + +from mypy.nodes import ARG_STAR, ARG_STAR2, Argument, TypeInfo, Var +from mypy.plugin import ClassDefContext, Plugin +from mypy.plugins.common import add_method +from mypy.types import ( + AnyType, + NoneTyp, + TupleType, + TypeOfAny, + TypeVarType, + UnpackType, +) + +T = TypeVar("T") +CB = Callable[[T], None] | None + +BLACKLISTED_ELEMENTS = { + "ludic.components.Component", + "ludic.components.ComponentStrict", + "ludic.web.endpoints.Endpoint", +} + + +def is_component_base(info: TypeInfo) -> bool: + """Check if this is a subclass of a Ludic element.""" + return info.fullname in ( + "ludic.base.Element", + "ludic.base.ElementStrict", + "ludic.components.Component", + "ludic.components.ComponentStrict", + ) + + +class LudicPlugin(Plugin): + def get_base_class_hook(self, fullname: str) -> "CB[ClassDefContext]": + sym = self.lookup_fully_qualified(fullname) + if sym and isinstance(sym.node, TypeInfo): + if is_component_base(sym.node): + return add_init_hook + return None + + +def add_init_hook(ctx: ClassDefContext) -> None: + """Add a dummy __init__() to a model and record it is generated. + + Instantiation will be checked more precisely when we inferred types + (using get_function_hook and model_hook). + """ + node = ctx.cls.info + + if "__init__" in node.names or node.fullname in BLACKLISTED_ELEMENTS: + # Don't override existing definition. + return + + for base in node.bases: + if is_component_base(base.type) and any( + not isinstance(arg, TypeVarType) for arg in base.args + ): + break + else: + return + + match base.type.name: + case "Element" | "Component": + args_type = base.args[0] + case "ElementStrict" | "ComponentStrict": + args_type = UnpackType( + TupleType( + list(base.args[:-1]), + ctx.api.builtin_type("builtins.tuple"), + ) + ) + case _: + return + + args_var = Var("args", args_type) + args_arg = Argument( + variable=args_var, + type_annotation=args_type, + initializer=None, + kind=ARG_STAR, + ) + + kwargs_type = ( + AnyType(TypeOfAny.special_form) + if isinstance(base.args[-1], TypeVarType) + else UnpackType(base.args[-1]) + ) + kwargs_var = Var("kwargs", kwargs_type) + kwargs_arg = Argument( + variable=kwargs_var, + type_annotation=kwargs_type, + initializer=None, + kind=ARG_STAR2, + ) + + add_method(ctx, "__init__", [args_arg, kwargs_arg], NoneTyp()) + ctx.cls.info.metadata.setdefault("ludic", {})["generated_init"] = True + + +def plugin(version: str) -> type[LudicPlugin]: + return LudicPlugin diff --git a/ludic/types.py b/ludic/types.py index c17659a..0ad98d5 100644 --- a/ludic/types.py +++ b/ludic/types.py @@ -1,6 +1,7 @@ from collections.abc import Iterable, Mapping from typing import TypedDict +from .attrs import URLType as URLType from .base import ( AnyChildren, Attrs, @@ -28,10 +29,10 @@ "HXHeaders", { "HX-Location": JSONType, - "HX-Push-Url": str, + "HX-Push-Url": URLType | bool, "HX-Redirect": str, "HX-Refresh": bool, - "HX-Replace-Url": str, + "HX-Replace-Url": URLType, "HX-Reswap": str, "HX-Retarget": str, "HX-Reselect": str, diff --git a/ludic/web/endpoints.py b/ludic/web/endpoints.py index d6ca542..8caee31 100644 --- a/ludic/web/endpoints.py +++ b/ludic/web/endpoints.py @@ -71,7 +71,7 @@ def lazy_load( """ return LazyLoader( placeholder=placeholder, - load_url=self.url_for(endpoint, **kwargs), + load_url=self.url_for(endpoint, **kwargs).path, ) def url_for(self, endpoint: type[RoutedProtocol] | str, **path_params: Any) -> URL: diff --git a/pyproject.toml b/pyproject.toml index 94cbb64..86ad50e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ known-third-party = ["examples"] python_version = "3.12" strict = true disallow_subclassing_any = false +plugins = "ludic/mypy_plugin.py" [tool.pytest.ini_options] testpaths = "tests" diff --git a/tests/styles/test_themes.py b/tests/styles/test_themes.py index ac2de51..dbd7290 100644 --- a/tests/styles/test_themes.py +++ b/tests/styles/test_themes.py @@ -85,7 +85,7 @@ def test_element_theme_switching() -> None: set_default_theme(bar) - class C1(Component[str, GlobalAttrs]): + class C1(Component[str, GlobalAttrs]): # type: ignore styles = style.use( lambda theme: { "#c1 a": {"color": theme.colors.warning}, @@ -100,7 +100,7 @@ def render(self) -> div: id="c1", ) - class C2(Component[str, GlobalAttrs]): + class C2(Component[str, GlobalAttrs]): # type: ignore styles = style.use( lambda theme: { "#c2 a": {"color": theme.colors.danger}, diff --git a/tests/test_elements.py b/tests/test_elements.py index 7523b2b..92a9140 100644 --- a/tests/test_elements.py +++ b/tests/test_elements.py @@ -170,7 +170,7 @@ def test_repr_and_str() -> None: def test_data_attributes() -> None: - dom = div("content", data_foo="1", data_bar="test") + dom = div("content", data_foo="1", data_bar="test") # type: ignore assert dom.attrs == {"data_foo": "1", "data_bar": "test"} assert dom.to_html() == '
content
'