django-fastresolve 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_fastresolve/__init__.py +9 -0
- django_fastresolve/apps.py +11 -0
- django_fastresolve/resolver.py +234 -0
- django_fastresolve-0.1.0.dist-info/METADATA +123 -0
- django_fastresolve-0.1.0.dist-info/RECORD +7 -0
- django_fastresolve-0.1.0.dist-info/WHEEL +4 -0
- django_fastresolve-0.1.0.dist-info/licenses/LICENSE +12 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""A faster drop-in for ``URLResolver.resolve``.
|
|
2
|
+
|
|
3
|
+
Django resolves a path by trying every child pattern of a resolver in order,
|
|
4
|
+
and recursing into includes. With hundreds of patterns at one level, that's
|
|
5
|
+
hundreds of regex searches or string compares per request.
|
|
6
|
+
|
|
7
|
+
This module indexes each resolver's children by their *literal prefix*: the
|
|
8
|
+
part of the pattern that must appear verbatim at the start of the path for the
|
|
9
|
+
pattern to have any chance of matching. A path then only needs to try the
|
|
10
|
+
children whose literal prefix it starts with, still in their original order.
|
|
11
|
+
Everything else (converters, ``ValueError`` from ``to_python`` falling through
|
|
12
|
+
to later patterns, namespaces, default kwargs) is Django's own code.
|
|
13
|
+
|
|
14
|
+
When the prefix of a child can't be determined safely, its prefix is the empty
|
|
15
|
+
string, meaning it is a candidate for every path. Correctness never depends on
|
|
16
|
+
the index being clever, only on it never excluding a pattern that could match.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import string
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from contextvars import ContextVar
|
|
22
|
+
|
|
23
|
+
from django.conf import settings
|
|
24
|
+
from django.urls.exceptions import Resolver404
|
|
25
|
+
from django.urls.resolvers import (
|
|
26
|
+
RegexPattern,
|
|
27
|
+
ResolverMatch,
|
|
28
|
+
RoutePattern,
|
|
29
|
+
URLPattern,
|
|
30
|
+
URLResolver,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
stock_resolve = URLResolver.resolve
|
|
34
|
+
|
|
35
|
+
_use_stock = ContextVar('django_fastresolve_use_stock', default=False)
|
|
36
|
+
|
|
37
|
+
# Characters that mean themselves when unescaped in a regex, outside a character class
|
|
38
|
+
_REGEX_LITERAL_CHARS = frozenset(string.ascii_letters + string.digits + '/-_~%=,;:@!&\'"<>')
|
|
39
|
+
_REGEX_QUANTIFIER_START = frozenset('?*+{')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def stock_resolving():
|
|
44
|
+
"""Resolve with Django's own implementation inside this block, in this context only."""
|
|
45
|
+
token = _use_stock.set(True)
|
|
46
|
+
try:
|
|
47
|
+
yield
|
|
48
|
+
finally:
|
|
49
|
+
_use_stock.reset(token)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def regex_literal_prefix(regex):
|
|
53
|
+
"""The literal text any match of `regex` (searched with `re.search`) must start with.
|
|
54
|
+
|
|
55
|
+
Conservative: returns '' whenever the regex isn't plainly ``^literal...``.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(regex, str) or not regex.startswith('^') or '|' in regex:
|
|
58
|
+
return ''
|
|
59
|
+
|
|
60
|
+
prefix = []
|
|
61
|
+
i = 1
|
|
62
|
+
while i < len(regex):
|
|
63
|
+
c = regex[i]
|
|
64
|
+
if c in _REGEX_LITERAL_CHARS:
|
|
65
|
+
literal, width = c, 1
|
|
66
|
+
elif c == '\\' and i + 1 < len(regex) and not regex[i + 1].isalnum():
|
|
67
|
+
literal, width = regex[i + 1], 2
|
|
68
|
+
else:
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
i += width
|
|
72
|
+
if i < len(regex) and regex[i] in _REGEX_QUANTIFIER_START:
|
|
73
|
+
# The char we just read is optional or repeated, so it's not a fixed prefix
|
|
74
|
+
break
|
|
75
|
+
prefix.append(literal)
|
|
76
|
+
|
|
77
|
+
return ''.join(prefix)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def literal_prefix(url_pattern):
|
|
81
|
+
"""The prefix a path must start with for `url_pattern` to possibly resolve it."""
|
|
82
|
+
resolve_method = getattr(type(url_pattern), 'resolve', None)
|
|
83
|
+
if resolve_method is not URLPattern.resolve and resolve_method is not fast_resolve:
|
|
84
|
+
# A subclass with its own idea of matching, we can't reason about it
|
|
85
|
+
return ''
|
|
86
|
+
|
|
87
|
+
pattern = url_pattern.pattern
|
|
88
|
+
if type(pattern) is RoutePattern:
|
|
89
|
+
route = pattern._route
|
|
90
|
+
if not isinstance(route, str):
|
|
91
|
+
# Lazily translated, depends on the active language
|
|
92
|
+
return ''
|
|
93
|
+
# Cutting at any '<' is safe even when it doesn't start a converter: a shorter prefix only means more candidates
|
|
94
|
+
index = route.find('<')
|
|
95
|
+
return route if index == -1 else route[:index]
|
|
96
|
+
|
|
97
|
+
if type(pattern) is RegexPattern:
|
|
98
|
+
return regex_literal_prefix(pattern._regex)
|
|
99
|
+
|
|
100
|
+
# LocalePrefixPattern, or a pattern type we don't know
|
|
101
|
+
return ''
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Index:
|
|
105
|
+
__slots__ = ('patterns', 'length', 'lengths_desc', 'candidates_by_prefix', 'is_root')
|
|
106
|
+
|
|
107
|
+
def __init__(self, resolver, patterns):
|
|
108
|
+
self.patterns = patterns
|
|
109
|
+
self.length = len(patterns)
|
|
110
|
+
self.is_root = type(resolver.pattern) is RegexPattern and resolver.pattern._regex == '^/'
|
|
111
|
+
|
|
112
|
+
indexes_by_own_prefix = {}
|
|
113
|
+
for i, url_pattern in enumerate(patterns):
|
|
114
|
+
indexes_by_own_prefix.setdefault(literal_prefix(url_pattern), []).append(i)
|
|
115
|
+
|
|
116
|
+
lengths = sorted({len(prefix) for prefix in indexes_by_own_prefix})
|
|
117
|
+
|
|
118
|
+
# For every prefix, the candidates are its own patterns plus those of every
|
|
119
|
+
# shorter prefix it starts with. Any path's candidates are then those of the
|
|
120
|
+
# longest prefix it starts with. Shortest first, so parents are done before
|
|
121
|
+
# their children.
|
|
122
|
+
indexes_by_prefix = {}
|
|
123
|
+
for prefix in sorted(indexes_by_own_prefix, key=len):
|
|
124
|
+
inherited = ()
|
|
125
|
+
for length in reversed(lengths):
|
|
126
|
+
if length < len(prefix):
|
|
127
|
+
parent = indexes_by_prefix.get(prefix[:length])
|
|
128
|
+
if parent is not None:
|
|
129
|
+
inherited = parent
|
|
130
|
+
break
|
|
131
|
+
indexes_by_prefix[prefix] = tuple(sorted({*inherited, *indexes_by_own_prefix[prefix]}))
|
|
132
|
+
|
|
133
|
+
self.candidates_by_prefix = {prefix: tuple(patterns[i] for i in indexes) for prefix, indexes in indexes_by_prefix.items()}
|
|
134
|
+
self.lengths_desc = tuple(reversed(lengths))
|
|
135
|
+
|
|
136
|
+
def candidates(self, path):
|
|
137
|
+
candidates_by_prefix = self.candidates_by_prefix
|
|
138
|
+
path_length = len(path)
|
|
139
|
+
for length in self.lengths_desc:
|
|
140
|
+
if length <= path_length:
|
|
141
|
+
candidates = candidates_by_prefix.get(path[:length])
|
|
142
|
+
if candidates is not None:
|
|
143
|
+
return candidates
|
|
144
|
+
return ()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_index(resolver):
|
|
148
|
+
patterns = resolver.url_patterns
|
|
149
|
+
index = resolver.__dict__.get('_fastresolve_index')
|
|
150
|
+
# The length check catches the common case of urlpatterns being appended to after the first resolve
|
|
151
|
+
if index is None or index.patterns is not patterns or index.length != len(patterns):
|
|
152
|
+
if not isinstance(patterns, (list, tuple)):
|
|
153
|
+
return None
|
|
154
|
+
index = Index(resolver, patterns)
|
|
155
|
+
resolver._fastresolve_index = index
|
|
156
|
+
return index
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fast_resolve(self, path):
|
|
160
|
+
if _use_stock.get():
|
|
161
|
+
return stock_resolve(self, path)
|
|
162
|
+
|
|
163
|
+
path = str(path) # path may be a reverse_lazy object
|
|
164
|
+
match = self.pattern.match(path)
|
|
165
|
+
if not match:
|
|
166
|
+
raise Resolver404({'path': path})
|
|
167
|
+
|
|
168
|
+
index = _get_index(self)
|
|
169
|
+
if index is None:
|
|
170
|
+
return stock_resolve(self, path)
|
|
171
|
+
|
|
172
|
+
new_path, args, kwargs = match
|
|
173
|
+
tried = []
|
|
174
|
+
# From here on, this is Django's URLResolver.resolve, only looping over the candidates instead of all patterns
|
|
175
|
+
for pattern in index.candidates(new_path):
|
|
176
|
+
try:
|
|
177
|
+
sub_match = pattern.resolve(new_path)
|
|
178
|
+
except Resolver404 as e:
|
|
179
|
+
self._extend_tried(tried, pattern, e.args[0].get('tried'))
|
|
180
|
+
else:
|
|
181
|
+
if sub_match:
|
|
182
|
+
# Merge captured arguments in match with submatch
|
|
183
|
+
sub_match_dict = {**kwargs, **self.default_kwargs}
|
|
184
|
+
# Update the sub_match_dict with the kwargs from the sub_match.
|
|
185
|
+
sub_match_dict.update(sub_match.kwargs)
|
|
186
|
+
# If there are *any* named groups, ignore all non-named
|
|
187
|
+
# groups. Otherwise, pass all non-named arguments as
|
|
188
|
+
# positional arguments.
|
|
189
|
+
sub_match_args = sub_match.args
|
|
190
|
+
if not sub_match_dict:
|
|
191
|
+
sub_match_args = args + sub_match.args
|
|
192
|
+
current_route = '' if isinstance(pattern, URLPattern) else str(pattern.pattern)
|
|
193
|
+
self._extend_tried(tried, pattern, sub_match.tried)
|
|
194
|
+
return ResolverMatch(
|
|
195
|
+
sub_match.func,
|
|
196
|
+
sub_match_args,
|
|
197
|
+
sub_match_dict,
|
|
198
|
+
sub_match.url_name,
|
|
199
|
+
[self.app_name, *sub_match.app_names],
|
|
200
|
+
[self.namespace, *sub_match.namespaces],
|
|
201
|
+
self._join_route(current_route, sub_match.route),
|
|
202
|
+
tried,
|
|
203
|
+
captured_kwargs=sub_match.captured_kwargs,
|
|
204
|
+
extra_kwargs={
|
|
205
|
+
**self.default_kwargs,
|
|
206
|
+
**sub_match.extra_kwargs,
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
tried.append([pattern])
|
|
210
|
+
|
|
211
|
+
if index.is_root and settings.DEBUG:
|
|
212
|
+
_check_stock_404(self, path)
|
|
213
|
+
|
|
214
|
+
raise Resolver404({'tried': tried, 'path': new_path})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _check_stock_404(resolver, path):
|
|
218
|
+
"""In DEBUG, redo a 404 the stock way.
|
|
219
|
+
|
|
220
|
+
The 404 debug page then lists every pattern instead of only the candidates,
|
|
221
|
+
and if stock Django does find a match, that's a bug in this library and we
|
|
222
|
+
say so loudly instead of serving a wrong 404.
|
|
223
|
+
"""
|
|
224
|
+
with stock_resolving():
|
|
225
|
+
match = stock_resolve(resolver, path)
|
|
226
|
+
raise AssertionError(f'django-fastresolve bug: {path!r} resolved to {match._func_path} with stock Django, but not with django-fastresolve. Please report this at https://github.com/boxed/django-fastresolve/issues')
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def install():
|
|
230
|
+
URLResolver.resolve = fast_resolve
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def uninstall():
|
|
234
|
+
URLResolver.resolve = stock_resolve
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-fastresolve
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Faster URL resolving for Django, by indexing url patterns on their literal prefixes
|
|
5
|
+
Project-URL: Homepage, https://github.com/boxed/django-fastresolve
|
|
6
|
+
Author-email: Anders Hovmöller <boxed@killingar.net>
|
|
7
|
+
License: BSD
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: django,performance,urls
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
|
+
Classifier: Framework :: Django :: 6.0
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: django>=4.2
|
|
20
|
+
Description-Content-Type: text/x-rst
|
|
21
|
+
|
|
22
|
+
django-fastresolve
|
|
23
|
+
==================
|
|
24
|
+
|
|
25
|
+
Faster URL resolving for Django, as a drop-in. No changes to your urlconfs.
|
|
26
|
+
|
|
27
|
+
Django resolves a path by trying every pattern of a urlconf in order, recursing
|
|
28
|
+
into includes. With a few hundred patterns at one level, every request pays for
|
|
29
|
+
a few hundred regex searches or string compares, and a 404 pays for all of them.
|
|
30
|
+
|
|
31
|
+
django-fastresolve indexes the patterns of each resolver on their *literal
|
|
32
|
+
prefix*: the text a path must start with for the pattern to have any chance of
|
|
33
|
+
matching (``'projects/'`` for ``path('projects/<int:pk>/', ...)``, ``'blog/'``
|
|
34
|
+
for ``re_path(r'^blog/(?P<slug>\w+)/$', ...)``). A path then only tries the
|
|
35
|
+
patterns whose prefix it starts with, still in their original order. All the
|
|
36
|
+
matching itself is still Django's own code: converters, a ``to_python`` raising
|
|
37
|
+
``ValueError`` falling through to later patterns, namespaces, default kwargs.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Installation
|
|
41
|
+
------------
|
|
42
|
+
|
|
43
|
+
.. code-block::
|
|
44
|
+
|
|
45
|
+
pip install django-fastresolve
|
|
46
|
+
|
|
47
|
+
and add ``'django_fastresolve'`` to ``INSTALLED_APPS``. That's it.
|
|
48
|
+
|
|
49
|
+
You can also call ``django_fastresolve.install()`` and
|
|
50
|
+
``django_fastresolve.uninstall()`` yourself.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
How much faster?
|
|
54
|
+
----------------
|
|
55
|
+
|
|
56
|
+
On a real project with three urlconfs, timing ``resolve()`` on every route,
|
|
57
|
+
with ``DEBUG = False``:
|
|
58
|
+
|
|
59
|
+
===================== ====== ============ ============== ========== ==========
|
|
60
|
+
urlconf routes stock median fast median stock 404 fast 404
|
|
61
|
+
===================== ====== ============ ============== ========== ==========
|
|
62
|
+
internal app 785 25 µs 6 µs 27 µs 2 µs
|
|
63
|
+
public site 894 64 µs 4.6 µs 125 µs 4.8 µs
|
|
64
|
+
small 83 8 µs 7 µs 6 µs 3 µs
|
|
65
|
+
===================== ====== ============ ============== ========== ==========
|
|
66
|
+
|
|
67
|
+
The more patterns share a level, the bigger the win. Patterns that stock Django
|
|
68
|
+
already finds in the first couple of tries get about 1 µs slower, from the
|
|
69
|
+
index lookup.
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
When does it fall back?
|
|
73
|
+
-----------------------
|
|
74
|
+
|
|
75
|
+
A pattern whose prefix can't be determined safely gets the empty prefix, which
|
|
76
|
+
means it is tried for every path, exactly where stock Django would try it. That
|
|
77
|
+
is the case for:
|
|
78
|
+
|
|
79
|
+
- lazily translated routes (``path(gettext_lazy('about/'), ...)``)
|
|
80
|
+
- regexes that aren't anchored with ``^``, or that contain ``|``
|
|
81
|
+
- ``i18n_patterns`` (``LocalePrefixPattern``)
|
|
82
|
+
- ``URLPattern``/``URLResolver`` subclasses that override ``resolve()``
|
|
83
|
+
- pattern classes it doesn't know
|
|
84
|
+
|
|
85
|
+
Only the leading literal part of a regex counts: ``r'^ab?c/'`` has the prefix
|
|
86
|
+
``'a'``.
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
Differences from stock Django
|
|
90
|
+
-----------------------------
|
|
91
|
+
|
|
92
|
+
``tried`` (on ``Resolver404`` and on ``ResolverMatch.tried``) only lists the
|
|
93
|
+
patterns that were actually tried, not every pattern before the match.
|
|
94
|
+
|
|
95
|
+
With ``DEBUG = True`` a 404 is resolved again the stock way, so the 404 debug
|
|
96
|
+
page lists every pattern as usual. That also acts as a self check: if stock
|
|
97
|
+
Django finds a match where django-fastresolve didn't, you get an
|
|
98
|
+
``AssertionError`` saying so, instead of a wrong 404. Please report it if that
|
|
99
|
+
happens.
|
|
100
|
+
|
|
101
|
+
The index for a resolver is built on first use. If you append to or remove from
|
|
102
|
+
``urlpatterns`` afterwards the index is rebuilt; replacing an item in place is
|
|
103
|
+
not noticed.
|
|
104
|
+
|
|
105
|
+
To resolve the stock way for a block of code, in the current context only:
|
|
106
|
+
|
|
107
|
+
.. code-block:: python
|
|
108
|
+
|
|
109
|
+
from django_fastresolve import stock_resolving
|
|
110
|
+
|
|
111
|
+
with stock_resolving():
|
|
112
|
+
match = resolve('/some/path/')
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
Running the tests
|
|
116
|
+
-----------------
|
|
117
|
+
|
|
118
|
+
.. code-block::
|
|
119
|
+
|
|
120
|
+
uv run pytest
|
|
121
|
+
|
|
122
|
+
The tests resolve a urlconf full of edge cases, plus a few thousand fuzzed
|
|
123
|
+
paths, both ways, and assert identical results.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
django_fastresolve/__init__.py,sha256=IdMJYacIEgfN35FGXcB38kS6WGqkYE8lBsYCQwQw8tE,171
|
|
2
|
+
django_fastresolve/apps.py,sha256=AUDRDJ6T8KIVJk8oLs1wC127CFg2_g4jDtEkncVgRQM,232
|
|
3
|
+
django_fastresolve/resolver.py,sha256=ZDuN7nskDYuE8KXTnTV75IBrtjGLDKdnpHABNhgJd6Y,9091
|
|
4
|
+
django_fastresolve-0.1.0.dist-info/METADATA,sha256=zbC65L6cJYhwy85B8lKD-dKiWop_CkBvFVrTEVSZmvc,4523
|
|
5
|
+
django_fastresolve-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
django_fastresolve-0.1.0.dist-info/licenses/LICENSE,sha256=5YqUYm5p9jhO9NonNaxla96fIbZPC2U0YSNoPTeTVvE,1479
|
|
7
|
+
django_fastresolve-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (c) 2026, Anders Hovmöller
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
5
|
+
|
|
6
|
+
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
7
|
+
|
|
8
|
+
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
9
|
+
|
|
10
|
+
* Neither the name of django-fastdev nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
11
|
+
|
|
12
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|