├── .github └── workflows │ ├── pre-commit.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── django_web_components ├── __init__.py ├── attributes.py ├── component.py ├── conf.py ├── registry.py ├── tag_formatter.py ├── template.py ├── templatetags │ ├── __init__.py │ └── components.py └── utils.py ├── poetry.lock ├── pyproject.toml ├── runtests.py ├── tests ├── __init__.py ├── settings.py ├── templates │ └── simple_template.html ├── test_attributes.py ├── test_component.py ├── test_registry.py ├── test_tag_formatter.py ├── test_template.py ├── test_templatetags.py └── test_utils.py └── tox.ini /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | - uses: pre-commit/action@v3.0.0 16 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.11" 16 | - name: Install Poetry 17 | run: | 18 | pip install poetry 19 | - name: Build the distribution 20 | run: | 21 | poetry build 22 | - name: Publish distribution to PyPI 23 | uses: pypa/gh-action-pypi-publish@release/v1 24 | with: 25 | password: ${{ secrets.PYPI_API_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | tests: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install tox 27 | - name: Run tests 28 | run: | 29 | tox 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | __pycache__/ 3 | .idea/ 4 | .coverage 5 | htmlcov/ 6 | .tox/ 7 | .ruff_cache/ 8 | dist/ 9 | *.egg-info/ 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.2.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-added-large-files 9 | - repo: https://github.com/charliermarsh/ruff-pre-commit 10 | rev: 'v0.0.247' 11 | hooks: 12 | - id: ruff 13 | args: [ --fix, --exit-non-zero-on-fix ] 14 | - repo: https://github.com/psf/black 15 | rev: 23.1.0 16 | hooks: 17 | - id: black 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.2.0] - 2023-03-27 6 | - Updated the `django_web_components.attributes.merge_attributes` function to make it easier to work with attributes, especially classes 7 | - The signature was changed to accept a list of dicts 8 | - `merge_attributes` no longer appends attributes, instead, there is a separate `append_attributes` function for that now 9 | - The `class` attribute can now be provided as a string, list, or dict, and it will be normalized into a string. This can be useful for example for toggling classes based on a truthy value. This works similarly to [Vue's mergeProps](https://vuejs.org/api/render-function.html#mergeprops) 10 | 11 | ```python 12 | active = context["attributes"].pop("active", False) 13 | 14 | context["attributes"] = merge_attributes( 15 | { 16 | "type": "button", 17 | "class": [ 18 | "btn", 19 | { 20 | "active": active, 21 | }, 22 | ], 23 | }, 24 | context["attributes"], 25 | ) 26 | ``` 27 | 28 | - Fixed `attributes_to_string` not ignoring attributes with `False` values, e.g. `attributes_to_string({"required": False})` will now return `""` instead of `"required"` 29 | 30 | ## [0.1.1] - 2023-02-26 31 | - Allow registering components without providing a name (e.g. `@component.register()`). In this case, the name of the component's function or class will be used as the component name 32 | 33 | ## [0.1.0] - 2023-02-25 34 | 35 | - Initial release 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dumitru Mihail Cristian 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-web-components 2 | 3 | [![Tests](https://github.com/Xzya/django-web-components/actions/workflows/tests.yml/badge.svg)](https://github.com/Xzya/django-web-components/actions/workflows/tests.yml) 4 | [![PyPI](https://img.shields.io/pypi/v/django-web-components)](https://pypi.org/project/django-web-components/) 5 | 6 | A simple way to create reusable template components in Django. 7 | 8 | ## Example 9 | 10 | You have to first register your component 11 | 12 | ```python 13 | from django_web_components import component 14 | 15 | @component.register("card") 16 | class Card(component.Component): 17 | template_name = "components/card.html" 18 | ``` 19 | 20 | The component's template: 21 | 22 | ```html 23 | # components/card.html 24 | 25 | {% load components %} 26 | 27 |
28 |
29 | {% render_slot slots.header %} 30 |
31 |
32 |
33 | {% render_slot slots.title %} 34 |
35 | 36 | {% render_slot slots.inner_block %} 37 |
38 |
39 | ``` 40 | 41 | You can now render this component with: 42 | 43 | ```html 44 | {% load components %} 45 | 46 | {% card %} 47 | {% slot header %} Featured {% endslot %} 48 | {% slot title %} Card title {% endslot %} 49 | 50 |

Some quick example text to build on the card title and make up the bulk of the card's content.

51 | 52 | Go somewhere 53 | {% endcard %} 54 | ``` 55 | 56 | Which will result in the following HTML being rendered: 57 | 58 | ```html 59 |
60 |
61 | Featured 62 |
63 |
64 |
65 | Card title 66 |
67 | 68 |

Some quick example text to build on the card title and make up the bulk of the card's content.

69 | 70 | Go somewhere 71 |
72 |
73 | ``` 74 | 75 | ## Installation 76 | 77 | ``` 78 | pip install django-web-components 79 | ``` 80 | 81 | Then add `django_web_components` to your `INSTALLED_APPS`. 82 | 83 | ```python 84 | INSTALLED_APPS = [ 85 | ..., 86 | "django_web_components", 87 | ] 88 | ``` 89 | 90 | ### Optional 91 | 92 | To avoid having to use `{% load components %}` in each template, you may add the tags to the `builtins` list inside your 93 | settings. 94 | 95 | ```python 96 | TEMPLATES = [ 97 | { 98 | ..., 99 | "OPTIONS": { 100 | "context_processors": [ 101 | ... 102 | ], 103 | "builtins": [ 104 | "django_web_components.templatetags.components", 105 | ], 106 | }, 107 | }, 108 | ] 109 | ``` 110 | 111 | ## Python / Django compatibility 112 | 113 | The library supports Python 3.8+ and Django 3.2+. 114 | 115 | | Python version | Django version | 116 | |----------------|-----------------------------------| 117 | | `3.12` | `5.0`, `4.2` | 118 | | `3.11` | `5.0`, `4.2`, `4.1` | 119 | | `3.10` | `5.0`, `4.2`, `4.1`, `4.0`, `3.2` | 120 | | `3.9` | `4.2`, `4.1`, `4.0`, `3.2` | 121 | | `3.8` | `4.2`, `4.1`, `4.0`, `3.2` | 122 | 123 | ## Components 124 | 125 | There are two approaches to writing components: class based components and function based components. 126 | 127 | ### Class based components 128 | 129 | ```python 130 | from django_web_components import component 131 | 132 | @component.register("alert") 133 | class Alert(component.Component): 134 | # You may also override the get_template_name() method instead 135 | template_name = "components/alert.html" 136 | 137 | # Extra context data will be passed to the template context 138 | def get_context_data(self, **kwargs) -> dict: 139 | return { 140 | "dismissible": False, 141 | } 142 | ``` 143 | 144 | The component will be rendered by calling the `render(context)` method, which by default will load the template file and render it. 145 | 146 | For tiny components, it may feel cumbersome to manage both the component class and the component's template. For this reason, you may define the template directly from the `render` method: 147 | 148 | ```python 149 | from django_web_components import component 150 | from django_web_components.template import CachedTemplate 151 | 152 | @component.register("alert") 153 | class Alert(component.Component): 154 | def render(self, context) -> str: 155 | return CachedTemplate( 156 | """ 157 | 160 | """, 161 | name="alert", 162 | ).render(context) 163 | ``` 164 | 165 | ### Function based components 166 | 167 | A component may also be defined as a single function that accepts a `context` and returns a string: 168 | 169 | ```python 170 | from django_web_components import component 171 | from django_web_components.template import CachedTemplate 172 | 173 | @component.register 174 | def alert(context): 175 | return CachedTemplate( 176 | """ 177 | 180 | """, 181 | name="alert", 182 | ).render(context) 183 | ``` 184 | 185 | The examples in this guide will mostly use function based components, since it's easier to exemplify as the component code and template are in the same place, but you are free to choose whichever method you prefer. 186 | 187 | ### Template files vs template strings 188 | 189 | The library uses the regular Django templates, which allows you to either [load templates from files](https://docs.djangoproject.com/en/dev/ref/templates/api/#loading-templates), or create [Template objects](https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.Template) directly using template strings. Both methods are supported, and both have advantages and disadvantages: 190 | 191 | - Template files 192 | - You get formatting support and syntax highlighting from your editor 193 | - You get [caching](https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader) by default 194 | - Harder to manage / reason about since your code is split from the template 195 | - Template strings 196 | - Easier to manage / reason about since your component's code and template are in the same place 197 | - You lose formatting support and syntax highlighting since the template is just a string 198 | - You lose caching 199 | 200 | Regarding caching, the library provides a `CachedTemplate`, which will cache and reuse the `Template` object as long as you provide a `name` for it, which will be used as the cache key: 201 | 202 | ```python 203 | from django_web_components import component 204 | from django_web_components.template import CachedTemplate 205 | 206 | @component.register 207 | def alert(context): 208 | return CachedTemplate( 209 | """ 210 | 213 | """, 214 | name="alert", 215 | ).render(context) 216 | ``` 217 | 218 | So in reality, the caching should not be an issue when using template strings, since `CachedTemplate` is just as fast as using the cached loader with template files. 219 | 220 | Regarding formatting support and syntax highlighting, there is no good solution for template strings. PyCharm supports [language injection](https://www.jetbrains.com/help/pycharm/using-language-injections.html#use-language-injection-comments) which allows you to add a `# language=html` comment before the template string and get syntax highlighting, however, it only highlights HTML and not the Django tags, and you are still missing support for formatting. Maybe the editors will add better support for this in the future, but for the moment you will be missing syntax highlighting and formatting if you go this route. There is an [open conversation](https://github.com/EmilStenstrom/django-components/issues/183) about this on the `django-components` repo, credits to [EmilStenstrom](https://github.com/EmilStenstrom) for moving the conversation forward with the VSCode team. 221 | 222 | In the end, it's a tradeoff. Use the method that makes the most sense for you. 223 | 224 | ## Registering your components 225 | 226 | [Just like signals](https://docs.djangoproject.com/en/dev/topics/signals/#connecting-receiver-functions), the components can live anywhere, but you need to make sure Django picks them up on startup. The easiest way to do this is to define your components in a `components.py` submodule of the application they relate to, and then connect them inside the `ready()` method of your application configuration class. 227 | 228 | ```python 229 | from django.apps import AppConfig 230 | from django_web_components import component 231 | 232 | class MyAppConfig(AppConfig): 233 | ... 234 | 235 | def ready(self): 236 | # Implicitly register components decorated with @component.register 237 | from . import components # noqa 238 | # OR explicitly register a component 239 | component.register("card", components.Card) 240 | ``` 241 | 242 | You may also `unregister` an existing component, or get a component from the registry: 243 | 244 | ```python 245 | from django_web_components import component 246 | # Unregister a component 247 | component.registry.unregister("card") 248 | # Get a component 249 | component.registry.get("card") 250 | # Remove all components 251 | component.registry.clear() 252 | # Get all components as a dict of name: component 253 | component.registry.all() 254 | ``` 255 | 256 | ## Rendering components 257 | 258 | Each registered component will have two tags available for use in your templates: 259 | 260 | - A block tag, e.g. `{% card %} ... {% endcard %}` 261 | - An inline tag, e.g. `{% #user_profile %}`. This can be useful for components that don't necessarily require a body 262 | 263 | ### Component tag formatter 264 | 265 | By default, components will be registered using the following tags: 266 | 267 | - Block start tag: `{% %}` 268 | - Block end tag: `{% end %}` 269 | - Inline tag: `{% # %}` 270 | 271 | This behavior may be changed by providing a custom tag formatter in your settings. For example, to change the block tags to `{% #card %} ... {% /card %}`, and the inline tag to `{% card %}` (similar to [slippers](https://github.com/mixxorz/slippers)), you can use the following formatter: 272 | 273 | ```python 274 | class ComponentTagFormatter: 275 | def format_block_start_tag(self, name): 276 | return f"#{name}" 277 | 278 | def format_block_end_tag(self, name): 279 | return f"/{name}" 280 | 281 | def format_inline_tag(self, name): 282 | return name 283 | 284 | # inside your settings 285 | WEB_COMPONENTS = { 286 | "DEFAULT_COMPONENT_TAG_FORMATTER": "path.to.your.ComponentTagFormatter", 287 | } 288 | ``` 289 | 290 | ## Passing data to components 291 | 292 | You may pass data to components using keyword arguments, which accept either hardcoded values or variables: 293 | 294 | ```html 295 | {% with error_message="Something bad happened!" %} 296 | {% #alert type="error" message=error_message %} 297 | {% endwith %} 298 | ``` 299 | 300 | All attributes will be added in an `attributes` dict which will be available in the template context: 301 | 302 | ```json 303 | { 304 | "attributes": { 305 | "type": "error", 306 | "message": "Something bad happened!" 307 | } 308 | } 309 | ``` 310 | 311 | You can then access it from your component's template: 312 | 313 | ```html 314 |
315 | {{ attributes.message }} 316 |
317 | ``` 318 | 319 | ### Rendering all attributes 320 | 321 | You may also render all attributes directly using `{{ attributes }}`. For example, if you have the following component 322 | 323 | ```html 324 | {% alert id="alerts" class="font-bold" %} ... {% endalert %} 325 | ``` 326 | 327 | You may render all attributes using 328 | 329 | ```html 330 |
331 | 332 |
333 | ``` 334 | 335 | Which will result in the following HTML being rendered: 336 | 337 | ```html 338 |
339 | 340 |
341 | ``` 342 | 343 | ### Attributes with special characters 344 | 345 | You can also pass attributes with special characters (`[@:_-.]`), as well as attributes with no value: 346 | 347 | ```html 348 | {% button @click="handleClick" data-id="123" required %} ... {% endbutton %} 349 | ``` 350 | 351 | Which will result in the follow dict available in the context: 352 | 353 | ```python 354 | { 355 | "attributes": { 356 | "@click": "handleClick", 357 | "data-id": "123", 358 | "required": True, 359 | } 360 | } 361 | ``` 362 | 363 | And will be rendered by `{{ attributes }}` as `@click="handleClick" data-id="123" required`. 364 | 365 | ### Default / merged attributes 366 | 367 | Sometimes you may need to specify default values for attributes, or merge additional values into some of the component's attributes. The library provides a `merge_attrs` tag that helps with this: 368 | 369 | ```html 370 | 373 | ``` 374 | 375 | If we assume this component is utilized like so: 376 | 377 | ```html 378 | {% alert class="mb-4" %} ... {% endalert %} 379 | ``` 380 | 381 | The final rendered HTML of the component will appear like the following: 382 | 383 | ```html 384 | 387 | ``` 388 | 389 | ### Non-class attribute merging 390 | 391 | When merging attributes that are not `class` attributes, the values provided to the `merge_attrs` tag will be considered the "default" values of the attribute. However, unlike the `class` attribute, these attributes will not be merged with injected attribute values. Instead, they will be overwritten. For example, a `button` component's implementation may look like the following: 392 | 393 | ```html 394 | 397 | ``` 398 | 399 | To render the button component with a custom `type`, it may be specified when consuming the component. If no type is specified, the `button` type will be used: 400 | 401 | ```html 402 | {% button type="submit" %} Submit {% endbutton %} 403 | ``` 404 | 405 | The rendered HTML of the `button` component in this example would be: 406 | 407 | ```html 408 | 411 | ``` 412 | 413 | ### Appendable attributes 414 | 415 | You may also treat other attributes as "appendable" by using the `+=` operator: 416 | 417 | ```html 418 |
419 | 420 |
421 | ``` 422 | 423 | If we assume this component is utilized like so: 424 | 425 | ```html 426 | {% alert data-value="foo" %} ... {% endalert %} 427 | ``` 428 | 429 | The rendered HTML will be: 430 | 431 | ```html 432 |
433 | 434 |
435 | ``` 436 | 437 | ### Manipulating the attributes 438 | 439 | By default, all attributes are added to an `attributes` dict inside the context. However, this may not always be what we want. For example, imagine we want to have an `alert` component that can be dismissed, while at the same time being able to pass extra attributes to the root element, like an `id` or `class`. Ideally we would want to be able to render a component like this: 440 | 441 | ```html 442 | {% alert id="alerts" dismissible %} Something went wrong! {% endalert %} 443 | ``` 444 | 445 | A naive way to implement this component would be something like the following: 446 | 447 | ```html 448 |
449 | {% render_slot slots.inner_block %} 450 | 451 | {% if attributes.dismissible %} 452 | 453 | {% endif %} 454 |
455 | ``` 456 | 457 | However, this would result in the `dismissible` attribute being included in the root element, which is not what we want: 458 | 459 | ```html 460 |
461 | Something went wrong! 462 | 463 | 464 |
465 | ``` 466 | 467 | Ideally we would want the `dismissible` attribute to be separated from the `attributes` since we only want to use it in logic, but not necessarily render it to the component. 468 | 469 | To achieve this, you can manipulate the context from your component in order to provide a better API for using the components. There are several ways to do this, choose the method that makes the most sense to you, for example: 470 | 471 | - You can override `get_context_data` and remove the `dismissible` attribute from `attributes` and return it in the context instead 472 | 473 | ```python 474 | from django_web_components import component 475 | 476 | @component.register("alert") 477 | class Alert(component.Component): 478 | template_name = "components/alert.html" 479 | 480 | def get_context_data(self, **kwargs): 481 | dismissible = self.attributes.pop("dismissible", False) 482 | 483 | return { 484 | "dismissible": dismissible, 485 | } 486 | ``` 487 | 488 | - You can override the `render` method and manipulate the context there 489 | 490 | ```python 491 | from django_web_components import component 492 | 493 | @component.register("alert") 494 | class Alert(component.Component): 495 | template_name = "components/alert.html" 496 | 497 | def render(self, context): 498 | context["dismissible"] = context["attributes"].pop("dismissible", False) 499 | 500 | return super().render(context) 501 | ``` 502 | 503 | Both of the above solutions will work, and you can do the same for function based components. The component's template can then look like this: 504 | 505 | ```html 506 |
507 | {% render_slot slots.inner_block %} 508 | 509 | {% if dismissible %} 510 | 511 | {% endif %} 512 |
513 | ``` 514 | 515 | Which should result in the correct HTML being rendered: 516 | 517 | ```html 518 |
519 | Something went wrong! 520 | 521 | 522 |
523 | ``` 524 | 525 | ## Slots 526 | 527 | You will often need to pass additional content to your components via "slots". A `slots` context variable is passed to your components, which consists of a dict with the slot name as the key and the slot as the value. You may then render the slots inside your components using the `render_slot` tag. 528 | 529 | ### The default slot 530 | 531 | To explore this concept, let's imagine we want to pass some content to an `alert` component: 532 | 533 | ```html 534 | {% alert %} 535 | Whoops! Something went wrong! 536 | {% endalert %} 537 | ``` 538 | 539 | By default, that content will be made available to your component in the default slot which is called `inner_block`. You can then render this slot using the `render_slot` tag inside your component: 540 | 541 | ```html 542 | {% load components %} 543 |
544 | {% render_slot slots.inner_block %} 545 |
546 | ``` 547 | 548 | Which should result in the following HTML being rendered: 549 | 550 | ```html 551 |
552 | Whoops! Something went wrong! 553 |
554 | ``` 555 | 556 | --- 557 | 558 | You may also rename the default slot by specifying it in your settings: 559 | 560 | ```python 561 | # inside your settings 562 | WEB_COMPONENTS = { 563 | "DEFAULT_SLOT_NAME": "inner_block", 564 | } 565 | ``` 566 | 567 | ### Named slots 568 | 569 | Sometimes a component may need to render multiple different slots in different locations within the component. Let's modify our alert component to allow for the injection of a "title" slot: 570 | 571 | ```html 572 | {% load components %} 573 |
574 | 575 | {% render_slot slots.title %} 576 | 577 | 578 | {% render_slot slots.inner_block %} 579 |
580 | ``` 581 | 582 | You may define the content of the named slot using the `slot` tag. Any content not within an explicit `slot` tag will be added to the default `inner_block` slot: 583 | 584 | ```html 585 | {% load components %} 586 | {% alert %} 587 | {% slot title %} Server error {% endslot %} 588 | 589 | Whoops! Something went wrong! 590 | {% endalert %} 591 | ``` 592 | 593 | The rendered HTML in this example would be: 594 | 595 | ```html 596 |
597 | 598 | Server error 599 | 600 | 601 | Whoops! Something went wrong! 602 |
603 | ``` 604 | 605 | ### Duplicate named slots 606 | 607 | You may define the same named slot multiple times: 608 | 609 | ```html 610 | {% unordered_list %} 611 | {% slot item %} First item {% endslot %} 612 | {% slot item %} Second item {% endslot %} 613 | {% slot item %} Third item {% endslot %} 614 | {% endunordered_list %} 615 | ``` 616 | 617 | You can then iterate over the slot inside your component: 618 | 619 | ```html 620 |
    621 | {% for item in slots.item %} 622 |
  • {% render_slot item %}
  • 623 | {% endfor %} 624 |
625 | ``` 626 | 627 | Which will result in the following HTML: 628 | 629 | ```html 630 |
    631 |
  • First item
  • 632 |
  • Second item
  • 633 |
  • Third item
  • 634 |
635 | ``` 636 | 637 | ### Scoped slots 638 | 639 | The slot content will also have access to the component's context. To explore this concept, imagine a list component that accepts an `entries` attribute representing a list of things, which it will then iterate over and render the `inner_block` slot for each entry. 640 | 641 | ```python 642 | from django_web_components import component 643 | from django_web_components.template import CachedTemplate 644 | 645 | @component.register 646 | def unordered_list(context): 647 | context["entries"] = context["attributes"].pop("entries", []) 648 | 649 | return CachedTemplate( 650 | """ 651 |
    652 | {% for entry in entries %} 653 |
  • 654 | {% render_slot slots.inner_block %} 655 |
  • 656 | {% endfor %} 657 |
658 | """, 659 | name="unordered_list", 660 | ).render(context) 661 | ``` 662 | 663 | We can then render the component as follows: 664 | 665 | ```html 666 | {% unordered_list entries=entries %} 667 | I like {{ entry }}! 668 | {% endunordered_list %} 669 | ``` 670 | 671 | In this example, the `entry` variable comes from the component's context. If we assume that `entries = ["apples", "bananas", "cherries"]`, the resulting HTML will be: 672 | 673 | ```html 674 |
    675 |
  • I like apples!
  • 676 |
  • I like bananas!
  • 677 |
  • I like cherries!
  • 678 |
679 | ``` 680 | 681 | --- 682 | 683 | You may also explicitly pass a second argument to `render_slot`: 684 | 685 | ```html 686 |
    687 | {% for entry in entries %} 688 |
  • 689 | 690 | {% render_slot slots.inner_block entry %} 691 |
  • 692 | {% endfor %} 693 |
694 | ``` 695 | 696 | When invoking the component, you can use the special attribute `:let` to take the value that was passed to `render_slot` and bind it to a variable: 697 | 698 | ```html 699 | {% unordered_list :let="fruit" entries=entries %} 700 | I like {{ fruit }}! 701 | {% endunordered_list %} 702 | ``` 703 | 704 | This would render the same HTML as above. 705 | 706 | ### Slot attributes 707 | 708 | Similar to the components, you may assign additional attributes to slots. Below is a table component illustrating multiple named slots with attributes: 709 | 710 | ```python 711 | from django_web_components import component 712 | from django_web_components.template import CachedTemplate 713 | 714 | @component.register 715 | def table(context): 716 | context["rows"] = context["attributes"].pop("rows", []) 717 | 718 | return CachedTemplate( 719 | """ 720 | 721 | 722 | {% for col in slots.column %} 723 | 724 | {% endfor %} 725 | 726 | {% for row in rows %} 727 | 728 | {% for col in slots.column %} 729 | 732 | {% endfor %} 733 | 734 | {% endfor %} 735 |
{{ col.attributes.label }}
730 | {% render_slot col row %} 731 |
736 | """, 737 | name="table", 738 | ).render(context) 739 | ``` 740 | 741 | You can invoke the component like so: 742 | 743 | ```html 744 | {% table rows=rows %} 745 | {% slot column :let="user" label="Name" %} 746 | {{ user.name }} 747 | {% endslot %} 748 | {% slot column :let="user" label="Age" %} 749 | {{ user.age }} 750 | {% endslot %} 751 | {% endtable %} 752 | ``` 753 | 754 | If we assume that `rows = [{ "name": "Jane", "age": "34" }, { "name": "Bob", "age": "51" }]`, the following HTML will be rendered: 755 | 756 | ```html 757 | 758 | 759 | 760 | 761 | 762 | 763 | 764 | 765 | 766 | 767 | 768 | 769 | 770 |
NameAge
Jane34
Bob51
771 | ``` 772 | 773 | ### Nested components 774 | 775 | You may also nest components to achieve more complicated elements. Here is an example of how you might implement an [Accordion component using Bootstrap](https://getbootstrap.com/docs/5.3/components/accordion/): 776 | 777 | ```python 778 | from django_web_components import component 779 | from django_web_components.template import CachedTemplate 780 | import uuid 781 | 782 | @component.register 783 | def accordion(context): 784 | context["accordion_id"] = context["attributes"].pop("id", str(uuid.uuid4())) 785 | context["always_open"] = context["attributes"].pop("always_open", False) 786 | 787 | return CachedTemplate( 788 | """ 789 |
790 | {% render_slot slots.inner_block %} 791 |
792 | """, 793 | name="accordion", 794 | ).render(context) 795 | 796 | 797 | @component.register 798 | def accordion_item(context): 799 | context["id"] = context["attributes"].pop("id", str(uuid.uuid4())) 800 | context["open"] = context["attributes"].pop("open", False) 801 | 802 | return CachedTemplate( 803 | """ 804 |
805 |

806 | 816 |

817 |
825 |
826 | {% render_slot slots.body %} 827 |
828 |
829 |
830 | """, 831 | name="accordion_item", 832 | ).render(context) 833 | ``` 834 | 835 | You can then use them as follows: 836 | 837 | ```html 838 | {% accordion %} 839 | 840 | {% accordion_item open %} 841 | {% slot title %} Accordion Item #1 {% endslot %} 842 | {% slot body %} 843 | This is the first item's accordion body. It is shown by default. 844 | {% endslot %} 845 | {% endaccordion_item %} 846 | 847 | {% accordion_item %} 848 | {% slot title %} Accordion Item #2 {% endslot %} 849 | {% slot body %} 850 | This is the second item's accordion body. It is hidden by default. 851 | {% endslot %} 852 | {% endaccordion_item %} 853 | 854 | {% accordion_item %} 855 | {% slot title %} Accordion Item #3 {% endslot %} 856 | {% slot body %} 857 | This is the third item's accordion body. It is hidden by default. 858 | {% endslot %} 859 | {% endaccordion_item %} 860 | 861 | {% endaccordion %} 862 | ``` 863 | 864 | ## Setup for development and running the tests 865 | 866 | The project uses `poetry` to manage the dependencies. Check out the documentation on how to install poetry here: https://python-poetry.org/docs/#installation 867 | 868 | Install the dependencies 869 | 870 | ```bash 871 | poetry install 872 | ``` 873 | 874 | Activate the environment 875 | 876 | ```bash 877 | poetry shell 878 | ``` 879 | 880 | Now you can run the tests 881 | 882 | ```bash 883 | python runtests.py 884 | ``` 885 | 886 | ## Motivation / Inspiration / Resources 887 | 888 | The project came to be after seeing how other languages / frameworks deal with components, and wanting to bring some of those ideas back to Django. 889 | 890 | - [django-components](https://github.com/EmilStenstrom/django-components) - The existing `django-components` library is already great and supports most of the features that this project has, but I thought the syntax could be improved a bit to feel less verbose, and add a few extra things that seemed necessary, like support for function based components and template strings, and working with attributes 891 | - [Phoenix Components](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) - I really liked the simplicity of Phoenix and how they deal with components, and this project is heavily inspired by it. In fact, some of the examples above are straight-up copied from there (like the table example). 892 | - [Laravel Blade Components](https://laravel.com/docs/9.x/blade#components) - The initial implementation was actually very different and was relying on HTML parsing to turn the HTML into template Nodes, and was heavily inspired by Laravel. This had the benefit of having a nicer syntax (e.g. rendering the components looked a lot like normal HTML `Server Error`), but the solution was a lot more complicated and I came to the conclusion that using a similar approach to `django-components` made a lot more sense in Django 893 | - [Vue Components](https://vuejs.org/guide/essentials/component-basics.html) 894 | - [slippers](https://github.com/mixxorz/slippers) 895 | - [django-widget-tweaks](https://github.com/jazzband/django-widget-tweaks) 896 | - [How EEx Turns Your Template Into HTML](https://www.mitchellhanberg.com/how-eex-turns-your-template-into-html/) 897 | 898 | ### Component libraries 899 | 900 | Many other languages / frameworks are using the same concepts for building components (slots, attributes), so a lot of the knowledge is transferable, and there is already a great deal of existing component libraries out there (e.g. using Bootstrap, Tailwind, Material design, etc.). I highly recommend looking at some of them to get inspired on how to build / structure your components. Here are some examples: 901 | 902 | - https://bootstrap-vue.org/docs/components/alert 903 | - https://coreui.io/bootstrap-vue/components/alert.html 904 | - https://laravel-bootstrap-components.com/components/alerts 905 | - https://flowbite.com/docs/components/alerts/ 906 | - https://www.creative-tim.com/vuematerial/components/button 907 | - https://phoenix-ui.fly.dev/components/alert 908 | - https://storybook.phenixgroupe.com/components/message 909 | - https://surface-ui.org/samplecomponents/Button 910 | - https://react-bootstrap.github.io/components/alerts/ 911 | -------------------------------------------------------------------------------- /django_web_components/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/django-web-components/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/__init__.py -------------------------------------------------------------------------------- /django_web_components/attributes.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Union 2 | 3 | from django.utils.html import format_html, conditional_escape 4 | from django.utils.safestring import mark_safe, SafeString 5 | 6 | 7 | class AttributeBag(dict): 8 | def __str__(self): 9 | """ 10 | Convert the attributes into a single HTML string. 11 | """ 12 | return attributes_to_string(self) 13 | 14 | 15 | def attributes_to_string(attributes: dict) -> str: 16 | """ 17 | Convert a dict of attributes to a string. 18 | """ 19 | attr_list = [] 20 | 21 | for key, value in attributes.items(): 22 | if value is None or value is False: 23 | continue 24 | if value is True: 25 | attr_list.append(conditional_escape(key)) 26 | else: 27 | attr_list.append(format_html('{}="{}"', key, value)) 28 | 29 | return mark_safe(SafeString(" ").join(attr_list)) 30 | 31 | 32 | def merge_attributes(*args: dict) -> dict: 33 | """ 34 | Merges the input dictionaries and returns a new dictionary. 35 | 36 | Notes: 37 | ------ 38 | The merge process is performed as follows: 39 | - "class" values are normalized / concatenated 40 | - Other values are added to the final dictionary as is 41 | """ 42 | result = AttributeBag() 43 | 44 | for to_merge in args: 45 | for key, value in to_merge.items(): 46 | if key == "class": 47 | klass = result.get("class") 48 | if klass != value: 49 | result["class"] = normalize_class([klass, value]) 50 | elif key != "": 51 | result[key] = value 52 | 53 | return result 54 | 55 | 56 | def append_attributes(*args: dict) -> dict: 57 | """ 58 | Merges the input dictionaries and returns a new dictionary. 59 | 60 | If a key is present in multiple dictionaries, its values are concatenated with a space character 61 | as separator in the final dictionary. 62 | """ 63 | result = AttributeBag() 64 | 65 | for to_merge in args: 66 | for key, value in to_merge.items(): 67 | if key in result: 68 | result[key] += " " + value 69 | else: 70 | result[key] = value 71 | 72 | return result 73 | 74 | 75 | def normalize_class(value: Union[str, list, tuple, dict]) -> str: 76 | """ 77 | Normalizes the given class value into a string. 78 | 79 | Notes: 80 | ------ 81 | The normalization process is performed as follows: 82 | - If the input value is a string, it is returned as is. 83 | - If the input value is a list or a tuple, its elements are recursively normalized and concatenated 84 | with a space character as separator. 85 | - If the input value is a dictionary, its keys are concatenated with a space character as separator 86 | only if their corresponding values are truthy. 87 | """ 88 | result = "" 89 | 90 | if isinstance(value, str): 91 | result = value 92 | elif isinstance(value, (list, tuple)): 93 | for v in value: 94 | normalized = normalize_class(v) 95 | if normalized: 96 | result += normalized + " " 97 | elif isinstance(value, dict): 98 | for key, val in value.items(): 99 | if val: 100 | result += key + " " 101 | 102 | return result.strip() 103 | 104 | 105 | def split_attributes(attributes: dict) -> Tuple[dict, dict]: 106 | """ 107 | Splits the given attributes into "special" attributes (like :let) and normal attributes. 108 | """ 109 | special_attrs = (":let",) 110 | 111 | attrs = {} 112 | special = {} 113 | for key, value in attributes.items(): 114 | if key in special_attrs: 115 | special[key] = value 116 | else: 117 | attrs[key] = value 118 | 119 | return special, attrs 120 | -------------------------------------------------------------------------------- /django_web_components/component.py: -------------------------------------------------------------------------------- 1 | from types import FunctionType 2 | from typing import Union 3 | 4 | from django import template 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.template import loader 7 | 8 | from django_web_components.attributes import AttributeBag 9 | from django_web_components.registry import ComponentRegistry 10 | from django_web_components.tag_formatter import get_component_tag_formatter 11 | 12 | 13 | class Component: 14 | """ 15 | Base class for components. 16 | """ 17 | 18 | template_name: str = None 19 | attributes: AttributeBag 20 | slots: dict 21 | 22 | def __init__( 23 | self, 24 | attributes: dict = None, 25 | slots: dict = None, 26 | ): 27 | self.attributes = attributes or AttributeBag() 28 | self.slots = slots or {} 29 | 30 | def get_context_data(self, **kwargs) -> dict: 31 | return {} 32 | 33 | def get_template_name(self) -> Union[str, list, tuple]: 34 | if not self.template_name: 35 | raise ImproperlyConfigured(f"Template name is not set for Component {self.__class__.__name__}") 36 | 37 | return self.template_name 38 | 39 | def render(self, context) -> str: 40 | template_name = self.get_template_name() 41 | 42 | return loader.render_to_string(template_name, context.flatten()) 43 | 44 | 45 | def render_component(*, name: str, attributes: dict, slots: dict, context: template.Context) -> str: 46 | """ 47 | Render the component with the given name. 48 | """ 49 | extra_context = { 50 | "attributes": attributes, 51 | "slots": slots, 52 | } 53 | 54 | component_class = registry.get(name) 55 | 56 | # handle function components 57 | if isinstance(component_class, FunctionType): 58 | with context.push(extra_context): 59 | return component_class(context) 60 | 61 | # handle class based components 62 | component = component_class( 63 | attributes=attributes, 64 | slots=slots, 65 | ) 66 | extra_context.update(component.get_context_data()) 67 | 68 | with context.push(extra_context): 69 | return component.render(context) 70 | 71 | 72 | # Global component registry 73 | registry = ComponentRegistry() 74 | 75 | 76 | def register(name=None, component=None, target_register: template.Library = None): 77 | """ 78 | Register a component. 79 | """ 80 | from django_web_components.templatetags.components import ( 81 | register as tag_register, 82 | ) 83 | 84 | # use the default library if none is passed 85 | if target_register is None: 86 | target_register = tag_register 87 | 88 | def decorator(component): 89 | return _register(name=component.__name__, component=component, target_register=target_register) 90 | 91 | def decorator_with_custom_name(component): 92 | return _register(name=name, component=component, target_register=target_register) 93 | 94 | if name is None and component is None: 95 | # @register() 96 | return decorator 97 | 98 | elif name is not None and component is None: 99 | if callable(name): 100 | # @register or register(alert) 101 | return decorator(name) 102 | else: 103 | # @register("alert") or @register(name="alert") 104 | return decorator_with_custom_name 105 | 106 | elif name is not None and component is not None: 107 | # register("alert", alert) 108 | return _register(name=name, component=component, target_register=target_register) 109 | 110 | else: 111 | raise ValueError("Unsupported arguments to component.register: (%r, %r)" % (name, component)) 112 | 113 | 114 | def _register(name: str, component, target_register: template.Library): 115 | from django_web_components.templatetags.components import ( 116 | create_component_tag, 117 | ) 118 | 119 | # add the component to the registry 120 | registry.register(name=name, component=component) 121 | 122 | formatter = get_component_tag_formatter() 123 | 124 | # register the inline tag 125 | target_register.tag(formatter.format_inline_tag(name), create_component_tag(name)) 126 | 127 | # register the block tag 128 | target_register.tag(formatter.format_block_start_tag(name), create_component_tag(name)) 129 | 130 | return component 131 | -------------------------------------------------------------------------------- /django_web_components/conf.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | SETTINGS_KEY = "WEB_COMPONENTS" 4 | 5 | DEFAULT_SLOT_NAME = "inner_block" 6 | DEFAULT_COMPONENT_TAG_FORMATTER = "django_web_components.tag_formatter.ComponentTagFormatter" 7 | 8 | 9 | class AppSettings: 10 | @property 11 | def settings(self): 12 | return getattr(settings, SETTINGS_KEY, {}) 13 | 14 | @property 15 | def DEFAULT_SLOT_NAME(self): 16 | return self.settings.get("DEFAULT_SLOT_NAME", DEFAULT_SLOT_NAME) 17 | 18 | @property 19 | def DEFAULT_COMPONENT_TAG_FORMATTER(self): 20 | return self.settings.get("DEFAULT_COMPONENT_TAG_FORMATTER", DEFAULT_COMPONENT_TAG_FORMATTER) 21 | 22 | 23 | app_settings = AppSettings() 24 | -------------------------------------------------------------------------------- /django_web_components/registry.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | 4 | class AlreadyRegistered(Exception): 5 | pass 6 | 7 | 8 | class NotRegistered(Exception): 9 | pass 10 | 11 | 12 | class ComponentRegistry: 13 | def __init__(self): 14 | self._registry: Dict[str, Any] = {} 15 | 16 | def register(self, name: str = None, component: Any = None): 17 | if name in self._registry: 18 | raise AlreadyRegistered('The component "%s" is already registered' % name) 19 | 20 | self._registry[name] = component 21 | 22 | def unregister(self, name): 23 | self.get(name) 24 | 25 | del self._registry[name] 26 | 27 | def get(self, name) -> Any: 28 | if name not in self._registry: 29 | raise NotRegistered('The component "%s" is not registered' % name) 30 | 31 | return self._registry[name] 32 | 33 | def all(self) -> Dict[str, Any]: 34 | return self._registry 35 | 36 | def clear(self): 37 | self._registry = {} 38 | -------------------------------------------------------------------------------- /django_web_components/tag_formatter.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import import_string 2 | 3 | from django_web_components.conf import app_settings 4 | 5 | 6 | class ComponentTagFormatter: 7 | """ 8 | The default component tag formatter. 9 | """ 10 | 11 | def format_block_start_tag(self, name: str) -> str: 12 | """ 13 | Formats the start tag of a block component. 14 | """ 15 | return name 16 | 17 | def format_block_end_tag(self, name: str) -> str: 18 | """ 19 | Formats the end tag of a block component. 20 | """ 21 | return f"end{name}" 22 | 23 | def format_inline_tag(self, name: str) -> str: 24 | """ 25 | Formats the tag of an inline component. 26 | """ 27 | return f"#{name}" 28 | 29 | 30 | def get_component_tag_formatter(): 31 | """ 32 | Returns an instance of the currently configured component tag formatter. 33 | """ 34 | return import_string(app_settings.DEFAULT_COMPONENT_TAG_FORMATTER)() 35 | -------------------------------------------------------------------------------- /django_web_components/template.py: -------------------------------------------------------------------------------- 1 | from django.template import Template 2 | 3 | template_cache = {} 4 | 5 | 6 | class CachedTemplate: 7 | def __init__(self, template_string, origin=None, name=None, engine=None): 8 | self.template_string = template_string 9 | self.origin = origin 10 | self.name = name 11 | self.engine = engine 12 | 13 | def render(self, context): 14 | key = self.name 15 | 16 | if key in template_cache: 17 | return template_cache[key].render(context) 18 | 19 | template = Template(self.template_string, self.origin, self.name, self.engine) 20 | 21 | if key is not None: 22 | template_cache[key] = template 23 | 24 | return template.render(context) 25 | -------------------------------------------------------------------------------- /django_web_components/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/django-web-components/b43eb0c832837db939a6f8c1980334b0adfdd6e4/django_web_components/templatetags/__init__.py -------------------------------------------------------------------------------- /django_web_components/templatetags/components.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Union 3 | 4 | from django import template 5 | from django.template import TemplateSyntaxError, NodeList 6 | from django.template.base import FilterExpression, Token, Parser 7 | from django.utils.regex_helper import _lazy_re_compile 8 | from django.utils.safestring import SafeString 9 | 10 | from django_web_components.attributes import ( 11 | AttributeBag, 12 | attributes_to_string, 13 | merge_attributes, 14 | split_attributes, 15 | append_attributes, 16 | ) 17 | from django_web_components.component import ( 18 | render_component, 19 | get_component_tag_formatter, 20 | ) 21 | from django_web_components.conf import app_settings 22 | from django_web_components.utils import token_kwargs 23 | 24 | register = template.Library() 25 | 26 | 27 | def create_component_tag(component_name: str): 28 | def do_component(parser: Parser, token: Token): 29 | tag_name, *remaining_bits = token.split_contents() 30 | 31 | formatter = get_component_tag_formatter() 32 | 33 | # If this is a block tag, expect the closing tag 34 | if tag_name == formatter.format_block_start_tag(component_name): 35 | nodelist = parser.parse((formatter.format_block_end_tag(component_name),)) 36 | parser.delete_first_token() 37 | else: 38 | nodelist = NodeList() 39 | 40 | # Bits that are not keyword args are interpreted as `True` values 41 | all_bits = [bit if "=" in bit else f"{bit}=True" for bit in remaining_bits] 42 | raw_attributes = token_kwargs(all_bits, parser) 43 | special, attrs = split_attributes(raw_attributes) 44 | 45 | # process the slots 46 | slots = {} 47 | 48 | # All child nodes that are not inside a slot will be added to a default slot 49 | default_slot_name = app_settings.DEFAULT_SLOT_NAME 50 | default_slot = SlotNode( 51 | name=default_slot_name, 52 | nodelist=NodeList(), 53 | unresolved_attributes={}, 54 | special=special, 55 | ) 56 | slots[default_slot_name] = SlotNodeList() 57 | 58 | for node in nodelist: 59 | if isinstance(node, SlotNode): 60 | slot_name = node.name 61 | 62 | # initialize the slot 63 | if slot_name not in slots: 64 | slots[slot_name] = SlotNodeList() 65 | 66 | slots[slot_name].append(node) 67 | else: 68 | # otherwise add the node to the default slot 69 | default_slot.nodelist.append(node) 70 | 71 | # add the default slot only if it's not empty 72 | if len(default_slot.nodelist) > 0: 73 | slots[default_slot_name].append(default_slot) 74 | 75 | return ComponentNode( 76 | name=component_name, 77 | unresolved_attributes=attrs, 78 | slots=slots, 79 | ) 80 | 81 | return do_component 82 | 83 | 84 | class ComponentNode(template.Node): 85 | name: str 86 | unresolved_attributes: dict 87 | slots: dict 88 | 89 | def __init__(self, name: str = None, unresolved_attributes: dict = None, slots: dict = None): 90 | self.name = name or "" 91 | self.unresolved_attributes = unresolved_attributes or {} 92 | self.slots = slots or {} 93 | 94 | def render(self, context): 95 | # We may need to access the slot's attributes inside the component's template, 96 | # so we need to resolve them 97 | # 98 | # We also clone the SlotNodes to make sure we don't have thread-safety issues since 99 | # we are storing the attributes on the node itself 100 | slots = {} 101 | for slot_name, slot_list in self.slots.items(): 102 | # initialize the slot 103 | if slot_name not in slots: 104 | slots[slot_name] = SlotNodeList() 105 | 106 | for slot in slot_list: 107 | if isinstance(slot, SlotNode): 108 | # clone the SlotNode 109 | cloned_slot = SlotNode( 110 | name=slot.name, 111 | nodelist=slot.nodelist, 112 | unresolved_attributes=slot.unresolved_attributes, 113 | special=slot.special, 114 | ) 115 | # resolve its attributes so that they can be accessed from the component template 116 | cloned_slot.resolve_attributes(context) 117 | 118 | slots[slot_name].append(cloned_slot) 119 | else: 120 | slots[slot_name].append(slot) 121 | 122 | attributes = AttributeBag({key: value.resolve(context) for key, value in self.unresolved_attributes.items()}) 123 | 124 | return render_component( 125 | name=self.name, 126 | attributes=attributes, 127 | slots=slots, 128 | context=context, 129 | ) 130 | 131 | 132 | @register.tag("slot") 133 | def do_slot(parser: Parser, token: Token): 134 | tag_name, *remaining_bits = token.split_contents() 135 | 136 | if len(remaining_bits) < 1: 137 | raise TemplateSyntaxError("'%s' tag takes at least one argument, the slot name" % tag_name) 138 | 139 | slot_name = remaining_bits.pop(0).strip('"') 140 | 141 | # Bits that are not keyword args are interpreted as `True` values 142 | all_bits = [bit if "=" in bit else f"{bit}=True" for bit in remaining_bits] 143 | raw_attributes = token_kwargs(all_bits, parser) 144 | special, attrs = split_attributes(raw_attributes) 145 | 146 | nodelist = parser.parse(("endslot",)) 147 | parser.delete_first_token() 148 | 149 | return SlotNode( 150 | name=slot_name, 151 | nodelist=nodelist, 152 | unresolved_attributes=attrs, 153 | special=special, 154 | ) 155 | 156 | 157 | class SlotNode(template.Node): 158 | name: str 159 | nodelist: NodeList 160 | unresolved_attributes: dict 161 | attributes: dict 162 | special: dict 163 | 164 | def __init__( 165 | self, name: str = None, nodelist: NodeList = None, unresolved_attributes: dict = None, special: dict = None 166 | ): 167 | self.name = name or "" 168 | self.nodelist = nodelist or NodeList() 169 | self.unresolved_attributes = unresolved_attributes or {} 170 | self.special = special or {} 171 | # Will be set by the ComponentNode 172 | self.attributes = AttributeBag() 173 | 174 | def resolve_attributes(self, context): 175 | self.attributes = AttributeBag( 176 | {key: value.resolve(context) for key, value in self.unresolved_attributes.items()} 177 | ) 178 | 179 | def render(self, context): 180 | attributes = AttributeBag({key: value.resolve(context) for key, value in self.unresolved_attributes.items()}) 181 | 182 | extra_context = { 183 | "attributes": attributes, 184 | } 185 | 186 | with context.update(extra_context): 187 | return self.nodelist.render(context) 188 | 189 | 190 | class SlotNodeList(NodeList): 191 | @property 192 | def attributes(self) -> dict: 193 | if len(self) == 1 and hasattr(self[0], "attributes"): 194 | return self[0].attributes 195 | return AttributeBag() 196 | 197 | 198 | @register.tag("render_slot") 199 | def do_render_slot(parser: Parser, token: Token): 200 | tag_name, *remaining_bits = token.split_contents() 201 | if not remaining_bits: 202 | raise TemplateSyntaxError("'%s' tag takes at least one argument, the slot" % tag_name) 203 | 204 | if len(remaining_bits) > 2: 205 | raise TemplateSyntaxError("'%s' tag takes at most two arguments, the slot and the argument" % tag_name) 206 | 207 | values = [parser.compile_filter(bit) for bit in remaining_bits] 208 | 209 | if len(values) == 2: 210 | [slot, argument] = values 211 | else: 212 | slot = values.pop() 213 | argument = None 214 | 215 | return RenderSlotNode(slot, argument) 216 | 217 | 218 | class RenderSlotNode(template.Node): 219 | def __init__(self, slot: FilterExpression, argument: Union[FilterExpression, None] = None): 220 | self.slot = slot 221 | self.argument = argument 222 | 223 | def render(self, context): 224 | argument = None 225 | if self.argument: 226 | argument = self.argument.resolve(context, ignore_failures=True) 227 | 228 | slot = self.slot.resolve(context, ignore_failures=True) 229 | if slot is None: 230 | return "" 231 | 232 | if isinstance(slot, NodeList): 233 | return SafeString("".join([self.render_slot(node, argument, context) for node in slot])) 234 | 235 | return self.render_slot(slot, argument, context) 236 | 237 | def render_slot(self, slot, argument, context): 238 | if isinstance(slot, SlotNode): 239 | let = slot.special.get(":let", None) 240 | if let: 241 | let = let.resolve(context, ignore_failures=True) 242 | 243 | # if we were passed an argument and the :let attribute is defined, 244 | # add the argument to the context with the new name 245 | if let and argument: 246 | with context.update( 247 | { 248 | let: argument, 249 | } 250 | ): 251 | return slot.render(context) 252 | 253 | return slot.render(context) 254 | 255 | 256 | attribute_re = _lazy_re_compile( 257 | r""" 258 | (?P 259 | [\w\-\:\@\.\_]+ 260 | ) 261 | (?P 262 | \+?= 263 | ) 264 | (?P 265 | ['"]? # start quote 266 | [^"']* 267 | ['"]? # end quote 268 | ) 269 | """, 270 | re.VERBOSE | re.UNICODE, 271 | ) 272 | 273 | 274 | @register.tag("merge_attrs") 275 | def do_merge_attrs(parser: Parser, token: Token): 276 | tag_name, *remaining_bits = token.split_contents() 277 | if not remaining_bits: 278 | raise TemplateSyntaxError("'%s' tag takes at least one argument, the attributes" % tag_name) 279 | 280 | attributes = parser.compile_filter(remaining_bits[0]) 281 | attr_list = remaining_bits[1:] 282 | 283 | default_attrs = [] 284 | append_attrs = [] 285 | for pair in attr_list: 286 | match = attribute_re.match(pair) 287 | if not match: 288 | raise TemplateSyntaxError( 289 | "Malformed arguments to '%s' tag. You must pass the attributes in the form attr=\"value\"." % tag_name 290 | ) 291 | dct = match.groupdict() 292 | attr, sign, value = ( 293 | dct["attr"], 294 | dct["sign"], 295 | parser.compile_filter(dct["value"]), 296 | ) 297 | if sign == "=": 298 | default_attrs.append((attr, value)) 299 | elif sign == "+=": 300 | append_attrs.append((attr, value)) 301 | else: 302 | raise TemplateSyntaxError("Unknown sign '%s' for attribute '%s'" % (sign, attr)) 303 | 304 | return MergeAttrsNode(attributes, default_attrs, append_attrs) 305 | 306 | 307 | class MergeAttrsNode(template.Node): 308 | def __init__(self, attributes, default_attrs, append_attrs): 309 | self.attributes = attributes 310 | self.default_attrs = default_attrs 311 | self.append_attrs = append_attrs 312 | 313 | def render(self, context): 314 | bound_attributes: dict = self.attributes.resolve(context) 315 | 316 | default_attrs = {key: value.resolve(context) for key, value in self.default_attrs} 317 | 318 | append_attrs = {key: value.resolve(context) for key, value in self.append_attrs} 319 | 320 | attrs = merge_attributes( 321 | default_attrs, 322 | bound_attributes, 323 | ) 324 | attrs = append_attributes(attrs, append_attrs) 325 | 326 | return attributes_to_string(attrs) 327 | -------------------------------------------------------------------------------- /django_web_components/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List 3 | 4 | from django.template.base import Parser 5 | from django.utils.regex_helper import _lazy_re_compile 6 | 7 | kwarg_re = _lazy_re_compile( 8 | r""" 9 | (?: 10 | ( 11 | [\w\-\:\@\.\_]+ # attribute name 12 | ) 13 | = 14 | )? 15 | (.+) # value 16 | """, 17 | re.VERBOSE, 18 | ) 19 | 20 | 21 | # This is the same as the original, but the regex is modified to accept 22 | # special characters 23 | def token_kwargs(bits: List[str], parser: Parser) -> dict: 24 | """ 25 | Parse token keyword arguments and return a dictionary of the arguments 26 | retrieved from the ``bits`` token list. 27 | 28 | `bits` is a list containing the remainder of the token (split by spaces) 29 | that is to be checked for arguments. Valid arguments are removed from this 30 | list. 31 | 32 | There is no requirement for all remaining token ``bits`` to be keyword 33 | arguments, so return the dictionary as soon as an invalid argument format 34 | is reached. 35 | """ 36 | if not bits: 37 | return {} 38 | match = kwarg_re.match(bits[0]) 39 | kwarg_format = match and match[1] 40 | if not kwarg_format: 41 | return {} 42 | 43 | kwargs = {} 44 | while bits: 45 | match = kwarg_re.match(bits[0]) 46 | if not match or not match[1]: 47 | return kwargs 48 | key, value = match.groups() 49 | del bits[:1] 50 | 51 | kwargs[key] = parser.compile_filter(value) 52 | return kwargs 53 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.7.2" 6 | description = "ASGI specs, helper code, and adapters" 7 | category = "main" 8 | optional = false 9 | python-versions = ">=3.7" 10 | files = [ 11 | {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, 12 | {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, 13 | ] 14 | 15 | [package.dependencies] 16 | typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} 17 | 18 | [package.extras] 19 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 20 | 21 | [[package]] 22 | name = "backports-zoneinfo" 23 | version = "0.2.1" 24 | description = "Backport of the standard library zoneinfo module" 25 | category = "main" 26 | optional = false 27 | python-versions = ">=3.6" 28 | files = [ 29 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, 30 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, 31 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, 32 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, 33 | {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, 34 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, 35 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, 36 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, 37 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, 38 | {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, 39 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, 40 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, 41 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, 42 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, 43 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, 44 | {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, 45 | ] 46 | 47 | [package.extras] 48 | tzdata = ["tzdata"] 49 | 50 | [[package]] 51 | name = "black" 52 | version = "23.10.0" 53 | description = "The uncompromising code formatter." 54 | category = "dev" 55 | optional = false 56 | python-versions = ">=3.8" 57 | files = [ 58 | {file = "black-23.10.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:f8dc7d50d94063cdfd13c82368afd8588bac4ce360e4224ac399e769d6704e98"}, 59 | {file = "black-23.10.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:f20ff03f3fdd2fd4460b4f631663813e57dc277e37fb216463f3b907aa5a9bdd"}, 60 | {file = "black-23.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3d9129ce05b0829730323bdcb00f928a448a124af5acf90aa94d9aba6969604"}, 61 | {file = "black-23.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:960c21555be135c4b37b7018d63d6248bdae8514e5c55b71e994ad37407f45b8"}, 62 | {file = "black-23.10.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:30b78ac9b54cf87bcb9910ee3d499d2bc893afd52495066c49d9ee6b21eee06e"}, 63 | {file = "black-23.10.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:0e232f24a337fed7a82c1185ae46c56c4a6167fb0fe37411b43e876892c76699"}, 64 | {file = "black-23.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31946ec6f9c54ed7ba431c38bc81d758970dd734b96b8e8c2b17a367d7908171"}, 65 | {file = "black-23.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:c870bee76ad5f7a5ea7bd01dc646028d05568d33b0b09b7ecfc8ec0da3f3f39c"}, 66 | {file = "black-23.10.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:6901631b937acbee93c75537e74f69463adaf34379a04eef32425b88aca88a23"}, 67 | {file = "black-23.10.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:481167c60cd3e6b1cb8ef2aac0f76165843a374346aeeaa9d86765fe0dd0318b"}, 68 | {file = "black-23.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74892b4b836e5162aa0452393112a574dac85e13902c57dfbaaf388e4eda37c"}, 69 | {file = "black-23.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:47c4510f70ec2e8f9135ba490811c071419c115e46f143e4dce2ac45afdcf4c9"}, 70 | {file = "black-23.10.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:76baba9281e5e5b230c9b7f83a96daf67a95e919c2dfc240d9e6295eab7b9204"}, 71 | {file = "black-23.10.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:a3c2ddb35f71976a4cfeca558848c2f2f89abc86b06e8dd89b5a65c1e6c0f22a"}, 72 | {file = "black-23.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db451a3363b1e765c172c3fd86213a4ce63fb8524c938ebd82919bf2a6e28c6a"}, 73 | {file = "black-23.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:7fb5fc36bb65160df21498d5a3dd330af8b6401be3f25af60c6ebfe23753f747"}, 74 | {file = "black-23.10.0-py3-none-any.whl", hash = "sha256:e223b731a0e025f8ef427dd79d8cd69c167da807f5710add30cdf131f13dd62e"}, 75 | {file = "black-23.10.0.tar.gz", hash = "sha256:31b9f87b277a68d0e99d2905edae08807c007973eaa609da5f0c62def6b7c0bd"}, 76 | ] 77 | 78 | [package.dependencies] 79 | click = ">=8.0.0" 80 | mypy-extensions = ">=0.4.3" 81 | packaging = ">=22.0" 82 | pathspec = ">=0.9.0" 83 | platformdirs = ">=2" 84 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 85 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 86 | 87 | [package.extras] 88 | colorama = ["colorama (>=0.4.3)"] 89 | d = ["aiohttp (>=3.7.4)"] 90 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 91 | uvloop = ["uvloop (>=0.15.2)"] 92 | 93 | [[package]] 94 | name = "cachetools" 95 | version = "5.3.1" 96 | description = "Extensible memoizing collections and decorators" 97 | category = "dev" 98 | optional = false 99 | python-versions = ">=3.7" 100 | files = [ 101 | {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, 102 | {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, 103 | ] 104 | 105 | [[package]] 106 | name = "cfgv" 107 | version = "3.4.0" 108 | description = "Validate configuration and produce human readable error messages." 109 | category = "dev" 110 | optional = false 111 | python-versions = ">=3.8" 112 | files = [ 113 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 114 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 115 | ] 116 | 117 | [[package]] 118 | name = "chardet" 119 | version = "5.2.0" 120 | description = "Universal encoding detector for Python 3" 121 | category = "dev" 122 | optional = false 123 | python-versions = ">=3.7" 124 | files = [ 125 | {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, 126 | {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, 127 | ] 128 | 129 | [[package]] 130 | name = "click" 131 | version = "8.1.7" 132 | description = "Composable command line interface toolkit" 133 | category = "dev" 134 | optional = false 135 | python-versions = ">=3.7" 136 | files = [ 137 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 138 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 139 | ] 140 | 141 | [package.dependencies] 142 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 143 | 144 | [[package]] 145 | name = "colorama" 146 | version = "0.4.6" 147 | description = "Cross-platform colored terminal text." 148 | category = "dev" 149 | optional = false 150 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 151 | files = [ 152 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 153 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 154 | ] 155 | 156 | [[package]] 157 | name = "coverage" 158 | version = "7.3.2" 159 | description = "Code coverage measurement for Python" 160 | category = "dev" 161 | optional = false 162 | python-versions = ">=3.8" 163 | files = [ 164 | {file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, 165 | {file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, 166 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, 167 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, 168 | {file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, 169 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, 170 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, 171 | {file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, 172 | {file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, 173 | {file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, 174 | {file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, 175 | {file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, 176 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, 177 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, 178 | {file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, 179 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, 180 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, 181 | {file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, 182 | {file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, 183 | {file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, 184 | {file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, 185 | {file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, 186 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, 187 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, 188 | {file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, 189 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, 190 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, 191 | {file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, 192 | {file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, 193 | {file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, 194 | {file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, 195 | {file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, 196 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, 197 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, 198 | {file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, 199 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, 200 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, 201 | {file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, 202 | {file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, 203 | {file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, 204 | {file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, 205 | {file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, 206 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, 207 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, 208 | {file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, 209 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, 210 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, 211 | {file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, 212 | {file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, 213 | {file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, 214 | {file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, 215 | {file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, 216 | ] 217 | 218 | [package.extras] 219 | toml = ["tomli"] 220 | 221 | [[package]] 222 | name = "distlib" 223 | version = "0.3.7" 224 | description = "Distribution utilities" 225 | category = "dev" 226 | optional = false 227 | python-versions = "*" 228 | files = [ 229 | {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, 230 | {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, 231 | ] 232 | 233 | [[package]] 234 | name = "django" 235 | version = "4.2.6" 236 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 237 | category = "main" 238 | optional = false 239 | python-versions = ">=3.8" 240 | files = [ 241 | {file = "Django-4.2.6-py3-none-any.whl", hash = "sha256:a64d2487cdb00ad7461434320ccc38e60af9c404773a2f95ab0093b4453a3215"}, 242 | {file = "Django-4.2.6.tar.gz", hash = "sha256:08f41f468b63335aea0d904c5729e0250300f6a1907bf293a65499496cdbc68f"}, 243 | ] 244 | 245 | [package.dependencies] 246 | asgiref = ">=3.6.0,<4" 247 | "backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} 248 | sqlparse = ">=0.3.1" 249 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 250 | 251 | [package.extras] 252 | argon2 = ["argon2-cffi (>=19.1.0)"] 253 | bcrypt = ["bcrypt"] 254 | 255 | [[package]] 256 | name = "filelock" 257 | version = "3.12.4" 258 | description = "A platform independent file lock." 259 | category = "dev" 260 | optional = false 261 | python-versions = ">=3.8" 262 | files = [ 263 | {file = "filelock-3.12.4-py3-none-any.whl", hash = "sha256:08c21d87ded6e2b9da6728c3dff51baf1dcecf973b768ef35bcbc3447edb9ad4"}, 264 | {file = "filelock-3.12.4.tar.gz", hash = "sha256:2e6f249f1f3654291606e046b09f1fd5eac39b360664c27f5aad072012f8bcbd"}, 265 | ] 266 | 267 | [package.extras] 268 | docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-autodoc-typehints (>=1.24)"] 269 | testing = ["covdefaults (>=2.3)", "coverage (>=7.3)", "diff-cover (>=7.7)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-timeout (>=2.1)"] 270 | typing = ["typing-extensions (>=4.7.1)"] 271 | 272 | [[package]] 273 | name = "identify" 274 | version = "2.5.30" 275 | description = "File identification library for Python" 276 | category = "dev" 277 | optional = false 278 | python-versions = ">=3.8" 279 | files = [ 280 | {file = "identify-2.5.30-py2.py3-none-any.whl", hash = "sha256:afe67f26ae29bab007ec21b03d4114f41316ab9dd15aa8736a167481e108da54"}, 281 | {file = "identify-2.5.30.tar.gz", hash = "sha256:f302a4256a15c849b91cfcdcec052a8ce914634b2f77ae87dad29cd749f2d88d"}, 282 | ] 283 | 284 | [package.extras] 285 | license = ["ukkonen"] 286 | 287 | [[package]] 288 | name = "mypy-extensions" 289 | version = "1.0.0" 290 | description = "Type system extensions for programs checked with the mypy type checker." 291 | category = "dev" 292 | optional = false 293 | python-versions = ">=3.5" 294 | files = [ 295 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 296 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 297 | ] 298 | 299 | [[package]] 300 | name = "nodeenv" 301 | version = "1.8.0" 302 | description = "Node.js virtual environment builder" 303 | category = "dev" 304 | optional = false 305 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 306 | files = [ 307 | {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, 308 | {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, 309 | ] 310 | 311 | [package.dependencies] 312 | setuptools = "*" 313 | 314 | [[package]] 315 | name = "packaging" 316 | version = "23.2" 317 | description = "Core utilities for Python packages" 318 | category = "dev" 319 | optional = false 320 | python-versions = ">=3.7" 321 | files = [ 322 | {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, 323 | {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, 324 | ] 325 | 326 | [[package]] 327 | name = "pathspec" 328 | version = "0.11.2" 329 | description = "Utility library for gitignore style pattern matching of file paths." 330 | category = "dev" 331 | optional = false 332 | python-versions = ">=3.7" 333 | files = [ 334 | {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, 335 | {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, 336 | ] 337 | 338 | [[package]] 339 | name = "platformdirs" 340 | version = "3.11.0" 341 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 342 | category = "dev" 343 | optional = false 344 | python-versions = ">=3.7" 345 | files = [ 346 | {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, 347 | {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, 348 | ] 349 | 350 | [package.extras] 351 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] 352 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] 353 | 354 | [[package]] 355 | name = "pluggy" 356 | version = "1.3.0" 357 | description = "plugin and hook calling mechanisms for python" 358 | category = "dev" 359 | optional = false 360 | python-versions = ">=3.8" 361 | files = [ 362 | {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, 363 | {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, 364 | ] 365 | 366 | [package.extras] 367 | dev = ["pre-commit", "tox"] 368 | testing = ["pytest", "pytest-benchmark"] 369 | 370 | [[package]] 371 | name = "pre-commit" 372 | version = "3.5.0" 373 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 374 | category = "dev" 375 | optional = false 376 | python-versions = ">=3.8" 377 | files = [ 378 | {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, 379 | {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, 380 | ] 381 | 382 | [package.dependencies] 383 | cfgv = ">=2.0.0" 384 | identify = ">=1.0.0" 385 | nodeenv = ">=0.11.1" 386 | pyyaml = ">=5.1" 387 | virtualenv = ">=20.10.0" 388 | 389 | [[package]] 390 | name = "pyproject-api" 391 | version = "1.6.1" 392 | description = "API to interact with the python pyproject.toml based projects" 393 | category = "dev" 394 | optional = false 395 | python-versions = ">=3.8" 396 | files = [ 397 | {file = "pyproject_api-1.6.1-py3-none-any.whl", hash = "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675"}, 398 | {file = "pyproject_api-1.6.1.tar.gz", hash = "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538"}, 399 | ] 400 | 401 | [package.dependencies] 402 | packaging = ">=23.1" 403 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 404 | 405 | [package.extras] 406 | docs = ["furo (>=2023.8.19)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] 407 | testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68.1.2)", "wheel (>=0.41.2)"] 408 | 409 | [[package]] 410 | name = "pyyaml" 411 | version = "6.0.1" 412 | description = "YAML parser and emitter for Python" 413 | category = "dev" 414 | optional = false 415 | python-versions = ">=3.6" 416 | files = [ 417 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, 418 | {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, 419 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, 420 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, 421 | {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, 422 | {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, 423 | {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, 424 | {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, 425 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, 426 | {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, 427 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, 428 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, 429 | {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, 430 | {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, 431 | {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, 432 | {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, 433 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, 434 | {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, 435 | {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, 436 | {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, 437 | {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, 438 | {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, 439 | {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, 440 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, 441 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, 442 | {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, 443 | {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, 444 | {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, 445 | {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, 446 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, 447 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, 448 | {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, 449 | {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, 450 | {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, 451 | {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, 452 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, 453 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, 454 | {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, 455 | {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, 456 | {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, 457 | {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, 458 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, 459 | {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, 460 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, 461 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, 462 | {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, 463 | {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, 464 | {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, 465 | {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, 466 | {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, 467 | ] 468 | 469 | [[package]] 470 | name = "ruff" 471 | version = "0.1.0" 472 | description = "An extremely fast Python linter, written in Rust." 473 | category = "dev" 474 | optional = false 475 | python-versions = ">=3.7" 476 | files = [ 477 | {file = "ruff-0.1.0-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:87114e254dee35e069e1b922d85d4b21a5b61aec759849f393e1dbb308a00439"}, 478 | {file = "ruff-0.1.0-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:764f36d2982cc4a703e69fb73a280b7c539fd74b50c9ee531a4e3fe88152f521"}, 479 | {file = "ruff-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65f4b7fb539e5cf0f71e9bd74f8ddab74cabdd673c6fb7f17a4dcfd29f126255"}, 480 | {file = "ruff-0.1.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:299fff467a0f163baa282266b310589b21400de0a42d8f68553422fa6bf7ee01"}, 481 | {file = "ruff-0.1.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d412678bf205787263bb702c984012a4f97e460944c072fd7cfa2bd084857c4"}, 482 | {file = "ruff-0.1.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a5391b49b1669b540924640587d8d24128e45be17d1a916b1801d6645e831581"}, 483 | {file = "ruff-0.1.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee8cd57f454cdd77bbcf1e11ff4e0046fb6547cac1922cc6e3583ce4b9c326d1"}, 484 | {file = "ruff-0.1.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa7aeed7bc23861a2b38319b636737bf11cfa55d2109620b49cf995663d3e888"}, 485 | {file = "ruff-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b04cd4298b43b16824d9a37800e4c145ba75c29c43ce0d74cad1d66d7ae0a4c5"}, 486 | {file = "ruff-0.1.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7186ccf54707801d91e6314a016d1c7895e21d2e4cd614500d55870ed983aa9f"}, 487 | {file = "ruff-0.1.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d88adfd93849bc62449518228581d132e2023e30ebd2da097f73059900d8dce3"}, 488 | {file = "ruff-0.1.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ad2ccdb3bad5a61013c76a9c1240fdfadf2c7103a2aeebd7bcbbed61f363138f"}, 489 | {file = "ruff-0.1.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b77f6cfa72c6eb19b5cac967cc49762ae14d036db033f7d97a72912770fd8e1c"}, 490 | {file = "ruff-0.1.0-py3-none-win32.whl", hash = "sha256:480bd704e8af1afe3fd444cc52e3c900b936e6ca0baf4fb0281124330b6ceba2"}, 491 | {file = "ruff-0.1.0-py3-none-win_amd64.whl", hash = "sha256:a76ba81860f7ee1f2d5651983f87beb835def94425022dc5f0803108f1b8bfa2"}, 492 | {file = "ruff-0.1.0-py3-none-win_arm64.whl", hash = "sha256:45abdbdab22509a2c6052ecf7050b3f5c7d6b7898dc07e82869401b531d46da4"}, 493 | {file = "ruff-0.1.0.tar.gz", hash = "sha256:ad6b13824714b19c5f8225871cf532afb994470eecb74631cd3500fe817e6b3f"}, 494 | ] 495 | 496 | [[package]] 497 | name = "setuptools" 498 | version = "68.2.2" 499 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 500 | category = "dev" 501 | optional = false 502 | python-versions = ">=3.8" 503 | files = [ 504 | {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, 505 | {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, 506 | ] 507 | 508 | [package.extras] 509 | docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 510 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 511 | testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 512 | 513 | [[package]] 514 | name = "sqlparse" 515 | version = "0.4.4" 516 | description = "A non-validating SQL parser." 517 | category = "main" 518 | optional = false 519 | python-versions = ">=3.5" 520 | files = [ 521 | {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, 522 | {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, 523 | ] 524 | 525 | [package.extras] 526 | dev = ["build", "flake8"] 527 | doc = ["sphinx"] 528 | test = ["pytest", "pytest-cov"] 529 | 530 | [[package]] 531 | name = "tomli" 532 | version = "2.0.1" 533 | description = "A lil' TOML parser" 534 | category = "dev" 535 | optional = false 536 | python-versions = ">=3.7" 537 | files = [ 538 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 539 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 540 | ] 541 | 542 | [[package]] 543 | name = "tox" 544 | version = "4.11.3" 545 | description = "tox is a generic virtualenv management and test command line tool" 546 | category = "dev" 547 | optional = false 548 | python-versions = ">=3.8" 549 | files = [ 550 | {file = "tox-4.11.3-py3-none-any.whl", hash = "sha256:599af5e5bb0cad0148ac1558a0b66f8fff219ef88363483b8d92a81e4246f28f"}, 551 | {file = "tox-4.11.3.tar.gz", hash = "sha256:5039f68276461fae6a9452a3b2c7295798f00a0e92edcd9a3b78ba1a73577951"}, 552 | ] 553 | 554 | [package.dependencies] 555 | cachetools = ">=5.3.1" 556 | chardet = ">=5.2" 557 | colorama = ">=0.4.6" 558 | filelock = ">=3.12.3" 559 | packaging = ">=23.1" 560 | platformdirs = ">=3.10" 561 | pluggy = ">=1.3" 562 | pyproject-api = ">=1.6.1" 563 | tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} 564 | virtualenv = ">=20.24.3" 565 | 566 | [package.extras] 567 | docs = ["furo (>=2023.8.19)", "sphinx (>=7.2.4)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 568 | testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12)", "wheel (>=0.41.2)"] 569 | 570 | [[package]] 571 | name = "typing-extensions" 572 | version = "4.8.0" 573 | description = "Backported and Experimental Type Hints for Python 3.8+" 574 | category = "main" 575 | optional = false 576 | python-versions = ">=3.8" 577 | files = [ 578 | {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, 579 | {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, 580 | ] 581 | 582 | [[package]] 583 | name = "tzdata" 584 | version = "2023.3" 585 | description = "Provider of IANA time zone data" 586 | category = "main" 587 | optional = false 588 | python-versions = ">=2" 589 | files = [ 590 | {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"}, 591 | {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"}, 592 | ] 593 | 594 | [[package]] 595 | name = "virtualenv" 596 | version = "20.24.5" 597 | description = "Virtual Python Environment builder" 598 | category = "dev" 599 | optional = false 600 | python-versions = ">=3.7" 601 | files = [ 602 | {file = "virtualenv-20.24.5-py3-none-any.whl", hash = "sha256:b80039f280f4919c77b30f1c23294ae357c4c8701042086e3fc005963e4e537b"}, 603 | {file = "virtualenv-20.24.5.tar.gz", hash = "sha256:e8361967f6da6fbdf1426483bfe9fca8287c242ac0bc30429905721cefbff752"}, 604 | ] 605 | 606 | [package.dependencies] 607 | distlib = ">=0.3.7,<1" 608 | filelock = ">=3.12.2,<4" 609 | platformdirs = ">=3.9.1,<4" 610 | 611 | [package.extras] 612 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 613 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] 614 | 615 | [metadata] 616 | lock-version = "2.0" 617 | python-versions = ">=3.8,<4.0" 618 | content-hash = "95af0f6e663a24ef086a789743248a76d0dad0c6828c7c97810a20f62dd2a483" 619 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-web-components" 3 | version = "0.2.0" 4 | description = "Build reusable template components in Django" 5 | authors = ["Mihail Cristian Dumitru"] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/Xzya/django-web-components" 9 | repository = "https://github.com/Xzya/django-web-components" 10 | keywords = ["django", "components"] 11 | classifiers = [ 12 | "Development Status :: 3 - Alpha", 13 | "Environment :: Web Environment", 14 | "Operating System :: OS Independent", 15 | "Framework :: Django", 16 | "Framework :: Django :: 3.2", 17 | "Framework :: Django :: 4.0", 18 | "Framework :: Django :: 4.1", 19 | "Framework :: Django :: 4.2", 20 | "Framework :: Django :: 5.0", 21 | ] 22 | packages = [{ include = "django_web_components" }] 23 | include = [ 24 | "LICENSE", 25 | "CHANGELOG.md", 26 | ] 27 | 28 | [tool.poetry.dependencies] 29 | python = ">=3.8,<4.0" 30 | Django = ">=3.2" 31 | 32 | [tool.poetry.dev-dependencies] 33 | black = "^23.10.0" 34 | ruff = "^0.1.0" 35 | tox = "^4.11.3" 36 | pre-commit = "^3.5.0" 37 | coverage = "^7.3.2" 38 | 39 | 40 | [tool.ruff] 41 | line-length = 120 42 | 43 | [tool.black] 44 | line-length = 120 45 | 46 | 47 | [build-system] 48 | requires = ["poetry-core"] 49 | build-backend = "poetry.core.masonry.api" 50 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from django.core.management import execute_from_command_line 5 | 6 | os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" 7 | 8 | if __name__ == "__main__": 9 | command, *rest = sys.argv 10 | execute_from_command_line([command, "test", *rest]) 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/django-web-components/b43eb0c832837db939a6f8c1980334b0adfdd6e4/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).resolve().parent 4 | 5 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 6 | 7 | INSTALLED_APPS = ["django_web_components"] 8 | 9 | TEMPLATES = [ 10 | { 11 | "BACKEND": "django.template.backends.django.DjangoTemplates", 12 | "DIRS": [BASE_DIR / "templates"], 13 | "OPTIONS": { 14 | "builtins": ["django_web_components.templatetags.components"], 15 | }, 16 | }, 17 | ] 18 | -------------------------------------------------------------------------------- /tests/templates/simple_template.html: -------------------------------------------------------------------------------- 1 |
Hello, {{ message }}!
2 | -------------------------------------------------------------------------------- /tests/test_attributes.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.utils.safestring import mark_safe, SafeString 3 | 4 | from django_web_components.attributes import ( 5 | AttributeBag, 6 | attributes_to_string, 7 | merge_attributes, 8 | split_attributes, 9 | normalize_class, 10 | append_attributes, 11 | ) 12 | 13 | 14 | class AttributeBagTest(TestCase): 15 | def test_str_converts_to_string(self): 16 | self.assertEqual( 17 | str(AttributeBag({"foo": "bar"})), 18 | 'foo="bar"', 19 | ) 20 | 21 | 22 | class AttributesToStringTest(TestCase): 23 | def test_simple_attribute(self): 24 | self.assertEqual( 25 | attributes_to_string({"foo": "bar"}), 26 | 'foo="bar"', 27 | ) 28 | 29 | def test_multiple_attributes(self): 30 | self.assertEqual( 31 | attributes_to_string({"class": "foo", "style": "color: red;"}), 32 | 'class="foo" style="color: red;"', 33 | ) 34 | 35 | def test_escapes_special_characters(self): 36 | self.assertEqual( 37 | attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}), 38 | 'x-on:click="bar" @click="'baz'"', 39 | ) 40 | 41 | def test_does_not_escape_special_characters_if_safe_string(self): 42 | self.assertEqual( 43 | attributes_to_string({"foo": mark_safe("'bar'")}), 44 | "foo=\"'bar'\"", 45 | ) 46 | 47 | def test_result_is_safe_string(self): 48 | result = attributes_to_string({"foo": mark_safe("'bar'")}) 49 | self.assertTrue(type(result) == SafeString) 50 | 51 | def test_attribute_with_no_value(self): 52 | self.assertEqual( 53 | attributes_to_string({"required": None}), 54 | "", 55 | ) 56 | 57 | def test_attribute_with_false_value(self): 58 | self.assertEqual( 59 | attributes_to_string({"required": False}), 60 | "", 61 | ) 62 | 63 | def test_attribute_with_true_value(self): 64 | self.assertEqual( 65 | attributes_to_string({"required": True}), 66 | "required", 67 | ) 68 | 69 | 70 | class MergeAttributesTest(TestCase): 71 | def test_merges_attributes(self): 72 | self.assertEqual( 73 | merge_attributes({"foo": "bar"}, {"bar": "baz"}), 74 | {"foo": "bar", "bar": "baz"}, 75 | ) 76 | 77 | def test_overwrites_attributes(self): 78 | self.assertEqual( 79 | merge_attributes({"foo": "bar"}, {"foo": "baz", "data": "foo"}), 80 | {"foo": "baz", "data": "foo"}, 81 | ) 82 | 83 | def test_normalizes_classes(self): 84 | self.assertEqual( 85 | merge_attributes({"foo": "bar", "class": "baz"}, {"class": "qux"}), 86 | {"foo": "bar", "class": "baz qux"}, 87 | ) 88 | 89 | def test_merge_multiple_dicts(self): 90 | self.assertEqual( 91 | merge_attributes( 92 | {"foo": "bar"}, 93 | {"class": "baz"}, 94 | {"id": "qux"}, 95 | ), 96 | {"foo": "bar", "class": "baz", "id": "qux"}, 97 | ) 98 | 99 | def test_returns_attribute_bag(self): 100 | result = merge_attributes(AttributeBag({"foo": "bar"}), {}) 101 | self.assertTrue(type(result) == AttributeBag) 102 | 103 | 104 | class SplitAttributesTest(TestCase): 105 | def test_returns_normal_attrs(self): 106 | self.assertEqual(split_attributes({"foo": "bar"}), ({}, {"foo": "bar"})) 107 | 108 | def test_returns_special_attrs(self): 109 | self.assertEqual(split_attributes({":let": "bar"}), ({":let": "bar"}, {})) 110 | 111 | def test_splits_attrs(self): 112 | self.assertEqual(split_attributes({":let": "fruit", "foo": "bar"}), ({":let": "fruit"}, {"foo": "bar"})) 113 | 114 | 115 | class AppendAttributesTest(TestCase): 116 | def test_single_dict(self): 117 | self.assertEqual( 118 | append_attributes({"foo": "bar"}), 119 | {"foo": "bar"}, 120 | ) 121 | 122 | def test_appends_dicts(self): 123 | self.assertEqual( 124 | append_attributes({"class": "foo"}, {"id": "bar"}, {"class": "baz"}), 125 | {"class": "foo baz", "id": "bar"}, 126 | ) 127 | 128 | 129 | class NormalizeClassTest(TestCase): 130 | def test_str(self): 131 | self.assertEqual( 132 | normalize_class("foo"), 133 | "foo", 134 | ) 135 | 136 | def test_list(self): 137 | self.assertEqual( 138 | normalize_class(["foo", "bar"]), 139 | "foo bar", 140 | ) 141 | 142 | def test_nested_list(self): 143 | self.assertEqual( 144 | normalize_class(["foo", ["bar", "baz"]]), 145 | "foo bar baz", 146 | ) 147 | 148 | def test_tuple(self): 149 | self.assertEqual( 150 | normalize_class(("foo", "bar")), 151 | "foo bar", 152 | ) 153 | 154 | def test_dict(self): 155 | self.assertEqual( 156 | normalize_class({"foo": True, "bar": False, "baz": None}), 157 | "foo", 158 | ) 159 | 160 | def test_combined(self): 161 | self.assertEqual( 162 | normalize_class( 163 | [ 164 | "class1", 165 | ["class2", "class3"], 166 | { 167 | "class4": True, 168 | "class5": False, 169 | "class6": "foo", 170 | }, 171 | ] 172 | ), 173 | "class1 class2 class3 class4 class6", 174 | ) 175 | -------------------------------------------------------------------------------- /tests/test_component.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ImproperlyConfigured 2 | from django.template import Context, Template, NodeList 3 | from django.template.base import TextNode 4 | from django.test import TestCase 5 | 6 | import django_web_components.attributes 7 | from django_web_components import component 8 | from django_web_components.component import ( 9 | Component, 10 | ) 11 | from django_web_components.templatetags.components import SlotNodeList, SlotNode 12 | 13 | 14 | class ComponentTest(TestCase): 15 | def test_get_template_name_returns_template_name(self): 16 | class DummyComponent(Component): 17 | template_name = "foo.html" 18 | 19 | self.assertEqual(DummyComponent().get_template_name(), "foo.html") 20 | 21 | def test_get_template_name_raises_if_no_template_name_set(self): 22 | class DummyComponent(Component): 23 | pass 24 | 25 | with self.assertRaises(ImproperlyConfigured): 26 | DummyComponent().get_template_name() 27 | 28 | def test_renders_template(self): 29 | class DummyComponent(Component): 30 | template_name = "simple_template.html" 31 | 32 | self.assertHTMLEqual( 33 | DummyComponent().render( 34 | Context( 35 | { 36 | "message": "world", 37 | } 38 | ) 39 | ), 40 | """
Hello, world!
""", 41 | ) 42 | 43 | 44 | class ExampleComponentsTest(TestCase): 45 | def setUp(self) -> None: 46 | component.registry.clear() 47 | 48 | def test_component_with_inline_tag(self): 49 | @component.register("hello") 50 | def dummy(context): 51 | return Template("""
Hello, world!
""").render(context) 52 | 53 | self.assertHTMLEqual( 54 | Template( 55 | """ 56 | {% #hello %} 57 | """ 58 | ).render(Context({})), 59 | """ 60 |
Hello, world!
61 | """, 62 | ) 63 | 64 | def test_component_with_inline_tag_and_attributes(self): 65 | @component.register("hello") 66 | def dummy(context): 67 | return Template("""
Hello, world!
""").render(context) 68 | 69 | self.assertHTMLEqual( 70 | Template( 71 | """ 72 | {% #hello class="foo" x-on:click='bar' @click="baz" foo:bar.baz="foo" required %} 73 | """ 74 | ).render(Context({})), 75 | """ 76 |
Hello, world!
77 | """, 78 | ) 79 | 80 | def test_simple_component(self): 81 | @component.register("hello") 82 | def dummy(context): 83 | return Template("""
Hello, world!
""").render(context) 84 | 85 | self.assertHTMLEqual( 86 | Template( 87 | """ 88 | {% hello %}{% endhello %} 89 | """ 90 | ).render(Context({})), 91 | """ 92 |
Hello, world!
93 | """, 94 | ) 95 | 96 | def test_component_with_name_with_colon(self): 97 | @component.register("hello:foo") 98 | def dummy(context): 99 | return Template("""
Hello, world!
""").render(context) 100 | 101 | self.assertHTMLEqual( 102 | Template( 103 | """ 104 | {% hello:foo %}{% endhello:foo %} 105 | """ 106 | ).render(Context({})), 107 | """ 108 |
Hello, world!
109 | """, 110 | ) 111 | 112 | def test_component_with_name_with_dot(self): 113 | @component.register("hello.foo") 114 | def dummy(context): 115 | return Template("""
Hello, world!
""").render(context) 116 | 117 | self.assertHTMLEqual( 118 | Template( 119 | """ 120 | {% hello.foo %}{% endhello.foo %} 121 | """ 122 | ).render(Context({})), 123 | """ 124 |
Hello, world!
125 | """, 126 | ) 127 | 128 | def test_component_with_context_passed_in(self): 129 | @component.register("hello") 130 | def dummy(context): 131 | return Template( 132 | """ 133 |
{{ message }}
134 | """ 135 | ).render(context) 136 | 137 | self.assertHTMLEqual( 138 | Template( 139 | """ 140 | {% with message="hello" %} 141 | {% hello message="hello" %}{% endhello %} 142 | {% endwith %} 143 | """ 144 | ).render(Context({})), 145 | """ 146 |
hello
147 | """, 148 | ) 149 | 150 | # Attributes 151 | 152 | def test_component_with_attributes(self): 153 | @component.register("hello") 154 | def dummy(context): 155 | return Template("""
Hello, world!
""").render(context) 156 | 157 | self.assertHTMLEqual( 158 | Template( 159 | """ 160 | {% hello class="foo" x-on:click='bar' @click="baz" foo:bar.baz="foo" required %}{% endhello %} 161 | """ 162 | ).render(Context({})), 163 | """ 164 |
Hello, world!
165 | """, 166 | ) 167 | 168 | def test_component_with_empty_attributes(self): 169 | @component.register("hello") 170 | def dummy(context): 171 | return Template("""
Hello, world!
""").render(context) 172 | 173 | self.assertHTMLEqual( 174 | Template( 175 | """ 176 | {% hello class="" limit='' required %}{% endhello %} 177 | """ 178 | ).render(Context({})), 179 | """ 180 |
Hello, world!
181 | """, 182 | ) 183 | 184 | def test_attributes_from_context_variables(self): 185 | @component.register("hello") 186 | def dummy(context): 187 | return Template( 188 | """ 189 |
190 | {% render_slot slots.inner_block %} 191 |
192 | """ 193 | ).render(context) 194 | 195 | self.assertHTMLEqual( 196 | Template( 197 | """ 198 | {% with object_id="123" message="Hello" %} 199 | {% hello id=object_id %} 200 |
{{ message }}
201 | {% endhello %} 202 | {% endwith %} 203 | """ 204 | ).render(Context({})), 205 | """ 206 |
207 |
Hello
208 |
209 | """, 210 | ) 211 | 212 | def test_attributes_with_defaults(self): 213 | @component.register("hello") 214 | def dummy(context): 215 | return Template( 216 | """ 217 |
218 | """ 219 | ).render(context) 220 | 221 | self.assertHTMLEqual( 222 | Template( 223 | """ 224 | {% hello id="123" class="some-class" %}{% endhello %} 225 | """ 226 | ).render(Context({})), 227 | """ 228 |
229 | """, 230 | ) 231 | 232 | # Slots 233 | 234 | def test_component_with_default_slot(self): 235 | @component.register("hello") 236 | def dummy(context): 237 | return Template( 238 | """ 239 |
{% render_slot slots.inner_block %}
240 | """ 241 | ).render(context) 242 | 243 | self.assertHTMLEqual( 244 | Template( 245 | """ 246 | {% hello %}Hello{% endhello %} 247 | """ 248 | ).render(Context({})), 249 | """ 250 |
Hello
251 | """, 252 | ) 253 | 254 | def test_component_with_named_slot(self): 255 | @component.register("hello") 256 | def dummy(context): 257 | return Template( 258 | """ 259 |
260 |
{% render_slot slots.title %}
261 |
{% render_slot slots.inner_block %}
262 |
263 | """ 264 | ).render(context) 265 | 266 | self.assertHTMLEqual( 267 | Template( 268 | """ 269 | {% hello %} 270 | {% slot title %}Title{% endslot %} 271 | Default slot 272 | {% endhello %} 273 | """ 274 | ).render(Context({})), 275 | """ 276 |
277 |
Title
278 |
Default slot
279 |
280 | """, 281 | ) 282 | 283 | def test_component_with_multiple_slots(self): 284 | @component.register("hello") 285 | def dummy(context): 286 | return Template( 287 | """ 288 |
289 |

{% render_slot slots.title %}

290 |
{% render_slot slots.body %}
291 |
{% render_slot slots.inner_block %}
292 |
293 | """ 294 | ).render(context) 295 | 296 | self.assertHTMLEqual( 297 | Template( 298 | """ 299 | {% hello %} 300 | {% slot title %}Title{% endslot %} 301 | {% slot body %}Body{% endslot %} 302 | Hello 303 | {% endhello %} 304 | """ 305 | ).render(Context({})), 306 | """ 307 |
308 |

Title

309 |
Body
310 |
Hello
311 |
312 | """, 313 | ) 314 | 315 | def test_component_with_duplicate_slot(self): 316 | @component.register("hello") 317 | def dummy(context): 318 | return Template( 319 | """ 320 |
    321 | {% for row in slots.row %} 322 |
  • {% render_slot row %}
  • 323 | {% endfor %} 324 |
325 | """ 326 | ).render(context) 327 | 328 | self.assertHTMLEqual( 329 | Template( 330 | """ 331 | {% hello %} 332 | {% slot row %}Row 1{% endslot %} 333 | {% slot row %}Row 2{% endslot %} 334 | {% slot row %}Row 3{% endslot %} 335 | {% endhello %} 336 | """ 337 | ).render(Context({})), 338 | """ 339 |
    340 |
  • Row 1
  • 341 |
  • Row 2
  • 342 |
  • Row 3
  • 343 |
344 | """, 345 | ) 346 | 347 | def test_component_with_duplicate_slot_without_for_loop(self): 348 | @component.register("hello") 349 | def dummy(context): 350 | return Template( 351 | """ 352 |
    353 | {% render_slot slots.row %} 354 |
355 | """ 356 | ).render(context) 357 | 358 | self.assertHTMLEqual( 359 | Template( 360 | """ 361 | {% hello %} 362 | {% slot row %} 363 |
  • Row 1
  • 364 | {% endslot %} 365 | {% slot row %} 366 |
  • Row 2
  • 367 | {% endslot %} 368 | {% slot row %} 369 |
  • Row 3
  • 370 | {% endslot %} 371 | {% endhello %} 372 | """ 373 | ).render(Context({})), 374 | """ 375 |
      376 |
    • Row 1
    • 377 |
    • Row 2
    • 378 |
    • Row 3
    • 379 |
    380 | """, 381 | ) 382 | 383 | def test_component_with_duplicate_slot_but_only_one_passed_in(self): 384 | @component.register("hello") 385 | def dummy(context): 386 | return Template( 387 | """ 388 |
      389 | {% for row in slots.row %} 390 |
    • {% render_slot row %}
    • 391 | {% endfor %} 392 |
    393 | """ 394 | ).render(context) 395 | 396 | self.assertHTMLEqual( 397 | Template( 398 | """ 399 | {% hello %} 400 | {% slot row %}Row 1{% endslot %} 401 | {% endhello %} 402 | """ 403 | ).render(Context({})), 404 | """ 405 |
      406 |
    • Row 1
    • 407 |
    408 | """, 409 | ) 410 | 411 | # Slot attributes 412 | 413 | def test_slots_with_attributes(self): 414 | @component.register("hello") 415 | def dummy(context): 416 | return Template( 417 | """ 418 |
    419 |

    {% render_slot slots.title %}

    420 |
    {% render_slot slots.body %}
    421 |
    422 | """ 423 | ).render(context) 424 | 425 | self.assertHTMLEqual( 426 | Template( 427 | """ 428 | {% hello id="123" %} 429 | {% slot title class="title" %}Title{% endslot %} 430 | {% slot body class="foo" x-on:click='bar' @click="baz" foo:bar.baz="foo" required %} 431 | Body 432 | {% endslot %} 433 | {% endhello %} 434 | """ 435 | ).render(Context({})), 436 | """ 437 |
    438 |

    Title

    439 |
    Body
    440 |
    441 | """, 442 | ) 443 | 444 | # Scoped slots 445 | 446 | def test_can_render_scoped_slots(self): 447 | @component.register("table") 448 | def table(context): 449 | return Template( 450 | """ 451 | 452 | 453 | {% for col in slots.column %} 454 | 455 | {% endfor %} 456 | 457 | {% for row in rows %} 458 | 459 | {% for col in slots.column %} 460 | 463 | {% endfor %} 464 | 465 | {% endfor %} 466 |
    {{ col.attributes.label }}
    461 | {% render_slot col row %} 462 |
    467 | """ 468 | ).render(context) 469 | 470 | context = Context( 471 | { 472 | "rows": [ 473 | { 474 | "name": "John", 475 | "age": 31, 476 | }, 477 | { 478 | "name": "Bob", 479 | "age": 51, 480 | }, 481 | { 482 | "name": "Alice", 483 | "age": 27, 484 | }, 485 | ], 486 | } 487 | ) 488 | 489 | # directly accessing the variable 490 | self.assertHTMLEqual( 491 | Template( 492 | """ 493 | {% table %} 494 | {% slot column label="Name" %} 495 | {{ row.name }} 496 | {% endslot %} 497 | {% slot column label="Age" %} 498 | {{ row.age }} 499 | {% endslot %} 500 | {% endtable %} 501 | """ 502 | ).render(context), 503 | """ 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 |
    NameAge
    John31
    Bob51
    Alice27
    522 | """, 523 | ) 524 | 525 | # using ':let' to define the context variable 526 | self.assertHTMLEqual( 527 | Template( 528 | """ 529 | {% table %} 530 | {% slot column :let="user" label="Name" %} 531 | {{ user.name }} 532 | {% endslot %} 533 | {% slot column :let="user" label="Age" %} 534 | {{ user.age }} 535 | {% endslot %} 536 | {% endtable %} 537 | """ 538 | ).render(context), 539 | """ 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 |
    NameAge
    John31
    Bob51
    Alice27
    558 | """, 559 | ) 560 | 561 | def test_scoped_slot_works_on_default_slot(self): 562 | @component.register("unordered_list") 563 | def unordered_list(context): 564 | context["entries"] = context["attributes"].pop("entries", []) 565 | return Template( 566 | """ 567 |
      568 | {% for entry in entries %} 569 |
    • 570 | {% render_slot slots.inner_block entry %} 571 |
    • 572 | {% endfor %} 573 |
    574 | """ 575 | ).render(context) 576 | 577 | context = Context( 578 | { 579 | "entries": ["apples", "bananas", "cherries"], 580 | } 581 | ) 582 | 583 | # directly accessing the variable 584 | self.assertHTMLEqual( 585 | Template( 586 | """ 587 | {% unordered_list entries=entries %} 588 | I like {{ entry }}! 589 | {% endunordered_list %} 590 | """ 591 | ).render(context), 592 | """ 593 |
      594 |
    • I like apples!
    • 595 |
    • I like bananas!
    • 596 |
    • I like cherries!
    • 597 |
    598 | """, 599 | ) 600 | 601 | # using ':let' to define the context variable 602 | self.assertHTMLEqual( 603 | Template( 604 | """ 605 | {% unordered_list :let="fruit" entries=entries %} 606 | I like {{ fruit }}! 607 | {% endunordered_list %} 608 | """ 609 | ).render(context), 610 | """ 611 |
      612 |
    • I like apples!
    • 613 |
    • I like bananas!
    • 614 |
    • I like cherries!
    • 615 |
    616 | """, 617 | ) 618 | 619 | # Nested components 620 | 621 | def test_nested_component(self): 622 | @component.register("hello") 623 | def dummy(context): 624 | return Template( 625 | """ 626 |
    {% render_slot slots.inner_block %}
    627 | """ 628 | ).render(context) 629 | 630 | self.assertHTMLEqual( 631 | Template( 632 | """ 633 | {% hello class="foo" %} 634 | {% hello class="bar" %} 635 | Hello, world! 636 | {% endhello %} 637 | {% endhello %} 638 | """ 639 | ).render(Context({})), 640 | """ 641 |
    642 |
    643 | Hello, world! 644 |
    645 |
    646 | """, 647 | ) 648 | 649 | def test_nested_component_with_slots(self): 650 | @component.register("hello") 651 | def dummy(context): 652 | return Template( 653 | """ 654 |
    655 |
    {% render_slot slots.body %}
    656 |
    657 | """ 658 | ).render(context) 659 | 660 | self.assertHTMLEqual( 661 | Template( 662 | """ 663 | {% hello class="hello1" %} 664 | {% slot body class="foo" %} 665 | {% hello class="hello2" %} 666 | {% slot body class="bar" %} 667 | Hello, world! 668 | {% endslot %} 669 | {% endhello %} 670 | {% endslot %} 671 | {% endhello %} 672 | """ 673 | ).render(Context({})), 674 | """ 675 |
    676 |
    677 |
    678 |
    679 | Hello, world! 680 |
    681 |
    682 |
    683 |
    684 | """, 685 | ) 686 | 687 | def test_component_using_other_components(self): 688 | @component.register("header") 689 | def header(context): 690 | return Template( 691 | """ 692 |

    693 | {% render_slot slots.inner_block %} 694 |

    695 | """ 696 | ).render(context) 697 | 698 | @component.register("hello") 699 | def dummy(context): 700 | context["title"] = context["attributes"].pop("title", "") 701 | return Template( 702 | """ 703 |
    704 | {% header %} 705 | {{ title }} 706 | {% endheader %} 707 | 708 |
    709 | {% render_slot slots.inner_block %} 710 |
    711 |
    712 | """ 713 | ).render(context) 714 | 715 | self.assertHTMLEqual( 716 | Template( 717 | """ 718 | {% hello title="Some title" %} 719 | Hello, world! 720 | {% endhello %} 721 | """ 722 | ).render(Context({})), 723 | """ 724 |
    725 |

    726 | Some title 727 |

    728 |
    729 | Hello, world! 730 |
    731 |
    732 | """, 733 | ) 734 | 735 | # Settings 736 | 737 | def test_can_change_default_slot_name_from_settings(self): 738 | @component.register("hello") 739 | def dummy(context): 740 | return Template( 741 | """ 742 |
    {% render_slot slots.default_slot %}
    743 | """ 744 | ).render(context) 745 | 746 | with self.settings( 747 | WEB_COMPONENTS={ 748 | "DEFAULT_SLOT_NAME": "default_slot", 749 | } 750 | ): 751 | self.assertHTMLEqual( 752 | Template( 753 | """ 754 | {% hello %}Hello{% endhello %} 755 | """ 756 | ).render(Context({})), 757 | """ 758 |
    Hello
    759 | """, 760 | ) 761 | 762 | 763 | class RenderComponentTest(TestCase): 764 | def setUp(self) -> None: 765 | component.registry.clear() 766 | 767 | def test_renders_class_component(self): 768 | @component.register("test") 769 | class Dummy(component.Component): 770 | def render(self, context): 771 | return "foo" 772 | 773 | self.assertEqual( 774 | component.render_component( 775 | name="test", 776 | attributes=django_web_components.attributes.AttributeBag(), 777 | slots={}, 778 | context=Context({}), 779 | ), 780 | "foo", 781 | ) 782 | 783 | def test_passes_context_to_class_component(self): 784 | @component.register("test") 785 | class Dummy(component.Component): 786 | def get_context_data(self, **kwargs) -> dict: 787 | return { 788 | "context_data": "value from get_context_data", 789 | } 790 | 791 | def render(self, context): 792 | return Template( 793 | """ 794 |
    795 |
    {{ context_data }}
    796 | {% render_slot slots.inner_block %} 797 |
    798 | """ 799 | ).render(context) 800 | 801 | self.assertHTMLEqual( 802 | component.render_component( 803 | name="test", 804 | attributes=django_web_components.attributes.AttributeBag( 805 | { 806 | "class": "font-bold", 807 | } 808 | ), 809 | slots={ 810 | "inner_block": SlotNodeList( 811 | [ 812 | SlotNode( 813 | name="", 814 | unresolved_attributes={}, 815 | nodelist=NodeList( 816 | [ 817 | TextNode("Hello, world!"), 818 | ] 819 | ), 820 | ), 821 | ] 822 | ), 823 | }, 824 | context=Context({}), 825 | ), 826 | """ 827 |
    828 |
    value from get_context_data
    829 | Hello, world! 830 |
    831 | """, 832 | ) 833 | 834 | def test_renders_function_component(self): 835 | @component.register("test") 836 | def dummy(context): 837 | return "foo" 838 | 839 | self.assertEqual( 840 | component.render_component( 841 | name="test", 842 | attributes=django_web_components.attributes.AttributeBag(), 843 | slots={}, 844 | context=Context({}), 845 | ), 846 | "foo", 847 | ) 848 | 849 | def test_passes_context_to_function_component(self): 850 | @component.register("test") 851 | def dummy(context): 852 | return Template( 853 | """ 854 |
    855 | {% render_slot slots.inner_block %} 856 |
    857 | """ 858 | ).render(context) 859 | 860 | self.assertHTMLEqual( 861 | component.render_component( 862 | name="test", 863 | attributes=django_web_components.attributes.AttributeBag( 864 | { 865 | "class": "font-bold", 866 | } 867 | ), 868 | slots={ 869 | "inner_block": SlotNodeList( 870 | [ 871 | SlotNode( 872 | name="", 873 | unresolved_attributes={}, 874 | nodelist=NodeList( 875 | [ 876 | TextNode("Hello, world!"), 877 | ] 878 | ), 879 | ), 880 | ] 881 | ), 882 | }, 883 | context=Context({}), 884 | ), 885 | """ 886 |
    887 | Hello, world! 888 |
    889 | """, 890 | ) 891 | 892 | 893 | class RegisterTest(TestCase): 894 | def setUp(self) -> None: 895 | component.registry.clear() 896 | 897 | def test_called_as_decorator_with_name(self): 898 | @component.register("hello") 899 | def dummy(context): 900 | pass 901 | 902 | self.assertEqual( 903 | component.registry.get("hello"), 904 | dummy, 905 | ) 906 | 907 | def test_called_as_decorator_with_no_name(self): 908 | @component.register() 909 | def hello(context): 910 | pass 911 | 912 | self.assertEqual( 913 | component.registry.get("hello"), 914 | hello, 915 | ) 916 | 917 | def test_called_as_decorator_with_no_parenthesis(self): 918 | @component.register 919 | def hello(context): 920 | pass 921 | 922 | self.assertEqual( 923 | component.registry.get("hello"), 924 | hello, 925 | ) 926 | 927 | def test_called_directly_with_name(self): 928 | def dummy(context): 929 | pass 930 | 931 | component.register("hello", dummy) 932 | 933 | self.assertEqual( 934 | component.registry.get("hello"), 935 | dummy, 936 | ) 937 | 938 | def test_called_directly_with_no_name(self): 939 | def hello(context): 940 | pass 941 | 942 | component.register(hello) 943 | 944 | self.assertEqual( 945 | component.registry.get("hello"), 946 | hello, 947 | ) 948 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_web_components.registry import ( 4 | ComponentRegistry, 5 | AlreadyRegistered, 6 | NotRegistered, 7 | ) 8 | 9 | 10 | class ComponentRegistryTest(TestCase): 11 | def test_register(self): 12 | registry = ComponentRegistry() 13 | 14 | def dummy(context): 15 | pass 16 | 17 | registry.register("hello", dummy) 18 | 19 | self.assertEqual( 20 | registry.get("hello"), 21 | dummy, 22 | ) 23 | 24 | def test_register_raises_if_component_already_registered(self): 25 | registry = ComponentRegistry() 26 | 27 | def dummy(context): 28 | pass 29 | 30 | registry.register("hello", dummy) 31 | 32 | with self.assertRaises(AlreadyRegistered): 33 | registry.register("hello", dummy) 34 | 35 | def test_get_raises_if_component_not_registered(self): 36 | registry = ComponentRegistry() 37 | 38 | with self.assertRaises(NotRegistered): 39 | registry.get("hello") 40 | 41 | def test_unregister(self): 42 | registry = ComponentRegistry() 43 | 44 | def dummy(context): 45 | pass 46 | 47 | registry.register("hello", dummy) 48 | 49 | self.assertEqual( 50 | registry.get("hello"), 51 | dummy, 52 | ) 53 | 54 | registry.unregister("hello") 55 | 56 | with self.assertRaises(NotRegistered): 57 | registry.get("hello") 58 | 59 | def test_clear(self): 60 | registry = ComponentRegistry() 61 | 62 | def dummy(context): 63 | pass 64 | 65 | registry.register("hello", dummy) 66 | 67 | self.assertEqual( 68 | registry.get("hello"), 69 | dummy, 70 | ) 71 | 72 | registry.clear() 73 | 74 | with self.assertRaises(NotRegistered): 75 | registry.get("hello") 76 | -------------------------------------------------------------------------------- /tests/test_tag_formatter.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | from django.test import TestCase 3 | 4 | from django_web_components import component 5 | from django_web_components.tag_formatter import ComponentTagFormatter 6 | 7 | 8 | class CustomComponentTagFormatter(ComponentTagFormatter): 9 | def format_block_start_tag(self, name: str) -> str: 10 | return f"#{name}" 11 | 12 | def format_block_end_tag(self, name: str) -> str: 13 | return f"/{name}" 14 | 15 | def format_inline_tag(self, name: str) -> str: 16 | return f"_{name}" 17 | 18 | 19 | class ComponentTagFormatterTest(TestCase): 20 | def setUp(self) -> None: 21 | component.registry.clear() 22 | 23 | def test_can_change_component_block_tags(self): 24 | with self.settings( 25 | WEB_COMPONENTS={ 26 | "DEFAULT_COMPONENT_TAG_FORMATTER": "tests.test_tag_formatter.CustomComponentTagFormatter", 27 | }, 28 | ): 29 | 30 | @component.register("hello") 31 | def dummy(context): 32 | return Template("""
    {% render_slot slots.inner_block %}
    """).render(context) 33 | 34 | self.assertHTMLEqual( 35 | Template( 36 | """ 37 | {% #hello %}Hello, world!{% /hello %} 38 | """ 39 | ).render(Context({})), 40 | """ 41 |
    Hello, world!
    42 | """, 43 | ) 44 | 45 | def test_can_change_component_inline_tag(self): 46 | with self.settings( 47 | WEB_COMPONENTS={ 48 | "DEFAULT_COMPONENT_TAG_FORMATTER": "tests.test_tag_formatter.CustomComponentTagFormatter", 49 | }, 50 | ): 51 | 52 | @component.register("hello") 53 | def dummy(context): 54 | return Template("""
    Hello, world!
    """).render(context) 55 | 56 | self.assertHTMLEqual( 57 | Template( 58 | """ 59 | {% _hello %} 60 | """ 61 | ).render(Context({})), 62 | """ 63 |
    Hello, world!
    64 | """, 65 | ) 66 | -------------------------------------------------------------------------------- /tests/test_template.py: -------------------------------------------------------------------------------- 1 | from django.template import Context, Template 2 | from django.test import TestCase 3 | 4 | from django_web_components import component 5 | from django_web_components.template import template_cache, CachedTemplate 6 | 7 | 8 | class CachedTemplateTest(TestCase): 9 | def setUp(self) -> None: 10 | template_cache.clear() 11 | component.registry.clear() 12 | 13 | def test_caches_template(self): 14 | template = CachedTemplate("hello", name="test") 15 | 16 | self.assertEqual( 17 | template.render(Context()), 18 | "hello", 19 | ) 20 | self.assertTrue("test" in template_cache) 21 | self.assertTrue(isinstance(template_cache["test"], Template)) 22 | 23 | def test_doesnt_cache_template_if_no_name_given(self): 24 | template = CachedTemplate("hello") 25 | 26 | self.assertEqual( 27 | template.render(Context()), 28 | "hello", 29 | ) 30 | self.assertTrue("test" not in template_cache) 31 | 32 | def test_uses_cached_template(self): 33 | template_cache["test"] = cached_template = Template("cached hello") 34 | 35 | template = CachedTemplate("hello", name="test") 36 | self.assertEqual( 37 | template.render(Context()), 38 | "cached hello", 39 | ) 40 | self.assertTrue(template_cache["test"] is cached_template) 41 | 42 | def test_can_render_component(self): 43 | @component.register("hello") 44 | def dummy(context): 45 | return CachedTemplate( 46 | """
    Hello, world!
    """, 47 | name="hello", 48 | ).render(context) 49 | 50 | self.assertHTMLEqual( 51 | Template( 52 | """ 53 | {% hello %}{% endhello %} 54 | """ 55 | ).render(Context()), 56 | """ 57 |
    Hello, world!
    58 | """, 59 | ) 60 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.template import TemplateSyntaxError, Template, Context, NodeList, Node 2 | from django.template.base import TextNode, FilterExpression, VariableNode 3 | from django.test import TestCase 4 | 5 | from django_web_components import component 6 | from django_web_components.attributes import AttributeBag 7 | from django_web_components.conf import app_settings 8 | from django_web_components.templatetags.components import SlotNode, SlotNodeList, ComponentNode 9 | 10 | 11 | class DoComponentTest(TestCase): 12 | def setUp(self) -> None: 13 | component.registry.clear() 14 | 15 | def test_parses_component(self): 16 | @component.register("hello") 17 | def dummy(context): 18 | pass 19 | 20 | template = Template("""{% hello %}{% endhello %}""") 21 | 22 | node = template.nodelist[0] 23 | 24 | self.assertTrue(type(node) == ComponentNode) 25 | self.assertEqual(node.name, "hello") 26 | self.assertEqual(node.unresolved_attributes, {}) 27 | self.assertEqual(node.slots, {app_settings.DEFAULT_SLOT_NAME: NodeList()}) 28 | 29 | def test_interprets_attributes_with_no_value_as_true(self): 30 | @component.register("hello") 31 | def dummy(context): 32 | pass 33 | 34 | template = Template("""{% hello required %}{% endhello %}""") 35 | 36 | node = template.nodelist[0] 37 | 38 | context = Context() 39 | 40 | self.assertEqual( 41 | {key: value.resolve(context) for key, value in node.unresolved_attributes.items()}, 42 | { 43 | "required": True, 44 | }, 45 | ) 46 | 47 | def test_adds_non_slot_child_nodes_to_default_slot(self): 48 | @component.register("hello") 49 | def dummy(context): 50 | pass 51 | 52 | template = Template("""{% hello %}Hello{% endhello %}""") 53 | 54 | node = template.nodelist[0] 55 | 56 | self.assertTrue(app_settings.DEFAULT_SLOT_NAME in node.slots) 57 | 58 | default_slot = node.slots[app_settings.DEFAULT_SLOT_NAME][0] 59 | 60 | self.assertEqual(default_slot.name, app_settings.DEFAULT_SLOT_NAME) 61 | self.assertEqual(default_slot.unresolved_attributes, {}) 62 | self.assertEqual(len(default_slot.nodelist), 1) 63 | self.assertEqual(default_slot.special, {}) 64 | 65 | def test_passes_special_attributes_to_default_slot(self): 66 | @component.register("hello") 67 | def dummy(context): 68 | pass 69 | 70 | template = Template("""{% hello class="foo" :let="user" %}Hello{% endhello %}""") 71 | 72 | node = template.nodelist[0] 73 | 74 | context = Context() 75 | 76 | self.assertEqual( 77 | {key: value.resolve(context) for key, value in node.unresolved_attributes.items()}, 78 | { 79 | "class": "foo", 80 | }, 81 | ) 82 | 83 | default_slot = node.slots[app_settings.DEFAULT_SLOT_NAME][0] 84 | 85 | self.assertEqual( 86 | {key: value.resolve(context) for key, value in default_slot.special.items()}, 87 | { 88 | ":let": "user", 89 | }, 90 | ) 91 | 92 | def test_parses_slots(self): 93 | @component.register("hello") 94 | def dummy(context): 95 | pass 96 | 97 | template = Template("""{% hello %}{% slot title %}{% endslot %}Hello{% endhello %}""") 98 | 99 | node = template.nodelist[0] 100 | 101 | self.assertEqual(len(node.slots["title"]), 1) 102 | self.assertEqual(len(node.slots[app_settings.DEFAULT_SLOT_NAME]), 1) 103 | 104 | 105 | class DoSlotTest(TestCase): 106 | def test_parses_slot(self): 107 | template = Template("""{% slot title %}{% endslot %}""") 108 | 109 | node = template.nodelist[0] 110 | 111 | self.assertTrue(type(node) == SlotNode) 112 | self.assertEqual(node.name, "title") 113 | self.assertEqual(node.nodelist, NodeList()) 114 | self.assertEqual(node.unresolved_attributes, {}) 115 | 116 | def test_parses_slot_with_quoted_name(self): 117 | template = Template("""{% slot "title" %}{% endslot %}""") 118 | 119 | node = template.nodelist[0] 120 | 121 | self.assertTrue(type(node) == SlotNode) 122 | self.assertEqual(node.name, "title") 123 | 124 | def test_interprets_attributes_with_no_value_as_true(self): 125 | template = Template("""{% slot title required %}{% endslot %}""") 126 | 127 | node = template.nodelist[0] 128 | 129 | context = Context() 130 | 131 | self.assertEqual( 132 | {key: value.resolve(context) for key, value in node.unresolved_attributes.items()}, 133 | { 134 | "required": True, 135 | }, 136 | ) 137 | 138 | def test_splits_special_attributes(self): 139 | template = Template("""{% slot title class="foo" :let="user" %}{% endslot %}""") 140 | 141 | node = template.nodelist[0] 142 | 143 | context = Context() 144 | 145 | self.assertEqual( 146 | {key: value.resolve(context) for key, value in node.unresolved_attributes.items()}, 147 | { 148 | "class": "foo", 149 | }, 150 | ) 151 | self.assertEqual( 152 | {key: value.resolve(context) for key, value in node.special.items()}, 153 | { 154 | ":let": "user", 155 | }, 156 | ) 157 | 158 | 159 | class SlotNodeListTest(TestCase): 160 | def test_attributes_returns_empty_if_no_elements(self): 161 | self.assertEqual( 162 | SlotNodeList().attributes, 163 | AttributeBag(), 164 | ) 165 | 166 | def test_attributes_returns_empty_if_element_doesnt_have_attributes_property(self): 167 | self.assertEqual( 168 | SlotNodeList([TextNode("hello")]).attributes, 169 | AttributeBag(), 170 | ) 171 | 172 | def test_attributes_returns_attributes_of_first_element(self): 173 | node = Node() 174 | node.attributes = {"foo": "bar"} 175 | 176 | self.assertEqual( 177 | SlotNodeList([node]).attributes, 178 | {"foo": "bar"}, 179 | ) 180 | 181 | def test_attributes_returns_empty_if_multiple_elements(self): 182 | node1 = Node() 183 | node1.attributes = {"foo": "bar"} 184 | node2 = Node() 185 | node2.attributes = {"foo": "bar"} 186 | 187 | self.assertEqual( 188 | SlotNodeList([node1, node2]).attributes, 189 | {}, 190 | ) 191 | 192 | 193 | class DoRenderSlotTest(TestCase): 194 | def test_raises_if_no_arguments_passed(self): 195 | with self.assertRaises(TemplateSyntaxError): 196 | Template( 197 | """ 198 | {% render_slot %} 199 | """ 200 | ).render(Context({})) 201 | 202 | def test_raises_if_more_than_two_arguments_passed(self): 203 | with self.assertRaises(TemplateSyntaxError): 204 | Template( 205 | """ 206 | {% render_slot foo bar baz %} 207 | """ 208 | ).render(Context({})) 209 | 210 | def test_renders_slot(self): 211 | self.assertHTMLEqual( 212 | Template( 213 | """ 214 | {% render_slot inner_block %} 215 | """ 216 | ).render( 217 | Context( 218 | { 219 | "inner_block": SlotNode( 220 | nodelist=NodeList( 221 | [ 222 | TextNode("Hello, world!"), 223 | ], 224 | ), 225 | ), 226 | } 227 | ) 228 | ), 229 | """ 230 | Hello, world! 231 | """, 232 | ) 233 | 234 | def test_ignores_nonexistant_variables(self): 235 | self.assertHTMLEqual( 236 | Template( 237 | """ 238 | {% render_slot foo %} 239 | """ 240 | ).render(Context({})), 241 | """ 242 | """, 243 | ) 244 | 245 | def test_renders_slot_with_argument(self): 246 | slot_node = SlotNode( 247 | special={ 248 | ":let": FilterExpression('"user"', None), 249 | }, 250 | nodelist=NodeList( 251 | [ 252 | VariableNode(FilterExpression("user.name", None)), 253 | ] 254 | ), 255 | ) 256 | 257 | self.assertHTMLEqual( 258 | Template( 259 | """ 260 | {% render_slot inner_block arg %} 261 | """ 262 | ).render( 263 | Context( 264 | { 265 | "inner_block": SlotNodeList( 266 | [ 267 | slot_node, 268 | ] 269 | ), 270 | "arg": { 271 | "name": "John Doe", 272 | }, 273 | } 274 | ) 275 | ), 276 | """ 277 | John Doe 278 | """, 279 | ) 280 | 281 | 282 | class DoMergeAttrsTest(TestCase): 283 | def test_merges_attributes_with_defaults(self): 284 | self.assertHTMLEqual( 285 | Template( 286 | """ 287 |
    288 | """ 289 | ).render(Context({"attributes": {"class": "foo"}})), 290 | """ 291 |
    292 | """, 293 | ) 294 | 295 | def test_appends_class(self): 296 | self.assertHTMLEqual( 297 | Template( 298 | """ 299 |
    300 | """ 301 | ).render(Context({"attributes": {"class": "foo"}})), 302 | """ 303 |
    304 | """, 305 | ) 306 | 307 | def test_appends_other_attributes(self): 308 | self.assertHTMLEqual( 309 | Template( 310 | """ 311 |
    312 | """ 313 | ).render(Context({"attributes": {"data": "foo"}})), 314 | """ 315 |
    316 | """, 317 | ) 318 | 319 | def test_supports_attributes_with_hyphen(self): 320 | self.assertHTMLEqual( 321 | Template( 322 | """ 323 |
    324 | """ 325 | ).render(Context({"attributes": {}})), 326 | """ 327 |
    328 | """, 329 | ) 330 | 331 | def test_raises_if_no_arguments_passed(self): 332 | with self.assertRaises(TemplateSyntaxError): 333 | Template( 334 | """ 335 | {% merge_attrs %} 336 | """ 337 | ).render(Context({})) 338 | 339 | def test_raises_if_attributes_are_malformed(self): 340 | with self.assertRaises(TemplateSyntaxError): 341 | Template( 342 | """ 343 | {% merge_attrs attributes foo %} 344 | """ 345 | ).render(Context({})) 346 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from django.template import Context 2 | from django.template.base import Parser 3 | from django.test import TestCase 4 | 5 | from django_web_components.utils import token_kwargs 6 | 7 | 8 | class TokenKwargsTest(TestCase): 9 | def test_parses_raw_value(self): 10 | p = Parser([]) 11 | context = Context() 12 | 13 | self.assertEqual( 14 | {key: value.resolve(context) for key, value in token_kwargs(['foo="bar"'], p).items()}, 15 | { 16 | "foo": "bar", 17 | }, 18 | ) 19 | 20 | def test_parses_key_with_symbols(self): 21 | p = Parser([]) 22 | context = Context() 23 | 24 | self.assertEqual( 25 | { 26 | key: value.resolve(context) 27 | for key, value in token_kwargs(['x-on:click="bar"', '@click="bar"', 'foo:bar.baz="bar"'], p).items() 28 | }, 29 | { 30 | "x-on:click": "bar", 31 | "@click": "bar", 32 | "foo:bar.baz": "bar", 33 | }, 34 | ) 35 | 36 | def test_parses_variable_value(self): 37 | p = Parser([]) 38 | context = Context({"bar": "baz"}) 39 | 40 | self.assertEqual( 41 | {key: value.resolve(context) for key, value in token_kwargs(["foo=bar"], p).items()}, 42 | { 43 | "foo": "baz", 44 | }, 45 | ) 46 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4 4 | env_list = 5 | py{38,39,310}-django{32,40} 6 | py{38,39,310,311}-django{41} 7 | py{38,39,310,311,312}-django{42} 8 | py{310,311,312}-django{50} 9 | 10 | [testenv] 11 | description = run unit tests 12 | deps = 13 | django32: Django>=3.2,<3.3 14 | django40: Django>=4.0,<4.1 15 | django41: Django>=4.1,<4.2 16 | django42: Django>=4.2rc1,<5.0 17 | django50: Django>=5.0a,<5.1 18 | commands = 19 | python runtests.py 20 | --------------------------------------------------------------------------------