├── .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 | [](https://github.com/Xzya/django-web-components/actions/workflows/tests.yml)
4 | [](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 |
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 |
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 |
158 | {% render_slot slots.inner_block %}
159 |
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 |
178 | {% render_slot slots.inner_block %}
179 |
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 |
211 | {% render_slot slots.inner_block %}
212 |
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 |
371 |
372 |
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 |
385 |
386 |
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 |
395 | {% render_slot slots.inner_block %}
396 |
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 |
409 | Submit
410 |
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 | {{ col.attributes.label }}
724 | {% endfor %}
725 |
726 | {% for row in rows %}
727 |
728 | {% for col in slots.column %}
729 |
730 | {% render_slot col row %}
731 |
732 | {% endfor %}
733 |
734 | {% endfor %}
735 |
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 | Name
760 | Age
761 |
762 |
763 | Jane
764 | 34
765 |
766 |
767 | Bob
768 | 51
769 |
770 |
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 |
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 |
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 |
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 | {{ col.attributes.label }}
455 | {% endfor %}
456 |
457 | {% for row in rows %}
458 |
459 | {% for col in slots.column %}
460 |
461 | {% render_slot col row %}
462 |
463 | {% endfor %}
464 |
465 | {% endfor %}
466 |
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 | Name
507 | Age
508 |
509 |
510 | John
511 | 31
512 |
513 |
514 | Bob
515 | 51
516 |
517 |
518 | Alice
519 | 27
520 |
521 |
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 | Name
543 | Age
544 |
545 |
546 | John
547 | 31
548 |
549 |
550 | Bob
551 | 51
552 |
553 |
554 | Alice
555 | 27
556 |
557 |
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 |
--------------------------------------------------------------------------------