Skip to content

Commit 4bfb74c

Browse files
committed
Fix a few url generation bugs and add complexity failsafe.
closes #9 closes #10
1 parent 90efae2 commit 4bfb74c

File tree

9 files changed

+340
-91
lines changed

9 files changed

+340
-91
lines changed

doc/source/configuration.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,19 @@ parameter must contain the full path where the template will be rendered includi
151151

152152
Provide additional parameters for each template in the ``context`` dictionary. Any context variables
153153
specified here that clash with global context variables will override them.
154+
155+
156+
``RENDER_STATIC_REVERSAL_LIMIT``
157+
--------------------------------
158+
159+
The guess and check reversal mechanism used to ensure that `urls_to_js` produces the same reversals
160+
as Django's `reverse` is an **O(n^p)** operation where **n** is the number of placeholder candidates
161+
to try and **p** is the number of arguments in the url. Its possible for this to produce a
162+
complexity explosion for rare cases where the URL has a large number of arguments with unregistered
163+
placeholders. A limit on the number of tries is enforced to guard against this. User's may adjust
164+
the limit via the ``RENDER_STATIC_REVERSAL_LIMIT`` settings parameter. By default it is 2**14 tries
165+
which runs in ~seconds per URL.
166+
167+
The solution if this limit is hit, is to provide more specific placeholders as placeholders are
168+
attempted in order of specificity where specificity is defined by url name, variable name,
169+
app name and/or converter type.

doc/source/reference.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ exceptions
6565
.. automodule:: render_static.exceptions
6666

6767
.. autoclass:: URLGenerationFailed
68+
.. autoclass:: ReversalLimitHit
6869

6970

7071
.. _placeholders:

render_static/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ class URLGenerationFailed(Exception):
1010
Thrown by `urls_to_js` under any circumstance where URL generation fails for a specific
1111
fully qualified URL name.
1212
"""
13+
14+
15+
class ReversalLimitHit(Exception):
16+
"""
17+
Thrown by `urls_to_js` under any circumstance where the configured maximum number of tries has
18+
been hit when attempting to reverse a URL.
19+
"""

render_static/placeholders.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def register_unnamed_placeholders(
101101
) -> None:
102102
"""
103103
Register a list of placeholders for a url_name and optionally an app_name that takes unnamed
104-
arguments.
104+
arguments. The list indices should correspond to the argument order as passed to reverse.
105105
106106
:param url_name: The name of the url path to register the placeholders for
107107
:param placeholders: The list of placeholders to use
@@ -147,22 +147,35 @@ def resolve_placeholders(
147147

148148
def resolve_unnamed_placeholders(
149149
url_name: str,
150-
app_name: Optional[str] = None
150+
nargs: int,
151+
app_name: Optional[str] = None,
151152
) -> Iterable:
152153
"""
153154
Resolve placeholders to use for a url with unnamed parameters based on the url name and
154155
optionally the app_name.
155156
156157
:param url_name: The name of the URL to search for
158+
:param nargs: The number of arguments for this url
157159
:param app_name: The optional app_name to search for
158-
:return: A list of lists of placeholders to try
160+
:return: A list of lists of placeholders to try, where the outer list is indexed by argument
161+
index
159162
"""
160163

161-
placeholders = []
164+
placeholders: List[List[object]] = [[] for arg in range(0, nargs)]
165+
166+
def add_candidates(candidates: List[List[object]]) -> None:
167+
for candidate in candidates:
168+
if len(candidate) == nargs:
169+
for idx, arg in enumerate(candidate):
170+
placeholders[idx].append(arg)
162171
if app_name:
163-
placeholders.extend(app_unnamed_placeholders.get(app_name, {}).get(url_name, []))
164-
placeholders.extend(unnamed_placeholders.get(url_name, []))
165-
return placeholders + [always for always in ALWAYS_TRY_THESE if always not in placeholders]
172+
add_candidates(app_unnamed_placeholders.get(app_name, {}).get(url_name, []))
173+
add_candidates(unnamed_placeholders.get(url_name, []))
174+
175+
for arg in placeholders:
176+
arg.extend([always for always in ALWAYS_TRY_THESE if always not in arg])
177+
178+
return placeholders
166179

167180

168181
register_variable_placeholder('app_label', 'site', app_name='admin')

render_static/tests/tests.py

Lines changed: 199 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import os
55
import shutil
66
import subprocess
7+
import traceback
78
import uuid
89
from pathlib import Path
10+
from time import perf_counter
911

1012
import js2py
1113
from deepdiff import DeepDiff
@@ -17,6 +19,7 @@
1719
from django.template.utils import InvalidTemplateEngineError
1820
from django.test import TestCase, override_settings
1921
from django.urls import reverse
22+
from django.urls.exceptions import NoReverseMatch
2023
from django.utils.module_loading import import_string
2124
from render_static import placeholders
2225
from render_static.backends import StaticDjangoTemplates, StaticJinja2Templates
@@ -1538,7 +1541,7 @@ def tearDown(self):
15381541

15391542

15401543
@override_settings(ROOT_URLCONF='render_static.tests.urls2')
1541-
class UnregisteredURLTest(URLJavascriptMixin, BaseTestCase):
1544+
class CornerCaseTest(URLJavascriptMixin, BaseTestCase):
15421545

15431546
def setUp(self):
15441547
self.clear_placeholder_registries()
@@ -1574,7 +1577,38 @@ def test_no_default_registered(self):
15741577
self.compare('default', args=['unnamed'])
15751578

15761579
@override_settings(STATIC_TEMPLATES={
1577-
'context': {'include': ['default']},
1580+
'ENGINES': [{
1581+
'BACKEND': 'render_static.backends.StaticDjangoTemplates',
1582+
'OPTIONS': {
1583+
'loaders': [
1584+
('render_static.loaders.StaticLocMemLoader', {
1585+
'urls.js': ('{% urls_to_js '
1586+
'visitor="render_static.ClassURLWriter" '
1587+
'include=include '
1588+
'%}')
1589+
})
1590+
],
1591+
'builtins': ['render_static.templatetags.render_static']
1592+
},
1593+
}],
1594+
'templates': {'urls.js': {'context': {'include': ['no_capture']}}}
1595+
})
1596+
def test_non_capturing_unnamed(self):
1597+
"""
1598+
Tests that unnamed arguments can still work when the users also include non-capturing groups
1599+
for whatever reason. Hard to imagine an actual use case for these - but reverse still seems
1600+
to work, so javascript reverse should too
1601+
:return:
1602+
"""
1603+
self.es6_mode = True
1604+
self.url_js = None
1605+
self.class_mode = ClassURLWriter.class_name_
1606+
1607+
placeholders.register_unnamed_placeholders('no_capture', ['0000'])
1608+
call_command('render_static', 'urls.js')
1609+
self.compare('no_capture', args=['5555'])
1610+
1611+
@override_settings(STATIC_TEMPLATES={
15781612
'ENGINES': [{
15791613
'BACKEND': 'render_static.backends.StaticDjangoTemplates',
15801614
'OPTIONS': {
@@ -1599,22 +1633,20 @@ def test_named_unnamed_conflation1(self):
15991633
self.url_js = None
16001634
self.class_mode = ClassURLWriter.class_name_
16011635

1602-
print(reverse('special', kwargs={'choice': 'first'}))
1603-
print(reverse('special', args=['first']))
1604-
16051636
self.assertRaises(CommandError, lambda: call_command('render_static', 'urls.js'))
16061637

16071638
placeholders.register_variable_placeholder('choice', 'first')
1639+
placeholders.register_variable_placeholder('choice1', 'second')
16081640
self.assertRaises(CommandError, lambda: call_command('render_static', 'urls.js'))
1609-
placeholders.register_unnamed_placeholders('special', ['first'])
1641+
placeholders.register_unnamed_placeholders('special', ['third'])
16101642

16111643
call_command('render_static', 'urls.js')
1612-
self.compare('special', {'choice': 'first'})
1613-
self.compare('special', ['first'])
1614-
1644+
self.compare('special', {'choice': 'second'})
1645+
self.compare('special', {'choice': 'second', 'choice1': 'first'})
1646+
self.compare('special', args=['third'])
16151647

16161648
@override_settings(
1617-
ROOT_URLCONF='render_static.tests.urls2',
1649+
ROOT_URLCONF='render_static.tests.urls3',
16181650
STATIC_TEMPLATES={
16191651
'context': {'include': ['default']},
16201652
'ENGINES': [{
@@ -1642,23 +1674,130 @@ def test_named_unnamed_conflation2(self):
16421674
self.url_js = None
16431675
self.class_mode = ClassURLWriter.class_name_
16441676

1645-
print(reverse('special', kwargs={'choice': 'first'}))
1646-
print(reverse('special', args=['first']))
1647-
16481677
self.assertRaises(CommandError, lambda: call_command('render_static', 'urls.js'))
16491678

16501679
placeholders.register_variable_placeholder('choice', 'first')
1680+
placeholders.register_variable_placeholder('choice1', 'second')
16511681
self.assertRaises(CommandError, lambda: call_command('render_static', 'urls.js'))
1682+
placeholders.register_unnamed_placeholders('special', ['third'])
1683+
1684+
call_command('render_static', 'urls.js')
1685+
self.compare('special', {'choice': 'second'})
1686+
self.compare('special', {'choice': 'second', 'choice1': 'first'})
1687+
self.compare('special', args=['third'])
1688+
1689+
@override_settings(
1690+
ROOT_URLCONF='render_static.tests.urls4',
1691+
STATIC_TEMPLATES={
1692+
'context': {'include': ['default']},
1693+
'ENGINES': [{
1694+
'BACKEND': 'render_static.backends.StaticDjangoTemplates',
1695+
'OPTIONS': {
1696+
'loaders': [
1697+
('render_static.loaders.StaticLocMemLoader', {
1698+
'urls.js': '{% urls_to_js visitor="render_static.ClassURLWriter" %}'
1699+
})
1700+
],
1701+
'builtins': ['render_static.templatetags.render_static']
1702+
},
1703+
}]
1704+
}
1705+
)
1706+
def test_named_unnamed_conflation3(self):
1707+
"""
1708+
This tests surfaces what appears to be a Django bug in reverse(). urls_to_js should not
1709+
fail in this circumstance, but should leave a comment breadcrumb in the generated JS that
1710+
indicates why no reversal was produced - alternatively if bug is fixed it should also pass
1711+
1712+
https://github.com/bckohan/django-render-static/issues/9
1713+
"""
1714+
self.es6_mode = True
1715+
self.url_js = None
1716+
self.class_mode = ClassURLWriter.class_name_
1717+
1718+
placeholders.register_variable_placeholder('choice', 'first')
16521719
placeholders.register_unnamed_placeholders('special', ['first'])
1720+
call_command('render_static', 'urls.js')
1721+
1722+
with open(GLOBAL_STATIC_DIR / 'urls.js', 'r') as urls:
1723+
if 'reverse matched unexpected pattern' not in urls.read():
1724+
self.compare('special', kwargs={'choice': 'first'}) # pragma: no cover
1725+
self.compare('special', args=['first']) # pragma: no cover
1726+
1727+
self.assertTrue(True)
16531728

1729+
@override_settings(
1730+
STATIC_TEMPLATES={
1731+
'context': {'include': ['bad_mix']},
1732+
'ENGINES': [{
1733+
'BACKEND': 'render_static.backends.StaticDjangoTemplates',
1734+
'OPTIONS': {
1735+
'loaders': [
1736+
('render_static.loaders.StaticLocMemLoader', {
1737+
'urls.js': '{% urls_to_js '
1738+
'visitor="render_static.ClassURLWriter" '
1739+
'include=include %}'
1740+
})
1741+
],
1742+
'builtins': ['render_static.templatetags.render_static']
1743+
},
1744+
}]
1745+
}
1746+
)
1747+
def test_named_unnamed_bad_mix(self):
1748+
"""
1749+
Mix of named and unnamed arguments should not be reversible!
1750+
"""
1751+
self.es6_mode = True
1752+
self.url_js = None
1753+
self.class_mode = ClassURLWriter.class_name_
1754+
1755+
placeholders.register_variable_placeholder('named', '1111')
1756+
placeholders.register_unnamed_placeholders('bad_mix', ['unnamed'])
16541757
call_command('render_static', 'urls.js')
1655-
self.compare('special', {'choice': 'first'})
1656-
self.compare('special', ['first'])
16571758

1759+
with open(GLOBAL_STATIC_DIR / 'urls.js', 'r') as urls:
1760+
self.assertTrue('this path may not be reversible' in urls.read())
1761+
1762+
self.assertRaises(
1763+
ValueError,
1764+
lambda: reverse(
1765+
'bad_mix',
1766+
kwargs={'named': '1111'}, args=['unnamed'])
1767+
)
1768+
self.assertRaises(NoReverseMatch, lambda: reverse('bad_mix', kwargs={'named': '1111'}))
1769+
1770+
@override_settings(
1771+
STATIC_TEMPLATES={
1772+
'context': {'include': ['bad_mix2']},
1773+
'ENGINES': [{
1774+
'BACKEND': 'render_static.backends.StaticDjangoTemplates',
1775+
'OPTIONS': {
1776+
'loaders': [
1777+
('render_static.loaders.StaticLocMemLoader', {
1778+
'urls.js': '{% urls_to_js '
1779+
'visitor="render_static.ClassURLWriter" '
1780+
'include=include %}'
1781+
})
1782+
],
1783+
'builtins': ['render_static.templatetags.render_static']
1784+
},
1785+
}]
1786+
}
1787+
)
1788+
def test_named_unnamed_bad_mix2(self):
1789+
"""
1790+
Mix of named and unnamed arguments should not be reversible!
1791+
"""
1792+
self.es6_mode = True
1793+
self.url_js = None
1794+
self.class_mode = ClassURLWriter.class_name_
1795+
1796+
placeholders.register_variable_placeholder('named', '1111')
1797+
placeholders.register_unnamed_placeholders('bad_mix2', ['unnamed'])
1798+
self.assertRaises(CommandError, lambda: call_command('render_static', 'urls.js'))
16581799

1659-
"""
16601800
@override_settings(STATIC_TEMPLATES={
1661-
'context': {'include': ['default']},
16621801
'ENGINES': [{
16631802
'BACKEND': 'render_static.backends.StaticDjangoTemplates',
16641803
'OPTIONS': {
@@ -1673,20 +1812,58 @@ def test_named_unnamed_conflation2(self):
16731812
'builtins': ['render_static.templatetags.render_static']
16741813
},
16751814
}],
1676-
'templates': {'urls.js': {'context': {'include': ['special']}}}
1815+
'templates': {'urls.js': {'context': {'include': ['complex']}}}
16771816
})
16781817
def test_complexity_boundary(self):
1818+
"""
16791819
https://github.com/bckohan/django-render-static/issues/10
1820+
16801821
For URLs with lots of unregistered arguments, the reversal attempts may produce an explosion
1681-
of complexity. If there are
1822+
of complexity. Check that the failsafe is working.
16821823
:return:
1824+
"""
16831825
self.es6_mode = True
1826+
self.class_mode = ClassURLWriter.class_name_
16841827
self.url_js = None
16851828

1686-
self.assertRaises(CommandError, call_command('render_static', 'urls.js'))
1829+
t1 = perf_counter()
1830+
tb_str = ''
1831+
try:
1832+
call_command('render_static', 'urls.js')
1833+
except Exception as complexity_error:
1834+
tb_str = traceback.format_exc()
1835+
t2 = perf_counter()
1836+
1837+
self.assertTrue('ReversalLimitHit' in tb_str)
1838+
1839+
# very generous reversal timing threshold of 20 seconds - anecdotally the default limit of
1840+
# 2**15 should be hit in about 3 seconds.
1841+
self.assertTrue(t2-t1 < 20)
1842+
1843+
placeholders.register_variable_placeholder('one', '666')
1844+
placeholders.register_variable_placeholder('two', '666')
1845+
placeholders.register_variable_placeholder('three', '666')
1846+
placeholders.register_variable_placeholder('four', '666')
1847+
placeholders.register_variable_placeholder('five', '666')
1848+
placeholders.register_variable_placeholder('six', '666')
1849+
placeholders.register_variable_placeholder('seven', '666')
1850+
placeholders.register_variable_placeholder('eight', '666')
16871851

1688-
self.compare('default')
1689-
"""
1852+
call_command('render_static', 'urls.js')
1853+
1854+
self.compare(
1855+
'complex',
1856+
{
1857+
'one': 666,
1858+
'two': 666,
1859+
'three': 666,
1860+
'four': 666,
1861+
'five': 666,
1862+
'six': 666,
1863+
'seven': 666,
1864+
'eight': 666,
1865+
}
1866+
)
16901867

16911868
# uncomment to not delete generated js
16921869
def tearDown(self):
@@ -1854,7 +2031,6 @@ def test_unknown_pattern(self):
18542031
self.assertRaises(CommandError, lambda: call_command('render_static', 'urls.js'))
18552032

18562033
def test_register_bogus_converter(self):
1857-
from render_static import placeholders as gen
18582034
self.assertRaises(
18592035
ValueError,
18602036
lambda: placeholders.register_converter_placeholder('Not a converter type!', 1234)

0 commit comments

Comments
 (0)