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.
@@ -0,0 +1,13 @@
1
+ *.py[cod]
2
+ __pycache__
3
+ venv
4
+ .venv
5
+ .DS_Store
6
+ .pytest_cache
7
+ *.egg-info
8
+ dist
9
+ build
10
+ .coverage
11
+ htmlcov
12
+ testreport.xml
13
+ mutants
@@ -0,0 +1,7 @@
1
+ Changelog
2
+ ---------
3
+
4
+ 0.1.0
5
+ ~~~~~
6
+
7
+ * Initial release
@@ -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,9 @@
1
+ __version__ = '0.1.0'
2
+
3
+ from django_fastresolve.resolver import (
4
+ install,
5
+ stock_resolving,
6
+ uninstall,
7
+ )
8
+
9
+ __all__ = ['install', 'stock_resolving', 'uninstall']
@@ -0,0 +1,11 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class FastResolveConfig(AppConfig):
5
+ name = 'django_fastresolve'
6
+ verbose_name = 'django-fastresolve'
7
+
8
+ def ready(self):
9
+ from django_fastresolve import install
10
+
11
+ install()
@@ -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,8 @@
1
+ SECRET_KEY = 'tests'
2
+ DEBUG = False
3
+ INSTALLED_APPS = ['django_fastresolve']
4
+ ROOT_URLCONF = 'tests.urls'
5
+ USE_I18N = True
6
+ LANGUAGE_CODE = 'en'
7
+ LANGUAGES = [('en', 'English'), ('sv', 'Swedish')]
8
+ DATABASES = {}
@@ -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
+ ]