├── .github ├── dependabot.yml └── workflows │ └── main.yaml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── __init__.py ├── example_0.py ├── example_1.py ├── example_2.py ├── example_3.py ├── example_4.py ├── example_5.py ├── example_6.py ├── example_7.py ├── example_8.py ├── examples.py ├── run.sh ├── tictactoe │ ├── __init__.py │ ├── core.py │ ├── game.py │ ├── run.sh │ └── static │ │ └── ttt.css └── todo │ ├── __init__.py │ ├── core.py │ ├── db.py │ ├── run.sh │ └── static │ └── images │ └── trash-2.svg ├── main.py ├── poetry.lock ├── pyproject.toml ├── redmage ├── __init__.py ├── components.py ├── convertors.py ├── core.py ├── elements.py ├── exceptions.py ├── py.typed ├── targets.py ├── triggers.py ├── types.py └── utils.py ├── requirements.txt ├── scripts ├── format.sh ├── generate_elements.py └── test.sh └── tests ├── test_convertors.py ├── test_core.py ├── test_elements.py ├── test_triggers.py └── test_utils.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "poetry" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | on: [push] 3 | jobs: 4 | run-tests: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python: ["3.8", "3.9", "3.10", "3.11", "3.12"] 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Setup Python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: ${{ matrix.python }} 15 | - run: pip install -r requirements.txt 16 | - run: ./scripts/test.sh -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | env/ 2 | *.pyc 3 | TODO.md 4 | log.txt 5 | .coverage 6 | *.db 7 | scratch* 8 | dist/ 9 | env* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) <2023> 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redmage 2 | 3 | [![run-tests](https://github.com/redmage-labs/redmage/actions/workflows/main.yaml/badge.svg)](https://github.com/redmage-labs/redmage/actions/workflows/main.yaml) 4 | 5 | Redmage is component based library for building [htmx](https://htmx.org/) powered web applications. 6 | 7 | It is built on top of the [starlette](https://www.starlette.io/) web framework. 8 | 9 | ## Example 10 | 11 | Redmage is meant to reduce the complexity of designing htmx powered applications by abstracting the need to explicitly register routes and configure the hx-* attributes for each interaction on your app. 12 | 13 | Consider the example below. 14 | 15 | ``` 16 | from redmage import Component, Redmage, Target 17 | from redmage.elements import Button, Div, H1, Script 18 | 19 | 20 | app = Redmage() 21 | 22 | 23 | class Counter(Component, routes=("/",)): 24 | count: int 25 | 26 | def __init__(self): 27 | self.count = 0 28 | 29 | async def render(self): 30 | return Div( 31 | H1(f"Clicked {self.count} times."), 32 | Button( 33 | "Add 1", 34 | click=self.add_one(), 35 | ), 36 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 37 | ) 38 | 39 | @Target.post 40 | def add_one(self): 41 | self.count += 1 42 | ``` 43 | 44 | The **Counter** component will add one to the count every time the button is clicked. If you're familiar with htmx, you might notice that this would usually require registering a new route in our backend, maybe something like _/add_one_. And in the html, we would have to explicitly add an **hx-post**, **hx-target**, and possibly more **hx-\*** attributes. 45 | 46 | Redmage abstracts this using the **Target.post** decorator method. To the developer it simply looks like the _add_one_ method is executed when the button is clicked which updates the component state and causes the component to re-render. 47 | 48 | ## Installation 49 | 50 | 51 | Redmage is available on pypi. 52 | 53 | ``` 54 | pip install redmage 55 | ``` 56 | 57 | ## Redmage application 58 | 59 | 60 | The first thing you need to do is create an instance of the Redmage class. 61 | 62 | ``` 63 | from redmage import Redmage 64 | 65 | 66 | app = Redmage() 67 | ``` 68 | 69 | At this point our app won't do anything because we haven't registered any routes by sublassing Component. But you can start it up using your favorite ASGI server like uvicorn. 70 | 71 | ``` 72 | uvicorn ::app.starlette 73 | ``` 74 | 75 | ### Application Options 76 | 77 | You can pass the following keyword arguments to the Redmage constructor which work as proxies to the underlying Starlette app. 78 | 79 | * debug 80 | * middleware 81 | 82 | ## First Component 83 | 84 | 85 | Let's create a Component. In the example we just returned a **div** element. This works but we're going to want to create a proper html page with **html**, **header**, **body** tags etc. 86 | 87 | ``` 88 | from redmage import Component, Redmage 89 | from redmage.elements import Body, Doc, H1, Head, Html, Script, Title 90 | 91 | 92 | app = Redmage() 93 | 94 | 95 | class Index(Component, routes=("/",)): 96 | 97 | async def render(self): 98 | return Doc( 99 | Html( 100 | Head( 101 | Title("Example"), 102 | ), 103 | Body( 104 | H1("Hello Redmage"), 105 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 106 | ), 107 | ) 108 | ) 109 | ``` 110 | 111 | This tells our app to register the **Index** component with the route "/". When you navigate to _localhost:8000/_ an instance of **Index** will be created and it's render method will be called to generate the html. 112 | 113 | The **Component** class is abstract and has a single abstract base method, **render**, that must be implemented and return and instance of **redmage.elements.Element**. 114 | 115 | 116 | ## Elements 117 | 118 | Redmage internally uses python classes associated with each html tag (**Div**, **Body**, **H1** etc.). They can all be imported from **redmage.elements**. Each of these classes subclasses **redmage.elements.Element**. Pass the elements inner html as positional arguments and add attributes with keyword arguments. 119 | 120 | ``` 121 | from redmage.elements import Div 122 | 123 | 124 | div = Div( 125 | H1("Title"), 126 | P("paragraph"), 127 | my_attribute="a cool attribute value" 128 | _class="my-class" 129 | ) 130 | 131 | 132 | print(div) 133 | 134 | 135 | # output 136 | #
137 | #

Title

138 | #

paragraph

139 | #
140 | ``` 141 | 142 | Notice that underscores in keywords are converted to hyphens and leading underscores are stripped so you can avoid conflict with Python keywords like class. 143 | 144 | Additionally, a number of htmx specific keywords are supported. 145 | 146 | | keyword | htmx attribute | type | default | documentation | notes | 147 | |-------------|--------------------------------------|----------------|---------------------|---------------|------------------------------------| 148 | | trigger | hx-trigger | str or Trigger | None | | See Trigger section below | 149 | | swap | hx-swap | str | HTMXSwap.OUTER_HTML | | redmage.types.HTMXSwap enum | 150 | | swap_oob | hx-swap-oob | bool | False | | | 151 | | confirm | hx-confirm | bool | False | | | 152 | | boost | hx-boost | bool | False | | | 153 | | on | hx-on | str | None | | | 154 | | indicator | N/A | bool | False | | | 155 | | target | hx-target, hx-\ | Target | None | | See Target section below | 156 | | click | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 157 | | submit | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 158 | | change | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 159 | | mouse_over | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 160 | | mouse_enter | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 161 | | load | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 162 | | intersect | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 163 | | revealed | hx-target, hx-\, hx-trigger | Target | None | | See Trigger keywords section below | 164 | 165 | > Redmage doesn't have any support for a specific template engine, but it should be pretty easy to build a **Component** subclass to support one, such as Jinja2. See the todo_jinja2 example. 166 | 167 | 168 | ## Nesting Components 169 | 170 | 171 | Components can be easily nested by using them just like you would use any other html **Element** object. 172 | 173 | ``` 174 | from redmage import Redmage 175 | from redmage.elements import Body, Doc, H1, Head, Html, Script, Title 176 | 177 | 178 | app = Redmage() 179 | 180 | 181 | class Index(Component, routes=("/",)): 182 | 183 | async def render(self): 184 | return Doc( 185 | Html( 186 | Head( 187 | Title("Todo App"), 188 | ), 189 | Body( 190 | ChildComponent(), 191 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 192 | ), 193 | ) 194 | ) 195 | 196 | 197 | class ChildComponent(Component): 198 | 199 | async def render(self): 200 | return H1("Child Component") 201 | 202 | ``` 203 | 204 | > In the following examples I'm going to assume that the components we write are rendered in an **Index** component like above so it will be ommited. 205 | 206 | 207 | ## Targets 208 | 209 | [htmx targets](https://htmx.org/docs/#targets) 210 | 211 | ### Simple Example 212 | 213 | Thus far, our components have been static. With Redmage we have the ability to react to events on the frontend and update the state of our component using htmx. To do, this we use **redmage.Target** class. It has decorator attributes associated with the following HTTP methods. 214 | 215 | * get 216 | * post 217 | * put 218 | * patch 219 | * delete 220 | 221 | These decorators wrap methods of our component. We can pass the output of these methods to an Element's **target** keyword argument. Check out the example below. 222 | 223 | ``` 224 | from redmage import Target 225 | 226 | 227 | class ClickComponent(Component): 228 | 229 | def __init__(self): 230 | self.count = 0 231 | 232 | async def render(self): 233 | return Button( 234 | self.count, 235 | target=self.set_count(self.count + 1) 236 | ) 237 | 238 | @Target.post 239 | def set_count(self, count: int): 240 | self.count = count 241 | ``` 242 | 243 | When the button is clicked an HTTP POST request is issued to our application. The **set_count** method is ran, updating the component state, and the component is re-rendered and swapped into the DOM. 244 | 245 | By default, if the target method returns **None** then **self** is rendered. We could also explicitly return self, another component, or a tuple of components (this can be useful in conjunction with out of bounds swaps). 246 | 247 | ### Target method arguments 248 | 249 | All of the arguments of a render method, except **self**, require type hints so that Redmage can build a route. Positional or keyword (and keyword only) arguments are added to the route as path arguments or query parameters respectively. 250 | 251 | If the request has a body, the first argument must be a [positional only argument](https://peps.python.org/pep-0570/). It's type must be a class that de-serializes the body by passing the fields as keyword arguments to it's constructor, like a dataclass. 252 | 253 | ``` 254 | @dataclass 255 | class UpdateMessageForm: 256 | content: str 257 | 258 | 259 | class Message(Component): 260 | 261 | def __init__(self, content): 262 | self.content = content 263 | 264 | async def render(self): 265 | return Div( 266 | P(f"{self.content=}"), 267 | Form( 268 | Input( 269 | type="text", 270 | id="content", 271 | name="content", 272 | ), 273 | Button("Update message", type="submit"), 274 | target=self.update_message(), 275 | ), 276 | ) 277 | 278 | @Target.post 279 | def update_message(self, form: UpdateMessageForm, /): 280 | self.content = form.content 281 | ``` 282 | 283 | Redmage (and the underlying starlette app) must know how to convert your types to strings so that they can be encoded in URLs and converted back to your object types. 284 | 285 | Use the starlette app to register your custom converters according to it's [documentation](https://www.starlette.io/routing/). Redmage will use these converters. 286 | 287 | > Redmage adds a couple of custom convertors that starlette does not provide. One is a boolean converter to convert **bool** type. The other is a custom string converter that is used for **str**. Since Redmage building URLs we need to convert the empty string to _\_\_empty\_\__. 288 | 289 | ### Component State 290 | 291 | The component's state will also be encoded in the url so it can be recreated when the request is issued. Only attributes that have a class annotation will be included. The same converters described above will be used to serialize/de-serialize the component's attributes. 292 | 293 | ``` 294 | @dataclass 295 | class UpdateMessageForm: 296 | content: str 297 | 298 | 299 | class MessageAndCounter(Component): 300 | content: str 301 | count: int 302 | 303 | def __init__(self, content, count): 304 | self.content = content 305 | self.count = count 306 | 307 | async def render(self): 308 | return Div( 309 | P(f"{self.content=}"), 310 | Form( 311 | Input( 312 | type="text", 313 | id="content", 314 | name="content", 315 | ), 316 | Button("Update message", type="submit"), 317 | target=self.update_message(), 318 | ), 319 | P(f"{self.count=}"), 320 | Button("Add 1", click=self.update_count(self.count + 1)), 321 | ) 322 | 323 | @Target.post 324 | def update_message(self, form: UpdateMessageForm, /): 325 | self.content = form.content 326 | 327 | @Target.post 328 | def update_count(self, count: int): 329 | self.count = count 330 | ``` 331 | 332 | In this example, if we didn't add the class annotations, when the message was updated the count would not be set and vice versa, breaking our component. 333 | 334 | ## Triggers 335 | 336 | [htmx triggers](https://htmx.org/docs/#triggers) 337 | 338 | Redmage provides a very thin abstraction over _hx-trigger_ attributes. An element's **trigger** keyword argument can be used to tell Redamge which event type should trigger a component update. You can just pass a string value of the [event](https://developer.mozilla.org/en-US/docs/Web/Events) name. 339 | 340 | ``` 341 | class HoverCount(Component): 342 | def __init__(self): 343 | self.count = 0 344 | 345 | async def render(self): 346 | return Div( 347 | self.count, 348 | target=self.set_count(self.count + 1), 349 | trigger="mouseover", 350 | ) 351 | 352 | @Target.post 353 | def set_count(self, count: int): 354 | self.count = count 355 | ``` 356 | 357 | Now when you mouse over the number the count increases by one. Check the htmx documentation because it provides a number of modifiers that can be used to customize the trigger behavior. 358 | 359 | Redmage has built-in classes that can be used to build triggers as well. 360 | 361 | * **redmage.triggers.Trigger** 362 | * **redmage.triggers.TriggerModifier** 363 | * **redmage.triggers.DelayTriggerModifier** 364 | * **redmage.triggers.ThrottleTriggerModifier** 365 | 366 | > TODO document redmage.triggers.* classes. 367 | 368 | Below is an example of using **Trigger** classes to add a delay to a trigger. 369 | 370 | ``` 371 | from redmage.triggers import DelayTriggerModifier, Trigger 372 | from redmage.types import HTMXTrigger 373 | 374 | 375 | class HoverCount(Component): 376 | def __init__(self): 377 | self.count = 0 378 | 379 | async def render(self): 380 | trigger = Trigger(HTMXTrigger.MOUSEOVER, DelayTriggerModifier(1000)) 381 | 382 | return Div( 383 | self.count, target=self.set_count(self.count + 1), trigger=trigger 384 | ) 385 | 386 | @Target.post 387 | def set_count(self, count: int): 388 | self.count = count 389 | ``` 390 | 391 | Now the count will update after one second instead of immediately. 392 | 393 | ## Trigger keywords 394 | 395 | The **Element** class has a number of keyword arguments associated with events that we can pass **Target** objects too. This can simplify our code by not having to add both target and trigger keyword arguments. Below is an example. 396 | 397 | ``` 398 | class HoverCount(Component): 399 | def __init__(self): 400 | self.count = 0 401 | 402 | async def render(self): 403 | return Div( 404 | self.count, 405 | mouse_over=self.set_count(self.count + 1), 406 | ) 407 | 408 | @Target.post 409 | def set_count(self, count: int): 410 | self.count = count 411 | ``` 412 | 413 | The **Element** class currently provides keyword arguments for the following events: 414 | 415 | * click 416 | * submit 417 | * change 418 | * mouse_over 419 | * mouse_enter 420 | * load 421 | * intersect 422 | * revealed 423 | 424 | 425 | ## Render Extensions 426 | 427 | We can use render extensions to inject objects as positional arguments to each **render** method in our application. 428 | 429 | > TODO give an example of how to register a render extension and when you might use one. 430 | 431 | 432 | ## Examples 433 | 434 | > TODO add cool examples. 435 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redmage-labs/redmage/781275fe694e6adf2ac37ae23993ab17ca0ea412/examples/__init__.py -------------------------------------------------------------------------------- /examples/example_0.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from redmage import Component, Redmage, Target 4 | from redmage.elements import H1, Button, Div, Script 5 | 6 | app = Redmage() 7 | 8 | 9 | class Counter(Component, routes=("/",)): 10 | count: int 11 | 12 | def __init__(self): 13 | self.count = 0 14 | 15 | async def render(self): 16 | return Div( 17 | H1(f"Clicked {self.count} times."), 18 | Button( 19 | "Add 1", 20 | click=self.add_one(), 21 | ), 22 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 23 | ) 24 | 25 | @Target.post 26 | def add_one(self): 27 | self.count += 1 28 | 29 | 30 | if __name__ == "__main__": 31 | uvicorn.run(app.starlette, port=8000) 32 | -------------------------------------------------------------------------------- /examples/example_1.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from redmage import Component, Redmage 4 | from redmage.elements import H1, Body, Doc, Head, Html, Script, Title 5 | 6 | app = Redmage() 7 | 8 | 9 | class Index(Component, routes=("/",)): 10 | async def render(self): 11 | return Doc( 12 | Html( 13 | Head( 14 | Title("Redmage | Example 1"), 15 | ), 16 | Body( 17 | H1("Hello Redmage"), 18 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 19 | ), 20 | ) 21 | ) 22 | 23 | 24 | if __name__ == "__main__": 25 | uvicorn.run(app.starlette, port=8000) 26 | -------------------------------------------------------------------------------- /examples/example_2.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from redmage import Component, Redmage 4 | from redmage.elements import H1, Body, Doc, Head, Html, Script, Title 5 | 6 | app = Redmage() 7 | 8 | 9 | class Index(Component, routes=("/",)): 10 | async def render(self): 11 | return Doc( 12 | Html( 13 | Head( 14 | Title("Redmage | Example 2"), 15 | ), 16 | Body( 17 | ChildComponent(), 18 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 19 | ), 20 | ) 21 | ) 22 | 23 | 24 | class ChildComponent(Component): 25 | def render(self): 26 | return H1("Child Component") 27 | 28 | 29 | if __name__ == "__main__": 30 | uvicorn.run(app.starlette, port=8000) 31 | -------------------------------------------------------------------------------- /examples/example_3.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from redmage import Component, Redmage, Target 4 | from redmage.elements import Body, Button, Doc, Head, Html, Script, Title 5 | 6 | app = Redmage() 7 | 8 | 9 | class Index(Component, routes=("/",)): 10 | async def render(self): 11 | return Doc( 12 | Html( 13 | Head(Title("Redmage | Example 3")), 14 | Body( 15 | ClickComponent(), 16 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 17 | ), 18 | ) 19 | ) 20 | 21 | 22 | class ClickComponent(Component): 23 | def __init__(self): 24 | self.count = 0 25 | 26 | def render(self): 27 | return Button(self.count, target=self.set_count(self.count + 1)) 28 | 29 | @Target.post 30 | def set_count(self, count: int): 31 | self.count = count 32 | 33 | 34 | if __name__ == "__main__": 35 | uvicorn.run(app.starlette, port=8000) 36 | -------------------------------------------------------------------------------- /examples/example_4.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import uvicorn 4 | 5 | from redmage import Component, Redmage, Target 6 | from redmage.elements import ( 7 | Body, 8 | Button, 9 | Div, 10 | Doc, 11 | Form, 12 | Head, 13 | Html, 14 | Input, 15 | P, 16 | Script, 17 | Title, 18 | ) 19 | 20 | app = Redmage() 21 | 22 | 23 | class Index(Component, routes=("/",)): 24 | async def render(self): 25 | return Doc( 26 | Html( 27 | Head( 28 | Title("Redmage | Example 4"), 29 | ), 30 | Body( 31 | Message("Initial message"), 32 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 33 | ), 34 | ) 35 | ) 36 | 37 | 38 | @dataclass 39 | class UpdateMessageForm: 40 | content: str 41 | 42 | 43 | class Message(Component): 44 | def __init__(self, content): 45 | self.content = content 46 | 47 | async def render(self): 48 | return Div( 49 | P(f"{self.content=}"), 50 | Form( 51 | Input( 52 | type="text", 53 | id="content", 54 | name="content", 55 | ), 56 | Button("Update message", type="submit"), 57 | target=self.update_message(), 58 | ), 59 | ) 60 | 61 | @Target.post 62 | def update_message(self, form: UpdateMessageForm, /): 63 | self.content = form.content 64 | 65 | 66 | if __name__ == "__main__": 67 | uvicorn.run(app.starlette, port=8000) 68 | -------------------------------------------------------------------------------- /examples/example_5.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import uvicorn 4 | 5 | from redmage import Component, Redmage, Target 6 | from redmage.elements import ( 7 | Body, 8 | Button, 9 | Div, 10 | Doc, 11 | Form, 12 | Head, 13 | Html, 14 | Input, 15 | P, 16 | Script, 17 | Title, 18 | ) 19 | 20 | app = Redmage() 21 | 22 | 23 | class Index(Component, routes=("/",)): 24 | async def render(self): 25 | return Doc( 26 | Html( 27 | Head( 28 | Title("Redmage | Example 5"), 29 | ), 30 | Body( 31 | MessageAndCounter("Initial message", 0), 32 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 33 | ), 34 | ) 35 | ) 36 | 37 | 38 | @dataclass 39 | class UpdateMessageForm: 40 | content: str 41 | 42 | 43 | class MessageAndCounter(Component): 44 | content: str 45 | count: int 46 | 47 | def __init__(self, content, count): 48 | self.content = content 49 | self.count = count 50 | 51 | async def render(self): 52 | return Div( 53 | P(f"{self.content=}"), 54 | Form( 55 | Input( 56 | type="text", 57 | id="content", 58 | name="content", 59 | ), 60 | Button("Update message", type="submit"), 61 | target=self.update_message(), 62 | ), 63 | P(f"{self.count=}"), 64 | Button("Add 1", click=self.update_count(self.count + 1)), 65 | ) 66 | 67 | @Target.post 68 | def update_message(self, form: UpdateMessageForm, /): 69 | self.content = form.content 70 | 71 | @Target.post 72 | def update_count(self, count: int): 73 | self.count = count 74 | 75 | 76 | if __name__ == "__main__": 77 | uvicorn.run(app.starlette, port=8000) 78 | -------------------------------------------------------------------------------- /examples/example_6.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from redmage import Component, Redmage, Target 4 | from redmage.elements import Body, Div, Doc, Head, Html, Script, Title 5 | 6 | app = Redmage() 7 | 8 | 9 | class Index(Component, routes=("/",)): 10 | async def render(self): 11 | return Doc( 12 | Html( 13 | Head( 14 | Title("Redmage | Example 6"), 15 | ), 16 | Body( 17 | HoverCount(), 18 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 19 | ), 20 | ) 21 | ) 22 | 23 | 24 | class HoverCount(Component): 25 | def __init__(self): 26 | self.count = 0 27 | 28 | async def render(self): 29 | return Div( 30 | self.count, target=self.set_count(self.count + 1), trigger="mouseover" 31 | ) 32 | 33 | @Target.post 34 | def set_count(self, count: int): 35 | self.count = count 36 | 37 | 38 | if __name__ == "__main__": 39 | uvicorn.run(app.starlette, port=8000) 40 | -------------------------------------------------------------------------------- /examples/example_7.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from redmage import Component, Redmage, Target 4 | from redmage.elements import Body, Div, Doc, Head, Html, Script, Title 5 | from redmage.triggers import DelayTriggerModifier, Trigger 6 | from redmage.types import HTMXTrigger 7 | 8 | app = Redmage() 9 | 10 | 11 | class Index(Component, routes=("/",)): 12 | async def render(self): 13 | return Doc( 14 | Html( 15 | Head( 16 | Title("Redmage | Example 6"), 17 | ), 18 | Body( 19 | HoverCount(), 20 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 21 | ), 22 | ) 23 | ) 24 | 25 | 26 | class HoverCount(Component): 27 | def __init__(self): 28 | self.count = 0 29 | 30 | async def render(self): 31 | trigger = Trigger(HTMXTrigger.MOUSEOVER, DelayTriggerModifier(1000)) 32 | 33 | return Div(self.count, target=self.set_count(self.count + 1), trigger=trigger) 34 | 35 | @Target.post 36 | def set_count(self, count: int): 37 | self.count = count 38 | 39 | 40 | if __name__ == "__main__": 41 | uvicorn.run(app.starlette, port=8000) 42 | -------------------------------------------------------------------------------- /examples/example_8.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from redmage import Component, Redmage, Target 4 | from redmage.elements import Body, Div, Doc, Head, Html, Script, Title 5 | 6 | app = Redmage() 7 | 8 | 9 | class Index(Component, routes=("/",)): 10 | async def render(self): 11 | return Doc( 12 | Html( 13 | Head( 14 | Title("Redmage | Example 6"), 15 | ), 16 | Body( 17 | HoverCount(), 18 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 19 | ), 20 | ) 21 | ) 22 | 23 | 24 | class HoverCount(Component): 25 | def __init__(self): 26 | self.count = 0 27 | 28 | async def render(self): 29 | return Div( 30 | self.count, 31 | mouse_over=self.set_count(self.count + 1), 32 | ) 33 | 34 | @Target.post 35 | def set_count(self, count: int): 36 | self.count = count 37 | 38 | 39 | if __name__ == "__main__": 40 | uvicorn.run(app.starlette, port=8000) 41 | -------------------------------------------------------------------------------- /examples/examples.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import uvicorn 4 | 5 | from redmage import Component, Redmage, Target 6 | from redmage.elements import ( 7 | H1, 8 | Body, 9 | Button, 10 | Div, 11 | Form, 12 | Hr, 13 | Html, 14 | Input, 15 | Li, 16 | P, 17 | Script, 18 | Table, 19 | Td, 20 | Th, 21 | Tr, 22 | Ul, 23 | ) 24 | from redmage.triggers import DelayTriggerModifier, Trigger, TriggerModifier 25 | from redmage.types import HTMXTrigger 26 | 27 | app = Redmage() 28 | 29 | 30 | class Index(Component, routes=("/",)): 31 | async def render(self): 32 | return Html( 33 | Body( 34 | Examples(), 35 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 36 | ), 37 | ) 38 | 39 | 40 | class Examples(Component): 41 | async def render(self): 42 | list_component = ListComponent() 43 | 44 | return Div( 45 | Div( 46 | H1("Examples"), 47 | Hr(), 48 | # counter 49 | Counter(), 50 | Hr(), 51 | # simple form 52 | Message(), 53 | Hr(), 54 | # Add to a list 55 | list_component, 56 | Button("Append Item", target=list_component.append(), swap="beforeend"), 57 | Hr(), 58 | MouseOverTriggerExample(), 59 | Hr(), 60 | MouseOverTriggerExample( 61 | message="I have a delay of 2 seconds", 62 | delay=2000, 63 | ), 64 | Hr(), 65 | Confirm(), 66 | Hr(), 67 | ActiveSearch(), 68 | Hr(), 69 | # Reset 70 | Button("Reset", target=self.reset()), 71 | ) 72 | ) 73 | 74 | @Target.get 75 | def reset(self): 76 | # Just do nothing it will cause the component to just rerender 77 | # Since nothing is stored in the component state it just resets everything 78 | ... 79 | 80 | 81 | class Counter(Component): 82 | n: int 83 | 84 | def __init__(self, n: int = 0): 85 | self.n = n 86 | 87 | async def render(self): 88 | return Div( 89 | P(f"count={self.n}"), 90 | Button("Add 1", target=self.iterate(self.n + 1)), 91 | ) 92 | 93 | @Target.get 94 | def iterate(self, n: int): 95 | self.n = n 96 | 97 | 98 | class Message(Component): 99 | content: str 100 | 101 | def __init__(self, content: str = "initial message"): 102 | self.content = content 103 | 104 | @dataclass 105 | class UpdateMessageForm: 106 | content: str 107 | 108 | async def render(self): 109 | return Div( 110 | P(f"{self.content=}" if self.content else "No message has been posted"), 111 | Form( 112 | Input( 113 | type="text", 114 | id="content", 115 | name="content", 116 | ), 117 | Button("Update message", type="submit"), 118 | target=self.update_message(), # keyword arg will be added by the form 119 | ), 120 | ) 121 | 122 | @Target.post 123 | def update_message(self, form: UpdateMessageForm, /): 124 | self.content = form.content 125 | 126 | 127 | @dataclass 128 | class ListComponent(Component): 129 | async def render(self): 130 | items = [] 131 | return Ul(*[ListItemComponent(i) for i in items]) 132 | 133 | @Target.get 134 | def append(self): 135 | item = "List Item" 136 | return ListItemComponent(item) 137 | 138 | 139 | @dataclass 140 | class ListItemComponent(Component): 141 | message: str 142 | 143 | async def render(self): 144 | return Li(self.message) 145 | 146 | 147 | @dataclass 148 | class MouseOverTriggerExample(Component): 149 | message: str = "Hover over the button to trigger the event" 150 | delay: int = 0 151 | 152 | async def render(self): 153 | if self.delay: 154 | trigger = Trigger( 155 | HTMXTrigger.MOUSEOVER, DelayTriggerModifier(milliseconds=self.delay) 156 | ) 157 | else: 158 | trigger = Trigger(HTMXTrigger.MOUSEOVER) 159 | 160 | return Div( 161 | P(self.message), 162 | Button( 163 | "Trigger Event", 164 | target=self.trigger_event(), 165 | trigger=trigger, 166 | ), 167 | ) 168 | 169 | @Target.get 170 | def trigger_event(self): 171 | self.message = "I was triggered!" 172 | return self 173 | 174 | 175 | @dataclass 176 | class Confirm(Component): 177 | message: str = "Click the button to trigger the event" 178 | 179 | async def render(self): 180 | return Div( 181 | P(self.message), 182 | Button( 183 | "Trigger Confirm Event", 184 | target=self.trigger_event(), 185 | confirm="Are you sure you want to trigger the event?", 186 | ), 187 | ) 188 | 189 | @Target.get 190 | def trigger_event(self): 191 | self.message = "I was triggered!" 192 | return self 193 | 194 | 195 | @dataclass 196 | class SearchCriteria: 197 | search_string: str 198 | 199 | 200 | class ActiveSearch(Component): 201 | def __init__(self): 202 | self.search_string = "" 203 | 204 | async def render(self): 205 | poeple = [ 206 | ("John", 20), 207 | ("Jane", 21), 208 | ("Bob", 22), 209 | ("Alice", 23), 210 | ] 211 | 212 | return Div( 213 | Form( 214 | Input( 215 | type="search", 216 | id="search_string", 217 | name="search_string", 218 | value=self.search_string, 219 | target=self.search(), 220 | trigger=( 221 | Trigger( 222 | HTMXTrigger.KEYUP, 223 | TriggerModifier(HTMXTrigger.CHANGE), 224 | DelayTriggerModifier(milliseconds=500), 225 | ), 226 | Trigger(HTMXTrigger.SEARCH), 227 | ), 228 | ) 229 | ), 230 | Table( 231 | Tr( 232 | Th("Name"), 233 | Th("Age"), 234 | ), 235 | *[ 236 | Tr(Td(p[0]), Td(p[1])) 237 | for p in poeple 238 | if p[0].startswith(self.search_string) 239 | ], 240 | ), 241 | ) 242 | 243 | @Target.post 244 | def search(self, search_criteria: SearchCriteria, /): 245 | self.search_string = search_criteria.search_string 246 | 247 | 248 | if __name__ == "__main__": 249 | uvicorn.run(app.starlette, host="0.0.0.0", port=8000) 250 | -------------------------------------------------------------------------------- /examples/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | python examples/$1 -------------------------------------------------------------------------------- /examples/tictactoe/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import app 2 | -------------------------------------------------------------------------------- /examples/tictactoe/core.py: -------------------------------------------------------------------------------- 1 | from starlette.routing import Mount 2 | from starlette.staticfiles import StaticFiles 3 | 4 | from redmage import Component, Redmage, Target 5 | from redmage.elements import Body, Button, Div, Head, Html, Link, P, Script 6 | 7 | from .game import Players, TicTacToeGameState 8 | 9 | app = Redmage() 10 | 11 | 12 | app.routes.append( 13 | Mount( 14 | "/static", 15 | app=StaticFiles(directory="./examples/tictactoe/static"), 16 | name="static", 17 | ) 18 | ) 19 | 20 | 21 | game = TicTacToeGameState() 22 | 23 | 24 | class Index(Component, routes=("/",)): 25 | async def render(self): 26 | r = Html( 27 | Head( 28 | Link( 29 | rel="stylesheet", 30 | href="static/ttt.css", 31 | ), 32 | ), 33 | Body( 34 | Board(), 35 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 36 | ), 37 | ) 38 | return r 39 | 40 | 41 | class Board(Component): 42 | def __init__(self): 43 | self.game_over = False 44 | self.draw = False 45 | 46 | async def render(self): 47 | squares = [] 48 | for i, row in enumerate(game.board_state): 49 | for j, cell in enumerate(row): 50 | squares.append(square(self, i, j, cell, self.game_over or self.draw)) 51 | 52 | if self.game_over: 53 | message_content = f"game over, {game.current_turn} won!" 54 | elif self.draw: 55 | message_content = f"Game is a draw!" 56 | else: 57 | message_content = f"current turn: {game.current_turn}" 58 | 59 | el = Div( 60 | Div( 61 | *squares, 62 | _class="board", 63 | ), 64 | P(message_content), 65 | ) 66 | 67 | if self.game_over or self.draw: 68 | el.append(Button("Restart Game", target=self.reset())) 69 | 70 | return el 71 | 72 | @Target.get 73 | def reset(self): 74 | self.game_over = False 75 | self.draw = False 76 | game.reset() 77 | 78 | @Target.get 79 | def move(self, x: int, y: int): 80 | game_over, draw = game.take_turn(x, y) 81 | self.game_over = game_over 82 | self.draw = draw 83 | 84 | 85 | def square(board, x, y, val, disable): 86 | disable = True if disable or val in (Players.X, Players.O) else False 87 | cell_style = f"grid-row: {x + 1}; grid-column: {y + 1};" 88 | cell_val = f"{val}" 89 | 90 | div = Div( 91 | cell_val, 92 | style=cell_style, 93 | _class="square", 94 | ) 95 | 96 | if not disable: 97 | div.target = board.move(x, y) 98 | 99 | return div 100 | -------------------------------------------------------------------------------- /examples/tictactoe/game.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Players(str, Enum): 5 | X = "X" 6 | O = "O" 7 | 8 | 9 | class TicTacToeGameState: 10 | def __init__(self): 11 | self.reset() 12 | 13 | def change_turn(self): 14 | self.current_turn = Players.X if self.current_turn != Players.X else Players.O 15 | 16 | def take_turn(self, x, y): 17 | self.board_state[int(x)][int(y)] = self.current_turn 18 | game_over = self.is_over() 19 | draw = self.is_draw() 20 | if not game_over and not draw: 21 | self.change_turn() 22 | 23 | return game_over, draw 24 | 25 | def is_over(self): 26 | return any( 27 | [ 28 | all([self.board_state[0][x] == self.current_turn for x in range(3)]), 29 | all([self.board_state[1][x] == self.current_turn for x in range(3)]), 30 | all([self.board_state[2][x] == self.current_turn for x in range(3)]), 31 | all([self.board_state[x][0] == self.current_turn for x in range(3)]), 32 | all([self.board_state[x][1] == self.current_turn for x in range(3)]), 33 | all([self.board_state[x][2] == self.current_turn for x in range(3)]), 34 | all([self.board_state[x][x] == self.current_turn for x in range(3)]), 35 | all( 36 | [self.board_state[x][2 - x] == self.current_turn for x in range(3)] 37 | ), 38 | ] 39 | ) 40 | 41 | def is_draw(self): 42 | return all(all(v for v in row) for row in self.board_state) 43 | 44 | def reset(self): 45 | self.current_turn = Players.X 46 | self.board_state = [["" for _ in range(3)] for _ in range(3)] 47 | -------------------------------------------------------------------------------- /examples/tictactoe/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | uvicorn examples.tictactoe:app.starlette --host '0.0.0.0' --log-level=debug --reload -------------------------------------------------------------------------------- /examples/tictactoe/static/ttt.css: -------------------------------------------------------------------------------- 1 | .board { 2 | width: 300px; 3 | display: grid; 4 | grid-template-columns: auto auto auto; 5 | } 6 | 7 | .square { 8 | border: 1px solid black; 9 | width: 100px; 10 | height: 100px; 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | font-size: 2em; 15 | } -------------------------------------------------------------------------------- /examples/todo/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import app 2 | -------------------------------------------------------------------------------- /examples/todo/core.py: -------------------------------------------------------------------------------- 1 | from starlette.convertors import Convertor, register_url_convertor 2 | from starlette.routing import Mount 3 | from starlette.staticfiles import StaticFiles 4 | 5 | from examples.todo import db 6 | from redmage import Component, Redmage, Target 7 | from redmage.elements import ( 8 | A, 9 | Body, 10 | Button, 11 | Div, 12 | Doc, 13 | Form, 14 | Head, 15 | Html, 16 | Img, 17 | Input, 18 | Li, 19 | Link, 20 | Nav, 21 | S, 22 | Script, 23 | Strong, 24 | Textarea, 25 | Title, 26 | Ul, 27 | ) 28 | 29 | app = Redmage() 30 | 31 | 32 | app.routes.append( 33 | Mount( 34 | "/static", 35 | app=StaticFiles(directory="./examples/todo/static"), 36 | name="static", 37 | ) 38 | ) 39 | 40 | 41 | class RouteConvertor(Convertor): 42 | regex = "add|edit|list" 43 | 44 | def convert(self, value: str) -> str: 45 | return value 46 | 47 | def to_string(self, value: str) -> str: 48 | return value 49 | 50 | 51 | register_url_convertor("route", RouteConvertor()) 52 | 53 | 54 | class TodoAppComponent( 55 | Component, 56 | routes=( 57 | "/", 58 | "/{route:route}", 59 | "/{route:route}/{todo_id:int}", 60 | ), 61 | ): 62 | def __init__(self, route: str = "list", todo_id: int = 0): 63 | self.todo_id = todo_id 64 | self.route = route 65 | self.router_component = TodoRouterComponent(self.route, self.todo_id) 66 | 67 | async def render(self): 68 | return Doc( 69 | Html( 70 | Head( 71 | Title("Todo App"), 72 | Link( 73 | rel="stylesheet", 74 | href="https://unpkg.com/@picocss/pico@1.*/css/pico.min.css", 75 | ), 76 | ), 77 | Body( 78 | self.router_component, 79 | Script(src="https://unpkg.com/htmx.org@2.0.0-beta4"), 80 | ), 81 | data_theme="dark", 82 | ) 83 | ) 84 | 85 | 86 | class TodoRouterComponent(Component): 87 | def __init__(self, route: str, todo_id: int = 0) -> None: 88 | self.route = route 89 | self.todo_id = todo_id 90 | Component.add_render_extension(router=self.router) 91 | 92 | @classmethod 93 | def get_route(cls, route: str, todo_id: int = 0): 94 | if route == "list": 95 | route_comp = TodoListComponent() 96 | elif route == "edit": 97 | route_comp = TodoEditComponent(todo_id) 98 | elif route == "add": 99 | route_comp = TodoAddComponent() 100 | else: 101 | raise RuntimeError(f"Unknown route: {route}") 102 | 103 | return route_comp 104 | 105 | async def render(self): 106 | return Div( 107 | TodoHeaderComponent(), 108 | self.get_route(self.route, self.todo_id), 109 | _class="container", 110 | ) 111 | 112 | @Target.get 113 | def router(self, route: str, todo_id: int = 0): 114 | self.route = route 115 | self.todo_id = todo_id 116 | return self 117 | 118 | 119 | class TodoHeaderComponent(Component): 120 | async def render(self, router): 121 | return Nav( 122 | Ul( 123 | Li(Strong("Todo App")), 124 | ), 125 | Ul( 126 | Li( 127 | A( 128 | "Todo List", 129 | href="javascript:void(0);", 130 | click=router("list"), 131 | push_url="/", 132 | ), 133 | ), 134 | Li( 135 | A( 136 | "Add Todo", 137 | href="javascript:void(0);", 138 | click=router("add"), 139 | push_url="/add", 140 | ), 141 | ), 142 | ), 143 | ) 144 | 145 | 146 | class TodoListComponent(Component): 147 | async def render(self, router): 148 | return Ul( 149 | *[ 150 | Li( 151 | Form( 152 | Input( 153 | type="checkbox", 154 | checked=todo.finished, 155 | click=self.toggle(todo.id), 156 | ), 157 | style="display: inline;", 158 | ), 159 | A( 160 | todo.message if not todo.finished else S(todo.message), 161 | href="javascript:void(0);", 162 | click=router("edit", todo_id=todo.id), 163 | push_url=f"/edit/{todo.id}", 164 | style="display: inline;", 165 | ), 166 | A( 167 | Img(src="/static/images/trash-2.svg"), 168 | href="javascript:void(0);", 169 | click=self.delete_todo(todo.id), 170 | style="display: inline;", 171 | confirm="Are you sure you want to delete this todo?", 172 | ), 173 | ) 174 | for todo in db.get_todos() 175 | ], 176 | ) 177 | 178 | @Target.delete 179 | def delete_todo(self, todo_id: int): 180 | db.delete_todo(todo_id) 181 | return TodoRouterComponent.get_route("list") 182 | 183 | @Target.put 184 | def toggle(self, /, todo_id: int): 185 | todo = db.get_todo(todo_id) 186 | db.update_todo(todo.id, todo.message, not todo.finished) 187 | return TodoRouterComponent.get_route("list") 188 | 189 | 190 | class TodoAddComponent(Component): 191 | async def render(self): 192 | return Form( 193 | Textarea(type="text", name="message", rows=5), 194 | Button("Add", type="submit", click=self.add_todo(), push_url="/"), 195 | ) 196 | 197 | @Target.post 198 | def add_todo(self, todo: db.Todo, /): 199 | db.create_todo(todo.message, False) 200 | return TodoRouterComponent.get_route("list") 201 | 202 | 203 | class TodoEditComponent(Component): 204 | def __init__(self, todo_id: int): 205 | self.todo_id = todo_id 206 | 207 | @property 208 | def todo(self): 209 | if not hasattr(self, "_todo"): 210 | self._todo = db.get_todo(self.todo_id) 211 | return self._todo 212 | 213 | async def render(self): 214 | return Form( 215 | Textarea(self.todo.message, type="text", name="message", rows=5), 216 | Button( 217 | "Edit", type="submit", click=self.edit_todo(self.todo_id), push_url="/" 218 | ), 219 | ) 220 | 221 | @Target.put 222 | def edit_todo(self, todo: db.Todo, /, todo_id: int): 223 | self.todo_id = todo_id 224 | db.update_todo(todo_id, todo.message, self.todo.finished) 225 | return TodoRouterComponent.get_route("list") 226 | -------------------------------------------------------------------------------- /examples/todo/db.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | 5 | con = sqlite3.connect("todos.db") 6 | cur = con.cursor() 7 | cur.execute( 8 | "CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY, message TEXT, finished INTEGER)" 9 | ) 10 | 11 | 12 | @dataclass 13 | class Todo: 14 | id: Optional[int] = None 15 | message: Optional[str] = None 16 | finished: bool = False 17 | 18 | 19 | def get_todo(id): 20 | cur.execute("SELECT * FROM todos WHERE id = ?", (id,)) 21 | todo = Todo(*cur.fetchone()) 22 | return todo 23 | 24 | 25 | def get_todos(): 26 | cur.execute("SELECT * FROM todos") 27 | todos = [Todo(*todo) for todo in cur.fetchall()] 28 | return todos 29 | 30 | 31 | def create_todo(message, finished): 32 | cur.execute( 33 | "INSERT INTO todos (message, finished) VALUES (?, ?)", (message, finished) 34 | ) 35 | con.commit() 36 | 37 | 38 | def update_todo(id, message, finished): 39 | cur.execute( 40 | "UPDATE todos SET message = ?, finished = ? WHERE id = ?", 41 | (message, finished, id), 42 | ) 43 | con.commit() 44 | 45 | 46 | def delete_todo(id): 47 | cur.execute("DELETE FROM todos WHERE id = ?", (id,)) 48 | con.commit() 49 | -------------------------------------------------------------------------------- /examples/todo/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | uvicorn examples.todo:app.starlette --host '0.0.0.0' --log-level=debug --reload -------------------------------------------------------------------------------- /examples/todo/static/images/trash-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # setup logging before importing app 4 | logging.basicConfig() 5 | logging.getLogger("redmage").setLevel(logging.DEBUG) 6 | 7 | import uvicorn 8 | 9 | from examples.todo_jinja2 import app 10 | 11 | if __name__ == "__main__": 12 | uvicorn.run(app.starlette, port=8000) 13 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "anyio" 5 | version = "4.1.0" 6 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "anyio-4.1.0-py3-none-any.whl", hash = "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f"}, 11 | {file = "anyio-4.1.0.tar.gz", hash = "sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da"}, 12 | ] 13 | 14 | [package.dependencies] 15 | exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} 16 | idna = ">=2.8" 17 | sniffio = ">=1.1" 18 | 19 | [package.extras] 20 | doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 21 | test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] 22 | trio = ["trio (>=0.23)"] 23 | 24 | [[package]] 25 | name = "black" 26 | version = "24.1.1" 27 | description = "The uncompromising code formatter." 28 | optional = false 29 | python-versions = ">=3.8" 30 | files = [ 31 | {file = "black-24.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c"}, 32 | {file = "black-24.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445"}, 33 | {file = "black-24.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a"}, 34 | {file = "black-24.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4"}, 35 | {file = "black-24.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7"}, 36 | {file = "black-24.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8"}, 37 | {file = "black-24.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161"}, 38 | {file = "black-24.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d"}, 39 | {file = "black-24.1.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8"}, 40 | {file = "black-24.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e"}, 41 | {file = "black-24.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6"}, 42 | {file = "black-24.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b"}, 43 | {file = "black-24.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62"}, 44 | {file = "black-24.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5"}, 45 | {file = "black-24.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6"}, 46 | {file = "black-24.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717"}, 47 | {file = "black-24.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9"}, 48 | {file = "black-24.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024"}, 49 | {file = "black-24.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2"}, 50 | {file = "black-24.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac"}, 51 | {file = "black-24.1.1-py3-none-any.whl", hash = "sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168"}, 52 | {file = "black-24.1.1.tar.gz", hash = "sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b"}, 53 | ] 54 | 55 | [package.dependencies] 56 | click = ">=8.0.0" 57 | mypy-extensions = ">=0.4.3" 58 | packaging = ">=22.0" 59 | pathspec = ">=0.9.0" 60 | platformdirs = ">=2" 61 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 62 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 63 | 64 | [package.extras] 65 | colorama = ["colorama (>=0.4.3)"] 66 | d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] 67 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 68 | uvloop = ["uvloop (>=0.15.2)"] 69 | 70 | [[package]] 71 | name = "certifi" 72 | version = "2023.11.17" 73 | description = "Python package for providing Mozilla's CA Bundle." 74 | optional = false 75 | python-versions = ">=3.6" 76 | files = [ 77 | {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, 78 | {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, 79 | ] 80 | 81 | [[package]] 82 | name = "click" 83 | version = "8.1.7" 84 | description = "Composable command line interface toolkit" 85 | optional = false 86 | python-versions = ">=3.7" 87 | files = [ 88 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 89 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 90 | ] 91 | 92 | [package.dependencies] 93 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 94 | 95 | [[package]] 96 | name = "colorama" 97 | version = "0.4.6" 98 | description = "Cross-platform colored terminal text." 99 | optional = false 100 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 101 | files = [ 102 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 103 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 104 | ] 105 | 106 | [[package]] 107 | name = "coverage" 108 | version = "7.3.3" 109 | description = "Code coverage measurement for Python" 110 | optional = false 111 | python-versions = ">=3.8" 112 | files = [ 113 | {file = "coverage-7.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d874434e0cb7b90f7af2b6e3309b0733cde8ec1476eb47db148ed7deeb2a9494"}, 114 | {file = "coverage-7.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee6621dccce8af666b8c4651f9f43467bfbf409607c604b840b78f4ff3619aeb"}, 115 | {file = "coverage-7.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1367aa411afb4431ab58fd7ee102adb2665894d047c490649e86219327183134"}, 116 | {file = "coverage-7.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f0f8f0c497eb9c9f18f21de0750c8d8b4b9c7000b43996a094290b59d0e7523"}, 117 | {file = "coverage-7.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db0338c4b0951d93d547e0ff8d8ea340fecf5885f5b00b23be5aa99549e14cfd"}, 118 | {file = "coverage-7.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d31650d313bd90d027f4be7663dfa2241079edd780b56ac416b56eebe0a21aab"}, 119 | {file = "coverage-7.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9437a4074b43c177c92c96d051957592afd85ba00d3e92002c8ef45ee75df438"}, 120 | {file = "coverage-7.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9e17d9cb06c13b4f2ef570355fa45797d10f19ca71395910b249e3f77942a837"}, 121 | {file = "coverage-7.3.3-cp310-cp310-win32.whl", hash = "sha256:eee5e741b43ea1b49d98ab6e40f7e299e97715af2488d1c77a90de4a663a86e2"}, 122 | {file = "coverage-7.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:593efa42160c15c59ee9b66c5f27a453ed3968718e6e58431cdfb2d50d5ad284"}, 123 | {file = "coverage-7.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c944cf1775235c0857829c275c777a2c3e33032e544bcef614036f337ac37bb"}, 124 | {file = "coverage-7.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eda7f6e92358ac9e1717ce1f0377ed2b9320cea070906ece4e5c11d172a45a39"}, 125 | {file = "coverage-7.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c854c1d2c7d3e47f7120b560d1a30c1ca221e207439608d27bc4d08fd4aeae8"}, 126 | {file = "coverage-7.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:222b038f08a7ebed1e4e78ccf3c09a1ca4ac3da16de983e66520973443b546bc"}, 127 | {file = "coverage-7.3.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff4800783d85bff132f2cc7d007426ec698cdce08c3062c8d501ad3f4ea3d16c"}, 128 | {file = "coverage-7.3.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fc200cec654311ca2c3f5ab3ce2220521b3d4732f68e1b1e79bef8fcfc1f2b97"}, 129 | {file = "coverage-7.3.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:307aecb65bb77cbfebf2eb6e12009e9034d050c6c69d8a5f3f737b329f4f15fb"}, 130 | {file = "coverage-7.3.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ffb0eacbadb705c0a6969b0adf468f126b064f3362411df95f6d4f31c40d31c1"}, 131 | {file = "coverage-7.3.3-cp311-cp311-win32.whl", hash = "sha256:79c32f875fd7c0ed8d642b221cf81feba98183d2ff14d1f37a1bbce6b0347d9f"}, 132 | {file = "coverage-7.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:243576944f7c1a1205e5cd658533a50eba662c74f9be4c050d51c69bd4532936"}, 133 | {file = "coverage-7.3.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a2ac4245f18057dfec3b0074c4eb366953bca6787f1ec397c004c78176a23d56"}, 134 | {file = "coverage-7.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9191be7af41f0b54324ded600e8ddbcabea23e1e8ba419d9a53b241dece821d"}, 135 | {file = "coverage-7.3.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31c0b1b8b5a4aebf8fcd227237fc4263aa7fa0ddcd4d288d42f50eff18b0bac4"}, 136 | {file = "coverage-7.3.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee453085279df1bac0996bc97004771a4a052b1f1e23f6101213e3796ff3cb85"}, 137 | {file = "coverage-7.3.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1191270b06ecd68b1d00897b2daddb98e1719f63750969614ceb3438228c088e"}, 138 | {file = "coverage-7.3.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:007a7e49831cfe387473e92e9ff07377f6121120669ddc39674e7244350a6a29"}, 139 | {file = "coverage-7.3.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:af75cf83c2d57717a8493ed2246d34b1f3398cb8a92b10fd7a1858cad8e78f59"}, 140 | {file = "coverage-7.3.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:811ca7373da32f1ccee2927dc27dc523462fd30674a80102f86c6753d6681bc6"}, 141 | {file = "coverage-7.3.3-cp312-cp312-win32.whl", hash = "sha256:733537a182b5d62184f2a72796eb6901299898231a8e4f84c858c68684b25a70"}, 142 | {file = "coverage-7.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:e995efb191f04b01ced307dbd7407ebf6e6dc209b528d75583277b10fd1800ee"}, 143 | {file = "coverage-7.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fbd8a5fe6c893de21a3c6835071ec116d79334fbdf641743332e442a3466f7ea"}, 144 | {file = "coverage-7.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:50c472c1916540f8b2deef10cdc736cd2b3d1464d3945e4da0333862270dcb15"}, 145 | {file = "coverage-7.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e9223a18f51d00d3ce239c39fc41410489ec7a248a84fab443fbb39c943616c"}, 146 | {file = "coverage-7.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f501e36ac428c1b334c41e196ff6bd550c0353c7314716e80055b1f0a32ba394"}, 147 | {file = "coverage-7.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:475de8213ed95a6b6283056d180b2442eee38d5948d735cd3d3b52b86dd65b92"}, 148 | {file = "coverage-7.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:afdcc10c01d0db217fc0a64f58c7edd635b8f27787fea0a3054b856a6dff8717"}, 149 | {file = "coverage-7.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fff0b2f249ac642fd735f009b8363c2b46cf406d3caec00e4deeb79b5ff39b40"}, 150 | {file = "coverage-7.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a1f76cfc122c9e0f62dbe0460ec9cc7696fc9a0293931a33b8870f78cf83a327"}, 151 | {file = "coverage-7.3.3-cp38-cp38-win32.whl", hash = "sha256:757453848c18d7ab5d5b5f1827293d580f156f1c2c8cef45bfc21f37d8681069"}, 152 | {file = "coverage-7.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad2453b852a1316c8a103c9c970db8fbc262f4f6b930aa6c606df9b2766eee06"}, 153 | {file = "coverage-7.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b15e03b8ee6a908db48eccf4e4e42397f146ab1e91c6324da44197a45cb9132"}, 154 | {file = "coverage-7.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:89400aa1752e09f666cc48708eaa171eef0ebe3d5f74044b614729231763ae69"}, 155 | {file = "coverage-7.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c59a3e59fb95e6d72e71dc915e6d7fa568863fad0a80b33bc7b82d6e9f844973"}, 156 | {file = "coverage-7.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ede881c7618f9cf93e2df0421ee127afdfd267d1b5d0c59bcea771cf160ea4a"}, 157 | {file = "coverage-7.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3bfd2c2f0e5384276e12b14882bf2c7621f97c35320c3e7132c156ce18436a1"}, 158 | {file = "coverage-7.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7f3bad1a9313401ff2964e411ab7d57fb700a2d5478b727e13f156c8f89774a0"}, 159 | {file = "coverage-7.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:65d716b736f16e250435473c5ca01285d73c29f20097decdbb12571d5dfb2c94"}, 160 | {file = "coverage-7.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a702e66483b1fe602717020a0e90506e759c84a71dbc1616dd55d29d86a9b91f"}, 161 | {file = "coverage-7.3.3-cp39-cp39-win32.whl", hash = "sha256:7fbf3f5756e7955174a31fb579307d69ffca91ad163467ed123858ce0f3fd4aa"}, 162 | {file = "coverage-7.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:cad9afc1644b979211989ec3ff7d82110b2ed52995c2f7263e7841c846a75348"}, 163 | {file = "coverage-7.3.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:d299d379b676812e142fb57662a8d0d810b859421412b4d7af996154c00c31bb"}, 164 | {file = "coverage-7.3.3.tar.gz", hash = "sha256:df04c64e58df96b4427db8d0559e95e2df3138c9916c96f9f6a4dd220db2fdb7"}, 165 | ] 166 | 167 | [package.dependencies] 168 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 169 | 170 | [package.extras] 171 | toml = ["tomli"] 172 | 173 | [[package]] 174 | name = "exceptiongroup" 175 | version = "1.2.0" 176 | description = "Backport of PEP 654 (exception groups)" 177 | optional = false 178 | python-versions = ">=3.7" 179 | files = [ 180 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 181 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 182 | ] 183 | 184 | [package.extras] 185 | test = ["pytest (>=6)"] 186 | 187 | [[package]] 188 | name = "h11" 189 | version = "0.14.0" 190 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 191 | optional = false 192 | python-versions = ">=3.7" 193 | files = [ 194 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 195 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 196 | ] 197 | 198 | [[package]] 199 | name = "httpcore" 200 | version = "0.17.3" 201 | description = "A minimal low-level HTTP client." 202 | optional = false 203 | python-versions = ">=3.7" 204 | files = [ 205 | {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, 206 | {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, 207 | ] 208 | 209 | [package.dependencies] 210 | anyio = ">=3.0,<5.0" 211 | certifi = "*" 212 | h11 = ">=0.13,<0.15" 213 | sniffio = "==1.*" 214 | 215 | [package.extras] 216 | http2 = ["h2 (>=3,<5)"] 217 | socks = ["socksio (==1.*)"] 218 | 219 | [[package]] 220 | name = "httpx" 221 | version = "0.24.1" 222 | description = "The next generation HTTP client." 223 | optional = false 224 | python-versions = ">=3.7" 225 | files = [ 226 | {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, 227 | {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, 228 | ] 229 | 230 | [package.dependencies] 231 | certifi = "*" 232 | httpcore = ">=0.15.0,<0.18.0" 233 | idna = "*" 234 | sniffio = "*" 235 | 236 | [package.extras] 237 | brotli = ["brotli", "brotlicffi"] 238 | cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] 239 | http2 = ["h2 (>=3,<5)"] 240 | socks = ["socksio (==1.*)"] 241 | 242 | [[package]] 243 | name = "hype-html" 244 | version = "2.1.2" 245 | description = "A minimal python dsl for generating html." 246 | optional = false 247 | python-versions = ">=3.7,<4.0" 248 | files = [ 249 | {file = "hype_html-2.1.2-py3-none-any.whl", hash = "sha256:aa1286fee494804f6b95a3d8ec3c69154d34e84dec081852d30db822536afa79"}, 250 | {file = "hype_html-2.1.2.tar.gz", hash = "sha256:6f522a11ce880e32638529717a66354e79936663d6b9cda46a61b7c56322bcb4"}, 251 | ] 252 | 253 | [[package]] 254 | name = "idna" 255 | version = "3.6" 256 | description = "Internationalized Domain Names in Applications (IDNA)" 257 | optional = false 258 | python-versions = ">=3.5" 259 | files = [ 260 | {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, 261 | {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, 262 | ] 263 | 264 | [[package]] 265 | name = "iniconfig" 266 | version = "2.0.0" 267 | description = "brain-dead simple config-ini parsing" 268 | optional = false 269 | python-versions = ">=3.7" 270 | files = [ 271 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 272 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 273 | ] 274 | 275 | [[package]] 276 | name = "isort" 277 | version = "5.13.2" 278 | description = "A Python utility / library to sort Python imports." 279 | optional = false 280 | python-versions = ">=3.8.0" 281 | files = [ 282 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 283 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 284 | ] 285 | 286 | [package.extras] 287 | colors = ["colorama (>=0.4.6)"] 288 | 289 | [[package]] 290 | name = "jinja2" 291 | version = "3.1.2" 292 | description = "A very fast and expressive template engine." 293 | optional = false 294 | python-versions = ">=3.7" 295 | files = [ 296 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 297 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 298 | ] 299 | 300 | [package.dependencies] 301 | MarkupSafe = ">=2.0" 302 | 303 | [package.extras] 304 | i18n = ["Babel (>=2.7)"] 305 | 306 | [[package]] 307 | name = "markupsafe" 308 | version = "2.1.3" 309 | description = "Safely add untrusted strings to HTML/XML markup." 310 | optional = false 311 | python-versions = ">=3.7" 312 | files = [ 313 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, 314 | {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, 315 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, 316 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, 317 | {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, 318 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, 319 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, 320 | {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, 321 | {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, 322 | {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, 323 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, 324 | {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, 325 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, 326 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, 327 | {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, 328 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, 329 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, 330 | {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, 331 | {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, 332 | {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, 333 | {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, 334 | {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, 335 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, 336 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, 337 | {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, 338 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, 339 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, 340 | {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, 341 | {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, 342 | {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, 343 | {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, 344 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, 345 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, 346 | {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, 347 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, 348 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, 349 | {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, 350 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, 351 | {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, 352 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, 353 | {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, 354 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, 355 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, 356 | {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, 357 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, 358 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, 359 | {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, 360 | {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, 361 | {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, 362 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, 363 | {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, 364 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, 365 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, 366 | {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, 367 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, 368 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, 369 | {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, 370 | {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, 371 | {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, 372 | {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, 373 | ] 374 | 375 | [[package]] 376 | name = "mypy" 377 | version = "1.7.1" 378 | description = "Optional static typing for Python" 379 | optional = false 380 | python-versions = ">=3.8" 381 | files = [ 382 | {file = "mypy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12cce78e329838d70a204293e7b29af9faa3ab14899aec397798a4b41be7f340"}, 383 | {file = "mypy-1.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1484b8fa2c10adf4474f016e09d7a159602f3239075c7bf9f1627f5acf40ad49"}, 384 | {file = "mypy-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31902408f4bf54108bbfb2e35369877c01c95adc6192958684473658c322c8a5"}, 385 | {file = "mypy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f2c2521a8e4d6d769e3234350ba7b65ff5d527137cdcde13ff4d99114b0c8e7d"}, 386 | {file = "mypy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:fcd2572dd4519e8a6642b733cd3a8cfc1ef94bafd0c1ceed9c94fe736cb65b6a"}, 387 | {file = "mypy-1.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b901927f16224d0d143b925ce9a4e6b3a758010673eeded9b748f250cf4e8f7"}, 388 | {file = "mypy-1.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f7f6985d05a4e3ce8255396df363046c28bea790e40617654e91ed580ca7c51"}, 389 | {file = "mypy-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:944bdc21ebd620eafefc090cdf83158393ec2b1391578359776c00de00e8907a"}, 390 | {file = "mypy-1.7.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9c7ac372232c928fff0645d85f273a726970c014749b924ce5710d7d89763a28"}, 391 | {file = "mypy-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:f6efc9bd72258f89a3816e3a98c09d36f079c223aa345c659622f056b760ab42"}, 392 | {file = "mypy-1.7.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6dbdec441c60699288adf051f51a5d512b0d818526d1dcfff5a41f8cd8b4aaf1"}, 393 | {file = "mypy-1.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4fc3d14ee80cd22367caaaf6e014494415bf440980a3045bf5045b525680ac33"}, 394 | {file = "mypy-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c6e4464ed5f01dc44dc9821caf67b60a4e5c3b04278286a85c067010653a0eb"}, 395 | {file = "mypy-1.7.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d9b338c19fa2412f76e17525c1b4f2c687a55b156320acb588df79f2e6fa9fea"}, 396 | {file = "mypy-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:204e0d6de5fd2317394a4eff62065614c4892d5a4d1a7ee55b765d7a3d9e3f82"}, 397 | {file = "mypy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:84860e06ba363d9c0eeabd45ac0fde4b903ad7aa4f93cd8b648385a888e23200"}, 398 | {file = "mypy-1.7.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8c5091ebd294f7628eb25ea554852a52058ac81472c921150e3a61cdd68f75a7"}, 399 | {file = "mypy-1.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40716d1f821b89838589e5b3106ebbc23636ffdef5abc31f7cd0266db936067e"}, 400 | {file = "mypy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5cf3f0c5ac72139797953bd50bc6c95ac13075e62dbfcc923571180bebb662e9"}, 401 | {file = "mypy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:78e25b2fd6cbb55ddfb8058417df193f0129cad5f4ee75d1502248e588d9e0d7"}, 402 | {file = "mypy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75c4d2a6effd015786c87774e04331b6da863fc3fc4e8adfc3b40aa55ab516fe"}, 403 | {file = "mypy-1.7.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2643d145af5292ee956aa0a83c2ce1038a3bdb26e033dadeb2f7066fb0c9abce"}, 404 | {file = "mypy-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75aa828610b67462ffe3057d4d8a4112105ed211596b750b53cbfe182f44777a"}, 405 | {file = "mypy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ee5d62d28b854eb61889cde4e1dbc10fbaa5560cb39780c3995f6737f7e82120"}, 406 | {file = "mypy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:72cf32ce7dd3562373f78bd751f73c96cfb441de147cc2448a92c1a308bd0ca6"}, 407 | {file = "mypy-1.7.1-py3-none-any.whl", hash = "sha256:f7c5d642db47376a0cc130f0de6d055056e010debdaf0707cd2b0fc7e7ef30ea"}, 408 | {file = "mypy-1.7.1.tar.gz", hash = "sha256:fcb6d9afb1b6208b4c712af0dafdc650f518836065df0d4fb1d800f5d6773db2"}, 409 | ] 410 | 411 | [package.dependencies] 412 | mypy-extensions = ">=1.0.0" 413 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 414 | typing-extensions = ">=4.1.0" 415 | 416 | [package.extras] 417 | dmypy = ["psutil (>=4.0)"] 418 | install-types = ["pip"] 419 | mypyc = ["setuptools (>=50)"] 420 | reports = ["lxml"] 421 | 422 | [[package]] 423 | name = "mypy-extensions" 424 | version = "1.0.0" 425 | description = "Type system extensions for programs checked with the mypy type checker." 426 | optional = false 427 | python-versions = ">=3.5" 428 | files = [ 429 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 430 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 431 | ] 432 | 433 | [[package]] 434 | name = "packaging" 435 | version = "23.2" 436 | description = "Core utilities for Python packages" 437 | optional = false 438 | python-versions = ">=3.7" 439 | files = [ 440 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 441 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 442 | ] 443 | 444 | [[package]] 445 | name = "pathspec" 446 | version = "0.12.1" 447 | description = "Utility library for gitignore style pattern matching of file paths." 448 | optional = false 449 | python-versions = ">=3.8" 450 | files = [ 451 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 452 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 453 | ] 454 | 455 | [[package]] 456 | name = "platformdirs" 457 | version = "4.2.0" 458 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 459 | optional = false 460 | python-versions = ">=3.8" 461 | files = [ 462 | {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, 463 | {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, 464 | ] 465 | 466 | [package.extras] 467 | docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 468 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] 469 | 470 | [[package]] 471 | name = "pluggy" 472 | version = "1.3.0" 473 | description = "plugin and hook calling mechanisms for python" 474 | optional = false 475 | python-versions = ">=3.8" 476 | files = [ 477 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 478 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 479 | ] 480 | 481 | [package.extras] 482 | dev = ["pre-commit", "tox"] 483 | testing = ["pytest", "pytest-benchmark"] 484 | 485 | [[package]] 486 | name = "pycodestyle" 487 | version = "2.11.1" 488 | description = "Python style guide checker" 489 | optional = false 490 | python-versions = ">=3.8" 491 | files = [ 492 | {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, 493 | {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, 494 | ] 495 | 496 | [[package]] 497 | name = "pytest" 498 | version = "7.4.3" 499 | description = "pytest: simple powerful testing with Python" 500 | optional = false 501 | python-versions = ">=3.7" 502 | files = [ 503 | {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, 504 | {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, 505 | ] 506 | 507 | [package.dependencies] 508 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 509 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 510 | iniconfig = "*" 511 | packaging = "*" 512 | pluggy = ">=0.12,<2.0" 513 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 514 | 515 | [package.extras] 516 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 517 | 518 | [[package]] 519 | name = "pytest-asyncio" 520 | version = "0.21.1" 521 | description = "Pytest support for asyncio" 522 | optional = false 523 | python-versions = ">=3.7" 524 | files = [ 525 | {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, 526 | {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, 527 | ] 528 | 529 | [package.dependencies] 530 | pytest = ">=7.0.0" 531 | 532 | [package.extras] 533 | docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] 534 | testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] 535 | 536 | [[package]] 537 | name = "pytest-cov" 538 | version = "4.1.0" 539 | description = "Pytest plugin for measuring coverage." 540 | optional = false 541 | python-versions = ">=3.7" 542 | files = [ 543 | {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, 544 | {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, 545 | ] 546 | 547 | [package.dependencies] 548 | coverage = {version = ">=5.2.1", extras = ["toml"]} 549 | pytest = ">=4.6" 550 | 551 | [package.extras] 552 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] 553 | 554 | [[package]] 555 | name = "python-multipart" 556 | version = "0.0.6" 557 | description = "A streaming multipart parser for Python" 558 | optional = false 559 | python-versions = ">=3.7" 560 | files = [ 561 | {file = "python_multipart-0.0.6-py3-none-any.whl", hash = "sha256:ee698bab5ef148b0a760751c261902cd096e57e10558e11aca17646b74ee1c18"}, 562 | {file = "python_multipart-0.0.6.tar.gz", hash = "sha256:e9925a80bb668529f1b67c7fdb0a5dacdd7cbfc6fb0bff3ea443fe22bdd62132"}, 563 | ] 564 | 565 | [package.extras] 566 | dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatch", "invoke (==1.7.3)", "more-itertools (==4.3.0)", "pbr (==4.3.0)", "pluggy (==1.0.0)", "py (==1.11.0)", "pytest (==7.2.0)", "pytest-cov (==4.0.0)", "pytest-timeout (==2.1.0)", "pyyaml (==5.1)"] 567 | 568 | [[package]] 569 | name = "sniffio" 570 | version = "1.3.0" 571 | description = "Sniff out which async library your code is running under" 572 | optional = false 573 | python-versions = ">=3.7" 574 | files = [ 575 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 576 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 577 | ] 578 | 579 | [[package]] 580 | name = "starlette" 581 | version = "0.26.1" 582 | description = "The little ASGI library that shines." 583 | optional = false 584 | python-versions = ">=3.7" 585 | files = [ 586 | {file = "starlette-0.26.1-py3-none-any.whl", hash = "sha256:e87fce5d7cbdde34b76f0ac69013fd9d190d581d80681493016666e6f96c6d5e"}, 587 | {file = "starlette-0.26.1.tar.gz", hash = "sha256:41da799057ea8620e4667a3e69a5b1923ebd32b1819c8fa75634bbe8d8bea9bd"}, 588 | ] 589 | 590 | [package.dependencies] 591 | anyio = ">=3.4.0,<5" 592 | typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} 593 | 594 | [package.extras] 595 | full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] 596 | 597 | [[package]] 598 | name = "tomli" 599 | version = "2.0.1" 600 | description = "A lil' TOML parser" 601 | optional = false 602 | python-versions = ">=3.7" 603 | files = [ 604 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 605 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 606 | ] 607 | 608 | [[package]] 609 | name = "typing-extensions" 610 | version = "4.9.0" 611 | description = "Backported and Experimental Type Hints for Python 3.8+" 612 | optional = false 613 | python-versions = ">=3.8" 614 | files = [ 615 | {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, 616 | {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, 617 | ] 618 | 619 | [[package]] 620 | name = "uvicorn" 621 | version = "0.22.0" 622 | description = "The lightning-fast ASGI server." 623 | optional = false 624 | python-versions = ">=3.7" 625 | files = [ 626 | {file = "uvicorn-0.22.0-py3-none-any.whl", hash = "sha256:e9434d3bbf05f310e762147f769c9f21235ee118ba2d2bf1155a7196448bd996"}, 627 | {file = "uvicorn-0.22.0.tar.gz", hash = "sha256:79277ae03db57ce7d9aa0567830bbb51d7a612f54d6e1e3e92da3ef24c2c8ed8"}, 628 | ] 629 | 630 | [package.dependencies] 631 | click = ">=7.0" 632 | h11 = ">=0.8" 633 | 634 | [package.extras] 635 | standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] 636 | 637 | [metadata] 638 | lock-version = "2.0" 639 | python-versions = "^3.8" 640 | content-hash = "3cb985017ab12a9bb93f7afa9545dd1911df6d4cff18eef20a7439fb94ce58f4" 641 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "redmage" 3 | version = "0.5.0" 4 | description = "A component based library for building htmx powered applications." 5 | authors = ["Scott Russell "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.8" 11 | starlette = "^0.26.1" 12 | python-multipart = "^0.0.6" 13 | hype-html = "^2.1.2" 14 | 15 | [tool.poetry.group.dev.dependencies] 16 | pytest = "^7.3.1" 17 | pytest-cov = "^4.0.0" 18 | isort = "^5.12.0" 19 | httpx = "^0.24.0" 20 | jinja2 = "^3.1.2" 21 | mypy = "^1.2.0" 22 | uvicorn = "^0.22.0" 23 | pytest-asyncio = "^0.21.1" 24 | pycodestyle = "^2.11.1" 25 | black = "^24.1.1" 26 | 27 | [build-system] 28 | requires = ["poetry-core"] 29 | build-backend = "poetry.core.masonry.api" 30 | 31 | [tool.isort] 32 | profile = "black" 33 | -------------------------------------------------------------------------------- /redmage/__init__.py: -------------------------------------------------------------------------------- 1 | from starlette.convertors import register_url_convertor 2 | 3 | from .components import Component 4 | from .convertors import BoolConvertor, StringConverter 5 | from .core import Redmage 6 | from .targets import Target 7 | from .triggers import Trigger 8 | 9 | register_url_convertor("bool", BoolConvertor()) 10 | register_url_convertor("str", StringConverter()) 11 | -------------------------------------------------------------------------------- /redmage/components.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | from collections import OrderedDict 4 | from inspect import Parameter, signature 5 | from typing import Any, Dict, Optional 6 | from typing import OrderedDict as OrderedDictType 7 | from typing import Tuple, Type 8 | from uuid import uuid1 9 | 10 | from starlette.convertors import CONVERTOR_TYPES as starlette_convertors 11 | from starlette.responses import HTMLResponse 12 | 13 | from .utils import astr, group_signature_param_by_kind 14 | 15 | logger = logging.getLogger("redmage") 16 | 17 | 18 | class Component(ABC): 19 | app: "Redmage" # type: ignore 20 | request = None # type: ignore 21 | components = [] # type: ignore 22 | render_extensions: Dict[str, Any] = {} 23 | 24 | def __init_subclass__(cls, routes: Optional[Tuple[str]] = None, **kwargs: Any): 25 | super().__init_subclass__(**kwargs) 26 | Component.components.append((cls, routes)) 27 | 28 | @classmethod 29 | def set_app(cls, app: "Redmage") -> None: # type: ignore 30 | cls.app = app 31 | 32 | @classmethod 33 | def add_render_extension(cls, **kwargs: Any) -> None: 34 | for key, value in kwargs.items(): 35 | cls.render_extensions[key] = value 36 | 37 | @classmethod 38 | def get_base_path(cls, instance: Optional["Component"] = None) -> str: 39 | def get_uuid(instance: "Component") -> str: 40 | parts = instance.id.split("-") 41 | return "-".join(parts[1:]) 42 | 43 | uuid = get_uuid(instance) if instance else "{id:str}" 44 | path = f"/{cls.__name__}/{uuid}" 45 | 46 | if getattr(cls, "__annotations__", None): 47 | annotations = cls.__annotations__ 48 | annotations.pop("app", None) 49 | annotations.pop("render_extensions", None) 50 | 51 | for field, field_type in annotations.items(): 52 | convertor = starlette_convertors[ 53 | ( 54 | field_type 55 | if ( 56 | isinstance(field_type, str) 57 | or not hasattr(field_type, "__name__") 58 | ) 59 | else field_type.__name__ 60 | ) 61 | ] 62 | value = ( 63 | convertor.to_string(getattr(instance, field, None)) 64 | if instance 65 | else f"{{{field}:{field_type.__name__}}}" 66 | ) 67 | path += f"/{field}/{value}" 68 | return path 69 | 70 | @classmethod 71 | def get_target_path( 72 | cls, 73 | method_name: str, 74 | *args: Any, 75 | instance: Optional["Component"] = None, 76 | **kwargs: Any, 77 | ) -> str: 78 | # TODO refactor 79 | # This method behaves in two different ways 80 | # depending on whether the instance is passed or not 81 | # because once the method is bound the signature is different 82 | # Nice to have it in one spot though 83 | method_fn = getattr(cls, method_name) 84 | path = f"/{method_name}" 85 | 86 | if instance: 87 | # We want the developer to be able to pass the args as positional or keywords 88 | # like a normal python function but still know if it's path param or query param 89 | # it just depends on the order of the params in the signature 90 | positional_or_keyword_params = list(args) + list(kwargs.values()) 91 | grouped_params = group_signature_param_by_kind(method_fn.target_signature) 92 | 93 | # self could be position only or postion_or_keyword 94 | # so set an offset to account for it 95 | if grouped_params[Parameter.POSITIONAL_OR_KEYWORD]: 96 | offset = ( 97 | 1 98 | if grouped_params[Parameter.POSITIONAL_OR_KEYWORD][0].name == "self" 99 | else 0 100 | ) 101 | 102 | for n, param_value in enumerate( 103 | grouped_params[Parameter.POSITIONAL_OR_KEYWORD] 104 | ): 105 | if ( 106 | param_value.default == Parameter.empty 107 | and param_value.name != "self" 108 | and len(positional_or_keyword_params) >= n 109 | ): 110 | convertor = starlette_convertors[param_value.annotation.__name__] 111 | value = convertor.to_string( 112 | positional_or_keyword_params[n - offset] 113 | ) 114 | path += f"/{value}" 115 | 116 | path += "?" 117 | 118 | for n, param_value in enumerate( 119 | grouped_params[Parameter.POSITIONAL_OR_KEYWORD] 120 | + grouped_params[Parameter.KEYWORD_ONLY] 121 | ): 122 | if ( 123 | param_value.default != Parameter.empty 124 | and param_value.kind != Parameter.POSITIONAL_ONLY 125 | and param_value.name != "self" 126 | and len(positional_or_keyword_params) >= n 127 | ): 128 | ann = ( 129 | param_value.annotation 130 | if isinstance(param_value.annotation, str) 131 | else param_value.annotation.__name__ 132 | ) 133 | convertor = starlette_convertors[ann] 134 | value = convertor.to_string( 135 | positional_or_keyword_params[n - offset] 136 | ) 137 | path += f"{method_name}__{param_value.name}=" 138 | path += f"{value}&" 139 | 140 | path = path[:-1] # Have to remove whatever the last character is 141 | 142 | else: 143 | params = signature(method_fn).parameters 144 | for param_value in params.values(): 145 | if ( 146 | param_value.default == Parameter.empty 147 | and param_value.kind != Parameter.POSITIONAL_ONLY 148 | and param_value.name != "self" 149 | ): 150 | ann = ( 151 | param_value.annotation 152 | if isinstance(param_value.annotation, str) 153 | else param_value.annotation.__name__ 154 | ) 155 | path += f"/{{{method_name}__{param_value.name}:{ann}}}" 156 | 157 | return path 158 | 159 | @property 160 | def id(self) -> str: 161 | if not hasattr(self, "_id"): 162 | self._id = f"{self.__class__.__name__}-{str(uuid1())}" 163 | return self._id 164 | 165 | @abstractmethod 166 | async def render(self, **exts: Any) -> "Element": # type: ignore 167 | ... # pragma: no cover 168 | 169 | def _filter_render_extensions(self) -> OrderedDictType[str, Any]: 170 | args = OrderedDict() 171 | params = signature(self.render).parameters 172 | for param in params.values(): 173 | if param.name in self.render_extensions.keys(): 174 | args[param.name] = self.render_extensions[param.name] 175 | if param.kind == Parameter.VAR_KEYWORD: 176 | args.update(self.render_extensions) 177 | return args 178 | 179 | def set_element_id(self, el: "Element") -> None: # type: ignore 180 | el.attrs(_id=self.id) 181 | 182 | def build_response(self, content: Any) -> HTMLResponse: 183 | return HTMLResponse(content) 184 | 185 | async def _astr_(self) -> str: 186 | render_extentions = self._filter_render_extensions() 187 | el = await self.render(**render_extentions) 188 | self.set_element_id(el) 189 | return await astr(el) 190 | -------------------------------------------------------------------------------- /redmage/convertors.py: -------------------------------------------------------------------------------- 1 | from starlette.convertors import Convertor 2 | 3 | 4 | class BoolConvertor(Convertor): 5 | regex = "True|False" 6 | 7 | def convert(self, value: str) -> bool: 8 | return True if value == "True" else False 9 | 10 | def to_string(self, value: bool) -> str: 11 | return "True" if value else "False" 12 | 13 | 14 | class StringConverter(Convertor): 15 | """ 16 | Custom String Convertor to allow for empty strings for urls 17 | """ 18 | 19 | regex = "[^/]+" 20 | 21 | def convert(self, value: str) -> str: 22 | return "" if value == "__empty__" else value 23 | 24 | def to_string(self, value: str) -> str: 25 | return value if value else "__empty__" 26 | -------------------------------------------------------------------------------- /redmage/core.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from inspect import Parameter, getmembers, iscoroutine, isfunction, signature 3 | from types import FunctionType 4 | from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union 5 | 6 | from starlette.applications import Starlette 7 | from starlette.convertors import CONVERTOR_TYPES as starlette_convertors 8 | from starlette.datastructures import FormData, QueryParams 9 | from starlette.middleware import Middleware 10 | from starlette.requests import Request 11 | from starlette.responses import HTMLResponse 12 | from starlette.routing import Route 13 | 14 | from redmage.exceptions import RedmageError 15 | 16 | from .components import Component 17 | from .targets import Target 18 | from .types import HTTPMethod 19 | from .utils import astr 20 | 21 | logger = logging.getLogger("redmage") 22 | 23 | 24 | ComponentClass = Type[Component] 25 | 26 | 27 | class Redmage: 28 | def __init__( 29 | self, middleware: Optional[Sequence[Middleware]] = None, debug: bool = False 30 | ): 31 | self.debug = debug 32 | self.middleware = middleware 33 | self.routes: List[Route] = [] 34 | # Could cause problems if multiple apps are created 35 | Component.set_app(self) 36 | 37 | @property 38 | def starlette(self) -> Starlette: 39 | if not hasattr(self, "_starlette"): 40 | self.create_routes() 41 | if self.middleware: 42 | self._starlette = Starlette( 43 | debug=self.debug, 44 | routes=self.routes, 45 | middleware=self.middleware, 46 | ) 47 | else: 48 | self._starlette = Starlette(debug=self.debug, routes=self.routes) 49 | return self._starlette 50 | 51 | def create_routes(self) -> None: 52 | for cls, routes in Component.components: 53 | if routes: 54 | self._register_routes(cls, routes) 55 | self._register_targets(cls) 56 | 57 | def _get_explicit_route_function(self, cls: ComponentClass) -> Callable: 58 | async def route_function(request: Request) -> HTMLResponse: 59 | attrs = {**request.path_params, **request.query_params} 60 | instance = cls(**attrs) 61 | instance.request = request # type: ignore 62 | return instance.build_response(await astr(instance)) 63 | 64 | return route_function 65 | 66 | def _get_route_function( 67 | self, cls: ComponentClass, name: str, fn: Callable 68 | ) -> Callable: 69 | async def route_function(request: Request) -> HTMLResponse: 70 | # Starlette should validate and convert the path params 71 | instance_params, comp_params = self._split_params( 72 | request.path_params, 73 | name, 74 | fn, 75 | ) 76 | # query params need to be validated and converted 77 | # to the correct type 78 | instance_query_params, comp_query_params = self._split_params( 79 | request.query_params, 80 | name, 81 | fn, 82 | ) 83 | # body serializer object should validate the form data and 84 | # convert it to the correct type 85 | body = self._process_form( 86 | await request.form(), fn 87 | ) # always passed to the method 88 | instance = cls.__new__(cls) 89 | attrs = {**instance_params, **instance_query_params} 90 | attrs["_id"] = f"{cls.__name__}-{attrs['id']}" 91 | instance.__dict__.update(attrs) 92 | if body: 93 | components = fn( 94 | instance, 95 | body, 96 | **{**comp_params, **comp_query_params}, 97 | ) 98 | else: 99 | components = fn( 100 | instance, 101 | **{**comp_params, **comp_query_params}, 102 | ) 103 | 104 | # If the target function is async we need to await it 105 | if iscoroutine(components): 106 | components = await components 107 | 108 | if isinstance(components, tuple): 109 | return instance.build_response( 110 | "\n".join([await astr(c) for c in components]) 111 | ) 112 | elif components: 113 | return instance.build_response(await astr(components)) 114 | return instance.build_response(await astr(instance)) 115 | 116 | return route_function 117 | 118 | def _convert_value(self, key: str, value: str, fn: Callable) -> Any: 119 | params = signature(fn).parameters 120 | if key in params: 121 | ann = ( 122 | params[key].annotation 123 | if isinstance(params[key].annotation, str) 124 | else params[key].annotation.__name__ 125 | ) 126 | type_name = ann 127 | value = starlette_convertors[type_name].convert(value) 128 | return value 129 | 130 | def _split_params( 131 | self, 132 | params: Union[Dict[str, Any], QueryParams], 133 | method_name: str, 134 | method_fn: Callable, 135 | ) -> Tuple[Dict[str, Any], Dict[str, Any]]: 136 | comp_params = {} 137 | method_params = {} 138 | 139 | for k, v in params.items(): 140 | if k.startswith(f"{method_name}__"): 141 | k = k.replace(f"{method_name}__", "") 142 | method_params[k] = self._convert_value(k, v, method_fn) 143 | else: 144 | comp_params[k] = self._convert_value(k, v, method_fn) 145 | 146 | return comp_params, method_params 147 | 148 | def _process_form(self, form_data: FormData, fn: Callable) -> Any: 149 | serializer = self._get_body_serializer_class(fn) 150 | body = {} 151 | for k, v in form_data.items(): 152 | body[k] = v 153 | if body and not serializer: 154 | raise RedmageError("The request has a body but no serializer was provided") 155 | if body and serializer: 156 | return serializer(**body) if body else None 157 | return body 158 | 159 | def _get_body_serializer_class(self, fn: Callable) -> Optional[Type]: 160 | params = signature(fn).parameters 161 | for param_name, param_value in params.items(): 162 | if ( 163 | param_name != "self" 164 | and param_value.default == Parameter.empty 165 | and param_value.kind == Parameter.POSITIONAL_ONLY 166 | ): 167 | return param_value.annotation 168 | return None 169 | 170 | def _get_target_method(self, name: str, fn: Callable) -> Callable[..., Target]: 171 | def target_method(instance: Component, *args: Any, **kwargs: Any) -> Target: 172 | return Target(instance, name, fn.target_method, *args, **kwargs) # type: ignore 173 | 174 | setattr(target_method, "target_signature", signature(fn)) 175 | return target_method 176 | 177 | def _register_routes( 178 | self, cls: ComponentClass, routes: Tuple[str] 179 | ) -> ComponentClass: 180 | for route in routes: 181 | logger.debug(route) 182 | self.routes.append( 183 | Route( 184 | route, 185 | self._get_explicit_route_function(cls), 186 | methods=[ 187 | HTTPMethod.GET, 188 | ], 189 | ) 190 | ) 191 | 192 | return self._register_targets(cls) 193 | 194 | def _register_targets(self, cls: ComponentClass) -> ComponentClass: 195 | methods = filter( 196 | lambda m: hasattr(m[1], "is_target"), getmembers(cls, predicate=isfunction) 197 | ) 198 | 199 | for method in methods: 200 | self._register_target(cls, method) 201 | return cls 202 | 203 | def _register_target( 204 | self, cls: ComponentClass, method: Tuple[str, FunctionType] 205 | ) -> None: 206 | method_name, method_fn = method 207 | path = cls.get_base_path() 208 | path += cls.get_target_path(method_name) 209 | logger.debug(path) 210 | route_function = self._get_route_function(cls, method_name, method_fn) 211 | 212 | self.routes.append( 213 | Route( 214 | path, 215 | route_function, 216 | name=method_name, 217 | methods=[method_fn.target_method], # type: ignore 218 | ) 219 | ) 220 | 221 | target_method = self._get_target_method(method_name, method_fn) 222 | setattr(cls, method_name, target_method) 223 | -------------------------------------------------------------------------------- /redmage/elements.py: -------------------------------------------------------------------------------- 1 | import html 2 | from typing import Optional, Tuple, Type, Union 3 | 4 | import hype.asyncio as hype 5 | 6 | from . import Component 7 | from .targets import Target 8 | from .triggers import Trigger 9 | from .types import HTMXClass, HTMXSwap, HTMXTrigger 10 | from .utils import astr 11 | 12 | 13 | class Element: 14 | el: Type[hype.Element] 15 | 16 | def __init__( 17 | self, 18 | *content: Union[str, hype.Element, Component], 19 | safe: bool = False, 20 | # hx-* attributes 21 | swap: str = HTMXSwap.OUTER_HTML, 22 | target: Optional[Target] = None, 23 | trigger: Union[Trigger, Tuple[Trigger, ...]] = (), 24 | swap_oob: bool = False, 25 | confirm: Optional[str] = None, 26 | boost: bool = False, 27 | push_url: Optional[str] = None, 28 | indicator: bool = False, 29 | on: Optional[str] = None, 30 | # helper target+trigger combos 31 | click: Optional[Target] = None, 32 | submit: Optional[Target] = None, 33 | change: Optional[Target] = None, 34 | mouse_over: Optional[Target] = None, 35 | mouse_enter: Optional[Target] = None, 36 | load: Optional[Target] = None, 37 | intersect: Optional[Target] = None, 38 | revealed: Optional[Target] = None, 39 | **kwargs: str, 40 | ): 41 | self.safe = safe 42 | # use the render method if it's component 43 | 44 | self.content = list( 45 | [ 46 | ( 47 | self._async_helper(c) 48 | if isinstance(c, Element) or isinstance(c, Component) 49 | else self.escape(c) # type: ignore 50 | ) 51 | for c in content 52 | ] 53 | ) 54 | self.swap = swap 55 | self.target = target 56 | self.trigger = trigger 57 | self.swap_oob = swap_oob 58 | self.confirm = confirm 59 | self.boost = boost 60 | self.push_url = push_url 61 | self.indicator = indicator 62 | self.on = on 63 | self.kwargs = kwargs 64 | self.click = click 65 | self.submit = submit 66 | self.change = change 67 | self.mouse_over = mouse_over 68 | self.mouse_enter = mouse_enter 69 | self.load = load 70 | self.intersect = intersect 71 | self.revealed = revealed 72 | 73 | helper_keywords = { 74 | "click": HTMXTrigger.CLICK, 75 | "submit": HTMXTrigger.SUBMIT, 76 | "change": HTMXTrigger.CHANGE, 77 | "mouse_over": HTMXTrigger.MOUSEOVER, 78 | "mouse_enter": HTMXTrigger.MOUSEENTER, 79 | "load": HTMXTrigger.LOAD, 80 | "intersect": HTMXTrigger.INTERSECT, 81 | "revealed": HTMXTrigger.REVEALED, 82 | } 83 | 84 | for k, v in helper_keywords.items(): 85 | if getattr(self, k, None): 86 | self.target = getattr(self, k) 87 | self.trigger = Trigger(v) 88 | 89 | def _async_helper(self, foo): # type: ignore 90 | async def inner() -> str: 91 | return await astr(foo) 92 | 93 | return inner 94 | 95 | def escape(self, el: str) -> str: 96 | if self.safe: 97 | return el 98 | return html.escape(str(el)) 99 | 100 | def append(self, el: Union[str, hype.Element]) -> None: 101 | self.content.append( 102 | self._async_helper(el) 103 | if isinstance(el, Element) or isinstance(el, Component) 104 | else self.escape(el) # type: ignore 105 | ) 106 | 107 | def attrs(self, **kwargs: str) -> None: 108 | self.kwargs = {**self.kwargs, **kwargs} 109 | 110 | def render(self) -> hype.Element: 111 | _class = self.kwargs.pop("_class", "") 112 | if self.indicator: 113 | _class += HTMXClass.Indicator 114 | 115 | el = self.el( 116 | *self.content, 117 | # don't want hype to escape the content 118 | # we'll do it ourselves 119 | safe=True, 120 | _class=_class, 121 | **self.kwargs, 122 | ) 123 | 124 | if self.target: 125 | el.attrs( 126 | hx_swap=self.swap, 127 | hx_target=f"#{self.target.instance.id}", 128 | **{f"hx_{self.target.http_method.lower()}": self.target.path}, 129 | ) 130 | 131 | if self.push_url: 132 | el.attrs(hx_push_url=self.push_url) 133 | 134 | if self.trigger: 135 | if isinstance(self.trigger, tuple): 136 | el.attrs(hx_trigger=", ".join([str(t) for t in self.trigger])) 137 | else: 138 | el.attrs(hx_trigger=str(self.trigger)) 139 | 140 | if self.swap_oob: 141 | el.attrs(hx_swap_oob="true") 142 | 143 | if self.confirm: 144 | el.attrs(hx_confirm=self.confirm) 145 | 146 | if self.boost: 147 | el.attrs(hx_boost="true") 148 | 149 | if self.on: 150 | el.attrs(hx_on=self.on) 151 | 152 | return el 153 | 154 | async def _astr_(self) -> str: 155 | return await self.render().render() 156 | 157 | 158 | class Doc: 159 | def __init__(self, el: Element): 160 | self.el = el 161 | 162 | def attrs(self, **kwargs: str) -> None: 163 | self.el.attrs(**kwargs) 164 | 165 | async def _astr_(self) -> str: 166 | doc = await hype.Doc(await astr(self.el)).render() 167 | return str(doc) 168 | 169 | 170 | class A(Element): 171 | el = hype.A 172 | 173 | 174 | class Abbr(Element): 175 | el = hype.Abbr 176 | 177 | 178 | class Address(Element): 179 | el = hype.Address 180 | 181 | 182 | class Area(Element): 183 | el = hype.Area 184 | 185 | 186 | class Article(Element): 187 | el = hype.Article 188 | 189 | 190 | class Aside(Element): 191 | el = hype.Aside 192 | 193 | 194 | class Audio(Element): 195 | el = hype.Audio 196 | 197 | 198 | class B(Element): 199 | el = hype.B 200 | 201 | 202 | class Base(Element): 203 | el = hype.Base 204 | 205 | 206 | class Bdi(Element): 207 | el = hype.Bdi 208 | 209 | 210 | class Bdo(Element): 211 | el = hype.Bdo 212 | 213 | 214 | class Blockquote(Element): 215 | el = hype.Blockquote 216 | 217 | 218 | class Body(Element): 219 | el = hype.Body 220 | 221 | 222 | class Br(Element): 223 | el = hype.Br 224 | 225 | 226 | class Button(Element): 227 | el = hype.Button 228 | 229 | 230 | class Canvas(Element): 231 | el = hype.Canvas 232 | 233 | 234 | class Caption(Element): 235 | el = hype.Caption 236 | 237 | 238 | class Cite(Element): 239 | el = hype.Cite 240 | 241 | 242 | class Code(Element): 243 | el = hype.Code 244 | 245 | 246 | class Col(Element): 247 | el = hype.Col 248 | 249 | 250 | class Colgroup(Element): 251 | el = hype.Colgroup 252 | 253 | 254 | class Data(Element): 255 | el = hype.Data 256 | 257 | 258 | class Datalist(Element): 259 | el = hype.Datalist 260 | 261 | 262 | class Dd(Element): 263 | el = hype.Dd 264 | 265 | 266 | class Del(Element): 267 | el = hype.Del 268 | 269 | 270 | class Details(Element): 271 | el = hype.Details 272 | 273 | 274 | class Dfn(Element): 275 | el = hype.Dfn 276 | 277 | 278 | class Dialog(Element): 279 | el = hype.Dialog 280 | 281 | 282 | class Div(Element): 283 | el = hype.Div 284 | 285 | 286 | class Dl(Element): 287 | el = hype.Dl 288 | 289 | 290 | class Dt(Element): 291 | el = hype.Dt 292 | 293 | 294 | class Em(Element): 295 | el = hype.Em 296 | 297 | 298 | class Embed(Element): 299 | el = hype.Embed 300 | 301 | 302 | class Fieldset(Element): 303 | el = hype.Fieldset 304 | 305 | 306 | class Figcaption(Element): 307 | el = hype.Figcaption 308 | 309 | 310 | class Figure(Element): 311 | el = hype.Figure 312 | 313 | 314 | class Footer(Element): 315 | el = hype.Footer 316 | 317 | 318 | class Form(Element): 319 | el = hype.Form 320 | 321 | 322 | class H1(Element): 323 | el = hype.H1 324 | 325 | 326 | class H2(Element): 327 | el = hype.H2 328 | 329 | 330 | class H3(Element): 331 | el = hype.H3 332 | 333 | 334 | class H4(Element): 335 | el = hype.H4 336 | 337 | 338 | class H5(Element): 339 | el = hype.H5 340 | 341 | 342 | class H6(Element): 343 | el = hype.H6 344 | 345 | 346 | class Head(Element): 347 | el = hype.Head 348 | 349 | 350 | class Header(Element): 351 | el = hype.Header 352 | 353 | 354 | class Hgroup(Element): 355 | el = hype.Hgroup 356 | 357 | 358 | class Hr(Element): 359 | el = hype.Hr 360 | 361 | 362 | class Html(Element): 363 | el = hype.Html 364 | 365 | 366 | class I(Element): 367 | el = hype.I 368 | 369 | 370 | class Iframe(Element): 371 | el = hype.Iframe 372 | 373 | 374 | class Img(Element): 375 | el = hype.Img 376 | 377 | 378 | class Input(Element): 379 | el = hype.Input 380 | 381 | 382 | class Ins(Element): 383 | el = hype.Ins 384 | 385 | 386 | class Kbd(Element): 387 | el = hype.Kbd 388 | 389 | 390 | class Label(Element): 391 | el = hype.Label 392 | 393 | 394 | class Legend(Element): 395 | el = hype.Legend 396 | 397 | 398 | class Li(Element): 399 | el = hype.Li 400 | 401 | 402 | class Link(Element): 403 | el = hype.Link 404 | 405 | 406 | class Main(Element): 407 | el = hype.Main 408 | 409 | 410 | class Map(Element): 411 | el = hype.Map 412 | 413 | 414 | class Mark(Element): 415 | el = hype.Mark 416 | 417 | 418 | class Math(Element): 419 | el = hype.Math 420 | 421 | 422 | class Menu(Element): 423 | el = hype.Menu 424 | 425 | 426 | class Menuitem(Element): 427 | el = hype.Menuitem 428 | 429 | 430 | class Meta(Element): 431 | el = hype.Meta 432 | 433 | 434 | class Meter(Element): 435 | el = hype.Meter 436 | 437 | 438 | class Nav(Element): 439 | el = hype.Nav 440 | 441 | 442 | class Noscript(Element): 443 | el = hype.Noscript 444 | 445 | 446 | class Object(Element): 447 | el = hype.Object 448 | 449 | 450 | class Ol(Element): 451 | el = hype.Ol 452 | 453 | 454 | class Optgroup(Element): 455 | el = hype.Optgroup 456 | 457 | 458 | class Option(Element): 459 | el = hype.Option 460 | 461 | 462 | class Output(Element): 463 | el = hype.Output 464 | 465 | 466 | class P(Element): 467 | el = hype.P 468 | 469 | 470 | class Param(Element): 471 | el = hype.Param 472 | 473 | 474 | class Picture(Element): 475 | el = hype.Picture 476 | 477 | 478 | class Pre(Element): 479 | el = hype.Pre 480 | 481 | 482 | class Progress(Element): 483 | el = hype.Progress 484 | 485 | 486 | class Q(Element): 487 | el = hype.Q 488 | 489 | 490 | class Rb(Element): 491 | el = hype.Rb 492 | 493 | 494 | class Rp(Element): 495 | el = hype.Rp 496 | 497 | 498 | class Rt(Element): 499 | el = hype.Rt 500 | 501 | 502 | class Rtc(Element): 503 | el = hype.Rtc 504 | 505 | 506 | class Ruby(Element): 507 | el = hype.Ruby 508 | 509 | 510 | class S(Element): 511 | el = hype.S 512 | 513 | 514 | class Samp(Element): 515 | el = hype.Samp 516 | 517 | 518 | class Script(Element): 519 | el = hype.Script 520 | 521 | 522 | class Section(Element): 523 | el = hype.Section 524 | 525 | 526 | class Select(Element): 527 | el = hype.Select 528 | 529 | 530 | class SelfClosingElement(Element): 531 | el = hype.SelfClosingElement 532 | 533 | 534 | class Slot(Element): 535 | el = hype.Slot 536 | 537 | 538 | class Small(Element): 539 | el = hype.Small 540 | 541 | 542 | class Source(Element): 543 | el = hype.Source 544 | 545 | 546 | class Span(Element): 547 | el = hype.Span 548 | 549 | 550 | class Strong(Element): 551 | el = hype.Strong 552 | 553 | 554 | class Style(Element): 555 | el = hype.Style 556 | 557 | 558 | class Sub(Element): 559 | el = hype.Sub 560 | 561 | 562 | class Summary(Element): 563 | el = hype.Summary 564 | 565 | 566 | class Sup(Element): 567 | el = hype.Sup 568 | 569 | 570 | class Svg(Element): 571 | el = hype.Svg 572 | 573 | 574 | class Table(Element): 575 | el = hype.Table 576 | 577 | 578 | class Tbody(Element): 579 | el = hype.Tbody 580 | 581 | 582 | class Td(Element): 583 | el = hype.Td 584 | 585 | 586 | class Template(Element): 587 | el = hype.Template 588 | 589 | 590 | class Textarea(Element): 591 | el = hype.Textarea 592 | 593 | 594 | class Tfoot(Element): 595 | el = hype.Tfoot 596 | 597 | 598 | class Th(Element): 599 | el = hype.Th 600 | 601 | 602 | class Thead(Element): 603 | el = hype.Thead 604 | 605 | 606 | class Time(Element): 607 | el = hype.Time 608 | 609 | 610 | class Title(Element): 611 | el = hype.Title 612 | 613 | 614 | class Tr(Element): 615 | el = hype.Tr 616 | 617 | 618 | class Track(Element): 619 | el = hype.Track 620 | 621 | 622 | class U(Element): 623 | el = hype.U 624 | 625 | 626 | class Ul(Element): 627 | el = hype.Ul 628 | 629 | 630 | class Var(Element): 631 | el = hype.Var 632 | 633 | 634 | class Video(Element): 635 | el = hype.Video 636 | 637 | 638 | class Wbr(Element): 639 | el = hype.Wbr 640 | -------------------------------------------------------------------------------- /redmage/exceptions.py: -------------------------------------------------------------------------------- 1 | class RedmageError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /redmage/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redmage-labs/redmage/781275fe694e6adf2ac37ae23993ab17ca0ea412/redmage/py.typed -------------------------------------------------------------------------------- /redmage/targets.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Callable 3 | 4 | from redmage.components import Component 5 | 6 | from .types import HTTPMethod 7 | 8 | logger = logging.getLogger("redmage") 9 | 10 | 11 | class Target: 12 | @staticmethod 13 | def _decorator(fn: Callable, method: str = HTTPMethod.GET) -> Callable: 14 | setattr(fn, "is_target", True) 15 | setattr(fn, "target_method", method) 16 | return fn 17 | 18 | @classmethod 19 | def get(cls, fn: Callable) -> Callable: 20 | return cls._decorator(fn, HTTPMethod.GET) 21 | 22 | @classmethod 23 | def post(cls, fn: Callable) -> Callable: 24 | return cls._decorator(fn, HTTPMethod.POST) 25 | 26 | @classmethod 27 | def put(cls, fn: Callable) -> Callable: 28 | return cls._decorator(fn, HTTPMethod.PUT) 29 | 30 | @classmethod 31 | def delete(cls, fn: Callable) -> Callable: 32 | return cls._decorator(fn, HTTPMethod.DELETE) 33 | 34 | @classmethod 35 | def patch(cls, fn: Callable) -> Callable: 36 | return cls._decorator(fn, HTTPMethod.PATCH) 37 | 38 | def __init__( 39 | self, 40 | instance: Component, 41 | method_name: str, 42 | http_method: HTTPMethod, 43 | *args: Any, 44 | **kwargs: Any 45 | ): 46 | self.instance = instance 47 | self.method_name = method_name 48 | self.http_method = http_method 49 | self.args = args 50 | self.kwargs = kwargs 51 | 52 | @property 53 | def path(self) -> str: 54 | path = self.instance.get_base_path(instance=self.instance) 55 | path += self.instance.get_target_path( 56 | self.method_name, 57 | *self.args, 58 | instance=self.instance, 59 | **self.kwargs, 60 | ) 61 | return path 62 | -------------------------------------------------------------------------------- /redmage/triggers.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from redmage.types import HTMXTriggerModifier 4 | 5 | 6 | class TriggerModifier: 7 | def __init__( 8 | self, 9 | modifier: str, 10 | milliseconds: Optional[int] = None, 11 | selector: Optional[str] = None, 12 | threshold: Optional[float] = None, 13 | ): 14 | self.modifier = modifier 15 | self.milliseconds = milliseconds 16 | self.selector = selector 17 | self.threshold = threshold 18 | 19 | def create_modifier(self) -> str: 20 | if self.milliseconds: 21 | return f"{self.modifier}:{self.milliseconds}ms" 22 | if self.threshold: 23 | return f"{self.modifier}:{self.threshold}" 24 | if self.selector: 25 | return f"{self.modifier}:{self.selector}" 26 | return self.modifier 27 | 28 | def __str__(self) -> str: 29 | return self.create_modifier() 30 | 31 | 32 | class DelayTriggerModifier(TriggerModifier): 33 | def __init__(self, milliseconds: int): 34 | super().__init__(HTMXTriggerModifier.DELAY, milliseconds=milliseconds) 35 | 36 | 37 | class ThrottleTriggerModifier(TriggerModifier): 38 | def __init__(self, milliseconds: int): 39 | super().__init__(HTMXTriggerModifier.THROTTLE, milliseconds=milliseconds) 40 | 41 | 42 | class FromTriggerModifier(TriggerModifier): 43 | def __init__(self, selector: str): 44 | super().__init__(HTMXTriggerModifier.FROM, selector=selector) 45 | 46 | 47 | class RootTriggerModifier(TriggerModifier): 48 | def __init__(self, selector: str): 49 | super().__init__(HTMXTriggerModifier.ROOT, selector=selector) 50 | 51 | 52 | class ThresholdTriggerModifier(TriggerModifier): 53 | def __init__(self, threshold: float): 54 | super().__init__(HTMXTriggerModifier.THRESHHOLD, threshold=threshold) 55 | 56 | 57 | class Trigger: 58 | def __init__( 59 | self, type: str, *modifiers: TriggerModifier, filter: Optional[str] = None 60 | ): 61 | self.type = type 62 | self.modifiers = modifiers 63 | self.filter = filter 64 | 65 | def create_trigger(self) -> str: 66 | trigger = self.type 67 | if self.filter: 68 | trigger += f"[{self.filter}]" 69 | 70 | if self.modifiers: 71 | return f"{trigger} {' '.join([str(m) for m in self.modifiers])}" 72 | return trigger 73 | 74 | def __str__(self) -> str: 75 | return self.create_trigger() 76 | -------------------------------------------------------------------------------- /redmage/types.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | # This is a workaround for Python 3.11 which will have StrEnum 4 | # but breaks the (str, Enum) inheritance 5 | try: # pragma: no cover 6 | StrEnum = importlib.import_module("enum").StrEnum 7 | except AttributeError: # pragma: no cover 8 | from enum import Enum 9 | 10 | class StrEnum(str, Enum): # type: ignore 11 | pass 12 | 13 | 14 | class HTTPMethod(StrEnum): # type: ignore 15 | GET = "GET" 16 | POST = "POST" 17 | PUT = "PUT" 18 | DELETE = "DELETE" 19 | PATCH = "PATCH" 20 | 21 | 22 | class HTMXSwap(StrEnum): # type: ignore 23 | OUTER_HTML = "outerHTML" 24 | INNER_HTML = "innerHTML" 25 | AFTER_BEGIN = "afterbegin" 26 | BEFORE_BEGIN = "beforebegin" 27 | BEFORE_END = "beforeend" 28 | AFTER_END = "afterend" 29 | DELETE = "delete" 30 | NONE = "none" 31 | 32 | 33 | class HTMXClass(StrEnum): # type: ignore 34 | Indicator = "htmx-indicator" 35 | 36 | 37 | class HTMXTrigger(StrEnum): # type: ignore 38 | EVERY = "every" 39 | LOAD = "load" 40 | INTERSECT = "intersect" 41 | REVEALED = "revealed" 42 | CLICK = "click" 43 | CHANGE = "change" 44 | MOUSEOVER = "mouseover" 45 | MOUSEENTER = "mouseenter" 46 | SUBMIT = "submit" 47 | KEYUP = "keyup" 48 | SEARCH = "search" 49 | 50 | 51 | class HTMXTriggerModifier(StrEnum): # type: ignore 52 | ONCE = "once" 53 | CHANGED = "changed" 54 | THROTTLE = "throttle" 55 | DELAY = "delay" 56 | FROM = "from" 57 | ROOT = "root" 58 | THRESHHOLD = "threshold" 59 | 60 | 61 | class HTMXHeaders(StrEnum): # type: ignore 62 | HX_LOCATION = "HX-Location" 63 | HX_PUSH_URL = "HX-Push-Url" 64 | HX_REDIRECT = "HX-Redirect" 65 | HX_REFRESH = "HX-Refresh" 66 | HX_REPLACE_URL = "HX-Replace-Url" 67 | HX_RESWAP = "HX-Reswap" 68 | HX_RETARGET = "HX-Retarget" 69 | HX_TRIGGER = "HX-Trigger" 70 | HX_TRIGGER_AFTER_SETTLE = "HX-Trigger-After-Settle" 71 | HX_TRIGGER_AFTER_SWAP = "HX-Trigger-After-Swap" 72 | -------------------------------------------------------------------------------- /redmage/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from inspect import Parameter, _ParameterKind 3 | from typing import Any, Dict, List 4 | 5 | 6 | def group_signature_param_by_kind( 7 | sig: inspect.Signature, 8 | ) -> Dict[_ParameterKind, List[Parameter]]: 9 | # TODO fix type hints here 10 | # Need VAR_POSITIONAL and VAR_KEYWORD? 11 | grouped: Dict[_ParameterKind, List[Parameter]] = { 12 | Parameter.POSITIONAL_ONLY: [], 13 | Parameter.POSITIONAL_OR_KEYWORD: [], 14 | Parameter.KEYWORD_ONLY: [], 15 | } 16 | for param in sig.parameters.values(): 17 | grouped[param.kind].append(param) 18 | return grouped 19 | 20 | 21 | async def astr(astringable: Any) -> str: 22 | return await astringable._astr_() 23 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black>=23.3.0 2 | httpx>=0.24.0 3 | hype-html>=2.0.3 4 | isort>=5.12.0 5 | Jinja2>=3.1.2 6 | mypy>=1.2.0 7 | pycodestyle>=2.11.1 8 | pytest>=7.3.1 9 | pytest-cov>=4.0.0 10 | python-multipart>=0.0.6 11 | starlette>=0.26.1 12 | pytest-asyncio>=0.21.1 -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | black . 5 | isort -s env . -------------------------------------------------------------------------------- /scripts/generate_elements.py: -------------------------------------------------------------------------------- 1 | from inspect import getmembers, isclass 2 | 3 | import hype 4 | from jinja2 import Template 5 | 6 | if __name__ == "__main__": 7 | # generate the source code for all the Element classes 8 | template_string = """class {{ name }}(Element): 9 | el = hype.element.{{ name }}""" 10 | 11 | template = Template(template_string) 12 | class_strings = [] 13 | for member in getmembers(hype.element): 14 | if isclass(member[1]) and issubclass(member[1], hype.Element): 15 | class_strings.append(template.render(name=member[0])) 16 | 17 | with open("elements_temp.py", "w") as f: 18 | f.write("\n\n\n".join(class_strings)) 19 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | isort -s env -c . 5 | black redmage/. --check 6 | 7 | 8 | pycodestyle --max-line-length=100 --ignore=E742,W503 redmage/ 9 | retVal=$? 10 | if [ $retVal -ne 0 ]; then 11 | echo "Python Lint Error" 12 | exit $retVal 13 | fi 14 | 15 | python -m mypy redmage/ --exclude env/ --disallow-untyped-defs 16 | retVal=$? 17 | if [ $retVal -ne 0 ]; then 18 | echo "Python Type Checking Error" 19 | exit $retVal 20 | fi 21 | 22 | python -m pytest --cov-report term-missing --cov=redmage/ tests/ 23 | retVal=$? 24 | if [ $retVal -ne 0 ]; then 25 | echo "Python Test Error" 26 | exit $retVal 27 | fi -------------------------------------------------------------------------------- /tests/test_convertors.py: -------------------------------------------------------------------------------- 1 | from redmage.convertors import BoolConvertor, StringConverter 2 | 3 | 4 | def test_bool_convertor(): 5 | convertor = BoolConvertor() 6 | assert convertor.convert("True") 7 | assert not convertor.convert("False") 8 | assert convertor.to_string(True) == "True" 9 | assert convertor.to_string(False) == "False" 10 | 11 | 12 | def test_string_convertor(): 13 | convertor = StringConverter() 14 | assert convertor.convert("test") == "test" 15 | assert convertor.convert("__empty__") == "" 16 | assert convertor.to_string("test") == "test" 17 | assert convertor.to_string("") == "__empty__" 18 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | from starlette.convertors import Convertor, register_url_convertor 6 | from starlette.responses import HTMLResponse 7 | from starlette.testclient import TestClient 8 | 9 | from redmage import Component, Redmage, Target 10 | from redmage.elements import Div, Form, Input 11 | from redmage.exceptions import RedmageError 12 | from redmage.types import HTMXClass, HTMXHeaders, HTMXSwap 13 | 14 | 15 | @pytest.fixture(autouse=True) 16 | def redmage_app(): 17 | yield 18 | # Reset app after each test 19 | Component.app = None 20 | Component.components = [] 21 | 22 | 23 | def test_sanity(): 24 | assert True 25 | 26 | 27 | def test_redmage_create_app(): 28 | app = Redmage() 29 | assert app.debug is False 30 | 31 | 32 | def test_redmage_create_app_with_debug(): 33 | app = Redmage(debug=True) 34 | assert app.debug is True 35 | 36 | 37 | def test_redmage_create_app_with_middlware(): 38 | class MockMiddleware: 39 | def __init__(self, app): 40 | self.app = app 41 | 42 | async def __call__(self, scope, receive, send): 43 | await self.app(scope, receive, send) 44 | 45 | middleware = [MockMiddleware] 46 | app = Redmage(middleware=middleware) 47 | starlette = app.starlette 48 | assert starlette 49 | 50 | 51 | def test_component_constructor(): 52 | app = Redmage() 53 | 54 | class TestComponent(Component): 55 | async def render(self): ... 56 | 57 | component = TestComponent() 58 | assert component.id 59 | 60 | 61 | def test_component_registered_before_app(): 62 | class TestComponent(Component): 63 | async def render(self): ... 64 | 65 | component = TestComponent() 66 | assert component.id 67 | 68 | 69 | def test_redmage_register_component_with_no_targets(): 70 | app = Redmage() 71 | 72 | class TestComponent(Component): 73 | async def render(self): 74 | return "Hello World" 75 | 76 | app.create_routes() 77 | assert len(app.routes) == 0 78 | 79 | 80 | def test_redmage_register_component_index(): 81 | app = Redmage() 82 | 83 | class TestComponent(Component, routes=("/",)): 84 | async def render(self): 85 | return Div("Hello World") 86 | 87 | app.create_routes() 88 | assert len(app.routes) == 1 89 | assert app.routes[0].path == "/" 90 | 91 | client = TestClient(app.starlette) 92 | response = client.get("/") 93 | assert response.status_code == 200 94 | 95 | 96 | def test_redmage_register_component_with_get_target_with_args(): 97 | app = Redmage() 98 | 99 | class TestComponent(Component): 100 | async def render(self): 101 | return Div(f"Hello World {self.param1} {self.param2}") 102 | 103 | @Target.get 104 | def test_target(self, param1: int, param2: str): 105 | self.param1 = param1 106 | self.param2 = param2 107 | 108 | app.create_routes() 109 | assert len(app.routes) == 1 110 | assert app.routes[0].name == "test_target" 111 | assert ( 112 | app.routes[0].path 113 | == "/TestComponent/{id:str}/test_target/{test_target__param1:int}/{test_target__param2:str}" 114 | ) 115 | 116 | client = TestClient(app.starlette) 117 | response = client.get("/TestComponent/1/test_target/1/test") 118 | assert response.status_code == 200 119 | assert response.text.strip() == '
Hello World 1 test
' 120 | 121 | 122 | def test_redmage_register_component_with_get_async_target(): 123 | app = Redmage() 124 | 125 | class TestComponent(Component): 126 | async def render(self): 127 | return Div(f"Hello World {self.param1} {self.param2}") 128 | 129 | @Target.get 130 | async def test_target(self, param1: int, param2: str): 131 | self.param1 = param1 132 | self.param2 = param2 133 | 134 | app.create_routes() 135 | assert len(app.routes) == 1 136 | assert app.routes[0].name == "test_target" 137 | assert ( 138 | app.routes[0].path 139 | == "/TestComponent/{id:str}/test_target/{test_target__param1:int}/{test_target__param2:str}" 140 | ) 141 | 142 | client = TestClient(app.starlette) 143 | response = client.get("/TestComponent/1/test_target/1/test") 144 | # assert response.status_code == 200 145 | # assert response.text.strip() == '
Hello World 1 test
' 146 | 147 | 148 | def test_redmage_register_component_with_post_target_with_args(): 149 | app = Redmage() 150 | 151 | @dataclass 152 | class TestSerializer: 153 | param1: int 154 | param2: str 155 | 156 | class TestComponent(Component): 157 | async def render(self): 158 | return Div(f"Hello World {self.param1} {self.param2}") 159 | 160 | @Target.post 161 | def test_target(self, test_serializer: TestSerializer, /): 162 | self.param1 = test_serializer.param1 163 | self.param2 = test_serializer.param2 164 | 165 | app.create_routes() 166 | assert len(app.routes) == 1 167 | assert app.routes[0].name == "test_target" 168 | assert app.routes[0].path == "/TestComponent/{id:str}/test_target" 169 | 170 | client = TestClient(app.starlette) 171 | response = client.post( 172 | "/TestComponent/1/test_target", data={"param1": 1, "param2": "test"} 173 | ) 174 | assert response.status_code == 200 175 | assert response.text.strip() == '
Hello World 1 test
' 176 | 177 | 178 | def test_redmage_register_component_with_put_target_with_args(): 179 | app = Redmage() 180 | 181 | @dataclass 182 | class TestSerializer: 183 | param1: int 184 | param2: str 185 | 186 | class TestComponent(Component): 187 | async def render(self): 188 | return Div(f"Hello World {self.param1} {self.param2}") 189 | 190 | @Target.put 191 | def test_target(self, test_serializer: TestSerializer, /): 192 | self.param1 = test_serializer.param1 193 | self.param2 = test_serializer.param2 194 | 195 | app.create_routes() 196 | assert len(app.routes) == 1 197 | assert app.routes[0].name == "test_target" 198 | assert app.routes[0].path == "/TestComponent/{id:str}/test_target" 199 | 200 | client = TestClient(app.starlette) 201 | response = client.put( 202 | "/TestComponent/1/test_target", data={"param1": 1, "param2": "test"} 203 | ) 204 | assert response.status_code == 200 205 | assert response.text.strip() == '
Hello World 1 test
' 206 | 207 | 208 | def test_redmage_register_component_with_patch_target_with_args(): 209 | app = Redmage() 210 | 211 | @dataclass 212 | class TestSerializer: 213 | param1: int 214 | param2: str 215 | 216 | class TestComponent(Component): 217 | async def render(self): 218 | return Div(f"Hello World {self.param1} {self.param2}") 219 | 220 | @Target.patch 221 | def test_target(self, test_serializer: TestSerializer, /): 222 | self.param1 = test_serializer.param1 223 | self.param2 = test_serializer.param2 224 | 225 | app.create_routes() 226 | assert len(app.routes) == 1 227 | assert app.routes[0].name == "test_target" 228 | assert app.routes[0].path == "/TestComponent/{id:str}/test_target" 229 | 230 | client = TestClient(app.starlette) 231 | response = client.patch( 232 | "/TestComponent/1/test_target", data={"param1": 1, "param2": "test"} 233 | ) 234 | assert response.status_code == 200 235 | assert response.text.strip() == '
Hello World 1 test
' 236 | 237 | 238 | def test_redmage_register_component_with_delete_target_with_args(): 239 | app = Redmage() 240 | 241 | class TestComponent(Component): 242 | async def render(self): 243 | return Div(f"Hello World {self.param1} {self.param2}") 244 | 245 | @Target.delete 246 | def test_target(self, param1: int, param2: str): 247 | self.param1 = param1 248 | self.param2 = param2 249 | 250 | app.create_routes() 251 | assert len(app.routes) == 1 252 | assert app.routes[0].name == "test_target" 253 | assert ( 254 | app.routes[0].path 255 | == "/TestComponent/{id:str}/test_target/{test_target__param1:int}/{test_target__param2:str}" 256 | ) 257 | 258 | client = TestClient(app.starlette) 259 | response = client.delete("/TestComponent/1/test_target/1/test") 260 | assert response.status_code == 200 261 | assert response.text.strip() == '
Hello World 1 test
' 262 | 263 | 264 | def test_redmage_register_component_nested(): 265 | app = Redmage() 266 | 267 | class NestedComponent(Component): 268 | async def render(self): 269 | return Div(f"Hello World") 270 | 271 | @property 272 | def id(self) -> str: 273 | return "NestedComponent-1" 274 | 275 | class TestComponent(Component, routes=("/",)): 276 | async def render(self): 277 | return Div(NestedComponent()) 278 | 279 | @property 280 | def id(self) -> str: 281 | return "TestComponent-1" 282 | 283 | app.create_routes() 284 | 285 | client = TestClient(app.starlette) 286 | response = client.get("/") 287 | assert response.status_code == 200 288 | assert ( 289 | response.text.strip() 290 | == '
\n
Hello World
' 291 | ) 292 | 293 | 294 | def test_redmage_escape_element_content(): 295 | app = Redmage() 296 | 297 | class TestComponent(Component, routes=("/",)): 298 | async def render(self): 299 | return Div("

test

") 300 | 301 | @property 302 | def id(self) -> str: 303 | return "TestComponent-1" 304 | 305 | app.create_routes() 306 | 307 | client = TestClient(app.starlette) 308 | response = client.get("/") 309 | assert response.status_code == 200 310 | assert ( 311 | response.text.strip() 312 | == '
<h1>test</h1>
' 313 | ) 314 | 315 | 316 | def test_redmage_do_not_escape_element_content(): 317 | app = Redmage() 318 | 319 | class TestComponent(Component, routes=("/",)): 320 | async def render(self): 321 | return Div("

test

", safe=True) 322 | 323 | @property 324 | def id(self) -> str: 325 | return "TestComponent-1" 326 | 327 | app.create_routes() 328 | 329 | client = TestClient(app.starlette) 330 | response = client.get("/") 331 | assert response.status_code == 200 332 | assert response.text.strip() == '

test

' 333 | 334 | 335 | def test_redmage_register_component_with_get_target_with_query_params(): 336 | app = Redmage() 337 | 338 | class TestComponent(Component): 339 | async def render(self): 340 | return Div(f"Hello World {self.param1} {self.param2}") 341 | 342 | @Target.get 343 | def test_target(self, param1: int = 0, param2: str = "init"): 344 | self.param1 = param1 345 | self.param2 = param2 346 | 347 | app.create_routes() 348 | assert len(app.routes) == 1 349 | assert app.routes[0].name == "test_target" 350 | assert app.routes[0].path == "/TestComponent/{id:str}/test_target" 351 | 352 | client = TestClient(app.starlette) 353 | response = client.get("/TestComponent/1/test_target?test_target__param1=1") 354 | assert response.status_code == 200 355 | assert response.text.strip() == '
Hello World 1 init
' 356 | 357 | client = TestClient(app.starlette) 358 | response = client.get( 359 | "/TestComponent/1/test_target?test_target__param1=2&test_target__param2=test" 360 | ) 361 | assert response.status_code == 200 362 | assert response.text.strip() == '
Hello World 2 test
' 363 | 364 | 365 | def test_redmage_register_component_with_annotations(): 366 | app = Redmage() 367 | 368 | class TestComponent(Component): 369 | test_annotation: str 370 | 371 | async def render(self): 372 | return Div( 373 | f"Hello World {self.test_annotation} {self.param1} {self.param2}" 374 | ) 375 | 376 | @Target.get 377 | def test_target(self, param1: int = 0, param2: str = "init"): 378 | self.param1 = param1 379 | self.param2 = param2 380 | 381 | app.create_routes() 382 | assert len(app.routes) == 1 383 | assert app.routes[0].name == "test_target" 384 | assert ( 385 | app.routes[0].path 386 | == "/TestComponent/{id:str}/test_annotation/{test_annotation:str}/test_target" 387 | ) 388 | 389 | client = TestClient(app.starlette) 390 | response = client.get( 391 | "/TestComponent/1/test_annotation/new_annotation/test_target?test_target__param1=1" 392 | ) 393 | assert response.status_code == 200 394 | assert ( 395 | response.text.strip() 396 | == '
Hello World new_annotation 1 init
' 397 | ) 398 | 399 | 400 | def test_redmage_create_get_target(): 401 | app = Redmage() 402 | 403 | class TestComponent(Component): 404 | async def render(self): 405 | return Div( 406 | f"Hello World", 407 | target=self.test_target(1, param2="test"), 408 | ) 409 | 410 | @Target.get 411 | def test_target(self, param1: int, param2: str = "init"): ... 412 | 413 | client = TestClient(app.starlette) 414 | response = client.get("/TestComponent/1/test_target/0/?test_target__param2=init") 415 | assert response.status_code == 200 416 | assert ( 417 | response.text.strip() 418 | == f'
Hello World
' 419 | ) 420 | 421 | 422 | def test_redmage_create_get_target_with_optional_parameter(): 423 | app = Redmage() 424 | 425 | class OptionalInt(Convertor): 426 | regex = "[0-9]+|None" 427 | 428 | def convert(self, value: str) -> Optional[int]: 429 | return int(value) if value != "None" else None 430 | 431 | def to_string(self, value: Optional[int]) -> str: 432 | return str(value) if value else "None" 433 | 434 | register_url_convertor("Optional[int]", OptionalInt()) 435 | 436 | class TestComponent(Component): 437 | async def render(self): 438 | return Div( 439 | f"Hello World", 440 | target=self.test_target(1, param2=2), 441 | ) 442 | 443 | @Target.get 444 | def test_target(self, param1: int, param2: "Optional[int]" = None): ... 445 | 446 | client = TestClient(app.starlette) 447 | response = client.get("/TestComponent/1/test_target/1/?test_target__param2=None") 448 | assert response.status_code == 200 449 | assert ( 450 | response.text.strip() 451 | == f'
Hello World
' 452 | ) 453 | 454 | 455 | def test_redmage_create_get_target_with_annotation(): 456 | app = Redmage() 457 | 458 | class TestComponent(Component): 459 | test_annotation: str 460 | 461 | async def render(self): 462 | return Div( 463 | f"Hello World", 464 | target=self.test_target(1, param2="test"), 465 | ) 466 | 467 | @Target.get 468 | def test_target(self, param1: int, param2: str = "init"): ... 469 | 470 | client = TestClient(app.starlette) 471 | response = client.get( 472 | "/TestComponent/1/test_annotation/test/test_target/0/?test_target__param2=init" 473 | ) 474 | assert response.status_code == 200 475 | assert ( 476 | response.text.strip() 477 | == f'
Hello World
' 478 | ) 479 | 480 | 481 | def test_redmage_create_post_target(): 482 | app = Redmage() 483 | 484 | class TestComponent(Component): 485 | async def render(self): 486 | return Div( 487 | f"Hello World", 488 | target=self.test_target(self.param1), 489 | ) 490 | 491 | @Target.post 492 | def test_target(self, test_id: int): 493 | self.param1 = test_id 494 | 495 | client = TestClient(app.starlette) 496 | response = client.post("/TestComponent/1/test_target/4") 497 | assert response.status_code == 200 498 | assert ( 499 | response.text.strip() 500 | == f'
Hello World
' 501 | ) 502 | 503 | 504 | def test_redmage_create_post_target_swap(): 505 | app = Redmage() 506 | 507 | class TestComponent(Component): 508 | async def render(self): 509 | return Div( 510 | f"Hello World", 511 | target=self.test_target(1, "bar"), 512 | swap=HTMXSwap.INNER_HTML, 513 | ) 514 | 515 | @Target.post 516 | def test_target(self, test_id: int, test: str = "foo"): ... 517 | 518 | client = TestClient(app.starlette) 519 | response = client.post("/TestComponent/1/test_target/1") 520 | assert response.status_code == 200 521 | assert ( 522 | response.text.strip() 523 | == f'
Hello World
' 524 | ) 525 | 526 | 527 | def test_redmage_create_post_target_indicator(): 528 | app = Redmage() 529 | 530 | class TestComponent(Component): 531 | async def render(self): 532 | return Div( 533 | f"Hello World", 534 | target=self.test_target(), 535 | indicator=True, 536 | ) 537 | 538 | @Target.post 539 | def test_target(self): ... 540 | 541 | client = TestClient(app.starlette) 542 | response = client.post("/TestComponent/1/test_target") 543 | assert response.status_code == 200 544 | assert ( 545 | response.text.strip() 546 | == f'
Hello World
' 547 | ) 548 | 549 | 550 | def test_redmage_form(): 551 | app = Redmage() 552 | 553 | @dataclass 554 | class TestSerializer: 555 | param1: int 556 | 557 | class TestComponent(Component): 558 | async def render(self): 559 | return Form( 560 | Input(name="param1", value=1), 561 | target=self.test_target(), 562 | ) 563 | 564 | @Target.post 565 | def test_target(self, test_serializer: TestSerializer, /): ... 566 | 567 | client = TestClient(app.starlette) 568 | response = client.post("/TestComponent/1/test_target", data={"param1": 1}) 569 | assert response.status_code == 200 570 | assert ( 571 | response.text.strip() 572 | == f'
\n
' 573 | ) 574 | 575 | 576 | def test_redmage_form_without_serializer(): 577 | app = Redmage() 578 | 579 | class TestComponent(Component): 580 | async def render(self): 581 | return Form( 582 | Input(name="param1", value=1), 583 | target=self.test_target(), 584 | ) 585 | 586 | @Target.post 587 | def test_target(self): ... 588 | 589 | client = TestClient(app.starlette) 590 | with pytest.raises(RedmageError): 591 | client.post("/TestComponent/1/test_target", data={"param1": 1}) 592 | 593 | 594 | def test_redmage_component_that_returns_another_component(): 595 | app = Redmage() 596 | 597 | class ChildComponent(Component): 598 | async def render(self): 599 | return Div(f"Hello Child") 600 | 601 | class TestComponent(Component): 602 | async def render(self): 603 | return Div(f"Hello World") 604 | 605 | @Target.get 606 | def test_target(self): 607 | child = ChildComponent() 608 | child._id = "ChildComponent-1" 609 | return child 610 | 611 | client = TestClient(app.starlette) 612 | response = client.get("/TestComponent/1/test_target") 613 | assert response.status_code == 200 614 | assert response.text.strip() == '
Hello Child
' 615 | 616 | 617 | def test_redmage_component_that_returns_multiple_components(): 618 | app = Redmage() 619 | 620 | class ChildComponent(Component): 621 | async def render(self): 622 | return Div(f"Hello Child") 623 | 624 | class TestComponent(Component): 625 | async def render(self): 626 | return Div(f"Hello World") 627 | 628 | @Target.get 629 | def test_target(self): 630 | child = ChildComponent() 631 | child._id = "ChildComponent-1" 632 | return child, child 633 | 634 | client = TestClient(app.starlette) 635 | response = client.get("/TestComponent/1/test_target") 636 | assert response.status_code == 200 637 | assert ( 638 | response.text.strip() 639 | == '
Hello Child
\n\n
Hello Child
' 640 | ) 641 | 642 | 643 | def test_redmage_component_that_returns_multiple_components_async(): 644 | app = Redmage() 645 | 646 | class ChildComponent(Component): 647 | async def render(self): 648 | return Div(f"Hello Child") 649 | 650 | class TestComponent(Component): 651 | async def render(self): 652 | return Div(f"Hello World") 653 | 654 | @Target.get 655 | async def test_target(self): 656 | child = ChildComponent() 657 | child._id = "ChildComponent-1" 658 | return child, child 659 | 660 | client = TestClient(app.starlette) 661 | response = client.get("/TestComponent/1/test_target") 662 | assert response.status_code == 200 663 | assert ( 664 | response.text.strip() 665 | == '
Hello Child
\n\n
Hello Child
' 666 | ) 667 | 668 | 669 | def test_redmage_component_that_returns_headers_in_the_options_dict(): 670 | app = Redmage() 671 | 672 | class TestComponent(Component): 673 | async def render(self): 674 | return Div(f"Hello World") 675 | 676 | @Target.get 677 | def test_target(self): 678 | return Div(f"Hello World") 679 | 680 | @staticmethod 681 | def build_response(content: Any) -> HTMLResponse: 682 | return HTMLResponse( 683 | content=content, headers={HTMXHeaders.HX_LOCATION: "test"} 684 | ) 685 | 686 | client = TestClient(app.starlette) 687 | response = client.get("/TestComponent/1/test_target") 688 | assert response.status_code == 200 689 | assert response.headers[HTMXHeaders.HX_LOCATION] == "test" 690 | 691 | 692 | def test_redamge_component_with_render_extenstion(): 693 | app = Redmage() 694 | 695 | def extension(el): 696 | el.attrs(test="test") 697 | return el 698 | 699 | Component.add_render_extension(extension=extension) 700 | 701 | class TestComponent(Component): 702 | async def render(self, extension=extension): 703 | return extension(Div(f"Hello World")) 704 | 705 | @Target.get 706 | def test_target(self): ... 707 | 708 | client = TestClient(app.starlette) 709 | response = client.get("/TestComponent/1/test_target") 710 | assert response.status_code == 200 711 | assert ( 712 | response.text.strip() 713 | == '
Hello World
' 714 | ) 715 | 716 | 717 | def test_redamge_component_with_render_extenstion_var_keyword(): 718 | app = Redmage() 719 | 720 | def extension(el): 721 | el.attrs(test="test") 722 | return el 723 | 724 | Component.add_render_extension(extension=extension) 725 | 726 | class TestComponent(Component): 727 | async def render(self, **exts): 728 | return exts["extension"](Div(f"Hello World")) 729 | 730 | @Target.get 731 | def test_target(self): ... 732 | 733 | client = TestClient(app.starlette) 734 | response = client.get("/TestComponent/1/test_target") 735 | assert response.status_code == 200 736 | assert ( 737 | response.text.strip() 738 | == '
Hello World
' 739 | ) 740 | -------------------------------------------------------------------------------- /tests/test_elements.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from redmage import Component, Redmage, Target 4 | from redmage.elements import Div, Doc 5 | from redmage.utils import astr 6 | 7 | app = Redmage() 8 | 9 | 10 | class TestComponent(Component): 11 | async def render(self): 12 | return Div(f"Hello World") 13 | 14 | @property 15 | def id(self) -> str: 16 | return f"{self.__class__.__name__}-1" 17 | 18 | @Target.get 19 | def target_method(self): ... 20 | 21 | 22 | app.create_routes() 23 | 24 | 25 | test_component = TestComponent() 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_element(): 30 | div = await astr(Div("test")) 31 | assert div.strip() == "
test
" 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_element_helper_keyword(): 36 | div = await astr(Div("test", click=test_component.target_method())) 37 | assert ( 38 | div.strip() 39 | == '
test
' 40 | ) 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_element_append(): 45 | div = Div() 46 | div.append("test") 47 | assert (await astr(div)).strip() == "
test
" 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_element_push_url(): 52 | div = await astr(Div("test", push_url="/test")) 53 | assert div.strip() == '
test
' 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_element_multiple_triggers(): 58 | div = await astr(Div("test", trigger=("click", "mouseover"))) 59 | assert div.strip() == '
test
' 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_element_swap_oob(): 64 | div = await astr(Div("test", swap_oob=True)) 65 | assert div.strip() == '
test
' 66 | 67 | 68 | @pytest.mark.asyncio 69 | async def test_element_confirm(): 70 | div = await astr(Div("test", confirm="Are you sure?")) 71 | assert div.strip() == '
test
' 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_element_boost(): 76 | div = await astr(Div("test", boost=True)) 77 | assert div.strip() == '
test
' 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_element_on(): 82 | div = await astr(Div("test", on="click")) 83 | assert div.strip() == '
test
' 84 | 85 | 86 | @pytest.mark.asyncio 87 | async def test_doc(): 88 | doc = await astr(Doc(Div("test"))) 89 | assert doc.strip() == "\n
test
" 90 | 91 | 92 | @pytest.mark.asyncio 93 | async def test_doc_attrs(): 94 | doc = Doc(Div("test")) 95 | doc.attrs(test="test") 96 | assert (await astr(doc)).strip() == '\n
test
' 97 | -------------------------------------------------------------------------------- /tests/test_triggers.py: -------------------------------------------------------------------------------- 1 | from redmage.triggers import ( 2 | DelayTriggerModifier, 3 | FromTriggerModifier, 4 | RootTriggerModifier, 5 | ThresholdTriggerModifier, 6 | ThrottleTriggerModifier, 7 | Trigger, 8 | TriggerModifier, 9 | ) 10 | from redmage.types import HTMXTriggerModifier 11 | 12 | 13 | def test_trigger(): 14 | trigger = Trigger("click") 15 | assert trigger.create_trigger() == "click" 16 | 17 | 18 | def test_trigger_to_string(): 19 | trigger = Trigger("click") 20 | assert str(trigger) == "click" 21 | 22 | 23 | def test_trigger_filter(): 24 | trigger = Trigger("click", filter="test") 25 | assert trigger.create_trigger() == "click[test]" 26 | 27 | 28 | def test_trigger_modifier_with_no_argument(): 29 | modifier = TriggerModifier(HTMXTriggerModifier.CHANGED) 30 | assert modifier.create_modifier() == "changed" 31 | 32 | 33 | def test_trigger_modifier(): 34 | trigger = Trigger( 35 | "click", TriggerModifier(HTMXTriggerModifier.DELAY, milliseconds=100) 36 | ) 37 | assert trigger.create_trigger() == "click delay:100ms" 38 | 39 | 40 | def test_delay_trigger_modifier(): 41 | modifier = DelayTriggerModifier(100) 42 | assert modifier.create_modifier() == "delay:100ms" 43 | 44 | 45 | def test_throttle_trigger_modifier(): 46 | modifier = ThrottleTriggerModifier(100) 47 | assert modifier.create_modifier() == "throttle:100ms" 48 | 49 | 50 | def test_from_trigger_modifier(): 51 | modifier = FromTriggerModifier("test") 52 | assert modifier.create_modifier() == "from:test" 53 | 54 | 55 | def test_root_trigger_modifier(): 56 | modifier = RootTriggerModifier("test") 57 | assert modifier.create_modifier() == "root:test" 58 | 59 | 60 | def test_threshold_trigger_modifier(): 61 | modifier = ThresholdTriggerModifier(0.5) 62 | assert modifier.create_modifier() == "threshold:0.5" 63 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | 3 | from redmage.utils import group_signature_param_by_kind 4 | 5 | 6 | def test_group_signature_param_by_kind(): 7 | class ExampleClass: 8 | def method(self, a, /, b, c=None, *, d=None): 9 | pass 10 | 11 | sig = inspect.signature(ExampleClass.method) 12 | grouped = group_signature_param_by_kind(sig) 13 | assert len(grouped[inspect.Parameter.POSITIONAL_ONLY]) == 2 14 | assert len(grouped[inspect.Parameter.POSITIONAL_OR_KEYWORD]) == 2 15 | assert len(grouped[inspect.Parameter.KEYWORD_ONLY]) == 1 16 | --------------------------------------------------------------------------------