Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Error boundaries #10

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,18 @@ def pytest_configure():
},
],
SECRET_KEY="this-is-a-secret",
ANGLES={},
ANGLES={
"IS_IN_UNIT_TEST": True,
},
)


@pytest.fixture(autouse=True)
def reset_settings(settings):
# Make sure that ANGLES is empty before every test
settings.ANGLES = {}
settings.ANGLES = {
"IS_IN_UNIT_TEST": True,
}

# Clear the tag map before every test
clear_tag_map()
Expand Down
7 changes: 6 additions & 1 deletion example/www/templates/www/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ <h1>
<ul>
<li>
<a class="{% if request.path == "/include" %}current{% endif %}" href="/include">include</a>
<a class="{% if request.path == "/error-boundaries" %}current{% endif %}"
href="/error-boundaries">error-boundaries</a>
<a class="{% if request.path == "/bird" %}current{% endif %}" href="/bird">django-bird</a>
</li>
</ul>
Expand All @@ -30,7 +32,10 @@ <h1>

<main>
<dj-block name='content'>
</dj-block name='content'>
</dj-block>

<dj-block name='extra-content'>
</dj-block>
</main>

<footer>
Expand Down
3 changes: 3 additions & 0 deletions example/www/templates/www/components/error-fallback.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p>
This is an error fallback template.
</p>
3 changes: 3 additions & 0 deletions example/www/templates/www/components/include-bad-sub.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<div>
{{ invalid variable }}
</div>
1 change: 1 addition & 0 deletions example/www/templates/www/components/include-bad.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<dj-include src="www/components/include-bad-sub.html"></dj-include>
2 changes: 1 addition & 1 deletion example/www/templates/www/components/include.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div>
<p>
This is a regular include.
This is a regular include. {{ request }} {{ bad }}
</p>
</div>
29 changes: 29 additions & 0 deletions example/www/templates/www/error-boundaries.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<dj-extends parent='www/base.html' />

<dj-block name='content' error-boundary>
<dj-include src="www/components/include-bad.html" />
</dj-block>

<dj-block name='extra-content'>
<dj-error-boundary default='www/components/error-fallback.html'>
<dj-include src="www/components/include-bad.html" />
</dj-error-boundary>

<dj-error-boundary default='This is an error fallback string'>
<dj-include src="www/components/include-bad.html" />
</dj-error-boundary>

<dj-error-boundary>
<dj-include src="www/components/include-bad.html" />
</dj-error-boundary>

<dj-error-boundary>
<dj-include src="missing.html" />
</dj-error-boundary>

<dj-error-boundary>
<dj-include src="www/components/include.html" />
</dj-error-boundary>

<dj-include src="www/components/include.html" />
</dj-block>
9 changes: 9 additions & 0 deletions src/dj_angles/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ def parse(self):
self._attributes.append(attribute)
attribute_keys.add(attribute.key)

def has(self, name: str) -> bool:
"""Whether or not an there is an :obj:`~dj_angles.attributes.Attribute` by name.

Args:
param name: The name of the attribute.
"""

return self.get(name) is not None

def get(self, name: str) -> Optional[Attribute]:
"""Get an :obj:`~dj_angles.attributes.Attribute` by name. Returns `None` if the attribute is missing.

Expand Down
2 changes: 1 addition & 1 deletion src/dj_angles/caseconverter/caseconverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def stripable_punctuation(delimiters):


class CaseConverter:
def __init__(self, s, delimiters=DELIMITERS, strip_punctuation=True): # noqa: FBT002
def __init__(self, s, delimiters=DELIMITERS, *, strip_punctuation=True):
"""Initialize a case conversion.

On initialization, punctuation can be optionally stripped. If
Expand Down
5 changes: 1 addition & 4 deletions src/dj_angles/mappers/include.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,8 @@ def map_include(tag: "Tag") -> str:
extension_idx = template_file.index(".")
template_file = template_file[0:colon_idx] + template_file[extension_idx:]

if template := get_template(template_file):
if template := get_template(template_file, raise_exception=False):
template_file = f"'{template.template.name}'"
else:
# Ignore missing template because an exception will be thrown when the component is being rendered
pass

replacement = ""

Expand Down
3 changes: 2 additions & 1 deletion src/dj_angles/mappers/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"templatetag": "templatetag",
"image": map_image,
"css": map_css,
"error-boundary": lambda tag: "", # noqa: ARG005 # the inner_html gets handled in `regex_replacer`
}
"""Default mappings for tag names to Django template tags."""

Expand Down Expand Up @@ -65,7 +66,7 @@ def add_custom_mappers(self) -> None:
def add_default_mapper(self) -> None:
"""Add default mapper if in settings, or fallback to the default mapper."""

default_mapper = get_setting("default_mapper", "dj_angles.mappers.default_mapper")
default_mapper = get_setting("default_mapper", default="dj_angles.mappers.default_mapper")

if default_mapper is not None:
# Add the default with a magic key of `None`
Expand Down
130 changes: 91 additions & 39 deletions src/dj_angles/regex_replacer.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import re
from collections import deque
from typing import Optional

from django.template import Context, Origin, Template, TemplateDoesNotExist, TemplateSyntaxError
from minestrone import HTML

from dj_angles.exceptions import InvalidEndTagError
Expand All @@ -9,7 +11,81 @@
from dj_angles.tags import Tag


def get_replacements(html: str, *, raise_for_missing_start_tag: bool = True) -> list[tuple[str, str]]:
def set_tag_inner_html(html, match, tag):
if (
not tag.is_self_closing
and not tag.is_end
and (tag.django_template_tag is None or tag.is_include or tag.is_error_boundary)
):
match_end_idx = match.end()

try:
# Get next ending tag
# TODO: handle custom tag, not just /dj-
next_ending_tag_idx = html.index("</dj-", match_end_idx)

if tag.is_error_boundary:
# Get next error boundary; they cannot be nested
next_ending_tag_idx = html.index(f"</dj-{tag.tag_name}>", match_end_idx)

tag.inner_html = html[match_end_idx:next_ending_tag_idx].strip()
except ValueError as e:
raise


def check_for_missing_start_tag(raise_for_missing_start_tag, tag, tag_queue):
if raise_for_missing_start_tag:
if tag.is_end:
last_tag: Tag = tag_queue.pop()

if last_tag.tag_name != tag.tag_name:
raise InvalidEndTagError(tag=tag, last_tag=last_tag)
elif not tag.is_self_closing:
tag_queue.append(tag)


def get_replacements_for_error_boundaries(tag_regex, origin, tag, replacements):
matches_to_skip = 0

if get_setting(key_path="error_boundaries", setting_name="enabled", default=True) is True:
if tag.is_error_boundary and tag.inner_html:
matches_to_skip = len(re.findall(tag_regex, tag.inner_html))

try:
parsed_inner_html = replace_django_template_tags(tag.inner_html)

# Parse the inner HTML and create a template
inner_html_template = Template(parsed_inner_html, origin=origin)

# It would be nice to pass in the template context here, but cannot
# find access to it with this process, so it is empty
inner_html_template.render(context=Context())

replacements.append((tag.inner_html, parsed_inner_html))
except (TemplateDoesNotExist, TemplateSyntaxError) as e:
error_html = tag.get_error_html(e)

replacements.append((tag.inner_html, error_html))

return matches_to_skip


def get_slots(tag, replacements):
slots = []

# Parse the inner HTML for includes to handle slots
if get_setting("slots_enabled", default=False) and tag.inner_html:
for element in HTML(tag.inner_html).elements:
if slot_name := element.attributes.get("slot"):
slots.append((slot_name, element))

# Remove slot from the current HTML because it will be injected into the include component
replacements.append((tag.inner_html, ""))


def get_replacements(
html: str, *, origin: Origin = None, raise_for_missing_start_tag: bool = True
) -> list[tuple[str, str]]:
"""Get a list of replacements (tuples that consists of 2 strings) based on the template HTML.

Args:
Expand All @@ -27,9 +103,15 @@ def get_replacements(html: str, *, raise_for_missing_start_tag: bool = True) ->

tag_map = get_tag_map()

map_explicit_tags_only = get_setting("map_explicit_tags_only", False)
map_explicit_tags_only = get_setting("map_explicit_tags_only", default=False)

matches_to_skip = 0

for match in re.finditer(tag_regex, html):
if matches_to_skip > 0:
matches_to_skip -= 1
continue

tag_html = html[match.start() : match.end()].strip()
tag_name = match.group("tag_name").strip()
template_tag_args = match.group("template_tag_args").strip()
Expand All @@ -45,51 +127,21 @@ def get_replacements(html: str, *, raise_for_missing_start_tag: bool = True) ->
tag_queue=tag_queue,
)

if raise_for_missing_start_tag:
if tag.is_end:
last_tag: Tag = tag_queue.pop()

if last_tag.tag_name != tag.tag_name:
raise InvalidEndTagError(tag=tag, last_tag=last_tag)
elif not tag.is_self_closing:
tag_queue.append(tag)

slots = []

# Parse the inner HTML for includes to handle slots
if (
get_setting("slots_enabled", default=False)
and not tag.is_self_closing
and not tag.is_end
and (tag.django_template_tag is None or tag.is_include)
):
end_of_include_tag = match.end()

try:
# TODO: handle custom tag, not just /dj-
next_ending_tag_idx = html.index("</dj-", end_of_include_tag)
inner_html = html[end_of_include_tag:next_ending_tag_idx].strip()
set_tag_inner_html(html, match, tag)

if inner_html:
for element in HTML(inner_html).elements:
if slot_name := element.attributes.get("slot"):
slots.append((slot_name, element))
check_for_missing_start_tag(raise_for_missing_start_tag, tag, tag_queue)

# Remove slot from the current HTML because it will be injected into the include component
replacements.append((inner_html, ""))
except ValueError:
# Ending tag could not be found, so skip getting the inner html
pass
matches_to_skip = get_replacements_for_error_boundaries(tag_regex, origin, tag, replacements)

django_template_tag = tag.get_django_template_tag(slots=slots)
slots = get_slots(tag, replacements)

if django_template_tag:
if django_template_tag := tag.get_django_template_tag(slots=slots):
replacements.append((tag.html, django_template_tag))

return replacements


def replace_django_template_tags(html: str) -> str:
def replace_django_template_tags(html: str, origin: Optional[Origin] = None) -> str:
"""Gets a list of replacements based on template HTML, replaces the necessary strings, and returns the new string.

Args:
Expand All @@ -99,7 +151,7 @@ def replace_django_template_tags(html: str) -> str:
The converted template HTML.
"""

replacements = get_replacements(html=html)
replacements = get_replacements(html=html, origin=origin)

for r in replacements:
html = html.replace(
Expand Down
17 changes: 13 additions & 4 deletions src/dj_angles/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,28 @@
from django.conf import settings


def get_setting(setting_name: str, default=None) -> Any:
"""Get a `dj-angles` setting from the `ANGLES` setting dictionary.
def get_setting(setting_name: str, key_path: str = "", default: Any = None) -> Any:
"""Get a setting from the `ANGLES` dictionary in settings.

Args:
param setting_name: The name of the setting.
param key_path: The name of the sub-dictionary under `ANGLES`.
param default: The value that should be returned if the setting is missing.
"""

if not hasattr(settings, "ANGLES"):
settings.ANGLES = {}

if setting_name in settings.ANGLES:
return settings.ANGLES[setting_name]
data = settings.ANGLES

if key_path:
if key_path not in data:
data[key_path] = {}

data = data[key_path]

if setting_name in data:
return data[setting_name]

return default

Expand Down
Loading
Loading