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:
from ludic.web import LudicApp app = LudicApp()
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!") routes = [ Route("/", homepage), ] app = LudicApp(debug=True, routes=routes)
Alternatively, you can use a method decorator to register an endpoint:
from ludic.web import LudicApp, Request from ludic.web.routing import Route app = LudicApp(debug=True) @app.get("/") def homepage(request: Request) -> p: return p("Hello, world!")
The Application Instance
#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.database import db 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.database import db 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", ) @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", )
One 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 extracted fromContactForm(...).attrs
since theContactForm
andContact
share the same attributes –ContactAttrs
.- 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 oneparams: 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
#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 person
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.
Parsing Collections
#You can also use the ListParser
class to parse a list of data. It parses form data in a way similar to the Parser
class, however, it expects the form data to have the identifier column as key. For example, we want to parse a first and last name columns from a list of people, here are the example form data the ListParser
is able to handle:
first_name:id:1=Joe& first_name:id:2=Jane& last_name:id:1=Smith& last_name:id:2=Doe
This would be transformed in the following structure:
[ {"id": 1, "first_name": "Joe", "last_name": "Smith"}, {"id": 2, "first_name": "Jane", "last_name": "Doe"}, ]
This is how you could use the ListParser
to parse a list of this kind of data from your view:
from ludic.web.parsers import ListParser @app.put("/people/") async def update_people(cls, id: str, data: ListParser[PersonAttrs]) -> div: people.update(data.validate()) return div(...) # return updated people
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:
@app.exception_handler(500) async def server_error(request: Request, exc: Exception) -> Page: ...
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.