├── .gitignore ├── LICENSE ├── README.md ├── app ├── __init__.py ├── api.py ├── app.py ├── exception_handlers.py ├── htmy.py ├── i18n.py └── main.py ├── lc ├── __init__.py ├── authenticator.py ├── chat │ ├── __init__.py │ ├── api.py │ ├── bot │ │ ├── __init__.py │ │ ├── beer_corpus.py │ │ ├── bot.py │ │ ├── coffee_corpus.py │ │ └── utils.py │ └── model.py ├── fastapi.py ├── pages │ ├── __init__.py │ ├── chat_page.py │ ├── index_page.md │ ├── index_page.py │ ├── page.py │ └── profile_page.py ├── ui │ ├── __init__.py │ ├── base_page.html │ ├── base_page.py │ ├── centered.py │ ├── chat_bubble.py │ ├── chat_container.py │ ├── chat_input.py │ ├── copy_button.html │ ├── copy_button.py │ ├── dialog.html │ ├── dialog.py │ ├── login_dialog.py │ ├── md.py │ ├── navbar.html │ ├── navbar.py │ ├── not_found.py │ ├── t_function.py │ └── utils.py └── user │ ├── auth_api.py │ └── model.py ├── locale └── en.json ├── pyproject.toml ├── requirements.txt ├── uv.lock └── vercel.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Vercel 10 | .vercel 11 | 12 | # Virtual environments 13 | .venv 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Peter Volf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lipsum chat 2 | 3 | This project is a technology demonstration for server-side rendering with Python, FastAPI, and htmx; in the disguise of an AI chatbot that responds to questions about beer and coffee. More specifically the project showcases the following libraries: 4 | 5 | - [FastAPI](https://fastapi.tiangolo.com/): A modern, async, Python web framework. 6 | - [FastHX](https://volfpeter.github.io/fasthx/): Server-side rendering utility with HTMX support for FastAPI. 7 | - [htmy](https://volfpeter.github.io/htmy/): A powerful, async, pure-Python server-side rendering engine. 8 | - [htmx](https://htmx.org/): A JavaScript library for making AJAX requests and DOM updates using HTML attributes. 9 | 10 | Importantly, the project **does not** use Jinja or any other traditional templating engine. Instead, it uses `htmy` – in some cases with plain `html` and `markdown` snippets with no custom templating syntax –, so you can enjoy all the benefits of modern IDEs, linters, static code analysis tools, and coding assistants. 11 | 12 | Styling is done with [TailwindCSS v4](https://tailwindcss.com/) and [DaisyUI v5](https://daisyui.com/), but the project is not a TailwindCSS or DaisyUI demo, the focus is entirely on server-side rendering. 13 | 14 | ## Support 15 | 16 | Need help kickstarting a new project with this or a similar toolchain? [Get in touch!](https://www.volfp.com/contact) 17 | 18 | ## Getting started 19 | 20 | For a seamless experience, you need `uv` to be installed. If not available, you can find the required dependencies in `pyproject.toml`. 21 | 22 | With `uv` in place, all you need to do is run `uv run uvicorn app.main:app --reload` and open http://127.0.0.1:8000/ in your browser. 23 | 24 | `uv run` tools: 25 | 26 | - Linting: `ruff check .` or `ruff check . --fix` 27 | - Formatting: `ruff format .` or `ruff format . --check` for format check. 28 | - Static type analysis: `uv run mypy .` 29 | 30 | ## Deployment 31 | 32 | `vercel.json` contains the configuration for deployment on Vercel. 33 | 34 | The Vercel Python runtime only supports `requirements.txt`. The requirements file can be generated with `uv export --no-dev > requirements.txt`. It must be done manually before every push to the `main` branch. 35 | 36 | The Vercel Python runtime is experimental at this time (March 2025), so this config may become outdated relatively soon. 37 | 38 | ## Similar projects 39 | 40 | - [fastapi-htmx-tailwind-example](https://github.com/volfpeter/fastapi-htmx-tailwind-example): A similar, but slightly outdated technology demonstration using Jinja and MongDB, with more focus on HTMX. 41 | 42 | ## License - MIT 43 | 44 | The package is open-sourced under the conditions of the [MIT license](https://choosealicense.com/licenses/mit/). 45 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volfpeter/lipsum-chat/6b228e1d18087f25cb52ff046553e41a573c7f7c/app/__init__.py -------------------------------------------------------------------------------- /app/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException 2 | 3 | from lc.authenticator import Authenticator 4 | from lc.pages.chat_page import chat_page 5 | from lc.pages.index_page import index_page 6 | from lc.pages.profile_page import profile_page 7 | from lc.ui.navbar import ChatPageKey, chat_page_keys 8 | 9 | from .htmy import htmy 10 | 11 | 12 | def add_routes(app: FastAPI, *, auth: Authenticator) -> None: 13 | from lc.chat.api import make_api as make_chat_api 14 | from lc.user.auth_api import make_api as make_auth_api 15 | 16 | app.include_router(make_auth_api(auth, htmy)) 17 | app.include_router(make_chat_api(htmy)) 18 | 19 | @app.get("/ack", response_model=None) 20 | async def ack() -> None: 21 | """Just to have something to HTMX can call to trigger actions.""" 22 | ... 23 | 24 | @app.get("/profile") 25 | @htmy.page(profile_page) 26 | async def me() -> None: 27 | """Profile page.""" 28 | ... 29 | 30 | @app.get("/") 31 | @htmy.page(index_page) 32 | async def index() -> None: 33 | """Index page.""" 34 | ... 35 | 36 | @app.get("/{page}") 37 | @htmy.page(chat_page) 38 | async def pages(page: ChatPageKey | str = "home") -> ChatPageKey: 39 | if page in chat_page_keys: 40 | return page # type: ignore[return-value] 41 | 42 | raise HTTPException(status_code=404) 43 | -------------------------------------------------------------------------------- /app/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | App factory and configuration. 3 | """ 4 | 5 | from fastapi import FastAPI 6 | 7 | from lc.authenticator import Authenticator 8 | from lc.ui.t_function import TFunction 9 | 10 | from .api import add_routes 11 | from .exception_handlers import add_exception_handlers 12 | from .htmy import htmy 13 | from .i18n import i18n 14 | 15 | 16 | def make_app() -> FastAPI: 17 | # Create the app and the necessary utilities. 18 | app = FastAPI() 19 | auth = Authenticator() 20 | 21 | # Add the auth middleware. 22 | app.middleware("http")(auth.middleware) 23 | 24 | # Register the htmy context processors. 25 | htmy.request_processors.extend( 26 | ( 27 | # Authenticator 28 | auth.htmy_context_processor, 29 | # Translation function 30 | lambda r: TFunction(i18n, r.headers.get("Language-Selection-Not-Supported", "en")).to_context(), 31 | ) 32 | ) 33 | 34 | # Register exception handlers. 35 | add_exception_handlers(app) 36 | 37 | # Register all routes. 38 | add_routes(app, auth=auth) 39 | 40 | return app 41 | -------------------------------------------------------------------------------- /app/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, HTTPException, Request 2 | from fastapi.responses import HTMLResponse 3 | 4 | from lc.ui.base_page import base_page 5 | from lc.ui.not_found import not_found 6 | 7 | from .htmy import htmy 8 | 9 | 10 | def add_exception_handlers(app: FastAPI) -> None: 11 | @app.exception_handler(401) 12 | @app.exception_handler(403) 13 | @app.exception_handler(404) 14 | async def handle_not_found(request: Request, _: HTTPException) -> HTMLResponse: 15 | return HTMLResponse(await htmy.render_component(base_page(not_found()), request)) 16 | -------------------------------------------------------------------------------- /app/htmy.py: -------------------------------------------------------------------------------- 1 | from fasthx.htmy import HTMY 2 | 3 | htmy = HTMY(no_data=True) 4 | -------------------------------------------------------------------------------- /app/i18n.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from htmy.i18n import I18n 4 | 5 | _locale_dir = Path(__file__).parent.parent / "locale" 6 | 7 | i18n = I18n(_locale_dir) 8 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | The main entry point (application instance). 3 | 4 | Primarily for deployment. 5 | """ 6 | 7 | from .app import make_app 8 | 9 | app = make_app() 10 | -------------------------------------------------------------------------------- /lc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volfpeter/lipsum-chat/6b228e1d18087f25cb52ff046553e41a573c7f7c/lc/__init__.py -------------------------------------------------------------------------------- /lc/authenticator.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException, Request, Response 2 | from htmy import Context 3 | from pydantic import ValidationError 4 | 5 | from .fastapi import FastAPIMiddlewareNextFunction 6 | from .user.model import User 7 | 8 | 9 | class Authenticator: 10 | """ 11 | Authenticator with cookie-based login. 12 | 13 | It requires its middleware to be added to the FastAPI app. 14 | 15 | The authenticator also provides an `htmy` request processor that (if used) 16 | automatically adds the current user (may be `None`) to `htmy` rendering 17 | contexts under the `user` key. 18 | """ 19 | 20 | __slots__ = () 21 | 22 | def login(self, user: User, response: Response) -> Response: 23 | """Logs in the given user.""" 24 | response.set_cookie("user", user.model_dump_json()) 25 | return response 26 | 27 | def logout(self, response: Response) -> Response: 28 | """Logs out the current user.""" 29 | response.delete_cookie("user") 30 | return response 31 | 32 | def current_user(self, request: Request) -> User | None: 33 | """FastAPI dependency that returns the current user.""" 34 | return getattr(request.state, "user", None) 35 | 36 | async def middleware(self, request: Request, call_next: FastAPIMiddlewareNextFunction) -> Response: 37 | """ 38 | FastAPI middleware that sets the `request.state.user` attribute to the current user. 39 | """ 40 | cookie = request.cookies.get("user") 41 | if cookie: 42 | try: 43 | user: User | None = User.model_validate_json(cookie) 44 | request.state.user = user 45 | except ValidationError as e: 46 | raise HTTPException(status_code=401, detail="Invalid authentication token.") from e 47 | 48 | return await call_next(request) 49 | 50 | def htmy_context_processor(self, request: Request) -> Context: 51 | """ 52 | `htmy` context processor that adds the current user to the rendering context. 53 | """ 54 | return {"user": self.current_user(request)} 55 | 56 | def requires_anonymus(self, request: Request) -> None: 57 | """ 58 | FastAPI dependency that raises an `HTTPException` if there is a logged in user. 59 | """ 60 | user = self.current_user(request) 61 | if user is not None: 62 | HTTPException(status_code=403, detail="Forbidden") 63 | 64 | def requires_user(self, request: Request) -> User: 65 | """ 66 | FastAPI dependency that returns the current user or raises an 67 | `HTTPException` if there is no logged in user. 68 | """ 69 | user = self.current_user(request) 70 | if user is None: 71 | HTTPException(status_code=401, detail="Unauthorized") 72 | 73 | return user # type: ignore[return-value] 74 | -------------------------------------------------------------------------------- /lc/chat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/volfpeter/lipsum-chat/6b228e1d18087f25cb52ff046553e41a573c7f7c/lc/chat/__init__.py -------------------------------------------------------------------------------- /lc/chat/api.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from fastapi import APIRouter, Form 4 | from fasthx.htmy import HTMY 5 | from htmy import Component, Text 6 | 7 | from lc.ui.chat_bubble import chat_bubble, chat_bubble_with_clipboard 8 | from lc.ui.chat_container import chat_container_ref 9 | from lc.ui.chat_input import chat_input 10 | from lc.ui.md import markdown 11 | from lc.ui.navbar import ChatPageKey 12 | 13 | from .bot import get_response 14 | from .model import Message 15 | 16 | 17 | def make_api(htmy: HTMY) -> APIRouter: 18 | api = APIRouter(prefix="/chat") 19 | 20 | @api.post("/{key}", response_model=None) 21 | @htmy.page(lambda result: result) 22 | async def chat(message: Annotated[Message, Form()], key: ChatPageKey) -> Component: 23 | return ( 24 | chat_bubble(markdown(Text(message.message)), speaker="user"), 25 | chat_bubble_with_clipboard(*get_response(message.message, corpus=key)), 26 | chat_input( 27 | { 28 | "hx-swap-oob": "true", 29 | "hx-post": f"/chat/{key}", 30 | "hx-target": chat_container_ref, 31 | "hx-swap": "beforeend", 32 | } 33 | ), 34 | ) 35 | 36 | return api 37 | -------------------------------------------------------------------------------- /lc/chat/bot/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import get_response as get_response 2 | -------------------------------------------------------------------------------- /lc/chat/bot/beer_corpus.py: -------------------------------------------------------------------------------- 1 | from .utils import make_word_sets 2 | 3 | sentences: tuple[str, ...] = ( 4 | "Beer is one of the oldest and most widely consumed alcoholic drinks in the world.", 5 | "The brewing process involves fermentation of starches, typically from cereal grains.", 6 | "Beer can be made from barley, wheat, corn, or rice.", 7 | "The alcohol content of beer typically ranges from 4% to 6% by volume.", 8 | "Craft beer is a popular trend in the beer industry, emphasizing unique flavors and ingredients.", 9 | "Lager and ale are the two primary categories of beer.", 10 | "The hops used in beer provide bitterness, flavor, and aroma.", 11 | "Ales tend to be brewed at warmer temperatures than lagers.", 12 | "The word 'beer' comes from the Latin word 'bibere', meaning 'to drink'.", 13 | "Beer was a staple in ancient civilizations, including Egypt and Mesopotamia.", 14 | "The oldest known recipe for beer was discovered in ancient Sumerian tablets.", 15 | "The process of brewing beer involves mashing, boiling, fermenting, conditioning, and packaging.", 16 | "Beer is often served in a variety of glasses, including pint glasses, steins, and tulip glasses.", 17 | "The carbonation in beer is typically created by natural fermentation or by adding CO2 during packaging.", 18 | "IPAs (India Pale Ales) are known for their strong hop flavor and higher alcohol content.", 19 | "Pilsner is a type of pale lager that originated in the Czech Republic.", 20 | "Stouts are dark, rich beers with roasted malt flavors and a smooth finish.", 21 | "Porters are similar to stouts but typically have a slightly lighter body and flavor.", 22 | "Wheat beers are often cloudy and have a refreshing, light taste.", 23 | "Sours are beers intentionally brewed to have a tart or acidic flavor.", 24 | "Beer can be paired with food to enhance the flavors of both.", 25 | "Many people enjoy drinking beer while watching sports, especially during tailgates.", 26 | "Some breweries experiment with fruit and spices to create seasonal or unique flavors.", 27 | "Beers with higher alcohol content are often labeled as 'strong ales' or 'imperial' versions.", 28 | "The color of beer can range from pale yellow to deep brown, depending on the ingredients used.", 29 | "Pale ales are generally amber to copper in color, with a balanced hop flavor.", 30 | "A beer's head refers to the foam that forms on top when it is poured.", 31 | "A good beer should have a thick, lasting head that enhances the drinking experience.", 32 | "Beer is often served at different temperatures depending on the style, with lagers served colder than ales.", 33 | "The 'best before' date on a beer label refers to the beer's peak freshness, not an expiration date.", 34 | "Some beers are brewed with added spices, such as coriander or orange peel.", 35 | "Beer enthusiasts often participate in beer tastings to explore different styles and flavors.", 36 | "The alcohol by volume (ABV) of a beer indicates its strength, with higher ABV beers being stronger.", 37 | "Many cultures have traditional beer styles that reflect their unique brewing history.", 38 | "Beer is often served in bars, pubs, and restaurants, making it a popular social drink.", 39 | "Beer can be enjoyed straight from the bottle, in a glass, or in a can.", 40 | "Lagers are generally lighter in flavor and less hoppy than ales.", 41 | "Some beers, such as Belgian Tripels, are known for their fruity and spicy aromas.", 42 | "The perfect pour involves tilting the glass at a 45-degree angle to minimize excess foam.", 43 | "Beer can be made with a wide variety of additional ingredients, including fruit, spices, and herbs.", 44 | "Some breweries produce limited-edition beers that are available for a short time.", 45 | "The term 'session beer' refers to beers with a lower ABV, making them easy to drink over extended periods.", 46 | "Beer culture varies from country to country, with different traditions, beer styles, and drinking habits.", 47 | "The concept of a 'beer garden' originated in Germany and is a popular social gathering spot.", 48 | "Beer is often served at festivals, including Oktoberfest in Germany and the Great American Beer Festival.", 49 | "The brewing industry has seen a rise in microbreweries and homebrewers experimenting with different styles.", 50 | "Some beers undergo barrel aging, which imparts unique flavors from the wood.", 51 | "Beer can be made with a variety of yeasts, which contribute to the flavor and aroma profile.", 52 | "The first beer cans were introduced in the 1930s, changing the way beer was distributed and consumed.", 53 | "The process of aging beer can enhance its flavors, especially in styles like stouts and barleywines.", 54 | "Beer is often enjoyed as a refreshing beverage on hot summer days.", 55 | "Lager yeast ferments at cooler temperatures than ale yeast, leading to a cleaner flavor profile.", 56 | "Beer pairs well with a variety of foods, including pizza, burgers, and cheese.", 57 | "Some beers are brewed with exotic ingredients, such as coffee, chocolate, and vanilla.", 58 | "Belgian beers are known for their complex flavors, often with hints of fruit, spice, and earthiness.", 59 | "A brewery’s taproom is where customers can sample and purchase fresh, often exclusive beers.", 60 | "Beer is an essential part of many cultural and religious traditions worldwide.", 61 | "Some beers are fermented with wild yeast strains, producing funky or sour flavors.", 62 | "The concept of 'beer flight' refers to sampling small portions of several beers to compare flavors.", 63 | "Brewpubs combine breweries and restaurants, offering fresh beer and food in one location.", 64 | "Beer can be made with various malts, which contribute sweetness, color, and flavor.", 65 | "Beers can be categorized by their flavor profile, such as hoppy, malty, or fruity.", 66 | "Some people enjoy creating custom beer blends by mixing different beers together.", 67 | "Beer can be brewed to be low-carb, gluten-free, or alcohol-free for those with dietary preferences.", 68 | "Some beer styles, like barleywine, can be aged for several years to improve their flavors.", 69 | "Beer is commonly served in pints, which hold 16 fluid ounces in the U.S. and 20 fluid ounces in the UK.", 70 | "The term 'draft beer' refers to beer served directly from a keg rather than from a bottle or can.", 71 | "Beer drinkers often discuss the 'mouthfeel' of a beer, referring to its texture and body.", 72 | "A beer’s 'finish' refers to the lingering taste after swallowing.", 73 | "Beer is made with four main ingredients: water, malt, hops, and yeast.", 74 | "Some beers feature high hop content, which can impart a piney, floral, or citrusy taste.", 75 | "The world’s largest beer company is Anheuser-Busch InBev, known for brands like Budweiser and Corona.", 76 | "Many countries have their own beer laws, which regulate the strength, sale, and distribution of beer.", 77 | "Beer can be carbonated naturally during fermentation or artificially injected with CO2.", 78 | "A beer’s bitterness is measured in International Bitterness Units (IBUs), with higher numbers indicating a more bitter taste.", 79 | "Some beers are brewed with a mix of different malts, resulting in complex flavor profiles.", 80 | "Beer festivals celebrate the diverse range of beer styles and the craftsmanship of brewers.", 81 | "The IPA style of beer originated in England and was originally brewed to withstand long voyages to India.", 82 | "Beer can be paired with dessert, such as chocolate stouts with cake or fruit beers with sorbet.", 83 | "Many modern breweries use traditional brewing methods combined with innovative techniques.", 84 | "Beer is known to be a natural source of certain B vitamins, including folic acid.", 85 | "The most common beer glass shapes are pint glasses, tulip glasses, and snifters.", 86 | "The term 'lager' comes from the German word 'lagern', meaning 'to store', as lagers are aged at cool temperatures.", 87 | "Beer is one of the most versatile beverages, available in an almost endless variety of styles.", 88 | "Some beer aficionados take part in beer rating apps to document and compare their beer experiences.", 89 | "Many local craft breweries are focused on sustainability and eco-friendly brewing practices.", 90 | "Some people enjoy brewing their own beer at home, a hobby known as homebrewing.", 91 | "The craft beer movement in the U.S. has exploded in recent years, with thousands of breweries now in operation.", 92 | "Some breweries use special filtration techniques to remove yeast and other particles, resulting in clearer beer.", 93 | "Beer bottles come in different sizes, with the most common being 12 oz, 16 oz, and 22 oz.", 94 | "In some countries, beer is served alongside traditional pub foods, such as fish and chips or pretzels.", 95 | "The 'bitter' in bitter beers comes from the hops, which provide balance to the malt sweetness.", 96 | "Beers that are brewed with fruit are often referred to as fruit beers, which can be sweet or sour.", 97 | "Seasonal beers are often brewed for specific holidays or times of the year, such as pumpkin ales for fall.", 98 | "Some beers are brewed with a higher carbonation level, creating a fizzy, effervescent mouthfeel.", 99 | "Beers with a high malt content tend to have a sweet, bready flavor, while those with higher hop content are more bitter.", 100 | "The tradition of toasting with beer is thought to come from ancient customs of offering drinks to gods.", 101 | "The famous 'beer belly' is a result of consuming excess calories from alcohol and carbohydrates.", 102 | "Beer is sometimes used as an ingredient in cooking, with some dishes like beer-battered fish relying on beer for flavor and texture.", 103 | "Many beer enthusiasts collect beer cans or bottles as a hobby, often focusing on rare or vintage items.", 104 | "The popularity of beer continues to rise worldwide, with increasing numbers of people discovering new beer styles and flavors.", 105 | ) 106 | 107 | 108 | word_sets = make_word_sets(sentences) 109 | -------------------------------------------------------------------------------- /lc/chat/bot/bot.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from htmy import ComponentSequence 4 | 5 | from lc.ui.navbar import ChatPageKey 6 | 7 | from .beer_corpus import sentences as beer 8 | from .beer_corpus import word_sets as beer_word_sets 9 | from .coffee_corpus import sentences as coffee 10 | from .coffee_corpus import word_sets as coffee_word_sets 11 | from .utils import weights_for_message 12 | 13 | 14 | def get_response(message: str, *, corpus: ChatPageKey) -> ComponentSequence: 15 | if corpus == "beer": 16 | sentences, word_sets = beer, beer_word_sets 17 | elif corpus == "coffee": 18 | sentences, word_sets = coffee, coffee_word_sets 19 | else: 20 | raise ValueError(f"Unknown corpus: {corpus}") 21 | 22 | return random.choices( # noqa: S311 23 | sentences, 24 | weights_for_message(message, word_sets=word_sets), 25 | k=random.randint(2, 7), # noqa: S311 26 | ) 27 | -------------------------------------------------------------------------------- /lc/chat/bot/coffee_corpus.py: -------------------------------------------------------------------------------- 1 | from .utils import make_word_sets 2 | 3 | sentences: tuple[str, ...] = ( 4 | "Coffee is the perfect way to start your day.", 5 | "A cup of coffee can instantly boost your mood.", 6 | "Espresso is the base for many popular coffee drinks.", 7 | "There are countless ways to brew coffee, from French press to pour-over.", 8 | "Coffee beans are grown in tropical regions around the world.", 9 | "Dark roast coffee has a stronger, bolder flavor than light roast.", 10 | "Caffeine is the primary active ingredient in coffee.", 11 | "Some people prefer their coffee black, while others add milk or sugar.", 12 | "Drinking coffee can help improve concentration and alertness.", 13 | "Coffee is a popular beverage for social gatherings.", 14 | "A latte is a coffee drink made with espresso and steamed milk.", 15 | "Cappuccinos are topped with a layer of frothed milk.", 16 | "Coffee is a great way to fuel your productivity during work.", 17 | "The aroma of freshly brewed coffee can be very comforting.", 18 | "Cold brew coffee is a smooth and less acidic alternative to hot coffee.", 19 | "Many people enjoy a cup of coffee after a meal to aid digestion.", 20 | "Coffee has been enjoyed for centuries, dating back to the 15th century.", 21 | "The most popular coffee drink in the U.S. is drip coffee.", 22 | "A macchiato is an espresso drink with a small amount of milk.", 23 | "Coffee beans are actually the seeds of the coffee fruit.", 24 | "The word 'coffee' comes from the Arabic word 'qahwa'.", 25 | "Some people add flavored syrups to their coffee for extra sweetness.", 26 | "Coffee shops are a popular place to hang out, study, or work.", 27 | "Coffee has been linked to a lower risk of certain diseases, including Parkinson's.", 28 | "A good cup of coffee can be a real treat.", 29 | "There are many different types of coffee beans, including Arabica and Robusta.", 30 | "In Italy, espresso is an important part of daily life.", 31 | "Many people drink coffee to stay awake during long drives or study sessions.", 32 | "Decaffeinated coffee still has a small amount of caffeine.", 33 | "Coffee is best enjoyed fresh, right after it's brewed.", 34 | "A perfect espresso shot is often described as having a layer of crema on top.", 35 | "Some people drink coffee with a sprinkle of cinnamon or cocoa powder for extra flavor.", 36 | "Coffee has a rich, complex flavor profile that can vary based on its origin.", 37 | "The caffeine in coffee can help improve physical performance during exercise.", 38 | "In the morning, coffee often acts as a wake-up call.", 39 | "Coffee beans are roasted to develop their unique flavor characteristics.", 40 | "A flat white is a coffee drink similar to a latte, but with less foam.", 41 | "Coffee is often enjoyed with a sweet pastry or dessert.", 42 | "Coffee has become a global cultural phenomenon.", 43 | "The coffee industry is worth billions of dollars worldwide.", 44 | "Coffee can help you stay alert during a long workday.", 45 | "Some people love the ritual of brewing their own coffee every morning.", 46 | "A French press allows for a full-bodied and rich cup of coffee.", 47 | "Coffee is often a key ingredient in desserts like tiramisu and coffee cake.", 48 | "Some coffee drinkers swear by the health benefits of black coffee.", 49 | "Iced coffee is a refreshing way to enjoy coffee during the summer months.", 50 | "Caffeine is known to help with focus and mental clarity.", 51 | "Coffee can be a great conversation starter when meeting new people.", 52 | "A good cup of coffee can turn an ordinary moment into something special.", 53 | "Coffee is enjoyed by people of all ages around the world.", 54 | "Some coffee shops offer unique and creative coffee drinks.", 55 | "Drinking coffee in moderation can be part of a healthy lifestyle.", 56 | "The best coffee beans are often grown at high altitudes.", 57 | "Coffee is a key component of many traditional breakfast routines.", 58 | "Coffee culture has evolved over the years, with many new trends emerging.", 59 | "Some coffee drinkers prefer their coffee strong and bold, while others prefer it mild.", 60 | "Coffee is often used to make various coffee-based cocktails.", 61 | "The caffeine in coffee can help improve reaction time and coordination.", 62 | "Many people enjoy a cup of coffee to relax and unwind after a long day.", 63 | "Coffee can be a great companion while reading a book or working on a project.", 64 | "Many people find that coffee helps them feel more awake and alert in the morning.", 65 | "Coffee is often seen as a symbol of energy and productivity.", 66 | "Coffee beans undergo a complex process of roasting to bring out their flavors.", 67 | "A coffee grinder is an essential tool for fresh coffee enthusiasts.", 68 | "Coffee is sometimes paired with chocolate for a delicious combination.", 69 | "Espresso has a rich, concentrated flavor that many coffee lovers enjoy.", 70 | "The coffee bean's journey from farm to cup involves many steps.", 71 | "Iced coffee can be made at home by brewing coffee and chilling it.", 72 | "Many people enjoy drinking coffee while people-watching at cafés.", 73 | "Coffee is a source of antioxidants, which can be beneficial to your health.", 74 | "Drinking coffee can improve mental performance, especially in the morning.", 75 | "Coffee can be customized in many ways with various types of milk and sweeteners.", 76 | "A pour-over coffee maker provides control over the brewing process.", 77 | "Coffee consumption is a daily ritual for millions of people worldwide.", 78 | "Coffee's popularity has inspired countless cafés and specialty coffee shops.", 79 | "A coffee mug can be a comforting object that brings warmth and happiness.", 80 | "Coffee is often enjoyed as a midday pick-me-up.", 81 | "The coffee industry continues to innovate with new brewing methods and technologies.", 82 | "Coffee can be enjoyed in both hot and cold forms, depending on your preference.", 83 | "Many people love the ritual of sipping coffee while starting their day.", 84 | "Coffee has a complex flavor that varies depending on its roast and origin.", 85 | "Some coffee aficionados are always searching for the perfect cup of coffee.", 86 | "A well-made cup of coffee can be a work of art in itself.", 87 | "Drinking coffee can help you power through tough tasks or long hours of work.", 88 | "Coffee is a social drink, often shared with friends or colleagues.", 89 | "Coffee beans are sometimes blended to create unique flavor profiles.", 90 | "Some people prefer to drink their coffee with a splash of cream or milk.", 91 | "Coffee lovers enjoy experimenting with different brewing techniques and methods.", 92 | "The caffeine in coffee can provide a temporary energy boost.", 93 | "In many cultures, coffee is enjoyed with a sense of ceremony and tradition.", 94 | "Some people drink coffee to help with focus during study sessions or work meetings.", 95 | "The rich, bold flavor of coffee can provide a comforting sense of warmth.", 96 | "Many people find that drinking coffee helps them feel more productive and energetic.", 97 | "Coffee is often served with a variety of pastries or snacks at cafés.", 98 | "Some people prefer to drink their coffee black, while others enjoy adding flavors like vanilla or caramel.", 99 | "Coffee has a way of bringing people together for conversation and bonding.", 100 | "Caffeine sensitivity varies from person to person, with some people able to handle more than others.", 101 | "Coffee is sometimes used in desserts like ice cream or cakes for its rich flavor.", 102 | "A good coffee grinder is essential for ensuring that your coffee is ground to the right consistency.", 103 | "The coffee plant requires a specific climate and altitude to grow well.", 104 | "Coffee beans can have a range of flavors, from fruity to nutty to chocolaty.", 105 | "In many parts of the world, coffee is an integral part of the daily routine.", 106 | "Coffee is a drink that people often enjoy while relaxing or working on projects.", 107 | "Many coffee lovers appreciate the ritual of brewing their coffee just the way they like it.", 108 | "Coffee is known for its ability to provide a boost of energy and alertness.", 109 | "Espresso is often used as the base for other popular coffee drinks like lattes and cappuccinos.", 110 | "A well-made cup of coffee can be a satisfying and enjoyable experience.", 111 | ) 112 | 113 | 114 | word_sets = make_word_sets(sentences) 115 | -------------------------------------------------------------------------------- /lc/chat/bot/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | _splitter = re.compile(r"[^a-z0-9]+") 4 | 5 | 6 | def make_word_sets(sentences: tuple[str, ...]) -> tuple[set[str], ...]: 7 | """ 8 | Returns a tuple of sets of words from the given sentences. 9 | 10 | The order of the sets matches the order of the sentences. 11 | """ 12 | return tuple(set(re.split(_splitter, sentence.lower())) for sentence in sentences) 13 | 14 | 15 | def weights_for_message(sentence: str, *, word_sets: tuple[set[str], ...]) -> tuple[float, ...]: 16 | """ 17 | Returns a tuple of weights for the given sentence. 18 | """ 19 | words = set(re.split(_splitter, sentence.lower())) 20 | return tuple(1 + len(words & word_set) * 29 for word_set in word_sets) 21 | -------------------------------------------------------------------------------- /lc/chat/model.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class Message(BaseModel): 5 | message: str 6 | -------------------------------------------------------------------------------- /lc/fastapi.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Awaitable, TypeAlias 3 | 4 | from fastapi import Request, Response 5 | 6 | FastAPIMiddlewareNextFunction: TypeAlias = Callable[[Request], Awaitable[Response]] 7 | FastAPIMiddlewareFunction: TypeAlias = Callable[ 8 | [Request, FastAPIMiddlewareNextFunction], Awaitable[Response] 9 | ] 10 | -------------------------------------------------------------------------------- /lc/pages/__init__.py: -------------------------------------------------------------------------------- 1 | """Application and model-specific UI components.""" 2 | -------------------------------------------------------------------------------- /lc/pages/chat_page.py: -------------------------------------------------------------------------------- 1 | from htmy import Component, Context, component, html 2 | 3 | from lc.ui.chat_bubble import chat_bubble 4 | from lc.ui.chat_container import chat_container, chat_container_ref 5 | from lc.ui.chat_input import chat_input 6 | from lc.ui.navbar import ChatPageKey 7 | from lc.ui.t_function import TFunction 8 | 9 | from .page import page 10 | 11 | 12 | @component 13 | async def chat_page(key: ChatPageKey, ctx: Context) -> Component: 14 | t = TFunction.from_context(ctx) 15 | return page( 16 | html.div( 17 | chat_container( 18 | chat_bubble(await t(f"chat_page.{key}.hi")), 19 | chat_bubble(await t(f"chat_page.{key}.message")), 20 | chat_bubble(await t(f"chat_page.{key}.question")), 21 | # hx-swap=beforeend show:bottom should work on the chat input, but 22 | # it doesn't, so we trigger scroll to bottom manually. 23 | **{"hx-on:htmx:after-settle": "this.scroll(undefined, this.scrollHeight)"}, 24 | ), 25 | chat_input( 26 | { 27 | "hx-post": f"/chat/{key}", 28 | "hx-target": chat_container_ref, 29 | "hx-swap": "beforeend", 30 | } 31 | ), 32 | class_="flex flex-col px-6 gap-2 h-full w-full overflow-hidden", 33 | ), 34 | ) 35 | -------------------------------------------------------------------------------- /lc/pages/index_page.md: -------------------------------------------------------------------------------- 1 | # What is this? 2 | 3 | This project is a technology demonstration for server-side rendering with Python, FastAPI, and htmx; in the disguise of an AI chatbot that responds to questions about beer and coffee. More specifically the project showcases the following libraries: 4 | 5 | - [FastAPI](https://fastapi.tiangolo.com/): A modern, async, Python web framework. 6 | - [FastHX](https://volfpeter.github.io/fasthx/): Server-side rendering utility with HTMX support for FastAPI. 7 | - [htmy](https://volfpeter.github.io/htmy/): A powerful, async, pure-Python server-side rendering engine. 8 | - [htmx](https://htmx.org/): A JavaScript library for making AJAX requests and DOM updates using HTML attributes. 9 | 10 | Importantly, the project **does not** use Jinja or any other traditional templating engine. Instead, it uses `htmy` – in some cases with plain `html` and `markdown` snippets with no custom templating syntax –, so you can enjoy all the benefits of modern IDEs, linters, static code analysis tools, and coding assistants. 11 | 12 | Styling is done with [TailwindCSS v4](https://tailwindcss.com/) and [DaisyUI v5](https://daisyui.com/), but the project is not a TailwindCSS or DaisyUI demo, the focus is entirely on server-side rendering. 13 | 14 | # Is it open source? 15 | 16 | This project is fully open source, you can find the code on [GitHub](https://github.com/volfpeter/lipsum-chat). 17 | 18 | # Is my data secure? 19 | 20 | The backend **does not** store or process any data, so yes, your data is fully secure. 😌 21 | -------------------------------------------------------------------------------- /lc/pages/index_page.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from htmy import Component 4 | 5 | from lc.ui.md import markdown 6 | from lc.ui.utils import replace_py_extension 7 | 8 | from .page import page 9 | 10 | 11 | def index_page(_: Any) -> Component: 12 | """ 13 | Component factory for the index page. 14 | """ 15 | return page(markdown(replace_py_extension(__file__, "md"))) 16 | -------------------------------------------------------------------------------- /lc/pages/page.py: -------------------------------------------------------------------------------- 1 | from htmy import ComponentType, Context, component, html 2 | 3 | from lc.ui.base_page import base_page 4 | from lc.ui.navbar import navbar 5 | from lc.ui.t_function import TFunction 6 | from lc.user.model import User 7 | 8 | 9 | def login_button(title: str) -> html.button: 10 | return html.button( 11 | title, 12 | hx_get="/auth/login-dialog", 13 | hx_target="#dialogs", 14 | class_="btn btn-primary", 15 | ) 16 | 17 | 18 | @component 19 | async def page(content: ComponentType, ctx: Context) -> ComponentType: 20 | """ 21 | Page with navbar. 22 | """ 23 | user: User | None = ctx.get("user") 24 | t = TFunction.from_context(ctx) 25 | 26 | user_menu: ComponentType 27 | if user is None: 28 | user_menu = login_button(await t("page.button.login")) 29 | else: 30 | user_menu = user.user_menu() 31 | 32 | return base_page( 33 | html.div( 34 | navbar(user_menu), 35 | html.div(content, class_="flex flex-col h-full overflow-auto"), 36 | class_="flex flex-col h-full w-full p-4 gap-8 lg:w-5xl", # 5xl is the lg breakpoint width 37 | ) 38 | ) 39 | -------------------------------------------------------------------------------- /lc/pages/profile_page.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from htmy import Component, Context, component, html 4 | 5 | from lc.ui.centered import centered 6 | from lc.ui.t_function import TFunction 7 | from lc.user.model import User 8 | 9 | from .page import page 10 | 11 | 12 | @component 13 | async def profile_page(_: Any, ctx: Context) -> Component: 14 | user: User | None = ctx.get("user") 15 | t = TFunction.from_context(ctx) 16 | 17 | return page( 18 | centered( 19 | html.div( 20 | html.label(await t("profile_page.username"), class_="font-semibold"), 21 | html.label(user.username if user else await t("profile_page.anonymus")), 22 | html.label(await t("profile_page.logged_in_at"), class_="font-semibold"), 23 | html.label( 24 | user.logged_in_at if user else await t("profile_page.not_logged_in"), 25 | class_="text-warning" if user is None else None, 26 | ), 27 | class_="grid grid-cols-[max-content_1fr] gap-4", 28 | ) 29 | ) 30 | ) 31 | -------------------------------------------------------------------------------- /lc/ui/__init__.py: -------------------------------------------------------------------------------- 1 | """Generic UI components that don't rely on the model or the application.""" 2 | -------------------------------------------------------------------------------- /lc/ui/base_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lipsum Chat 5 | 6 | 7 | 8 | 9 | 14 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 48 | 49 | 50 | 54 | 55 | 56 | 57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /lc/ui/base_page.py: -------------------------------------------------------------------------------- 1 | from htmy import ComponentType, Slots, Snippet 2 | 3 | from .utils import replace_py_extension 4 | 5 | 6 | def base_page(*content: ComponentType) -> Snippet: 7 | """Page component factory.""" 8 | return Snippet(replace_py_extension(__file__), Slots({"content": content})) 9 | -------------------------------------------------------------------------------- /lc/ui/centered.py: -------------------------------------------------------------------------------- 1 | from htmy import ComponentType, PropertyValue, html 2 | 3 | 4 | def centered(*children: ComponentType, class_: str = "", **props: PropertyValue) -> html.div: 5 | """ 6 | Component factory that centers its children both horizontally and vertically. 7 | """ 8 | return html.div( 9 | *children, 10 | class_=f"flex flex-col items-center justify-center h-full w-full gap-4 {class_}", 11 | **props, 12 | ) 13 | -------------------------------------------------------------------------------- /lc/ui/chat_bubble.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from htmy import ComponentType, html 4 | 5 | from .copy_button import copy_button 6 | 7 | Speaker = Literal["user", "assistant"] 8 | 9 | _bubble_classes: dict[Speaker, str] = { 10 | "user": "chat-bubble chat-bubble-info", 11 | "assistant": "chat-bubble chat-bubble-success", 12 | } 13 | _chat_classes: dict[Speaker, str] = { 14 | "assistant": "chat chat-start", 15 | "user": "chat chat-end", 16 | } 17 | 18 | 19 | def chat_bubble(*parts: ComponentType, speaker: Speaker = "assistant") -> html.div: 20 | """Multi-line chat bubble component factory.""" 21 | return html.div( 22 | html.div(*parts, class_=_bubble_classes[speaker]), 23 | class_=f"flex flex-col chat {_chat_classes[speaker]}", 24 | ) 25 | 26 | 27 | def chat_bubble_with_clipboard(*parts: ComponentType, speaker: Speaker = "assistant") -> html.div: 28 | """Multi-line chat bubble component factory with clipboard support.""" 29 | return html.div( 30 | html.div( 31 | html.div(*parts, x_ref="clipboardContent"), 32 | copy_button(), 33 | class_=f"{_bubble_classes[speaker]} grid grid-cols-[1fr_max-content] gap-2", 34 | ), 35 | x_data="clipboard", 36 | class_=f"flex flex-col chat {_chat_classes[speaker]}", 37 | ) 38 | -------------------------------------------------------------------------------- /lc/ui/chat_container.py: -------------------------------------------------------------------------------- 1 | from htmy import ComponentType, PropertyValue, html 2 | 3 | _chat_container_id = "chat-container" 4 | chat_container_ref = f"#{_chat_container_id}" 5 | 6 | 7 | def chat_container(*children: ComponentType, **props: PropertyValue) -> html.div: 8 | return html.div( 9 | *children, 10 | id=_chat_container_id, 11 | class_="flex flex-col grow gap-1 px-6 overflow-y-auto", 12 | **props, 13 | ) 14 | -------------------------------------------------------------------------------- /lc/ui/chat_input.py: -------------------------------------------------------------------------------- 1 | from htmy import Component, Context, Properties, component, html 2 | 3 | from lc.ui.t_function import TFunction 4 | 5 | _chat_input_id = "chat-input" 6 | _hx_trigger = f"keyup[keyCode==13&&!shiftKey] consume from:#{_chat_input_id}" 7 | 8 | 9 | @component 10 | async def chat_input(props: Properties, ctx: Context) -> Component: 11 | """ 12 | Chat input component. 13 | """ 14 | t = TFunction.from_context(ctx) 15 | return html.textarea( 16 | id=_chat_input_id, 17 | name="message", 18 | placeholder=await t("chat_input.prompt"), 19 | class_="textarea w-full m-2", 20 | autofocus="", 21 | rows=3, 22 | **props, 23 | hx_trigger=_hx_trigger, 24 | ) 25 | -------------------------------------------------------------------------------- /lc/ui/copy_button.html: -------------------------------------------------------------------------------- 1 | 6 | 14 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /lc/ui/copy_button.py: -------------------------------------------------------------------------------- 1 | from htmy import Snippet 2 | 3 | from .utils import replace_py_extension 4 | 5 | 6 | def copy_button() -> Snippet: 7 | """ 8 | Copy button. 9 | 10 | It must be within an AlpineJS component that has a `copy()` action and a `copied` state. 11 | """ 12 | return Snippet(replace_py_extension(__file__)) 13 | -------------------------------------------------------------------------------- /lc/ui/dialog.html: -------------------------------------------------------------------------------- 1 | 2 | 20 | 21 | -------------------------------------------------------------------------------- /lc/ui/dialog.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from htmy import Component, Slots, Snippet 4 | 5 | from .utils import replace_py_extension 6 | 7 | 8 | class DialogProps(TypedDict): 9 | id: str 10 | title: str 11 | close: str 12 | content: Component 13 | submit: Component 14 | 15 | 16 | def dialog(props: DialogProps) -> Snippet: 17 | """Dialog component factory.""" 18 | return Snippet( 19 | replace_py_extension(__file__), 20 | Slots({"content": props["content"], "submit": props["submit"]}), 21 | text_processor=lambda text, _: text.format( 22 | id=props["id"], 23 | title=props["title"], 24 | close=props["close"], 25 | ), 26 | ) 27 | -------------------------------------------------------------------------------- /lc/ui/login_dialog.py: -------------------------------------------------------------------------------- 1 | from htmy import Component, Context, component, html 2 | 3 | from lc.ui.dialog import dialog 4 | from lc.ui.t_function import TFunction 5 | 6 | _dialog_id = "login-dialog" 7 | 8 | 9 | def _login_form(label: str) -> Component: 10 | """Login form component factory.""" 11 | return html.div( 12 | html.label(label), 13 | html.input_( 14 | type="text", 15 | name="username", 16 | autofocus="", 17 | required="", 18 | minlength="3", 19 | pattern="[a-zA-Z0-9]{3,42}", 20 | class_="grow input validator", 21 | ), 22 | class_="flex items-center gap-2", 23 | ) 24 | 25 | 26 | @component 27 | async def login_dialog(login_url: str, ctx: Context) -> Component: 28 | """Login dialog component.""" 29 | t = TFunction.from_context(ctx) 30 | return dialog( 31 | { 32 | "id": _dialog_id, 33 | "title": await t("login_dialog.title"), 34 | "close": await t("login_dialog.close"), 35 | "content": _login_form(await t("login_dialog.username")), 36 | "submit": html.button( 37 | await t("login_dialog.submit"), 38 | class_="btn btn-primary", 39 | hx_post=login_url, 40 | hx_target="closest dialog", 41 | hx_swap="delete", 42 | hx_include=f"#{_dialog_id} input", 43 | hx_trigger="click, keyup[keyCode==13] from:input", 44 | ), 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /lc/ui/md.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | 4 | from htmy import ComponentType, Properties, PropertyValue, Text, etree, html, md 5 | 6 | 7 | def _add_css_class(class_: str, properties: dict[str, Any]) -> Properties: 8 | """Adds a CSS class to the given `Properties` object.""" 9 | properties["class"] = f"{properties.get('class', '')} {class_}" 10 | return properties 11 | 12 | 13 | class _styled_components: 14 | """Conversion rules for some of the HTML tags.""" 15 | 16 | @staticmethod 17 | def h1(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 18 | """Rule for converting `h1` tags that adds some extra CSS classes to the tag.""" 19 | return html.h1(*children, **_add_css_class("text-xl font-bold pt-2 pb-4", properties)) 20 | 21 | @staticmethod 22 | def h2(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 23 | """Rule for converting `h2` tags that adds some extra CSS classes to the tag.""" 24 | return html.h2(*children, **_add_css_class("text-lg font-bold py-2", properties)) 25 | 26 | @staticmethod 27 | def h3(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 28 | """Rule for converting `h3` tags that adds some extra CSS classes to the tag.""" 29 | return html.h3(*children, **_add_css_class("text-lg font-semibold py-1", properties)) 30 | 31 | @staticmethod 32 | def p(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 33 | """Rule for converting `p` tags that adds some extra CSS classes to the tag.""" 34 | return html.p(*children, **_add_css_class("py-1.5", properties)) 35 | 36 | @staticmethod 37 | def ol(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 38 | """Rule for converting `ol` tags that adds some extra CSS classes to the tag.""" 39 | return html.ol(*children, **_add_css_class("list-decimal list-inside", properties)) 40 | 41 | @staticmethod 42 | def ul(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 43 | """Rule for converting `ul` tags that adds some extra CSS classes to the tag.""" 44 | return html.ul(*children, **_add_css_class("list-disc list-inside", properties)) 45 | 46 | @staticmethod 47 | def li(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 48 | """Rule for converting `li` tags that adds some extra CSS classes to the tag.""" 49 | return html.li(*children, **_add_css_class("py-0.5", properties)) 50 | 51 | @staticmethod 52 | def a(*children: ComponentType, **properties: PropertyValue) -> ComponentType: 53 | """Rule for converting `a` tags that adds some extra CSS classes to the tag.""" 54 | return html.a(*children, **_add_css_class("link", properties)) 55 | 56 | 57 | _md_converter = etree.ETreeConverter( 58 | {k: v for k, v in _styled_components.__dict__.items() if not k.startswith("_")} 59 | ).convert 60 | """The conversion function for the `MD` component.""" 61 | 62 | 63 | def markdown(path_or_text: Text | str | Path) -> md.MD: 64 | """Creates a `MD` component that uses custom styles for certain HTML tags.""" 65 | return md.MD(path_or_text, converter=_md_converter) 66 | -------------------------------------------------------------------------------- /lc/ui/navbar.html: -------------------------------------------------------------------------------- 1 | 38 | -------------------------------------------------------------------------------- /lc/ui/navbar.py: -------------------------------------------------------------------------------- 1 | from typing import Literal, get_args 2 | 3 | from fasthx.htmy import CurrentRequest 4 | from htmy import Component, Context, Slots, Snippet, component, html 5 | 6 | from .t_function import TFunction 7 | from .utils import replace_py_extension 8 | 9 | # Chat page keys in the order in which they should appear in the navbar. 10 | ChatPageKey = Literal["coffee", "beer"] 11 | chat_page_keys = get_args(ChatPageKey) 12 | 13 | # Page keys in the order in which they should appear in the navbar. 14 | PageKey = Literal[ 15 | "home", 16 | ChatPageKey, 17 | "profile", 18 | ] 19 | 20 | page_keys = get_args(PageKey) 21 | page_urls: dict[PageKey, str] = { 22 | "home": "/", 23 | "coffee": "/coffee", 24 | "beer": "/beer", 25 | "profile": "/profile", 26 | } 27 | """Page key to URL mapping.""" 28 | 29 | 30 | def _get_button_style(key: str, current_path: str) -> str: 31 | """Returns the button style for the given key and path.""" 32 | current_path = current_path.strip("/") 33 | if current_path == "": 34 | current_path = "home" 35 | 36 | base_style = "btn btn-ghost" 37 | return f"{base_style} btn-active" if current_path == key else base_style 38 | 39 | 40 | @component 41 | async def nav_item(key: PageKey, ctx: Context) -> html.li: 42 | """A single item in the navbar.""" 43 | request = CurrentRequest.from_context(ctx) 44 | t = TFunction.from_context(ctx) 45 | return html.li( 46 | html.a( 47 | await t(f"navbar.{key}"), 48 | href=page_urls[key], 49 | class_=_get_button_style(key, request.url.path), 50 | ) 51 | ) 52 | 53 | 54 | def navbar(nav_action: Component = "") -> Snippet: 55 | """Navbar component factory.""" 56 | return Snippet( 57 | replace_py_extension(__file__), 58 | Slots( 59 | { 60 | "nav-items": [nav_item(key) for key in page_keys], 61 | "nav-action": nav_action, 62 | } 63 | ), 64 | ) 65 | -------------------------------------------------------------------------------- /lc/ui/not_found.py: -------------------------------------------------------------------------------- 1 | from htmy import Component, Context, component, html 2 | 3 | from .base_page import base_page 4 | from .centered import centered 5 | from .navbar import page_urls 6 | from .t_function import TFunction 7 | 8 | 9 | @component.context_only 10 | async def not_found(ctx: Context) -> Component: 11 | t = TFunction.from_context(ctx) 12 | return base_page( 13 | centered( 14 | html.h1( 15 | await t("not_found.title"), 16 | class_="text-2xl font-semibold", 17 | ), 18 | html.p(await t("not_found.message"), class_="text-lg"), 19 | html.a( 20 | await t("not_found.navigate_home"), 21 | href=page_urls["home"], 22 | class_="btn btn-soft btn-primary", 23 | ), 24 | ) 25 | ) 26 | -------------------------------------------------------------------------------- /lc/ui/t_function.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from htmy import ContextAware 4 | from htmy.i18n import I18n 5 | 6 | 7 | class TFunction(ContextAware): 8 | """ 9 | `htmy` `ContextAware` translation function. 10 | """ 11 | 12 | __slots__ = ( 13 | "_i18n", 14 | "_lang", 15 | ) 16 | 17 | def __init__(self, i18n: I18n, lang: str | None = None): 18 | """ 19 | Initialization. 20 | 21 | Arguments: 22 | i18n: The `I18n` instance to use. 23 | lang: The language to use. If not provided, `en` will be used. 24 | """ 25 | self._i18n = i18n 26 | self._lang = lang or "en" 27 | 28 | async def __call__(self, key: str, **kwargs: Any) -> str: 29 | """ 30 | Returns the translation for the given key. 31 | 32 | Arguments: 33 | key: The translation key. 34 | **kwargs: The translation arguments for string formatting. 35 | """ 36 | result = await self._i18n.get("en", key, **kwargs) 37 | if isinstance(result, str): 38 | return result 39 | 40 | raise ValueError("Only string resources are supported here.") 41 | -------------------------------------------------------------------------------- /lc/ui/utils.py: -------------------------------------------------------------------------------- 1 | def replace_py_extension(py_file: str, ext: str = "html") -> str: 2 | """ 3 | Replaces the `py` extension in a file path with the given one, 4 | keeping the rest of the path the same. 5 | """ 6 | if not py_file.endswith(".py"): 7 | raise ValueError("Input file must have a `.py` extension.") 8 | 9 | if ext.startswith("."): 10 | ext = ext[1:] 11 | 12 | return f"{py_file[:-2]}{ext}" 13 | -------------------------------------------------------------------------------- /lc/user/auth_api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Annotated 4 | 5 | from fastapi import APIRouter, Depends, Form, Response 6 | from fasthx.htmy import HTMY 7 | 8 | from lc.ui.login_dialog import login_dialog 9 | from lc.user.model import User 10 | 11 | from .model import LoginForm 12 | 13 | if TYPE_CHECKING: 14 | from lc.authenticator import Authenticator 15 | 16 | _hx_redirect_home: dict[str, str] = {"HX-Redirect": "/", "HX-Push-URL": "true"} 17 | 18 | 19 | def make_api(auth: Authenticator, htmy: HTMY) -> APIRouter: 20 | api_prefix = "/auth" 21 | api = APIRouter(prefix=api_prefix) 22 | 23 | @api.get("/login-dialog") 24 | @htmy.hx(login_dialog) 25 | async def get_login_dialog() -> str: 26 | return f"{api_prefix}/login" 27 | 28 | @api.post("/login", dependencies=[Depends(auth.requires_anonymus)]) 29 | async def login(data: Annotated[LoginForm, Form()]) -> Response: 30 | user = User(username=data.username) 31 | return auth.login( 32 | user, 33 | Response( 34 | status_code=200, 35 | headers=_hx_redirect_home, 36 | ), 37 | ) 38 | 39 | @api.get("/logout", dependencies=[Depends(auth.requires_user)]) 40 | async def logout() -> Response: 41 | return auth.logout( 42 | Response(status_code=200, headers=_hx_redirect_home), 43 | ) 44 | 45 | return api 46 | -------------------------------------------------------------------------------- /lc/user/model.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | from htmy import Context, component, html 4 | from pydantic import BaseModel, Field 5 | 6 | from lc.ui.t_function import TFunction 7 | 8 | 9 | class LoginForm(BaseModel): 10 | username: str = Field(min_length=3, max_length=42, pattern=r"^[a-zA-Z0-9]+$") 11 | 12 | 13 | class User(BaseModel): 14 | username: str 15 | logged_in_at: str = Field(default_factory=lambda: datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S (UTC)")) 16 | 17 | @component.context_only_method 18 | async def user_menu(self, ctx: Context) -> html.details: 19 | """User menu component.""" 20 | t = TFunction.from_context(ctx) 21 | 22 | return html.details( 23 | html.summary(self.username, class_="btn btn-primary"), 24 | html.ul( 25 | html.li(html.a(await t("user.user_menu.logout"), href="/auth/logout")), 26 | class_="menu dropdown-content text-error rounded-box z-1 w-auto px-0 shadow-sm", 27 | ), 28 | class_="dropdown", 29 | hx_boost="true", 30 | ) 31 | -------------------------------------------------------------------------------- /locale/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "navbar": { 3 | "home": "About", 4 | "profile": "Profile", 5 | "coffee": "Coffee", 6 | "beer": "Beer" 7 | }, 8 | "not_found": { 9 | "title": "Page not found", 10 | "message": "Sorry, the page you are looking for does not exist.", 11 | "navigate_home": "Home" 12 | }, 13 | "user": { 14 | "user_menu": { 15 | "logout": "Logout" 16 | } 17 | }, 18 | "login_dialog": { 19 | "title": "Login", 20 | "close": "Cancel", 21 | "username": "Username:", 22 | "submit": "Login" 23 | }, 24 | "page": { 25 | "button": { 26 | "login": "Login" 27 | } 28 | }, 29 | "profile_page": { 30 | "username": "Username:", 31 | "logged_in_at": "Logged in at:", 32 | "anonymus": "Anonymus", 33 | "not_logged_in": "You are not logged in." 34 | }, 35 | "chat_page": { 36 | "beer": { 37 | "hi": "Hi, nice to see you!", 38 | "message": "I'm frontier language model, specialized in talking about beer.", 39 | "question": "How can I help you?" 40 | }, 41 | "coffee": { 42 | "hi": "Hi, nice to see you!", 43 | "message": "I'm frontier language model, specialized in talking about coffee.", 44 | "question": "How can I help you?" 45 | } 46 | }, 47 | "chat_input": { 48 | "prompt": "Type your message here, try markdown ;)\nNew line: Shift+Enter\nSubmit: Enter" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "lipsum-chat" 3 | version = "0.1.0" 4 | description = "Non-hallucinating chatbot for generating various lipsum texts." 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "fastapi>=0.115.11", 9 | "fasthx>=2.3.0", 10 | "htmy>=0.7.3", 11 | "python-multipart>=0.0.20", 12 | "uvicorn>=0.34.0", 13 | ] 14 | 15 | [dependency-groups] 16 | dev = ["mypy>=1.15.0", "ruff>=0.9.10"] 17 | 18 | [tool.mypy] 19 | strict = true 20 | show_error_codes = true 21 | 22 | [tool.ruff] 23 | line-length = 108 24 | exclude = [".git", ".mypy_cache", ".ruff_cache", ".venv"] 25 | lint.select = [ 26 | "B", # flake8-bugbear 27 | "C", # flake8-comprehensions 28 | "E", # pycodestyle errors 29 | "F", # pyflakes 30 | "I", # isort 31 | "S", # flake8-bandit - we must ignore these rules in tests 32 | "W", # pycodestyle warnings 33 | ] 34 | 35 | [tool.ruff.lint.per-file-ignores] 36 | "lc/chat/bot/*_corpus.py" = ["E501"] # line too long 37 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --no-dev 3 | annotated-types==0.7.0 \ 4 | --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ 5 | --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 6 | anyio==4.9.0 \ 7 | --hash=sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028 \ 8 | --hash=sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c 9 | async-lru==2.0.5 \ 10 | --hash=sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb \ 11 | --hash=sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943 12 | click==8.1.8 \ 13 | --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ 14 | --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a 15 | colorama==0.4.6 ; sys_platform == 'win32' \ 16 | --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ 17 | --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 18 | fastapi==0.115.12 \ 19 | --hash=sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681 \ 20 | --hash=sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d 21 | fasthx==2.3.0 \ 22 | --hash=sha256:8af01104cc8a62fc1e7a16928948050ce6b623c966735be415efa8e97a958aca \ 23 | --hash=sha256:c72b23f16690b5d40f5e49aedbe8861ca592f87494d735a2b62ef344a929dafa 24 | h11==0.14.0 \ 25 | --hash=sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d \ 26 | --hash=sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761 27 | htmy==0.7.3 \ 28 | --hash=sha256:228507a9346748c659b0d5a74cdebffe4fb882b019ab9306da4691aa9c3e8196 \ 29 | --hash=sha256:7d49badb2ae471f1abd3e3d16a0e8c78371af74ce7a3b767fe72ad40ad74625b 30 | idna==3.10 \ 31 | --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \ 32 | --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 33 | markdown==3.7 \ 34 | --hash=sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2 \ 35 | --hash=sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803 36 | pydantic==2.10.6 \ 37 | --hash=sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584 \ 38 | --hash=sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236 39 | pydantic-core==2.27.2 \ 40 | --hash=sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6 \ 41 | --hash=sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7 \ 42 | --hash=sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee \ 43 | --hash=sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc \ 44 | --hash=sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130 \ 45 | --hash=sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4 \ 46 | --hash=sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4 \ 47 | --hash=sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b \ 48 | --hash=sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934 \ 49 | --hash=sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2 \ 50 | --hash=sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9 \ 51 | --hash=sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27 \ 52 | --hash=sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b \ 53 | --hash=sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154 \ 54 | --hash=sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef \ 55 | --hash=sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4 \ 56 | --hash=sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee \ 57 | --hash=sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c \ 58 | --hash=sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0 \ 59 | --hash=sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57 \ 60 | --hash=sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b \ 61 | --hash=sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1 \ 62 | --hash=sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e \ 63 | --hash=sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9 \ 64 | --hash=sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1 \ 65 | --hash=sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3 \ 66 | --hash=sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39 \ 67 | --hash=sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a \ 68 | --hash=sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9 69 | python-multipart==0.0.20 \ 70 | --hash=sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104 \ 71 | --hash=sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13 72 | sniffio==1.3.1 \ 73 | --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ 74 | --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc 75 | starlette==0.46.1 \ 76 | --hash=sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230 \ 77 | --hash=sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227 78 | typing-extensions==4.13.0 \ 79 | --hash=sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b \ 80 | --hash=sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5 81 | uvicorn==0.34.0 \ 82 | --hash=sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4 \ 83 | --hash=sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9 84 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "annotated-types" 6 | version = "0.7.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 11 | ] 12 | 13 | [[package]] 14 | name = "anyio" 15 | version = "4.9.0" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "idna" }, 19 | { name = "sniffio" }, 20 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 21 | ] 22 | sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 } 23 | wheels = [ 24 | { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, 25 | ] 26 | 27 | [[package]] 28 | name = "async-lru" 29 | version = "2.0.5" 30 | source = { registry = "https://pypi.org/simple" } 31 | sdist = { url = "https://files.pythonhosted.org/packages/b2/4d/71ec4d3939dc755264f680f6c2b4906423a304c3d18e96853f0a595dfe97/async_lru-2.0.5.tar.gz", hash = "sha256:481d52ccdd27275f42c43a928b4a50c3bfb2d67af4e78b170e3e0bb39c66e5bb", size = 10380 } 32 | wheels = [ 33 | { url = "https://files.pythonhosted.org/packages/03/49/d10027df9fce941cb8184e78a02857af36360d33e1721df81c5ed2179a1a/async_lru-2.0.5-py3-none-any.whl", hash = "sha256:ab95404d8d2605310d345932697371a5f40def0487c03d6d0ad9138de52c9943", size = 6069 }, 34 | ] 35 | 36 | [[package]] 37 | name = "click" 38 | version = "8.1.8" 39 | source = { registry = "https://pypi.org/simple" } 40 | dependencies = [ 41 | { name = "colorama", marker = "sys_platform == 'win32'" }, 42 | ] 43 | sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } 44 | wheels = [ 45 | { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, 46 | ] 47 | 48 | [[package]] 49 | name = "colorama" 50 | version = "0.4.6" 51 | source = { registry = "https://pypi.org/simple" } 52 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 53 | wheels = [ 54 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 55 | ] 56 | 57 | [[package]] 58 | name = "fastapi" 59 | version = "0.115.12" 60 | source = { registry = "https://pypi.org/simple" } 61 | dependencies = [ 62 | { name = "pydantic" }, 63 | { name = "starlette" }, 64 | { name = "typing-extensions" }, 65 | ] 66 | sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } 67 | wheels = [ 68 | { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, 69 | ] 70 | 71 | [[package]] 72 | name = "fasthx" 73 | version = "2.3.0" 74 | source = { registry = "https://pypi.org/simple" } 75 | dependencies = [ 76 | { name = "fastapi" }, 77 | ] 78 | sdist = { url = "https://files.pythonhosted.org/packages/4b/2c/b4dbfb60f3167d885664cced6564c7f2be8c51c82cc069603367208d108b/fasthx-2.3.0.tar.gz", hash = "sha256:8af01104cc8a62fc1e7a16928948050ce6b623c966735be415efa8e97a958aca", size = 17959 } 79 | wheels = [ 80 | { url = "https://files.pythonhosted.org/packages/71/11/9e8e1826141c899b5b1d461da20b63e7019773a03a2f5ff45fe55eb87f7b/fasthx-2.3.0-py3-none-any.whl", hash = "sha256:c72b23f16690b5d40f5e49aedbe8861ca592f87494d735a2b62ef344a929dafa", size = 18715 }, 81 | ] 82 | 83 | [[package]] 84 | name = "h11" 85 | version = "0.14.0" 86 | source = { registry = "https://pypi.org/simple" } 87 | sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } 88 | wheels = [ 89 | { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, 90 | ] 91 | 92 | [[package]] 93 | name = "htmy" 94 | version = "0.7.3" 95 | source = { registry = "https://pypi.org/simple" } 96 | dependencies = [ 97 | { name = "anyio" }, 98 | { name = "async-lru" }, 99 | { name = "markdown" }, 100 | ] 101 | sdist = { url = "https://files.pythonhosted.org/packages/f6/e4/b0c12db3187de6f3d794939810d4b5d095c139aaa28238ae9f595025537e/htmy-0.7.3.tar.gz", hash = "sha256:228507a9346748c659b0d5a74cdebffe4fb882b019ab9306da4691aa9c3e8196", size = 33040 } 102 | wheels = [ 103 | { url = "https://files.pythonhosted.org/packages/15/ee/a9dc35606649c64918558c798dde2ec7a3aaff591b66d5d22316ee583352/htmy-0.7.3-py3-none-any.whl", hash = "sha256:7d49badb2ae471f1abd3e3d16a0e8c78371af74ce7a3b767fe72ad40ad74625b", size = 33296 }, 104 | ] 105 | 106 | [[package]] 107 | name = "idna" 108 | version = "3.10" 109 | source = { registry = "https://pypi.org/simple" } 110 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 111 | wheels = [ 112 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 113 | ] 114 | 115 | [[package]] 116 | name = "lipsum-chat" 117 | version = "0.1.0" 118 | source = { virtual = "." } 119 | dependencies = [ 120 | { name = "fastapi" }, 121 | { name = "fasthx" }, 122 | { name = "htmy" }, 123 | { name = "python-multipart" }, 124 | { name = "uvicorn" }, 125 | ] 126 | 127 | [package.dev-dependencies] 128 | dev = [ 129 | { name = "mypy" }, 130 | { name = "ruff" }, 131 | ] 132 | 133 | [package.metadata] 134 | requires-dist = [ 135 | { name = "fastapi", specifier = ">=0.115.11" }, 136 | { name = "fasthx", specifier = ">=2.3.0" }, 137 | { name = "htmy", specifier = ">=0.7.3" }, 138 | { name = "python-multipart", specifier = ">=0.0.20" }, 139 | { name = "uvicorn", specifier = ">=0.34.0" }, 140 | ] 141 | 142 | [package.metadata.requires-dev] 143 | dev = [ 144 | { name = "mypy", specifier = ">=1.15.0" }, 145 | { name = "ruff", specifier = ">=0.9.10" }, 146 | ] 147 | 148 | [[package]] 149 | name = "markdown" 150 | version = "3.7" 151 | source = { registry = "https://pypi.org/simple" } 152 | sdist = { url = "https://files.pythonhosted.org/packages/54/28/3af612670f82f4c056911fbbbb42760255801b3068c48de792d354ff4472/markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2", size = 357086 } 153 | wheels = [ 154 | { url = "https://files.pythonhosted.org/packages/3f/08/83871f3c50fc983b88547c196d11cf8c3340e37c32d2e9d6152abe2c61f7/Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803", size = 106349 }, 155 | ] 156 | 157 | [[package]] 158 | name = "mypy" 159 | version = "1.15.0" 160 | source = { registry = "https://pypi.org/simple" } 161 | dependencies = [ 162 | { name = "mypy-extensions" }, 163 | { name = "typing-extensions" }, 164 | ] 165 | sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } 166 | wheels = [ 167 | { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, 168 | { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, 169 | { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, 170 | { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, 171 | { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, 172 | { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, 173 | { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, 174 | { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, 175 | { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, 176 | { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, 177 | { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, 178 | { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, 179 | { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, 180 | ] 181 | 182 | [[package]] 183 | name = "mypy-extensions" 184 | version = "1.0.0" 185 | source = { registry = "https://pypi.org/simple" } 186 | sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } 187 | wheels = [ 188 | { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, 189 | ] 190 | 191 | [[package]] 192 | name = "pydantic" 193 | version = "2.10.6" 194 | source = { registry = "https://pypi.org/simple" } 195 | dependencies = [ 196 | { name = "annotated-types" }, 197 | { name = "pydantic-core" }, 198 | { name = "typing-extensions" }, 199 | ] 200 | sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } 201 | wheels = [ 202 | { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, 203 | ] 204 | 205 | [[package]] 206 | name = "pydantic-core" 207 | version = "2.27.2" 208 | source = { registry = "https://pypi.org/simple" } 209 | dependencies = [ 210 | { name = "typing-extensions" }, 211 | ] 212 | sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } 213 | wheels = [ 214 | { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, 215 | { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, 216 | { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, 217 | { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, 218 | { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, 219 | { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, 220 | { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, 221 | { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, 222 | { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, 223 | { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, 224 | { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, 225 | { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, 226 | { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, 227 | { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, 228 | { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, 229 | { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, 230 | { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, 231 | { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, 232 | { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, 233 | { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, 234 | { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, 235 | { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, 236 | { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, 237 | { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, 238 | { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, 239 | { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, 240 | { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, 241 | { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, 242 | ] 243 | 244 | [[package]] 245 | name = "python-multipart" 246 | version = "0.0.20" 247 | source = { registry = "https://pypi.org/simple" } 248 | sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } 249 | wheels = [ 250 | { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, 251 | ] 252 | 253 | [[package]] 254 | name = "ruff" 255 | version = "0.11.2" 256 | source = { registry = "https://pypi.org/simple" } 257 | sdist = { url = "https://files.pythonhosted.org/packages/90/61/fb87430f040e4e577e784e325351186976516faef17d6fcd921fe28edfd7/ruff-0.11.2.tar.gz", hash = "sha256:ec47591497d5a1050175bdf4e1a4e6272cddff7da88a2ad595e1e326041d8d94", size = 3857511 } 258 | wheels = [ 259 | { url = "https://files.pythonhosted.org/packages/62/99/102578506f0f5fa29fd7e0df0a273864f79af044757aef73d1cae0afe6ad/ruff-0.11.2-py3-none-linux_armv6l.whl", hash = "sha256:c69e20ea49e973f3afec2c06376eb56045709f0212615c1adb0eda35e8a4e477", size = 10113146 }, 260 | { url = "https://files.pythonhosted.org/packages/74/ad/5cd4ba58ab602a579997a8494b96f10f316e874d7c435bcc1a92e6da1b12/ruff-0.11.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2c5424cc1c4eb1d8ecabe6d4f1b70470b4f24a0c0171356290b1953ad8f0e272", size = 10867092 }, 261 | { url = "https://files.pythonhosted.org/packages/fc/3e/d3f13619e1d152c7b600a38c1a035e833e794c6625c9a6cea6f63dbf3af4/ruff-0.11.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ecf20854cc73f42171eedb66f006a43d0a21bfb98a2523a809931cda569552d9", size = 10224082 }, 262 | { url = "https://files.pythonhosted.org/packages/90/06/f77b3d790d24a93f38e3806216f263974909888fd1e826717c3ec956bbcd/ruff-0.11.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c543bf65d5d27240321604cee0633a70c6c25c9a2f2492efa9f6d4b8e4199bb", size = 10394818 }, 263 | { url = "https://files.pythonhosted.org/packages/99/7f/78aa431d3ddebfc2418cd95b786642557ba8b3cb578c075239da9ce97ff9/ruff-0.11.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20967168cc21195db5830b9224be0e964cc9c8ecf3b5a9e3ce19876e8d3a96e3", size = 9952251 }, 264 | { url = "https://files.pythonhosted.org/packages/30/3e/f11186d1ddfaca438c3bbff73c6a2fdb5b60e6450cc466129c694b0ab7a2/ruff-0.11.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:955a9ce63483999d9f0b8f0b4a3ad669e53484232853054cc8b9d51ab4c5de74", size = 11563566 }, 265 | { url = "https://files.pythonhosted.org/packages/22/6c/6ca91befbc0a6539ee133d9a9ce60b1a354db12c3c5d11cfdbf77140f851/ruff-0.11.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:86b3a27c38b8fce73bcd262b0de32e9a6801b76d52cdb3ae4c914515f0cef608", size = 12208721 }, 266 | { url = "https://files.pythonhosted.org/packages/19/b0/24516a3b850d55b17c03fc399b681c6a549d06ce665915721dc5d6458a5c/ruff-0.11.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3b66a03b248c9fcd9d64d445bafdf1589326bee6fc5c8e92d7562e58883e30f", size = 11662274 }, 267 | { url = "https://files.pythonhosted.org/packages/d7/65/76be06d28ecb7c6070280cef2bcb20c98fbf99ff60b1c57d2fb9b8771348/ruff-0.11.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0397c2672db015be5aa3d4dac54c69aa012429097ff219392c018e21f5085147", size = 13792284 }, 268 | { url = "https://files.pythonhosted.org/packages/ce/d2/4ceed7147e05852876f3b5f3fdc23f878ce2b7e0b90dd6e698bda3d20787/ruff-0.11.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:869bcf3f9abf6457fbe39b5a37333aa4eecc52a3b99c98827ccc371a8e5b6f1b", size = 11327861 }, 269 | { url = "https://files.pythonhosted.org/packages/c4/78/4935ecba13706fd60ebe0e3dc50371f2bdc3d9bc80e68adc32ff93914534/ruff-0.11.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2a2b50ca35457ba785cd8c93ebbe529467594087b527a08d487cf0ee7b3087e9", size = 10276560 }, 270 | { url = "https://files.pythonhosted.org/packages/81/7f/1b2435c3f5245d410bb5dc80f13ec796454c21fbda12b77d7588d5cf4e29/ruff-0.11.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7c69c74bf53ddcfbc22e6eb2f31211df7f65054bfc1f72288fc71e5f82db3eab", size = 9945091 }, 271 | { url = "https://files.pythonhosted.org/packages/39/c4/692284c07e6bf2b31d82bb8c32f8840f9d0627d92983edaac991a2b66c0a/ruff-0.11.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6e8fb75e14560f7cf53b15bbc55baf5ecbe373dd5f3aab96ff7aa7777edd7630", size = 10977133 }, 272 | { url = "https://files.pythonhosted.org/packages/94/cf/8ab81cb7dd7a3b0a3960c2769825038f3adcd75faf46dd6376086df8b128/ruff-0.11.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:842a472d7b4d6f5924e9297aa38149e5dcb1e628773b70e6387ae2c97a63c58f", size = 11378514 }, 273 | { url = "https://files.pythonhosted.org/packages/d9/3a/a647fa4f316482dacf2fd68e8a386327a33d6eabd8eb2f9a0c3d291ec549/ruff-0.11.2-py3-none-win32.whl", hash = "sha256:aca01ccd0eb5eb7156b324cfaa088586f06a86d9e5314b0eb330cb48415097cc", size = 10319835 }, 274 | { url = "https://files.pythonhosted.org/packages/86/54/3c12d3af58012a5e2cd7ebdbe9983f4834af3f8cbea0e8a8c74fa1e23b2b/ruff-0.11.2-py3-none-win_amd64.whl", hash = "sha256:3170150172a8f994136c0c66f494edf199a0bbea7a409f649e4bc8f4d7084080", size = 11373713 }, 275 | { url = "https://files.pythonhosted.org/packages/d6/d4/dd813703af8a1e2ac33bf3feb27e8a5ad514c9f219df80c64d69807e7f71/ruff-0.11.2-py3-none-win_arm64.whl", hash = "sha256:52933095158ff328f4c77af3d74f0379e34fd52f175144cefc1b192e7ccd32b4", size = 10441990 }, 276 | ] 277 | 278 | [[package]] 279 | name = "sniffio" 280 | version = "1.3.1" 281 | source = { registry = "https://pypi.org/simple" } 282 | sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 283 | wheels = [ 284 | { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, 285 | ] 286 | 287 | [[package]] 288 | name = "starlette" 289 | version = "0.46.1" 290 | source = { registry = "https://pypi.org/simple" } 291 | dependencies = [ 292 | { name = "anyio" }, 293 | ] 294 | sdist = { url = "https://files.pythonhosted.org/packages/04/1b/52b27f2e13ceedc79a908e29eac426a63465a1a01248e5f24aa36a62aeb3/starlette-0.46.1.tar.gz", hash = "sha256:3c88d58ee4bd1bb807c0d1acb381838afc7752f9ddaec81bbe4383611d833230", size = 2580102 } 295 | wheels = [ 296 | { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, 297 | ] 298 | 299 | [[package]] 300 | name = "typing-extensions" 301 | version = "4.13.0" 302 | source = { registry = "https://pypi.org/simple" } 303 | sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 } 304 | wheels = [ 305 | { url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 }, 306 | ] 307 | 308 | [[package]] 309 | name = "uvicorn" 310 | version = "0.34.0" 311 | source = { registry = "https://pypi.org/simple" } 312 | dependencies = [ 313 | { name = "click" }, 314 | { name = "h11" }, 315 | ] 316 | sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568 } 317 | wheels = [ 318 | { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315 }, 319 | ] 320 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "builds": [ 3 | { 4 | "src": "app/main.py", 5 | "use": "@vercel/python" 6 | } 7 | ], 8 | "rewrites": [{ "source": "/(.*)", "destination": "/app/main.py" }] 9 | } 10 | --------------------------------------------------------------------------------