django-fastresolve 0.1.0__tar.gz
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-0.1.0/.gitignore +13 -0
- django_fastresolve-0.1.0/HISTORY.rst +7 -0
- django_fastresolve-0.1.0/LICENSE +12 -0
- django_fastresolve-0.1.0/PKG-INFO +123 -0
- django_fastresolve-0.1.0/README.rst +102 -0
- django_fastresolve-0.1.0/django_fastresolve/__init__.py +9 -0
- django_fastresolve-0.1.0/django_fastresolve/apps.py +11 -0
- django_fastresolve-0.1.0/django_fastresolve/resolver.py +234 -0
- django_fastresolve-0.1.0/pyproject.toml +45 -0
- django_fastresolve-0.1.0/tests/__init__.py +0 -0
- django_fastresolve-0.1.0/tests/conftest.py +17 -0
- django_fastresolve-0.1.0/tests/settings.py +8 -0
- django_fastresolve-0.1.0/tests/test_resolver.py +358 -0
- django_fastresolve-0.1.0/tests/urls.py +98 -0
- django_fastresolve-0.1.0/uv.lock +247 -0
|
@@ -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.
|
|
@@ -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,102 @@
|
|
|
1
|
+
django-fastresolve
|
|
2
|
+
==================
|
|
3
|
+
|
|
4
|
+
Faster URL resolving for Django, as a drop-in. No changes to your urlconfs.
|
|
5
|
+
|
|
6
|
+
Django resolves a path by trying every pattern of a urlconf in order, recursing
|
|
7
|
+
into includes. With a few hundred patterns at one level, every request pays for
|
|
8
|
+
a few hundred regex searches or string compares, and a 404 pays for all of them.
|
|
9
|
+
|
|
10
|
+
django-fastresolve indexes the patterns of each resolver on their *literal
|
|
11
|
+
prefix*: the text a path must start with for the pattern to have any chance of
|
|
12
|
+
matching (``'projects/'`` for ``path('projects/<int:pk>/', ...)``, ``'blog/'``
|
|
13
|
+
for ``re_path(r'^blog/(?P<slug>\w+)/$', ...)``). A path then only tries the
|
|
14
|
+
patterns whose prefix it starts with, still in their original order. All the
|
|
15
|
+
matching itself is still Django's own code: converters, a ``to_python`` raising
|
|
16
|
+
``ValueError`` falling through to later patterns, namespaces, default kwargs.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Installation
|
|
20
|
+
------------
|
|
21
|
+
|
|
22
|
+
.. code-block::
|
|
23
|
+
|
|
24
|
+
pip install django-fastresolve
|
|
25
|
+
|
|
26
|
+
and add ``'django_fastresolve'`` to ``INSTALLED_APPS``. That's it.
|
|
27
|
+
|
|
28
|
+
You can also call ``django_fastresolve.install()`` and
|
|
29
|
+
``django_fastresolve.uninstall()`` yourself.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
How much faster?
|
|
33
|
+
----------------
|
|
34
|
+
|
|
35
|
+
On a real project with three urlconfs, timing ``resolve()`` on every route,
|
|
36
|
+
with ``DEBUG = False``:
|
|
37
|
+
|
|
38
|
+
===================== ====== ============ ============== ========== ==========
|
|
39
|
+
urlconf routes stock median fast median stock 404 fast 404
|
|
40
|
+
===================== ====== ============ ============== ========== ==========
|
|
41
|
+
internal app 785 25 µs 6 µs 27 µs 2 µs
|
|
42
|
+
public site 894 64 µs 4.6 µs 125 µs 4.8 µs
|
|
43
|
+
small 83 8 µs 7 µs 6 µs 3 µs
|
|
44
|
+
===================== ====== ============ ============== ========== ==========
|
|
45
|
+
|
|
46
|
+
The more patterns share a level, the bigger the win. Patterns that stock Django
|
|
47
|
+
already finds in the first couple of tries get about 1 µs slower, from the
|
|
48
|
+
index lookup.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
When does it fall back?
|
|
52
|
+
-----------------------
|
|
53
|
+
|
|
54
|
+
A pattern whose prefix can't be determined safely gets the empty prefix, which
|
|
55
|
+
means it is tried for every path, exactly where stock Django would try it. That
|
|
56
|
+
is the case for:
|
|
57
|
+
|
|
58
|
+
- lazily translated routes (``path(gettext_lazy('about/'), ...)``)
|
|
59
|
+
- regexes that aren't anchored with ``^``, or that contain ``|``
|
|
60
|
+
- ``i18n_patterns`` (``LocalePrefixPattern``)
|
|
61
|
+
- ``URLPattern``/``URLResolver`` subclasses that override ``resolve()``
|
|
62
|
+
- pattern classes it doesn't know
|
|
63
|
+
|
|
64
|
+
Only the leading literal part of a regex counts: ``r'^ab?c/'`` has the prefix
|
|
65
|
+
``'a'``.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
Differences from stock Django
|
|
69
|
+
-----------------------------
|
|
70
|
+
|
|
71
|
+
``tried`` (on ``Resolver404`` and on ``ResolverMatch.tried``) only lists the
|
|
72
|
+
patterns that were actually tried, not every pattern before the match.
|
|
73
|
+
|
|
74
|
+
With ``DEBUG = True`` a 404 is resolved again the stock way, so the 404 debug
|
|
75
|
+
page lists every pattern as usual. That also acts as a self check: if stock
|
|
76
|
+
Django finds a match where django-fastresolve didn't, you get an
|
|
77
|
+
``AssertionError`` saying so, instead of a wrong 404. Please report it if that
|
|
78
|
+
happens.
|
|
79
|
+
|
|
80
|
+
The index for a resolver is built on first use. If you append to or remove from
|
|
81
|
+
``urlpatterns`` afterwards the index is rebuilt; replacing an item in place is
|
|
82
|
+
not noticed.
|
|
83
|
+
|
|
84
|
+
To resolve the stock way for a block of code, in the current context only:
|
|
85
|
+
|
|
86
|
+
.. code-block:: python
|
|
87
|
+
|
|
88
|
+
from django_fastresolve import stock_resolving
|
|
89
|
+
|
|
90
|
+
with stock_resolving():
|
|
91
|
+
match = resolve('/some/path/')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Running the tests
|
|
95
|
+
-----------------
|
|
96
|
+
|
|
97
|
+
.. code-block::
|
|
98
|
+
|
|
99
|
+
uv run pytest
|
|
100
|
+
|
|
101
|
+
The tests resolve a urlconf full of edge cases, plus a few thousand fuzzed
|
|
102
|
+
paths, both ways, and assert identical results.
|
|
@@ -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,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-fastresolve"
|
|
7
|
+
description = "Faster URL resolving for Django, by indexing url patterns on their literal prefixes"
|
|
8
|
+
readme = "README.rst"
|
|
9
|
+
authors = [{ name = "Anders Hovmöller", email = "boxed@killingar.net" }]
|
|
10
|
+
license = { text = "BSD" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["django", "urls", "performance"]
|
|
13
|
+
dependencies = ["Django >= 4.2"]
|
|
14
|
+
dynamic = ["version"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Framework :: Django",
|
|
18
|
+
"Framework :: Django :: 4.2",
|
|
19
|
+
"Framework :: Django :: 5.2",
|
|
20
|
+
"Framework :: Django :: 6.0",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: BSD License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/boxed/django-fastresolve"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = ["pytest", "pytest-django", "django"]
|
|
31
|
+
|
|
32
|
+
[tool.hatch.version]
|
|
33
|
+
path = "django_fastresolve/__init__.py"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["django_fastresolve"]
|
|
37
|
+
|
|
38
|
+
[tool.pytest.ini_options]
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
addopts = "--strict-markers -r fEsxXw"
|
|
41
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
42
|
+
|
|
43
|
+
[tool.mutmut]
|
|
44
|
+
source_paths = ["django_fastresolve/"]
|
|
45
|
+
also_copy = ["tests/"]
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.urls import clear_url_caches, get_resolver
|
|
3
|
+
from django.urls.resolvers import URLResolver
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _drop_indexes(resolver):
|
|
7
|
+
resolver.__dict__.pop('_fastresolve_index', None)
|
|
8
|
+
for pattern in resolver.url_patterns:
|
|
9
|
+
if isinstance(pattern, URLResolver):
|
|
10
|
+
_drop_indexes(pattern)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def fresh_index():
|
|
15
|
+
"""Build indexes inside each test, so every test that depends on them exercises the indexing code."""
|
|
16
|
+
_drop_indexes(get_resolver())
|
|
17
|
+
clear_url_caches()
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import re
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from django.test import override_settings
|
|
7
|
+
from django.urls import (
|
|
8
|
+
Resolver404,
|
|
9
|
+
clear_url_caches,
|
|
10
|
+
get_resolver,
|
|
11
|
+
path,
|
|
12
|
+
)
|
|
13
|
+
from django.urls.resolvers import (
|
|
14
|
+
RegexPattern,
|
|
15
|
+
URLResolver,
|
|
16
|
+
)
|
|
17
|
+
from django.utils import translation
|
|
18
|
+
|
|
19
|
+
from django_fastresolve import stock_resolving
|
|
20
|
+
from django_fastresolve.resolver import (
|
|
21
|
+
fast_resolve,
|
|
22
|
+
install,
|
|
23
|
+
literal_prefix,
|
|
24
|
+
regex_literal_prefix,
|
|
25
|
+
stock_resolve,
|
|
26
|
+
uninstall,
|
|
27
|
+
)
|
|
28
|
+
from tests import urls
|
|
29
|
+
from tests.urls import make_view
|
|
30
|
+
|
|
31
|
+
SAMPLE_BY_CONVERTER = {
|
|
32
|
+
'int': ['0', '7', '12'],
|
|
33
|
+
'even': ['4', '5'],
|
|
34
|
+
'str': ['abc', 'a-b'],
|
|
35
|
+
'slug': ['some-slug'],
|
|
36
|
+
'path': ['x/y.txt', 'x'],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
FIXED_PATHS = [
|
|
40
|
+
'/',
|
|
41
|
+
'',
|
|
42
|
+
'no-leading-slash/',
|
|
43
|
+
'/even/4/',
|
|
44
|
+
'/even/5/',
|
|
45
|
+
'/even/x/',
|
|
46
|
+
'/literal/',
|
|
47
|
+
'/literal',
|
|
48
|
+
'/literal/x/',
|
|
49
|
+
'/lit',
|
|
50
|
+
'/lit/',
|
|
51
|
+
'/literally/',
|
|
52
|
+
'/foo-offer-12/',
|
|
53
|
+
'/foo-bar-offer-12/',
|
|
54
|
+
'/-offer-12/',
|
|
55
|
+
'/files/a/b/c.txt',
|
|
56
|
+
'/files/',
|
|
57
|
+
'/a<b/',
|
|
58
|
+
'/nested/',
|
|
59
|
+
'/nested/detail/3/',
|
|
60
|
+
'/nested/re/12/',
|
|
61
|
+
'/ns/detail/3/',
|
|
62
|
+
'/ns2/5/detail/3/',
|
|
63
|
+
'/ns2/5/',
|
|
64
|
+
'/kw/',
|
|
65
|
+
'/kw/detail/9/',
|
|
66
|
+
'/kwleaf/',
|
|
67
|
+
'/emptyprefix/',
|
|
68
|
+
'/deep/a/b/4/',
|
|
69
|
+
'/deep/a/c/',
|
|
70
|
+
'/deep/a/b/x/',
|
|
71
|
+
'/re-anchored/2026/',
|
|
72
|
+
'/re-anchored/26/',
|
|
73
|
+
'/re-pos/12/abc/',
|
|
74
|
+
'/unanchored/',
|
|
75
|
+
'/foo/bar/unanchored/',
|
|
76
|
+
'/anywhere/',
|
|
77
|
+
'/foo/bar/anywhere/baz',
|
|
78
|
+
'/abc/',
|
|
79
|
+
'/ac/',
|
|
80
|
+
'/abbc/',
|
|
81
|
+
'/xyyz/',
|
|
82
|
+
'/xyz/',
|
|
83
|
+
'/alt/',
|
|
84
|
+
'/other/',
|
|
85
|
+
'/esc.dot/',
|
|
86
|
+
'/escxdot/',
|
|
87
|
+
'/anyxchar/',
|
|
88
|
+
'/any.char/',
|
|
89
|
+
'/case/',
|
|
90
|
+
'/CASE/',
|
|
91
|
+
'/re-include/detail/1/',
|
|
92
|
+
'/re-pos-include/7/re/12/',
|
|
93
|
+
'/translated/',
|
|
94
|
+
'/nope/',
|
|
95
|
+
'/something/weird/',
|
|
96
|
+
'/catchall-child/',
|
|
97
|
+
'/en/i18n/',
|
|
98
|
+
'/sv/i18n/',
|
|
99
|
+
'/en/i18n/3/',
|
|
100
|
+
'/i18n/',
|
|
101
|
+
'/does/not/exist/',
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
MATCH_FIELDS = ['func', 'args', 'kwargs', 'url_name', 'app_names', 'namespaces', 'route', 'captured_kwargs', 'extra_kwargs', 'view_name', '_func_path']
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def outcome(path_to_resolve, resolve):
|
|
108
|
+
try:
|
|
109
|
+
match = resolve(path_to_resolve)
|
|
110
|
+
except Resolver404 as e:
|
|
111
|
+
return ('404', e.args[0].get('path'))
|
|
112
|
+
return tuple(getattr(match, field) for field in MATCH_FIELDS)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fast(path_to_resolve):
|
|
116
|
+
return get_resolver().resolve(path_to_resolve)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def stock(path_to_resolve):
|
|
120
|
+
with stock_resolving():
|
|
121
|
+
return get_resolver().resolve(path_to_resolve)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def assert_same(path_to_resolve):
|
|
125
|
+
assert outcome(path_to_resolve, fast) == outcome(path_to_resolve, stock), path_to_resolve
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_installed_by_the_app_config():
|
|
129
|
+
assert URLResolver.resolve is fast_resolve
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@pytest.mark.parametrize('language', ['en', 'sv'])
|
|
133
|
+
@pytest.mark.parametrize('path_to_resolve', FIXED_PATHS)
|
|
134
|
+
def test_same_as_stock(path_to_resolve, language):
|
|
135
|
+
with translation.override(language):
|
|
136
|
+
assert_same(path_to_resolve)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_fixed_paths_cover_matches_and_misses():
|
|
140
|
+
outcomes = [outcome(p, fast) for p in FIXED_PATHS]
|
|
141
|
+
assert sum(o[0] == '404' for o in outcomes) >= 10
|
|
142
|
+
assert len({o[0] for o in outcomes if o[0] != '404'}) >= 30
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_converter_value_error_falls_through_to_the_next_pattern():
|
|
146
|
+
assert fast('/even/4/').func.__name__ == 'even'
|
|
147
|
+
assert fast('/even/5/').func.__name__ == 'odd_fallback'
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_unanchored_regex_matches_anywhere():
|
|
151
|
+
assert fast('/foo/bar/anywhere/baz').func.__name__ == 're_anywhere'
|
|
152
|
+
assert fast('/unanchored/').func.__name__ == 're_unanchored_fullmatch'
|
|
153
|
+
with pytest.raises(Resolver404):
|
|
154
|
+
fast('/foo/bar/unanchored/')
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_pattern_subclass_with_own_resolve_is_always_tried():
|
|
158
|
+
assert fast('/something/weird/').func.__name__ == 'weird'
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def leaf_samples(patterns, prefix=''):
|
|
162
|
+
"""Concrete paths for every leaf route, built from its route string."""
|
|
163
|
+
param_re = re.compile(r'<(?:(?P<converter>[^>:]+):)?(?P<name>[^>]+)>')
|
|
164
|
+
for p in patterns:
|
|
165
|
+
route = str(p.pattern)
|
|
166
|
+
if hasattr(p.pattern, '_route'):
|
|
167
|
+
parts = ['']
|
|
168
|
+
last = 0
|
|
169
|
+
for m in param_re.finditer(route):
|
|
170
|
+
parts = [x + route[last : m.start()] + sample for x in parts for sample in SAMPLE_BY_CONVERTER[m['converter'] or 'str']]
|
|
171
|
+
last = m.end()
|
|
172
|
+
parts = [x + route[last:] for x in parts]
|
|
173
|
+
else:
|
|
174
|
+
parts = [re.sub(r'[\^$\\]', '', route)]
|
|
175
|
+
for part in parts:
|
|
176
|
+
if isinstance(p, URLResolver):
|
|
177
|
+
yield from leaf_samples(p.url_patterns, prefix + part)
|
|
178
|
+
else:
|
|
179
|
+
yield prefix + part
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_fuzz():
|
|
183
|
+
rng = random.Random(1337)
|
|
184
|
+
samples = sorted(set(leaf_samples(urls.urlpatterns)))
|
|
185
|
+
fragments = sorted({fragment for sample in samples for fragment in re.split('(/)', sample) if fragment}) + ['', 'x', '-', '.', 'en', 'sv']
|
|
186
|
+
paths = set()
|
|
187
|
+
for sample in samples:
|
|
188
|
+
paths.add('/' + sample)
|
|
189
|
+
for cut in range(len(sample)):
|
|
190
|
+
paths.add('/' + sample[:cut])
|
|
191
|
+
paths.add('/' + sample[:cut] + 'x' + sample[cut:])
|
|
192
|
+
paths.add('/' + sample + 'x')
|
|
193
|
+
paths.add('/' + sample + 'x/')
|
|
194
|
+
for _ in range(3000):
|
|
195
|
+
paths.add('/' + ''.join(rng.choice(fragments) for _ in range(rng.randint(1, 8))))
|
|
196
|
+
|
|
197
|
+
for p in sorted(paths):
|
|
198
|
+
for language in ['en', 'sv']:
|
|
199
|
+
with translation.override(language):
|
|
200
|
+
assert_same(p)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_appending_to_urlpatterns_after_first_resolve():
|
|
204
|
+
with pytest.raises(Resolver404):
|
|
205
|
+
fast('/appended/')
|
|
206
|
+
urls.urlpatterns.append(path('appended/', make_view('appended')))
|
|
207
|
+
try:
|
|
208
|
+
assert fast('/appended/').func.__name__ == 'appended'
|
|
209
|
+
finally:
|
|
210
|
+
urls.urlpatterns.pop()
|
|
211
|
+
clear_url_caches()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@override_settings(DEBUG=True)
|
|
215
|
+
def test_debug_404_has_the_full_tried_list():
|
|
216
|
+
with pytest.raises(Resolver404) as fast_404:
|
|
217
|
+
fast('/does/not/exist/')
|
|
218
|
+
with pytest.raises(Resolver404) as stock_404:
|
|
219
|
+
stock('/does/not/exist/')
|
|
220
|
+
assert len(fast_404.value.args[0]['tried']) == len(stock_404.value.args[0]['tried'])
|
|
221
|
+
assert len(fast_404.value.args[0]['tried']) > 30
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@override_settings(DEBUG=True)
|
|
225
|
+
def test_debug_404_that_stock_django_resolves_is_reported_as_a_bug(monkeypatch):
|
|
226
|
+
from django_fastresolve import resolver
|
|
227
|
+
|
|
228
|
+
monkeypatch.setattr(resolver.Index, 'candidates', lambda self, path: ())
|
|
229
|
+
clear_url_caches()
|
|
230
|
+
with pytest.raises(AssertionError, match='django-fastresolve bug'):
|
|
231
|
+
fast('/literal/')
|
|
232
|
+
clear_url_caches()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_tried_on_404_without_debug_only_has_candidates():
|
|
236
|
+
with pytest.raises(Resolver404) as e:
|
|
237
|
+
fast('/does/not/exist/')
|
|
238
|
+
assert len(e.value.args[0]['tried']) < 15
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@pytest.mark.parametrize(
|
|
242
|
+
'regex, expected',
|
|
243
|
+
[
|
|
244
|
+
(r'^foo/$', 'foo/'),
|
|
245
|
+
(r'^foo/(?P<x>\d+)/$', 'foo/'),
|
|
246
|
+
(r'foo/$', ''),
|
|
247
|
+
(r'^ab?c/', 'a'),
|
|
248
|
+
(r'^ab*c/', 'a'),
|
|
249
|
+
(r'^ab+c/', 'a'),
|
|
250
|
+
(r'^xy{2}z/', 'x'),
|
|
251
|
+
(r'^esc\.dot/', 'esc.dot/'),
|
|
252
|
+
(r'^esc\.?dot/', 'esc'),
|
|
253
|
+
(r'^\d+/', ''),
|
|
254
|
+
(r'^a.b/', 'a'),
|
|
255
|
+
(r'^a[bc]/', 'a'),
|
|
256
|
+
(r'^a|b', ''),
|
|
257
|
+
(r'(?i)^case/', ''),
|
|
258
|
+
(r'^', ''),
|
|
259
|
+
(r'^/', '/'),
|
|
260
|
+
(r'^a(b)/', 'a'),
|
|
261
|
+
(r'^a$', 'a'),
|
|
262
|
+
],
|
|
263
|
+
)
|
|
264
|
+
def test_regex_literal_prefix(regex, expected):
|
|
265
|
+
assert regex_literal_prefix(regex) == expected
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@pytest.mark.parametrize(
|
|
269
|
+
'regex, expected',
|
|
270
|
+
[
|
|
271
|
+
(r'^esc\.', 'esc.'),
|
|
272
|
+
('^a\\', 'a'),
|
|
273
|
+
],
|
|
274
|
+
)
|
|
275
|
+
def test_regex_literal_prefix_escape_at_end(regex, expected):
|
|
276
|
+
assert regex_literal_prefix(regex) == expected
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pytest.mark.parametrize(
|
|
280
|
+
'route, expected',
|
|
281
|
+
[
|
|
282
|
+
('literal/', 'literal/'),
|
|
283
|
+
('a<int:x>/', 'a'),
|
|
284
|
+
('foo/<int:x>/', 'foo/'),
|
|
285
|
+
('<int:x>/', ''),
|
|
286
|
+
],
|
|
287
|
+
)
|
|
288
|
+
def test_literal_prefix_of_route(route, expected):
|
|
289
|
+
assert literal_prefix(path(route, make_view('v'))) == expected
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def is_subsequence(needle, haystack):
|
|
293
|
+
it = iter(haystack)
|
|
294
|
+
return all(any(x == y for y in it) for x in needle)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def tried_routes(tried):
|
|
298
|
+
return [[str(p.pattern) for p in entry] for entry in tried]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@pytest.mark.parametrize('path_to_resolve', ['/nested/nope/', '/deep/a/b/x/', '/ns2/5/nope/'])
|
|
302
|
+
def test_tried_on_404_is_a_subsequence_of_stock(path_to_resolve):
|
|
303
|
+
with pytest.raises(Resolver404) as fast_404:
|
|
304
|
+
fast(path_to_resolve)
|
|
305
|
+
with pytest.raises(Resolver404) as stock_404:
|
|
306
|
+
stock(path_to_resolve)
|
|
307
|
+
fast_tried = tried_routes(fast_404.value.args[0]['tried'])
|
|
308
|
+
assert any(len(entry) > 1 for entry in fast_tried)
|
|
309
|
+
assert is_subsequence(fast_tried, tried_routes(stock_404.value.args[0]['tried']))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@pytest.mark.parametrize('path_to_resolve', ['/literal/', '/nested/detail/3/', '/deep/a/b/4/', '/even/5/'])
|
|
313
|
+
def test_tried_on_match_is_a_subsequence_of_stock(path_to_resolve):
|
|
314
|
+
fast_tried = tried_routes(fast(path_to_resolve).tried)
|
|
315
|
+
stock_tried = tried_routes(stock(path_to_resolve).tried)
|
|
316
|
+
assert fast_tried[-1] == stock_tried[-1]
|
|
317
|
+
assert is_subsequence(fast_tried, stock_tried)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_index_is_reused_and_rebuilt_when_patterns_are_replaced():
|
|
321
|
+
resolver = get_resolver()
|
|
322
|
+
fast('/literal/')
|
|
323
|
+
index = resolver._fastresolve_index
|
|
324
|
+
fast('/literal/')
|
|
325
|
+
assert resolver._fastresolve_index is index
|
|
326
|
+
|
|
327
|
+
resolver.url_patterns = [*resolver.url_patterns[:-1], path('replaced/', make_view('replaced'))]
|
|
328
|
+
try:
|
|
329
|
+
assert fast('/replaced/').func.__name__ == 'replaced'
|
|
330
|
+
assert resolver._fastresolve_index is not index
|
|
331
|
+
finally:
|
|
332
|
+
clear_url_caches()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class PatternSequence:
|
|
336
|
+
"""url patterns that are iterable, but neither a list nor a tuple."""
|
|
337
|
+
|
|
338
|
+
def __init__(self, patterns):
|
|
339
|
+
self.patterns = patterns
|
|
340
|
+
|
|
341
|
+
def __iter__(self):
|
|
342
|
+
return iter(self.patterns)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_patterns_that_are_not_a_list_fall_back_to_stock():
|
|
346
|
+
resolver = URLResolver(RegexPattern(r'^/'), SimpleNamespace(urlpatterns=PatternSequence([path('x/', make_view('x'))])))
|
|
347
|
+
assert resolver.resolve('/x/').func.__name__ == 'x'
|
|
348
|
+
with pytest.raises(Resolver404):
|
|
349
|
+
resolver.resolve('/y/')
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def test_uninstall_and_install():
|
|
353
|
+
uninstall()
|
|
354
|
+
try:
|
|
355
|
+
assert URLResolver.resolve is stock_resolve
|
|
356
|
+
finally:
|
|
357
|
+
install()
|
|
358
|
+
assert URLResolver.resolve is fast_resolve
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""A urlconf full of edge cases. Every test resolves against it both the stock way and the fast way."""
|
|
2
|
+
|
|
3
|
+
from django.conf.urls.i18n import i18n_patterns
|
|
4
|
+
from django.urls import (
|
|
5
|
+
include,
|
|
6
|
+
path,
|
|
7
|
+
re_path,
|
|
8
|
+
register_converter,
|
|
9
|
+
)
|
|
10
|
+
from django.urls.resolvers import (
|
|
11
|
+
ResolverMatch,
|
|
12
|
+
RoutePattern,
|
|
13
|
+
URLPattern,
|
|
14
|
+
)
|
|
15
|
+
from django.utils.translation import gettext_lazy
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_view(name):
|
|
19
|
+
def view(request, *args, **kwargs):
|
|
20
|
+
pass # pragma: no cover
|
|
21
|
+
|
|
22
|
+
view.__name__ = view.__qualname__ = name
|
|
23
|
+
return view
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EvenConverter:
|
|
27
|
+
regex = '[0-9]+'
|
|
28
|
+
|
|
29
|
+
def to_python(self, value):
|
|
30
|
+
if int(value) % 2:
|
|
31
|
+
raise ValueError()
|
|
32
|
+
return int(value)
|
|
33
|
+
|
|
34
|
+
def to_url(self, value):
|
|
35
|
+
return str(value) # pragma: no cover
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
register_converter(EvenConverter, 'even')
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MatchesEverythingEndingInWeird(URLPattern):
|
|
42
|
+
"""A subclass with its own resolve: its pattern says nothing about what it matches."""
|
|
43
|
+
|
|
44
|
+
def resolve(self, path):
|
|
45
|
+
if path.endswith('/weird/'):
|
|
46
|
+
return ResolverMatch(self.callback, (), {}, 'weird', route=str(self.pattern), captured_kwargs={}, extra_kwargs={})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
inner = [
|
|
50
|
+
path('', make_view('inner_index'), name='inner_index'),
|
|
51
|
+
path('detail/<int:pk>/', make_view('inner_detail'), name='inner_detail'),
|
|
52
|
+
re_path(r'^re/(\d+)/$', make_view('inner_re_positional')),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
urlpatterns = [
|
|
56
|
+
path('', make_view('index'), name='index'),
|
|
57
|
+
# The even converter raises ValueError on odd numbers, and the next pattern must get its turn
|
|
58
|
+
path('even/<even:n>/', make_view('even')),
|
|
59
|
+
path('even/<int:n>/', make_view('odd_fallback')),
|
|
60
|
+
path('literal/', make_view('literal'), name='literal'),
|
|
61
|
+
path('literal/', make_view('literal_shadowed')),
|
|
62
|
+
path('lit', make_view('lit_no_slash')),
|
|
63
|
+
path('literally/', make_view('literally')),
|
|
64
|
+
path('<slug:slug>-offer-<int:pk>/', make_view('slug_offer')),
|
|
65
|
+
path('files/<path:file_path>', make_view('files')),
|
|
66
|
+
path('a<b/', make_view('angle_bracket_without_converter')),
|
|
67
|
+
path('nested/', include(inner)),
|
|
68
|
+
path('ns/', include((inner, 'app'), namespace='ns1')),
|
|
69
|
+
path('ns2/<int:outer>/', include((inner, 'app'), namespace='ns2')),
|
|
70
|
+
path('kw/', include(inner), {'extra': 1}),
|
|
71
|
+
path('kwleaf/', make_view('kwleaf'), {'a': 1}),
|
|
72
|
+
path('', include([path('emptyprefix/', make_view('emptyprefix'))])),
|
|
73
|
+
path('deep/', include([path('a/', include([path('b/', include([path('<int:x>/', make_view('deep'))]))]))])),
|
|
74
|
+
path('deep/a/', include([path('c/', make_view('deep_c'))])),
|
|
75
|
+
re_path(r'^re-anchored/(?P<year>[0-9]{4})/$', make_view('re_named')),
|
|
76
|
+
re_path(r'^re-pos/([0-9]+)/([a-z]+)/$', make_view('re_pos')),
|
|
77
|
+
# Ends in $, so Django uses fullmatch: anchored after all
|
|
78
|
+
re_path(r'unanchored/$', make_view('re_unanchored_fullmatch')),
|
|
79
|
+
# Not anchored and no $: re.search finds this anywhere in the path
|
|
80
|
+
re_path(r'anywhere/', make_view('re_anywhere')),
|
|
81
|
+
re_path(r'^ab?c/$', make_view('re_quantifier')),
|
|
82
|
+
re_path(r'^xy{2}z/$', make_view('re_brace_quantifier')),
|
|
83
|
+
re_path(r'^alt/$|^other/$', make_view('re_alternation')),
|
|
84
|
+
re_path(r'^esc\.dot/$', make_view('re_escaped')),
|
|
85
|
+
re_path(r'^any.char/$', make_view('re_dot')),
|
|
86
|
+
re_path(r'(?i)^case/$', make_view('re_case_insensitive')),
|
|
87
|
+
re_path(r'^re-include/', include(inner)),
|
|
88
|
+
# Positional args from the include and the leaf are concatenated
|
|
89
|
+
re_path(r'^re-pos-include/([0-9]+)/', include(inner)),
|
|
90
|
+
path(gettext_lazy('translated/'), make_view('translated')),
|
|
91
|
+
MatchesEverythingEndingInWeird(RoutePattern('nope/', is_endpoint=True), make_view('weird')),
|
|
92
|
+
re_path(r'^', include([path('catchall-child/', make_view('catchall_child'))])),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
urlpatterns += i18n_patterns(
|
|
96
|
+
path('i18n/', make_view('i18n')),
|
|
97
|
+
path('i18n/<int:pk>/', make_view('i18n_pk')),
|
|
98
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.10"
|
|
4
|
+
resolution-markers = [
|
|
5
|
+
"python_full_version >= '3.12'",
|
|
6
|
+
"python_full_version < '3.12'",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
[[package]]
|
|
10
|
+
name = "asgiref"
|
|
11
|
+
version = "3.12.1"
|
|
12
|
+
source = { registry = "https://pypi.org/simple" }
|
|
13
|
+
dependencies = [
|
|
14
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
|
15
|
+
]
|
|
16
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" }
|
|
17
|
+
wheels = [
|
|
18
|
+
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[[package]]
|
|
22
|
+
name = "colorama"
|
|
23
|
+
version = "0.4.6"
|
|
24
|
+
source = { registry = "https://pypi.org/simple" }
|
|
25
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
26
|
+
wheels = [
|
|
27
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[package]]
|
|
31
|
+
name = "django"
|
|
32
|
+
version = "5.2.17"
|
|
33
|
+
source = { registry = "https://pypi.org/simple" }
|
|
34
|
+
resolution-markers = [
|
|
35
|
+
"python_full_version < '3.12'",
|
|
36
|
+
]
|
|
37
|
+
dependencies = [
|
|
38
|
+
{ name = "asgiref", marker = "python_full_version < '3.12'" },
|
|
39
|
+
{ name = "sqlparse", marker = "python_full_version < '3.12'" },
|
|
40
|
+
{ name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" },
|
|
41
|
+
]
|
|
42
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d5/d8/43e9d000519adceb189620b6869ff88031e046df91c2e9da72f8f6918399/django-5.2.17.tar.gz", hash = "sha256:9d4d93be539a18ab80d058eb515900e10951e04c537c5a6b394fc49528d3251f", size = 10889740, upload-time = "2026-08-04T15:04:03.173Z" }
|
|
43
|
+
wheels = [
|
|
44
|
+
{ url = "https://files.pythonhosted.org/packages/df/f8/ce120525ca78f12b07daf65786679c5d0b54a75285a8958d3ae55e39da35/django-5.2.17-py3-none-any.whl", hash = "sha256:f04fb3b36ee119e1af4fa1d397d5fd6cf12700f49321e84d4f4c642c5b1973db", size = 8315563, upload-time = "2026-08-04T15:03:59.1Z" },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[[package]]
|
|
48
|
+
name = "django"
|
|
49
|
+
version = "6.1.1"
|
|
50
|
+
source = { registry = "https://pypi.org/simple" }
|
|
51
|
+
resolution-markers = [
|
|
52
|
+
"python_full_version >= '3.12'",
|
|
53
|
+
]
|
|
54
|
+
dependencies = [
|
|
55
|
+
{ name = "asgiref", marker = "python_full_version >= '3.12'" },
|
|
56
|
+
{ name = "sqlparse", marker = "python_full_version >= '3.12'" },
|
|
57
|
+
{ name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
|
|
58
|
+
]
|
|
59
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f8/d4/8314b5bdef20832f0ac6ae3b3e4d4924e4759fa3b4af27609dd747e7a6be/django-6.1.1.tar.gz", hash = "sha256:7ab93536d8e677aa14554c56426290c69480bf2ee68d7513d4a22f57d6bfeb84", size = 11284905, upload-time = "2026-09-02T17:20:45.621Z" }
|
|
60
|
+
wheels = [
|
|
61
|
+
{ url = "https://files.pythonhosted.org/packages/8d/ca/c1040a4fd15754ede7df15fd72fc2e123045b473b253ab5f5284e11c651e/django-6.1.1-py3-none-any.whl", hash = "sha256:585fb82bf15053c42cf52e67c2f1b54a032dca384759315fd6678ab1870d1d72", size = 8419512, upload-time = "2026-09-02T17:20:39.153Z" },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
[[package]]
|
|
65
|
+
name = "django-fastresolve"
|
|
66
|
+
source = { editable = "." }
|
|
67
|
+
dependencies = [
|
|
68
|
+
{ name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
|
69
|
+
{ name = "django", version = "6.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[package.dev-dependencies]
|
|
73
|
+
dev = [
|
|
74
|
+
{ name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
|
75
|
+
{ name = "django", version = "6.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
76
|
+
{ name = "pytest" },
|
|
77
|
+
{ name = "pytest-django" },
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
[package.metadata]
|
|
81
|
+
requires-dist = [{ name = "django", specifier = ">=4.2" }]
|
|
82
|
+
|
|
83
|
+
[package.metadata.requires-dev]
|
|
84
|
+
dev = [
|
|
85
|
+
{ name = "django" },
|
|
86
|
+
{ name = "pytest" },
|
|
87
|
+
{ name = "pytest-django" },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
[[package]]
|
|
91
|
+
name = "exceptiongroup"
|
|
92
|
+
version = "1.3.1"
|
|
93
|
+
source = { registry = "https://pypi.org/simple" }
|
|
94
|
+
dependencies = [
|
|
95
|
+
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
|
|
96
|
+
]
|
|
97
|
+
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
|
98
|
+
wheels = [
|
|
99
|
+
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[[package]]
|
|
103
|
+
name = "iniconfig"
|
|
104
|
+
version = "2.3.0"
|
|
105
|
+
source = { registry = "https://pypi.org/simple" }
|
|
106
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
107
|
+
wheels = [
|
|
108
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[[package]]
|
|
112
|
+
name = "packaging"
|
|
113
|
+
version = "26.3"
|
|
114
|
+
source = { registry = "https://pypi.org/simple" }
|
|
115
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
|
116
|
+
wheels = [
|
|
117
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
[[package]]
|
|
121
|
+
name = "pluggy"
|
|
122
|
+
version = "1.6.0"
|
|
123
|
+
source = { registry = "https://pypi.org/simple" }
|
|
124
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
125
|
+
wheels = [
|
|
126
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
[[package]]
|
|
130
|
+
name = "pygments"
|
|
131
|
+
version = "2.21.0"
|
|
132
|
+
source = { registry = "https://pypi.org/simple" }
|
|
133
|
+
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
134
|
+
wheels = [
|
|
135
|
+
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
[[package]]
|
|
139
|
+
name = "pytest"
|
|
140
|
+
version = "9.1.1"
|
|
141
|
+
source = { registry = "https://pypi.org/simple" }
|
|
142
|
+
dependencies = [
|
|
143
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
144
|
+
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
|
145
|
+
{ name = "iniconfig" },
|
|
146
|
+
{ name = "packaging" },
|
|
147
|
+
{ name = "pluggy" },
|
|
148
|
+
{ name = "pygments" },
|
|
149
|
+
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
|
150
|
+
]
|
|
151
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
|
152
|
+
wheels = [
|
|
153
|
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
[[package]]
|
|
157
|
+
name = "pytest-django"
|
|
158
|
+
version = "4.14.0"
|
|
159
|
+
source = { registry = "https://pypi.org/simple" }
|
|
160
|
+
dependencies = [
|
|
161
|
+
{ name = "pytest" },
|
|
162
|
+
]
|
|
163
|
+
sdist = { url = "https://files.pythonhosted.org/packages/44/f6/3851312120c2bf2f19cafff931e75059aad1ba670703cd751e2fde9bc942/pytest_django-4.14.0.tar.gz", hash = "sha256:26787dd3f422cfbab8f55b80a776e2edea7a11092cb74e960bef1312515708ef", size = 94700, upload-time = "2026-08-10T14:13:08.319Z" }
|
|
164
|
+
wheels = [
|
|
165
|
+
{ url = "https://files.pythonhosted.org/packages/9c/03/850bffad2b581c440ca51c039d74504d5a422c94bda0bdb8a8ba5068d48b/pytest_django-4.14.0-py3-none-any.whl", hash = "sha256:c533b08d89cc675efcd5398eea270b34547e35f9a3608e2c9748dd88428ea187", size = 27067, upload-time = "2026-08-10T14:13:06.998Z" },
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
[[package]]
|
|
169
|
+
name = "sqlparse"
|
|
170
|
+
version = "0.6.0"
|
|
171
|
+
source = { registry = "https://pypi.org/simple" }
|
|
172
|
+
sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
|
|
173
|
+
wheels = [
|
|
174
|
+
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
[[package]]
|
|
178
|
+
name = "tomli"
|
|
179
|
+
version = "2.4.1"
|
|
180
|
+
source = { registry = "https://pypi.org/simple" }
|
|
181
|
+
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
|
182
|
+
wheels = [
|
|
183
|
+
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
|
184
|
+
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
|
185
|
+
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
|
186
|
+
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
|
187
|
+
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
|
188
|
+
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
|
189
|
+
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
|
190
|
+
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
|
191
|
+
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
|
192
|
+
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
|
193
|
+
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
|
194
|
+
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
|
195
|
+
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
|
196
|
+
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
|
197
|
+
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
|
198
|
+
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
|
199
|
+
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
|
200
|
+
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
|
201
|
+
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
|
202
|
+
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
|
203
|
+
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
|
204
|
+
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
|
205
|
+
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
|
206
|
+
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
|
207
|
+
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
|
208
|
+
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
|
209
|
+
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
|
210
|
+
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
|
211
|
+
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
|
212
|
+
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
|
213
|
+
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
|
214
|
+
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
|
215
|
+
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
|
216
|
+
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
|
217
|
+
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
|
218
|
+
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
|
219
|
+
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
|
220
|
+
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
|
221
|
+
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
|
222
|
+
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
|
223
|
+
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
|
224
|
+
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
|
225
|
+
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
|
226
|
+
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
|
227
|
+
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
|
228
|
+
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
[[package]]
|
|
232
|
+
name = "typing-extensions"
|
|
233
|
+
version = "4.16.0"
|
|
234
|
+
source = { registry = "https://pypi.org/simple" }
|
|
235
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
|
236
|
+
wheels = [
|
|
237
|
+
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
[[package]]
|
|
241
|
+
name = "tzdata"
|
|
242
|
+
version = "2026.4"
|
|
243
|
+
source = { registry = "https://pypi.org/simple" }
|
|
244
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
|
|
245
|
+
wheels = [
|
|
246
|
+
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
|
|
247
|
+
]
|