Skip to content

Commit

Permalink
feat: Add support for attributes via mypy plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
paveldedik committed Jun 7, 2024
1 parent 826b5b5 commit 9580801
Show file tree
Hide file tree
Showing 14 changed files with 142 additions and 27 deletions.
2 changes: 1 addition & 1 deletion examples/click_to_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions examples/click_to_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -26,7 +26,7 @@ class ContactsSliceAttrs(Attrs):


class LoadMoreAttrs(Attrs):
url: str
url: URLType


def load_contacts(page: int) -> list[ContactAttrs]:
Expand Down
4 changes: 2 additions & 2 deletions examples/delete_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions examples/edit_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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"],
)
23 changes: 15 additions & 8 deletions ludic/attrs.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ludic/catalog/lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand All @@ -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))
4 changes: 2 additions & 2 deletions ludic/catalog/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down Expand Up @@ -79,7 +79,7 @@ def render(self) -> div:


class LazyLoaderAttrs(GlobalAttrs):
load_url: str
load_url: URLType
placeholder: NotRequired[AnyChildren]


Expand Down
2 changes: 1 addition & 1 deletion ludic/catalog/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
106 changes: 106 additions & 0 deletions ludic/mypy_plugin.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions ludic/types.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion ludic/web/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions tests/styles/test_themes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion tests/test_elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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() == '<div data-foo="1" data-bar="test">content</div>'

0 comments on commit 9580801

Please sign in to comment.