Skip to content

Commit 5f98d0f

Browse files
authored
Merge branch 'adamghill:main' into main
2 parents a4bbe09 + eefe34b commit 5f98d0f

File tree

105 files changed

+2399
-1982
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

105 files changed

+2399
-1982
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
"import/no-unresolved": 0,
1515
"linebreak-style": 0,
1616
"comma-dangle": 0,
17+
"import/extensions": ["error", "always", { ignorePackages: true }],
1718
"import/prefer-default-export": 0,
1819
"no-unused-expressions": ["error", { allowTernary: true }],
1920
"no-underscore-dangle": 0,

.github/workflows/js.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,10 @@ jobs:
1616
- name: Install node packages
1717
run: npm install
1818

19+
- name: See if unicorn.min.js is up-to-date
20+
run: |
21+
npm run build
22+
git diff --stat --exit-code
23+
1924
- name: Test with ava
2025
run: npm run-script test

.github/workflows/python.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ jobs:
3131
run: poetry install
3232

3333
- name: black check
34-
run: poetry run black . --check --extend-exclude migrations
34+
run: poetry run black . --check
3535

36-
- name: isort check
37-
run: poetry run isort --settings pyproject.toml --check .
36+
- name: ruff check
37+
run: poetry run ruff .
3838

3939
- name: Run all tests
4040
run: poetry run nox

DEVELOPING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030

3131
1. Update changelog.md
3232
1. Update package.json
33-
1. Run all build processes: `poe build`
3433
1. `poetry version major|minor|patch`
34+
1. Run all build processes: `poe build`
3535
1. Commit/tag/push version bump
3636
1. `poe publish`
3737
1. Make sure test package can be installed as expected (https://test.pypi.org/project/django-unicorn/)

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
[![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-)
1414
<!-- ALL-CONTRIBUTORS-BADGE - Do not remove or modify above line -->
1515

16-
[Unicorn](https://www.django-unicorn.com) adds modern reactive component functionality to your Django templates without having to learn a new templating language or fight with complicated JavaScript frameworks. It seamlessly extends Django past its server-side framework roots without giving up all of its niceties or forcing you to re-building your application. With Django Unicorn, you can quickly and easily add rich front-end interactions to your templates, all while using the power of Django.
16+
[Unicorn](https://www.django-unicorn.com) adds modern reactive component functionality to your Django templates without having to learn a new templating language or fight with complicated JavaScript frameworks. It seamlessly extends Django past its server-side framework roots without giving up all of its niceties or forcing you to rebuild your application. With Django Unicorn, you can quickly and easily add rich front-end interactions to your templates, all while using the power of Django.
17+
18+
**[https://www.django-unicorn.com](https://www.django-unicorn.com) has extensive documentation, code examples, and more!**
1719

1820
## ⚡ Getting started
1921

@@ -61,12 +63,12 @@ urlpatterns = (
6163

6264
### 5. [Create a component](https://www.django-unicorn.com/docs/components/)
6365

64-
`python manage.py startunicorn COMPONENT_NAME`
66+
`python manage.py startunicorn myapp COMPONENT_NAME`
6567

6668
`Unicorn` uses the term "component" to refer to a set of interactive functionality that can be put into templates. A component consists of a Django HTML template and a Python view class which contains the backend code. After running the management command, two new files will be created:
6769

68-
- `your_app/templates/unicorn/COMPONENT_NAME.html` (component template)
69-
- `your_app/components/COMPONENT_NAME.py` (component view)
70+
- `myapp/templates/unicorn/COMPONENT_NAME.html` (component template)
71+
- `myapp/components/COMPONENT_NAME.py` (component view)
7072

7173
### 6. Add the component to your template
7274

@@ -91,7 +93,7 @@ urlpatterns = (
9193
The `unicorn:` attributes bind the element to data and can also trigger methods by listening for events, e.g. `click`, `input`, `keydown`, etc.
9294

9395
```html
94-
<!-- ../templates/unicorn/todo.html -->
96+
<!-- todo.html -->
9597

9698
<div>
9799
<form unicorn:submit.prevent="add">
@@ -118,7 +120,7 @@ The `unicorn:` attributes bind the element to data and can also trigger methods
118120
```
119121

120122
```python
121-
# ../components/todo.py
123+
# todo.py
122124

123125
from django_unicorn.components import UnicornView
124126
from django import forms

conftest.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
from django.conf import settings
2-
31
import pytest
2+
from django.conf import settings
43

54

65
def pytest_configure():

django_unicorn/call_method_parser.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66

77
from django_unicorn.utils import CASTERS
88

9-
109
logger = logging.getLogger(__name__)
1110

1211

13-
class InvalidKwarg(Exception):
12+
class InvalidKwargError(Exception):
1413
pass
1514

1615

@@ -85,7 +84,7 @@ def eval_value(value):
8584

8685

8786
@lru_cache(maxsize=128, typed=True)
88-
def parse_kwarg(kwarg: str, raise_if_unparseable=False) -> Dict[str, Any]:
87+
def parse_kwarg(kwarg: str, *, raise_if_unparseable=False) -> Dict[str, Any]:
8988
"""
9089
Parses a potential kwarg as a string into a dictionary.
9190
@@ -121,9 +120,9 @@ def parse_kwarg(kwarg: str, raise_if_unparseable=False) -> Dict[str, Any]:
121120
value = _get_expr_string(assign.value)
122121
return {target.id: value}
123122
else:
124-
raise InvalidKwarg(f"'{kwarg}' is invalid")
125-
except SyntaxError:
126-
raise InvalidKwarg(f"'{kwarg}' could not be parsed")
123+
raise InvalidKwargError(f"'{kwarg}' is invalid")
124+
except SyntaxError as e:
125+
raise InvalidKwargError(f"'{kwarg}' could not be parsed") from e
127126

128127

129128
@lru_cache(maxsize=128, typed=True)

django_unicorn/components/__init__.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from .mixins import ModelValueMixin
2-
from .typing import QuerySetType
3-
from .unicorn_view import UnicornField, UnicornView
4-
from .updaters import HashUpdate, LocationUpdate, PollUpdate
5-
1+
from django_unicorn.components.mixins import ModelValueMixin
2+
from django_unicorn.components.typing import QuerySetType
3+
from django_unicorn.components.unicorn_view import UnicornField, UnicornView
4+
from django_unicorn.components.updaters import HashUpdate, LocationUpdate, PollUpdate
65

76
__all__ = [
87
"QuerySetType",

django_unicorn/components/typing.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from django.db.models import Model, QuerySet
44

5-
65
M_co = TypeVar("M_co", bound=Model, covariant=True)
76

87

django_unicorn/components/unicorn_template_response.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@
22
import re
33
from collections import deque
44

5-
from django.template.response import TemplateResponse
6-
75
import orjson
86
from bs4 import BeautifulSoup
97
from bs4.dammit import EntitySubstitution
108
from bs4.element import Tag
119
from bs4.formatter import HTMLFormatter
10+
from django.template.response import TemplateResponse
1211

12+
from django_unicorn.decorators import timed
13+
from django_unicorn.errors import (
14+
MissingComponentElementError,
15+
MissingComponentViewElementError,
16+
MultipleRootComponentElementError,
17+
NoRootComponentElementError,
18+
)
1319
from django_unicorn.settings import get_minify_html_enabled, get_script_location
14-
from django_unicorn.utils import sanitize_html
15-
16-
from ..decorators import timed
17-
from ..errors import MissingComponentElement, MissingComponentViewElement
18-
from ..utils import generate_checksum
19-
20+
from django_unicorn.utils import generate_checksum, sanitize_html
2021

2122
logger = logging.getLogger(__name__)
2223

@@ -60,6 +61,25 @@ def is_html_well_formed(html: str) -> bool:
6061
return len(stack) == 0
6162

6263

64+
def assert_has_single_wrapper_element(root_element: Tag, component_name: str) -> None:
65+
# Check that the root element has at least one child
66+
try:
67+
next(root_element.descendants)
68+
except StopIteration:
69+
raise NoRootComponentElementError(
70+
f"The '{component_name}' component does not appear to have one root element."
71+
) from None
72+
73+
# Check that there is not more than one root element
74+
parent_element = root_element.parent
75+
tag_count = len([c for c in parent_element.children if isinstance(c, Tag)])
76+
77+
if tag_count > 1:
78+
raise MultipleRootComponentElementError(
79+
f"The '{component_name}' component appears to have multiple root elements."
80+
) from None
81+
82+
6383
class UnsortedAttributes(HTMLFormatter):
6484
"""
6585
Prevent beautifulsoup from re-ordering attributes.
@@ -69,23 +89,23 @@ def __init__(self):
6989
super().__init__(entity_substitution=EntitySubstitution.substitute_html)
7090

7191
def attributes(self, tag: Tag):
72-
for k, v in tag.attrs.items():
73-
yield k, v
92+
yield from tag.attrs.items()
7493

7594

7695
class UnicornTemplateResponse(TemplateResponse):
7796
def __init__(
7897
self,
7998
template,
8099
request,
100+
*,
81101
context=None,
82102
content_type=None,
83103
status=None,
84104
charset=None,
85105
using=None,
86106
component=None,
87107
init_js=False,
88-
**kwargs,
108+
**kwargs, # noqa: ARG002
89109
):
90110
super().__init__(
91111
template=template,
@@ -111,7 +131,8 @@ def render(self):
111131

112132
if not is_html_well_formed(content):
113133
logger.warning(
114-
f"The HTML in '{self.component.component_name}' appears to be missing a closing tag. That can potentially cause errors in Unicorn."
134+
f"The HTML in '{self.component.component_name}' appears to be missing a closing tag. That can \
135+
potentially cause errors in Unicorn."
115136
)
116137

117138
frontend_context_variables = self.component.get_frontend_context_variables()
@@ -122,13 +143,19 @@ def render(self):
122143
# despite https://thehftguy.com/2020/07/28/making-beautifulsoup-parsing-10-times-faster/
123144
soup = BeautifulSoup(content, features="html.parser")
124145
root_element = get_root_element(soup)
146+
147+
try:
148+
assert_has_single_wrapper_element(root_element, self.component.component_name)
149+
except (NoRootComponentElementError, MultipleRootComponentElementError) as ex:
150+
logger.warning(ex)
151+
125152
root_element["unicorn:id"] = self.component.component_id
126153
root_element["unicorn:name"] = self.component.component_name
127154
root_element["unicorn:key"] = self.component.component_key
128155
root_element["unicorn:checksum"] = checksum
129156

130-
# Generate the hash based on the rendered content (without script tag)
131-
hash = generate_checksum(UnicornTemplateResponse._desoupify(soup))
157+
# Generate the checksum based on the rendered content (without script tag)
158+
checksum = generate_checksum(UnicornTemplateResponse._desoupify(soup))
132159

133160
if self.init_js:
134161
init = {
@@ -137,11 +164,13 @@ def render(self):
137164
"key": self.component.component_key,
138165
"data": orjson.loads(frontend_context_variables),
139166
"calls": self.component.calls,
140-
"hash": hash,
167+
"hash": checksum,
141168
}
142169
init = orjson.dumps(init).decode("utf-8")
143170
json_element_id = f"unicorn:data:{self.component.component_id}"
144-
init_script = f"Unicorn.componentInit(JSON.parse(document.getElementById('{json_element_id}').textContent));"
171+
init_script = (
172+
f"Unicorn.componentInit(JSON.parse(document.getElementById('{json_element_id}').textContent));"
173+
)
145174

146175
json_tag = soup.new_tag("script")
147176
json_tag["type"] = "application/json"
@@ -165,7 +194,8 @@ def render(self):
165194

166195
script_tag = soup.new_tag("script")
167196
script_tag["type"] = "module"
168-
script_tag.string = f"if (typeof Unicorn === 'undefined') {{ console.error('Unicorn is missing. Do you need {{% load unicorn %}} or {{% unicorn_scripts %}}?') }} else {{ {init_script} }}"
197+
script_tag.string = f"if (typeof Unicorn === 'undefined') {{ console.error('Unicorn is missing. Do you \
198+
need {{% load unicorn %}} or {{% unicorn_scripts %}}?') }} else {{ {init_script} }}"
169199

170200
if get_script_location() == "append":
171201
root_element.append(script_tag)
@@ -213,17 +243,17 @@ def get_root_element(soup: BeautifulSoup) -> Tag:
213243
for element in soup.contents:
214244
if isinstance(element, Tag) and element.name:
215245
if element.name == "html":
216-
view_element = element.find_next(
217-
attrs={"unicorn:view": True}
218-
) or element.find_next(attrs={"u:view": True})
246+
view_element = element.find_next(attrs={"unicorn:view": True}) or element.find_next(
247+
attrs={"u:view": True}
248+
)
219249

220250
if not view_element:
221-
raise MissingComponentViewElement(
251+
raise MissingComponentViewElementError(
222252
"An element with an `unicorn:view` attribute is required for a direct view"
223253
)
224254

225255
return view_element
226256

227257
return element
228258

229-
raise MissingComponentElement("No root element for the component was found")
259+
raise MissingComponentElementError("No root element for the component was found")

0 commit comments

Comments
 (0)