Web Framework
The Ludic library provides wrappers around Starlette framework to make it easy to write asynchronous web applications based on HTMX and Ludic Components.
Ludic includes an application class LudicApp
that tight together all other functionality. Here is how you can create an instance of the class:
The LudicApp
class supports the same parameters as the Starlette
class from the Starlette framework.
Routing
To register handlers in your app, you can use the routes
arguments of the LudicApp
class like this:
from ludic.web import LudicApp, Request
from ludic.web.routing import Route
def homepage(request: Request) -> p:
return p("Hello, world!")
def startup():
print('Ready to go')
routes = [
Route("/", homepage),
]
app = LudicApp(debug=True, routes=routes, on_startup=[startup])
Instantiating The Application
class ludic.app.LudicApp
(debug=False, routes=None, middleware=None, exception_handlers=None, on_startup=None, on_shutdown=None, lifespan=None)
Creates an application instance.
Parameters:
The list of parameters can be found in the Starlette documentation.
Methods:
app.register_route
(path: str, method: str = "GET") - decorator to register function based endpoint handlerapp.get
(path: str, kwargs: Any) - decorator to register function endpoint handling GET HTTP methodapp.post
(path: str, kwargs: Any) - decorator to register function endpoint handling POST HTTP methodapp.put
(path: str, kwargs: Any) - decorator to register function endpoint handling PUT HTTP methodapp.patch
(path: str, kwargs: Any) - decorator to register function endpoint handling PATCH HTTP methodapp.delete
(path: str, kwargs: Any) - decorator to register function endpoint handling DELETE HTTP methodapp.endpoint
(path: str) - decorator to register component based endpointapp.add_route
(path: str, route: Callable[..., Any], method: str, kwargs: Any) - register any endpoint handlerapp.url_path_for
(name: str, /, path_params: Any) - get URL path for endpoint of given nameapp.exception_handler
(exc_class_or_status_code: int | type[Exception]) - register exception handler
Endpoints
There are three types of endpoints that you can create:
Function-Based
These are functions returning Ludic components, a tuple or the Starlette's Response class.
Here are some examples of function handlers registered in Ludic:
from ludic.web.datastructures import FormData
from ludic.web.exceptions import NotFoundError
from your_app.pages import Page
from your_app.models import Person
from your_app.components import Header
@app.get("/people/{id}")
async def show_person(id: str) -> Page:
person: Person = db.people.get(id)
if person is None:
raise NotFoundError("Contact not found")
return Page(
Header(person.name),
...
)
@app.post("/people/")
async def register_person(data: FormData) -> Page:
person: Person = Person.objects.create(**data)
return await show_person(person.id), 202
Component-Based
While it is possible to use function-based handlers everywhere, in the case of HTMX-based web applications, we want to also create a lot of endpoints rendering only sole form elements, tables, and so on. We don't need to always return the whole HTML document in <html>
tag. We could use function-based handlers for that, however, it is often better to think of endpoints as just another components.
Component-based endpoints can only have one generic argument which is the type of attributes. They cannot have children.
Here is an example where we create two component-based endpoints:
from ludic.web import Endpoint
from ludic.web.datastructures import FormData
from your_app.pages import Page
from your_app.models import Person
from your_app.components import Header, Body
@app.get("/")
async def index() -> Page:
return Page(
Header("Click To Edit"),
Body(*[await Contact.get(contact_id) for contact_id in db.contacts]),
)
@app.endpoint("/contacts/{id}")
class Contact(Endpoint[ContactAttrs]):
@classmethod
async def get(cls, id: str) -> Self:
contact = db.contacts.get(id)
return cls(**contact.as_dict())
@classmethod
async def put(cls, id: str, data: FormData) -> Self:
contact = db.contacts.get(id)
contact.update(**data)
return await cls.get(id)
@override
def render(self) -> div:
return div(
Pairs(items=self.attrs.items()),
ButtonPrimary(
"Click To Edit",
hx_get=self.url_for(ContactForm),
),
hx_target="this",
hx_swap="outerHTML",
)
@app.endpoint("/contacts/{id}/form/")
class ContactForm(Endpoint[ContactAttrs]):
@classmethod
async def get(cls, id: str) -> Self:
contact = db.contacts.get(id)
return cls(**contact.as_dict())
@override
def render(self) -> Form:
return Form(
# ... form fields definition here ...,
ButtonPrimary("Submit"),
ButtonDanger("Cancel", hx_get=self.url_for(Contact)),
hx_put=self.url_for(Contact),
hx_target="this",
)
The benefit of this approach is that you can create components that generate the URL path for other component-based endpoints with the url_for
method. More about that in the next section.
Reverse URL Lookups
There are two possible ways to generate the URL for a particular route handled by an endpoint:
Request.url_for
Endpoint.url_for
Request.url_for(endpoint: Callable[..., Any] | str, ...)
This method is available on the ludic.web.requests.Request
object. It generates and URLPath
object for a given endpoint.
Endpoint.url_for(endpoint: type[RoutedProtocol] | str, ...)
This method is available on a component-based endpoint. It has one small advantage over the request
's method -- if the destination component defines the same attributes, the path parameters are automatically extracted so you don't need to pass them via key-word arguments. Here are examples:
ContactForm(...).url_for(Contact)
- Even though theContactForm
endpoint requires theid
path parameter, it is automatically taken fromContactForm(...).attrs
since the type ofContactForm[ContactAttrs]
andContact[ContactAttrs]
are the same.- If these attribute types are not equal, you need to specify the URL path parameter explicitly, e.g.
ContactForm(...).url_for(Foo, id=self.attrs["foo_id"])
- if the first argument to
url_for
is the name of the endpoint, you need to always specify the URL path parameters explicitly.
Handler Responses
Your handler is not required to return only a valid element or component, you can also modify headers, status code, or return a JSONResponse
:
from ludic import types
from ludic.html import div
@app.get("/")
def index() -> tuple[div, types.Headers]:
return div("Headers Example"), {"Content-Type": "..."}
When it comes to the handler's return type, you have the following options:
BaseElement
- any element or componenttuple[BaseElement, int]
- any element or component and a status codetuple[BaseElement, types.Headers]
- any element or component and headers as a dicttuple[BaseElement, int, types.Headers]
- any element or component, status code, and headersstarlette.responses.Response
- valid StarletteResponse
object
Handler Arguments
Here is a list of arguments that your handlers can optionally define (they need to be correctly type-annotated):
<name>: <type>
- if the endpoint accepts path parameters, they can be specified in the handler's argumentsrequest: Request
- the Ludic's slightly modifiedludic.web.requests.Request
class based on Starlette's one.params: QueryParams
- contain query string parameters and can be imported fromludic.web.datastructures.QueryParams
data: FormData
- an immutable multi-dict, containing both file uploads and text input from form submissionheaders: Headers
- HTTP headers exposed as an immutable, case-insensitive, multi-dict
Parsers
Experimental
This module is in an experimental state. The API may change in the future.
The ludic.parsers
module contains helpers for parsing FormData
. The way it works is that you define Attrs
class with Annotated
arguments like here:
class PersonAttrs(Attrs):
id: NotRequired[int]
name: Annotated[str, parse_name]
email: Annotated[str, parse_email]
Now you can use the Parser
class to annotate arguments of your handler. The parser will attempt to parse form data if any Callable
is found in the metadata argument of Annotated
:
from ludic.web.parsers import Parser
@app.put("/people/{id}")
async def update_person(cls, id: str, data: Parser[PersonAttrs]) -> div:
person = db.people.get(id)
person.update(data.validate())
return div(...) # return updated user
The Parser.validate()
uses typeguard to validate the form data. If the validation fails, the method raises ludic.parsers.ValidationError
if the request's form data are not valid. If unhandled, this results in 403
status code.
Error Handlers
You can use error handlers for custom pages for non-ok HTTP status codes. You can register a handler with the app.exception_handler
decorator:
from your_app.pages import Page
@app.exception_handler(404)
async def not_found() -> Page:
return Page(
Header("Page Not Found"),
Body(Paragraph("The page you are looking for was not found.")),
)
@app.exception_handler(500)
async def server_error() -> Page:
return Page(
Header("Server Error"),
Body(Paragraph("Server encountered an error during processing.")),
)
Optionally, you can use the request: Request
and exc: Exception
arguments for the handler:
Exceptions
The ludic.web.exceptions
contains a lot of useful exceptions that can be raised in your views and caught in your custom error handlers:
ClientError(HTTPException)
- default status code400
BadRequestError(ClientError)
- default status code400
UnauthorizedError(ClientError)
- default status code401
PaymentRequiredError(ClientError)
- default status code402
ForbiddenError(ClientError)
- default status code403
NotFoundError(ClientError)
- default status code404
MethodNotAllowedError(ClientError)
- default status code405
TooManyRequestsError(ClientError)
- default status code429
ServerError(HTTPException)
- default status code500
InternalServerError(ServerError)
- default status code500
NotImplementedError(ServerError)
- default status code501
BadGatewayError(ServerError)
- default status code502
ServiceUnavailableError(ServerError)
- default status code503
GatewayTimeoutError(ServerError)
- default status code504
WebSockets
WebSockets support is not yet fully tested in Ludic. However, Starlette has good support for WebSockets so it should be possible to use Ludic as well.
Testing
Testing Ludic Web Apps is the same as testing Starlette apps which use a TestClient
class exposing the same interface as httpx
library. Read more about testing in the Starlette documentation.