diff --git a/RELEASE.rst b/RELEASE.rst index ed8f991a5c..3edded5cb0 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,14 @@ Release Notes ============= +Version 0.194.1 +--------------- + +- fix: Product and User CMS pages fixed (#3906) +- fix(pytest): move warning filters to pyproject and fix factory postgeneration warnings (#3905) +- chore(deps): update dependency granian to v2.7.4 [security] (#3904) +- feat: add domain redirect middleware (#3883) + Version 0.194.0 (Released May 07, 2026) --------------- diff --git a/app.json b/app.json index ffb82d0edd..72d2156e79 100644 --- a/app.json +++ b/app.json @@ -47,6 +47,10 @@ "description": "How long the blog should be cached", "required": false }, + "CANONICAL_HOSTNAME_REDIRECT_ENABLED": { + "description": "Whether to enable redirecting to the canonical hostname defined in SITE_BASE_URL when a request comes in with a different hostname", + "required": false + }, "CELERY_BROKER_URL": { "description": "Where celery should get tasks, default is Redis URL", "required": false diff --git a/cms/factories.py b/cms/factories.py index 401d639771..0801736fea 100644 --- a/cms/factories.py +++ b/cms/factories.py @@ -92,6 +92,7 @@ class ProgramPageFactory(wagtail_factories.PageFactory): class Meta: model = ProgramPage + skip_postgeneration_save = True @factory.post_generation def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805 @@ -123,6 +124,7 @@ class CoursePageFactory(wagtail_factories.PageFactory): class Meta: model = CoursePage + skip_postgeneration_save = True @factory.post_generation def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805 @@ -153,6 +155,7 @@ class ExternalCoursePageFactory(wagtail_factories.PageFactory): class Meta: model = ExternalCoursePage + skip_postgeneration_save = True @factory.post_generation def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805 @@ -183,6 +186,7 @@ class ExternalProgramPageFactory(wagtail_factories.PageFactory): class Meta: model = ExternalProgramPage + skip_postgeneration_save = True @factory.post_generation def post_gen(obj, create, extracted, **kwargs): # noqa: ARG002, N805 diff --git a/conftest.py b/conftest.py index e91030dd68..25c80b53bf 100644 --- a/conftest.py +++ b/conftest.py @@ -99,3 +99,9 @@ def django_db_setup(django_db_setup, django_db_blocker): # noqa: ARG001 if not index_page_class.objects.filter(**index_page_content).exists(): index_page = index_page_class(**index_page_content) home_page.add_child(instance=index_page) + + +@pytest.fixture(autouse=True) +def canonical_hostname_redirect_disabled_by_default(settings): + """Disable canonical hostname redirects in tests unless explicitly enabled.""" + settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = False diff --git a/courses/factories.py b/courses/factories.py index d6e22c6d32..62a6bbe6df 100644 --- a/courses/factories.py +++ b/courses/factories.py @@ -69,6 +69,7 @@ class ProgramFactory(DjangoModelFactory): class Meta: model = Program + skip_postgeneration_save = True class ProgramRunFactory(DjangoModelFactory): @@ -95,6 +96,7 @@ class CourseFactory(DjangoModelFactory): class Meta: model = Course + skip_postgeneration_save = True class Params: no_program = factory.Trait(program=None, position_in_program=None) diff --git a/ecommerce/factories.py b/ecommerce/factories.py index 0e38ac4a0f..ef41db4d91 100644 --- a/ecommerce/factories.py +++ b/ecommerce/factories.py @@ -225,6 +225,7 @@ def courses(self, create, extracted, **kwargs): # noqa: ARG002 class Meta: model = models.DataConsentAgreement + skip_postgeneration_save = True class DataConsentUserFactory(DjangoModelFactory): diff --git a/ecommerce/wagtail_views.py b/ecommerce/wagtail_views.py index 0c68d9b751..ad54ccdb18 100644 --- a/ecommerce/wagtail_views.py +++ b/ecommerce/wagtail_views.py @@ -52,7 +52,7 @@ class ProductReadOnlyIndexView(AbstractReadOnlyIndexView): """ model = Product - queryset = Product.all_objects + queryset = Product.all_objects.all() class ProductInspectView(InspectView): diff --git a/mitxpro/middleware.py b/mitxpro/middleware.py new file mode 100644 index 0000000000..1371e6a651 --- /dev/null +++ b/mitxpro/middleware.py @@ -0,0 +1,34 @@ +"""Middleware for MIT xPRO""" + +from urllib.parse import urlparse + +from django.conf import settings +from django.http import HttpResponseRedirect + + +class HostnameRedirectMiddleware: + """Middleware that redirects requests arriving at an incorrect hostname to the + canonical hostname configured in SITE_BASE_URL.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + site_base_url = getattr(settings, "SITE_BASE_URL", None) + if not site_base_url: + return self.get_response(request) + + parsed = urlparse(site_base_url) + canonical_host = parsed.netloc + canonical_scheme = parsed.scheme + + if ( + not settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED + or request.get_host() == canonical_host + ): + return self.get_response(request) + + redirect_url = "{}://{}{}".format( + canonical_scheme, canonical_host, request.get_full_path() + ) + return HttpResponseRedirect(redirect_url) diff --git a/mitxpro/middleware_test.py b/mitxpro/middleware_test.py new file mode 100644 index 0000000000..8424856634 --- /dev/null +++ b/mitxpro/middleware_test.py @@ -0,0 +1,102 @@ +"""Tests for mitxpro middleware""" + +import pytest +from rest_framework import status + +from mitxpro.middleware import HostnameRedirectMiddleware + + +CANONICAL_URL = "https://xpro.mit.edu" +CANONICAL_HOST = "xpro.mit.edu" +WRONG_HOST = "xpro-web.odl.mit.edu" + + +@pytest.fixture() +def middleware(mocker): + return HostnameRedirectMiddleware(get_response=mocker.Mock(return_value=None)) + + +@pytest.mark.parametrize( + ( + "site_base_url", + "server_name", + "redirect_enabled", + "expect_redirect", + "expected_location", + ), + [ + # Matching host -> passes through + (CANONICAL_URL, CANONICAL_HOST, True, False, None), + # Wrong host + redirect enabled -> redirect + (CANONICAL_URL, WRONG_HOST, True, True, f"{CANONICAL_URL}/some/path/"), + # Wrong host + redirect disabled -> passes through + (CANONICAL_URL, WRONG_HOST, False, False, None), + # No SITE_BASE_URL configured -> passes through + (None, WRONG_HOST, True, False, None), + ], +) +def test_hostname_redirect_middleware( + rf, + settings, + middleware, + site_base_url, + server_name, + redirect_enabled, + expect_redirect, + expected_location, +): + """Tests HostnameRedirectMiddleware redirects or passes through based on host and setting.""" + settings.SITE_BASE_URL = site_base_url + settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = redirect_enabled + request = rf.get("/some/path/", SERVER_NAME=server_name) + response = middleware(request) + + if expect_redirect: + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == expected_location + else: + middleware.get_response.assert_called_once_with(request) + + +def test_redirect_preserves_query_string(rf, settings, middleware): + """The redirect preserves the full path including query string.""" + settings.SITE_BASE_URL = CANONICAL_URL + settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True + request = rf.get( + "/some/path/", {"foo": "bar", "baz": "qux"}, SERVER_NAME=WRONG_HOST + ) + response = middleware(request) + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"].startswith(f"{CANONICAL_URL}/some/path/?") + assert "foo=bar" in response["Location"] + assert "baz=qux" in response["Location"] + + +def test_api_path_bypasses_redirect_checks(rf, settings, middleware): + """API routes still follow the same redirect setting behavior.""" + settings.SITE_BASE_URL = CANONICAL_URL + settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True + request = rf.get("/api/v1/topics/", SERVER_NAME=WRONG_HOST) + response = middleware(request) + assert response.status_code == status.HTTP_302_FOUND + assert response["Location"] == f"{CANONICAL_URL}/api/v1/topics/" + + +def test_hostname_redirect_checks_actual_http_host(rf, settings, middleware): + """Redirect decision is based on actual HTTP_HOST, not X-Forwarded-Host. + + This prevents infinite redirects when X-Forwarded-Host persists through + redirects in proxy scenarios. The middleware checks the real incoming + HTTP_HOST header, not Django's interpretation via request.get_host() + which respects USE_X_FORWARDED_HOST. + """ + settings.SITE_BASE_URL = CANONICAL_URL + settings.CANONICAL_HOSTNAME_REDIRECT_ENABLED = True + # Create request with wrong actual HTTP_HOST + request = rf.get("/some/path/") + # Manually set the actual HTTP_HOST to a non-canonical value + request.META["HTTP_HOST"] = WRONG_HOST + response = middleware(request) + # Should redirect because actual HTTP_HOST doesn't match canonical + assert response.status_code == 302 + assert response["Location"] == f"{CANONICAL_URL}/some/path/" diff --git a/mitxpro/settings.py b/mitxpro/settings.py index 8fc7037f85..581e6b12ec 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -26,7 +26,7 @@ from mitxpro.celery_utils import OffsettingSchedule from mitxpro.sentry import init_sentry -VERSION = "0.194.0" +VERSION = "0.194.1" env.reset() @@ -152,7 +152,7 @@ "wagtail.contrib.routable_page", "wagtail.embeds", "wagtail.sites", - "wagtail.users", + "users.apps.MitxproWagtailUsersAppConfig", "wagtail.snippets", "wagtail.documents", "wagtail.images", @@ -204,6 +204,7 @@ MIDDLEWARE = ( "django.middleware.security.SecurityMiddleware", + "mitxpro.middleware.HostnameRedirectMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "affiliate.middleware.AffiliateMiddleware", "oauth2_provider.middleware.OAuth2TokenMiddleware", @@ -1516,3 +1517,9 @@ default=[], description="Comma-separated list of email addresses to receive notifications about external data syncs", ) + +CANONICAL_HOSTNAME_REDIRECT_ENABLED = get_bool( + name="CANONICAL_HOSTNAME_REDIRECT_ENABLED", + default=False, + description="Whether to enable redirecting to the canonical hostname defined in SITE_BASE_URL when a request comes in with a different hostname", +) diff --git a/pyproject.toml b/pyproject.toml index 05409ca719..fcd253cf75 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,3 +99,61 @@ no-binary-package = ["lxml", "xmlsec"] [build-system] requires = ["uv_build>=0.11.6,<0.12.0"] build-backend = "uv_build" + +[tool.pytest.ini_options] +addopts = "--cov . --cov-report term --cov-report html --cov-report xml --ds=mitxpro.settings --reuse-db" +norecursedirs = [ + "node_modules", + ".git", + ".tox", + "static", + "templates", + ".*", + "CVS", + "_darcs", + "{arch}", + "*.egg", +] +filterwarnings = [ + "error", + # sentry-sdk deprecates configure_scope ahead of a future major release. + "ignore:sentry_sdk\\.configure_scope is deprecated and will be removed in the next major version.*:DeprecationWarning", + # Paramiko emits this warning when the test environment has no SSH host keys configured. + "ignore:Failed to load HostKeys", + # pytest-cov warns when coverage is disabled for targeted local runs. + "ignore:Coverage disabled via --no-cov switch!", + # Wagtail triggers Django 6.0 URLField scheme deprecations during test collection. + "ignore:The default scheme will be changed from 'http' to 'https' in Django 6\\.0.*:django.utils.deprecation.RemovedInDjango60Warning", + # pytest may import namespace-package directories while collecting tests. + "ignore:.*Not importing directory.*:ImportWarning", +] +env = [ + "CELERY_TASK_ALWAYS_EAGER=True", + "DJANGO_SETTINGS_MODULE=mitxpro.settings", + "CYBERSOURCE_WSDL_URL=", + "CYBERSOURCE_MERCHANT_ID=", + "CYBERSOURCE_TRANSACTION_KEY=", + "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY=", + "MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL=http://localhost:5000/", + "MITOL_DIGITAL_CREDENTIALS_HMAC_SECRET=test-hmac-secret", # pragma: allowlist secret + "MITXPRO_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend", + "MITXPRO_NOTIFICATION_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend", + "RECAPTCHA_SITE_KEY=", + "RECAPTCHA_SECRET_KEY=", + "DEBUG=False", + "WEBPACK_DISABLE_LOADER_STATS=True", + "MAILGUN_KEY=fake_mailgun_key", + "MAILGUN_SENDER_DOMAIN=other.fake.site", + "MITXPRO_ADMIN_EMAIL=example@localhost", + "MITXPRO_BASE_URL=http://localhost:8053", + "MITXPRO_DB_DISABLE_SSL=True", + "MITXPRO_SECURE_SSL_REDIRECT=False", + "MITXPRO_USE_S3=False", + "OPENEDX_API_BASE_URL=http://localhost:18000", + "OPENEDX_API_CLIENT_ID=fake_client_id", + "OPENEDX_API_CLIENT_SECRET=fake_client_secret", # pragma: allowlist secret + "SENTRY_DSN=", + "WAGTAIL_CACHE_BACKEND=django.core.cache.backends.dummy.DummyCache", + "WAGTAIL_CACHE_URL=", + "POSTHOG_ENABLED=True", +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 6488498412..0000000000 --- a/pytest.ini +++ /dev/null @@ -1,38 +0,0 @@ -[pytest] -addopts = --cov . --cov-report term --cov-report html --cov-report xml --ds=mitxpro.settings --reuse-db -norecursedirs = node_modules .git .tox static templates .* CVS _darcs {arch} *.egg -filterwarnings = - error - ignore::DeprecationWarning - ignore:Failed to load HostKeys - ignore:Coverage disabled via --no-cov switch! - ignore:.*Not importing directory.*:ImportWarning -env = - CELERY_TASK_ALWAYS_EAGER=True - DJANGO_SETTINGS_MODULE=mitxpro.settings - CYBERSOURCE_WSDL_URL= - CYBERSOURCE_MERCHANT_ID= - CYBERSOURCE_TRANSACTION_KEY= - CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY= - MITOL_DIGITAL_CREDENTIALS_VERIFY_SERVICE_BASE_URL=http://localhost:5000/ - MITOL_DIGITAL_CREDENTIALS_HMAC_SECRET=test-hmac-secret - MITXPRO_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend - MITXPRO_NOTIFICATION_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend - RECAPTCHA_SITE_KEY= - RECAPTCHA_SECRET_KEY= - DEBUG=False - WEBPACK_DISABLE_LOADER_STATS=True - MAILGUN_KEY=fake_mailgun_key - MAILGUN_SENDER_DOMAIN=other.fake.site - MITXPRO_ADMIN_EMAIL=example@localhost - MITXPRO_BASE_URL=http://localhost:8053 - MITXPRO_DB_DISABLE_SSL=True - MITXPRO_SECURE_SSL_REDIRECT=False - MITXPRO_USE_S3=False - OPENEDX_API_BASE_URL=http://localhost:18000 - OPENEDX_API_CLIENT_ID=fake_client_id - OPENEDX_API_CLIENT_SECRET=fake_client_secret - SENTRY_DSN= - WAGTAIL_CACHE_BACKEND=django.core.cache.backends.dummy.DummyCache - WAGTAIL_CACHE_URL= - POSTHOG_ENABLED=True diff --git a/users/apps.py b/users/apps.py index 4d1074fb4e..13a1ffae66 100644 --- a/users/apps.py +++ b/users/apps.py @@ -1,9 +1,21 @@ """Users application""" from django.apps import AppConfig +from wagtail.users.apps import WagtailUsersAppConfig class UsersConfig(AppConfig): """Config for users app""" name = "users" + + +class MitxproWagtailUsersAppConfig(WagtailUsersAppConfig): + """ + Custom WagtailUsersAppConfig for the mitxpro User model. + + The viewset string is resolved lazily by Wagtail after the app registry is + ready, so no Django model imports are needed here. + """ + + user_viewset = "users.wagtail_views.UserViewSet" diff --git a/users/factories.py b/users/factories.py index dd9c7ab469..f225292301 100644 --- a/users/factories.py +++ b/users/factories.py @@ -32,6 +32,7 @@ class UserFactory(DjangoModelFactory): class Meta: model = User + skip_postgeneration_save = True class UserSocialAuthFactory(DjangoModelFactory): diff --git a/users/wagtail_views.py b/users/wagtail_views.py new file mode 100644 index 0000000000..05ece8ec1e --- /dev/null +++ b/users/wagtail_views.py @@ -0,0 +1,27 @@ +"""Wagtail admin views for the users app""" + +from wagtail.users.views.users import ( + IndexView as WagtailUserIndexView, + UserViewSet as WagtailUserViewSet, +) + + +class UserIndexView(WagtailUserIndexView): + """ + Custom IndexView for the mitxpro User model. + + The mitxpro User model uses a single `name` field instead of the standard + Django `first_name` / `last_name` fields. Wagtail 6.x's built-in + order_queryset() maps ordering="name" to order_by("last_name", "first_name"), + which raises a FieldError. This overrides that behaviour. + """ + + def order_queryset(self, queryset): + ordering = self.ordering + if ordering in ("name", "-name"): + return queryset.order_by(ordering) + return super().order_queryset(queryset) + + +class UserViewSet(WagtailUserViewSet): + index_view_class = UserIndexView diff --git a/users/wagtail_views_test.py b/users/wagtail_views_test.py new file mode 100644 index 0000000000..1e4b31a866 --- /dev/null +++ b/users/wagtail_views_test.py @@ -0,0 +1,69 @@ +"""Tests for users Wagtail admin views""" + +import pytest +from unittest.mock import patch + +from wagtail.users.views.users import IndexView as WagtailUserIndexView + +from users.factories import UserFactory +from users.models import User +from users.wagtail_views import UserIndexView, UserViewSet + +pytestmark = pytest.mark.django_db + + +class TestUserIndexViewOrderQueryset: + """Tests for UserIndexView.order_queryset""" + + def _make_view(self, ordering): + view = UserIndexView() + view.ordering = ordering + view.model = User + return view + + def test_order_by_name_ascending(self): + """ordering='name' sorts by name ascending""" + UserFactory(name="Zebra") + UserFactory(name="Alpha") + view = self._make_view("name") + result = list( + view.order_queryset(User.objects.all()).values_list("name", flat=True) + ) + assert result == sorted(result) + + def test_order_by_name_descending(self): + """ordering='-name' sorts by name descending""" + UserFactory(name="Zebra") + UserFactory(name="Alpha") + view = self._make_view("-name") + result = list( + view.order_queryset(User.objects.all()).values_list("name", flat=True) + ) + assert result == sorted(result, reverse=True) + + @pytest.mark.parametrize("ordering", ["name", "-name"]) + def test_name_ordering_does_not_reference_last_name(self, ordering): + """name/−name ordering must not raise FieldError for last_name""" + UserFactory.create_batch(3) + view = self._make_view(ordering) + # Evaluating the queryset would raise FieldError if last_name is used + list(view.order_queryset(User.objects.all())) + + def test_unknown_ordering_delegates_to_super(self): + """Unrecognised ordering falls through to Wagtail's default implementation""" + view = self._make_view("email") + qs = User.objects.all() + with patch.object( + WagtailUserIndexView, "order_queryset", return_value=qs + ) as mock_super: + result = view.order_queryset(qs) + mock_super.assert_called_once() + assert result is qs + + +class TestUserViewSet: + """Tests for UserViewSet""" + + def test_index_view_class_is_custom(self): + """UserViewSet must use the custom UserIndexView""" + assert UserViewSet.index_view_class is UserIndexView diff --git a/uv.lock b/uv.lock index 261645c116..2cff086687 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,53 +1153,53 @@ wheels = [ [[package]] name = "granian" -version = "2.7.2" +version = "2.7.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/19/d4ea523715ba8dd2ed295932cc3dda6bb197060f78aada6e886ff08587b2/granian-2.7.2.tar.gz", hash = "sha256:cdae2f3a26fa998d41fefad58f1d1c84a0b035a6cc9377addd81b51ba82f927f", size = 128969, upload-time = "2026-02-24T23:04:23.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/bc/cf0bc29f583096a842cf0f26ae2fe40c72ed5286d4548be99ecfcdbb17e2/granian-2.7.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:76b840ff13dde8838fd33cd096f2e7cadf2c21a499a67f695f53de57deab6ff8", size = 6440868, upload-time = "2026-02-24T23:02:53.619Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0d/bae1dcd2182ba5d9a5df33eb50b56dc5bbe67e31033d822e079aa8c1ff30/granian-2.7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:00ccc8d7284bc7360f310179d0b4d17e5ca3077bbe24427e9e9310df397e3831", size = 6097336, upload-time = "2026-02-24T23:02:55.185Z" }, - { url = "https://files.pythonhosted.org/packages/65/7d/3e0a7f32b0ad5faa1d847c51191391552fa239821c95fc7c022688985df2/granian-2.7.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:675987c1b321dc8af593db8639e00c25277449b32e8c1b2ddd46b35f28d9fac4", size = 7098742, upload-time = "2026-02-24T23:02:57.898Z" }, - { url = "https://files.pythonhosted.org/packages/89/41/3b44386d636ac6467f0f13f45474c71fc3b90a4f0ba8b536de91b2845a09/granian-2.7.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:681c6fbe3354aaa6251e6191ec89f5174ac3b9fbc4b4db606fea456d01969fcb", size = 6430667, upload-time = "2026-02-24T23:02:59.789Z" }, - { url = "https://files.pythonhosted.org/packages/52/70/7b24e187aed3fb7ac2b29d2480a045559a509ef9fec54cffb8694a2d94af/granian-2.7.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e5c9ae65af5e572dca27d8ca0da4c5180b08473ac47e6f5329699e9455a5cc3", size = 6948424, upload-time = "2026-02-24T23:03:01.406Z" }, - { url = "https://files.pythonhosted.org/packages/fa/4c/cb74c367f9efb874f2c8433fe9bf3e824f05cf719f2251d40e29e07f08c0/granian-2.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e37fab2be919ceb195db00d7f49ec220444b1ecaa07c03f7c1c874cacff9de83", size = 7000407, upload-time = "2026-02-24T23:03:03.214Z" }, - { url = "https://files.pythonhosted.org/packages/58/98/dfed3966ed7fbd3aae56e123598f90dc206484092b8373d0a71e2d8b82a8/granian-2.7.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:8ec167ab30f5396b5caaff16820a39f4e91986d2fe5bdc02992a03c2b2b2b313", size = 7121626, upload-time = "2026-02-24T23:03:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/39/82/acec732a345cd03b2f6e48ac04b66b7b8b61f5c50eb08d7421fc8c56591a/granian-2.7.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:63f426d793f2116d23be265dd826bec1e623680baf94cc270fe08923113a86ba", size = 7253447, upload-time = "2026-02-24T23:03:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2b/64779e69b08c1ff1bfc09a4ede904ab761ff63f936c275710886057c52f7/granian-2.7.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1617cbb4efe3112f07fb6762cf81d2d9fe4bdb78971d1fd0a310f8b132f6a51e", size = 7053005, upload-time = "2026-02-24T23:03:09.021Z" }, - { url = "https://files.pythonhosted.org/packages/04/c9/83e546d5f6b0447a4b9ee48ce15c29e43bb3f6b5e1040d33ac61fc9e3b6f/granian-2.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:7a4bd347694ace7a48cd784b911f2d519c2a22154e0d1ed59f5b4864914a8cfe", size = 4145886, upload-time = "2026-02-24T23:03:10.829Z" }, - { url = "https://files.pythonhosted.org/packages/4c/49/9eb88875d709db7e7844e1c681546448dab5ff5651cd1c1d80ac4b1de4e3/granian-2.7.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:016c5857c8baedeab7eb065f98417f5ea26bb72b0f7e0544fe76071efc5ab255", size = 6401748, upload-time = "2026-02-24T23:03:12.802Z" }, - { url = "https://files.pythonhosted.org/packages/e3/80/85726ad9999ed89cb6a32f7f57eb50ce7261459d9c30c3b194ae4c5aa2c5/granian-2.7.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dcbe01fa141adf3f90964e86a959e250754aa7c6dad8fa7a855e6fd382de4c13", size = 6101265, upload-time = "2026-02-24T23:03:14.435Z" }, - { url = "https://files.pythonhosted.org/packages/07/82/0df56a42b9f4c327d0e0b052f43369127e1b565b9e66bf2c9488f1c8d759/granian-2.7.2-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:283ba23817a685784b66f45423d2f25715fdc076c8ffb43c49a807ee56a0ffc0", size = 6249488, upload-time = "2026-02-24T23:03:16.387Z" }, - { url = "https://files.pythonhosted.org/packages/ef/cc/d83a351560a3d6377672636129c52f06f8393f5831c5ee0f06f274883ea6/granian-2.7.2-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3258419c741897273ce155568b5a9cbacb7700a00516e87119a90f7d520d6783", size = 7104734, upload-time = "2026-02-24T23:03:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/84/d1/539907ee96d0ee2bcceabb4a6a9643b75378d6dfea09b7a9e4fd22cdf977/granian-2.7.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a196125c4837491c139c9cc83541b48c408c92b9cfbbf004fd28717f9c02ad21", size = 6785504, upload-time = "2026-02-24T23:03:19.763Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/4b6f45882f8341e7c6cb824d693deb94c306be6525b483c76fb373d1e749/granian-2.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:746555ac8a2dcd9257bfe7ad58f1d7a60892bc4613df6a7d8f736692b3bb3b88", size = 6902790, upload-time = "2026-02-24T23:03:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/44/b8/832970d2d4b144b87be39f5b9dfd31fdb17f298dc238a0b2100c95002cf8/granian-2.7.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:5ac1843c6084933a54a07d9dcae643365f1d83aaff3fd4f2676ea301185e4e8b", size = 7082682, upload-time = "2026-02-24T23:03:23.875Z" }, - { url = "https://files.pythonhosted.org/packages/38/bc/1521dbf026d1c9d2465cd54e016efd8ff6e1e72eff521071dab20dd61c44/granian-2.7.2-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:3612eb6a3f4351dd2c4df246ed0d21056c0556a6b1ed772dd865310aa55a9ba9", size = 7264742, upload-time = "2026-02-24T23:03:25.562Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/00884ab77045a2f54db90932f9d1ca522201e2a6b2cf2a9b38840db0fd54/granian-2.7.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:34708b145e31b4538e0556704a07454a76d6776c55c5bc3a1335e80ef6b3bae3", size = 7062571, upload-time = "2026-02-24T23:03:27.278Z" }, - { url = "https://files.pythonhosted.org/packages/ee/0e/4321e361bccb9681e1045c75e783476de5be7aa47cf05066907530772eba/granian-2.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:841c48608e55daa2fa434392397cc24175abd48bc5bcefa1e4f74b7243e36c72", size = 4098734, upload-time = "2026-02-24T23:03:28.973Z" }, - { url = "https://files.pythonhosted.org/packages/69/4a/8ce622f4f7d58e035d121b9957dd5a8929028dc99cfc5d2bf7f2aa28912c/granian-2.7.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:592806c28c491f9c1d1501bac706ecf5e72b73969f20f912678d53308786d658", size = 6442041, upload-time = "2026-02-24T23:03:30.986Z" }, - { url = "https://files.pythonhosted.org/packages/27/62/7d36ed38a40a68c2856b6d2a6fedd40833e7f82eb90ba0d03f2d69ffadf5/granian-2.7.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9dcde3968b921654bde999468e97d03031f28668bc1fc145c81d8bedb0fb2a4", size = 6100793, upload-time = "2026-02-24T23:03:32.734Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c5/17fea68f4cb280c217cbd65534664722c9c9b0138c2754e20c235d70b5f4/granian-2.7.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6d4d78408283ec51f0fb00557856b4593947ad5b48287c04e1c22764a0ac28a5", size = 7119810, upload-time = "2026-02-24T23:03:34.807Z" }, - { url = "https://files.pythonhosted.org/packages/0a/76/35e240d107e0f158662652fd61191de4fb0c2c080e3786ca8f16c71547b7/granian-2.7.2-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d28b078e8087f794b83822055f95caf93d83b23f47f4efcd5e2f0f7a5d8a81", size = 6450789, upload-time = "2026-02-24T23:03:36.81Z" }, - { url = "https://files.pythonhosted.org/packages/4c/55/a6d08cfecc808149a910e51c57883ab26fad69d922dc2e76fb2d87469e2d/granian-2.7.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ff7a93123ab339ba6cad51cc7141f8880ec47b152ce2491595bb08edda20106", size = 6902672, upload-time = "2026-02-24T23:03:38.655Z" }, - { url = "https://files.pythonhosted.org/packages/98/2e/c86d95f324248fcc5dcaf034c9f688b32f7a488f0b2a4a25e6673776107f/granian-2.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a52effb9889f0944f0353afd6ce5a9d9aa83826d44bbf3c8013e978a3d6ef7b7", size = 6964399, upload-time = "2026-02-24T23:03:40.459Z" }, - { url = "https://files.pythonhosted.org/packages/37/4b/44fde33fe10245a3fba76bf843c387fad2d548244345115b9d87e1c40994/granian-2.7.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:76c987c3ca78bf7666ab053c3ed7e3af405af91b2e5ce2f1cf92634c1581e238", size = 7034929, upload-time = "2026-02-24T23:03:42.149Z" }, - { url = "https://files.pythonhosted.org/packages/90/76/38d205cb527046241a9ee4f51048bf44101c626ad4d2af16dd9d14dc1db6/granian-2.7.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:6590f8092c2bb6614e561ba771f084cbf72ecbc38dbf9849762ac38718085c29", size = 7259609, upload-time = "2026-02-24T23:03:43.852Z" }, - { url = "https://files.pythonhosted.org/packages/00/37/04245c7259e65f1083ce193875c6c44da4c98604d3b00a264a74dd4f042b/granian-2.7.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7c1ce9b0c9446b680e9545e7fc95a75f0c53a25dedcf924b1750c3e5ba5bf908", size = 7073161, upload-time = "2026-02-24T23:03:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/23/e4/28097a852d8f93f8e3be2014a81f03aa914b8a2c12ca761fac5ae1344b8b/granian-2.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:a69cafb6518c630c84a9285674d45ea6f7342a6279dc25c6bd933b6fad5c55ab", size = 4121462, upload-time = "2026-02-24T23:03:47.322Z" }, - { url = "https://files.pythonhosted.org/packages/cc/07/0e56fb4f178e14b4c1fa1f6f00586ca81761ccbe2d8803f2c12b6b17a7d6/granian-2.7.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a698d9b662d5648c8ae3dc01ad01688e1a8afc3525e431e7cddb841c53e5e291", size = 6415279, upload-time = "2026-02-24T23:03:48.932Z" }, - { url = "https://files.pythonhosted.org/packages/27/bc/3e69305bf34806cd852f4683deec844a2cb9a4d8888d7f172b507f6080a8/granian-2.7.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:17516095b520b3c039ddbe41a6beb2c59d554b668cc229d36d82c93154a799af", size = 6090528, upload-time = "2026-02-24T23:03:50.52Z" }, - { url = "https://files.pythonhosted.org/packages/ec/10/7d58a922b44417a6207c0a3230b0841cd7385a36fc518ac15fed16ebf6f7/granian-2.7.2-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:96b0fd9eac60f939b3cbe44c8f32a42fdb7c1a1a9e07ca89e7795cdc7a606beb", size = 6252291, upload-time = "2026-02-24T23:03:52.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/56/65776c6d759dcef9cce15bc11bdea2c64fe668088faf35d87916bd88f595/granian-2.7.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e50fb13e053384b8bd3823d4967606c6fd89f2b0d20e64de3ae212b85ffdfed2", size = 7106748, upload-time = "2026-02-24T23:03:53.994Z" }, - { url = "https://files.pythonhosted.org/packages/81/ee/d9ed836316607401f158ac264a3f770469d1b1edbf119402777a9eff1833/granian-2.7.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bb1ef13125bc05ab2e18869ed311beaeb085a4c4c195d55d0865f5753a4c0b4", size = 6778883, upload-time = "2026-02-24T23:03:55.574Z" }, - { url = "https://files.pythonhosted.org/packages/a1/46/eabab80e07a14527c336dec6d902329399f3ba2b82dc94b6435651021359/granian-2.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b1c77189335070c6ba6b8d158518fde4c50f892753620f0b22a7552ad4347143", size = 6903426, upload-time = "2026-02-24T23:03:57.296Z" }, - { url = "https://files.pythonhosted.org/packages/24/8a/8ce186826066f6d453316229383a5be3b0b8a4130146c21f321ee64fe2cb/granian-2.7.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:1777166c3c853eed4440adb3cbbf34bba2b77d595bfc143a5826904a80b22f34", size = 7083877, upload-time = "2026-02-24T23:03:59.425Z" }, - { url = "https://files.pythonhosted.org/packages/cf/eb/91ed4646ce1c920ad39db0bcddb6f4755e1823002b14fb026104e3eb8bce/granian-2.7.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:0ffac19208ae548f3647c849579b803beaed2b50dfb0f3790ad26daac0033484", size = 7267282, upload-time = "2026-02-24T23:04:01.218Z" }, - { url = "https://files.pythonhosted.org/packages/49/2f/58cba479254530ab09132e150e4ab55362f6e875d9e82b6790477843e0aa/granian-2.7.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:82f34e78c1297bf5a1b6a5097e30428db98b59fce60a7387977b794855c0c3bc", size = 7054941, upload-time = "2026-02-24T23:04:03.211Z" }, - { url = "https://files.pythonhosted.org/packages/29/b3/fd13123ac936a4f79f1ba20ad67328a8d09d586262b8f28cc1cfaa555213/granian-2.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e8b87d7ada696eec7e9023974665c83cec978cb83c205eae8fe377de20622f25", size = 4101983, upload-time = "2026-02-24T23:04:04.792Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/db/0c/27aa25280b6c1f323312e83088304da8a7f3e5c1e568d3a560365ec6fa67/granian-2.7.4.tar.gz", hash = "sha256:1dc0530d7ae6b0ae43aafafe771ac0b8c38af68bbd71ab355828817faf13aac1", size = 128212, upload-time = "2026-04-23T11:55:55.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/0f/fa7c63afedcb214edb96703cade360d946d5f1ca59ddb0b3d8e04587fb45/granian-2.7.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d11da4a4527ba8dc28b5533d5e3241d8d9212e593195d27c6e72c8a422010af5", size = 6373513, upload-time = "2026-04-23T11:54:24.246Z" }, + { url = "https://files.pythonhosted.org/packages/be/39/3088ce32d940f7982102ea3bdc230090e34ac56dc0bce04f2d03b56ea435/granian-2.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:057a3db87e93eca1a11255dd13b45b5dd83f798a750fd87f02e14d54db5741b6", size = 6045232, upload-time = "2026-04-23T11:54:25.708Z" }, + { url = "https://files.pythonhosted.org/packages/ac/61/588f6b5397ea4f5bd9fc8de4b8cc092c555b8d95371c03d149b3bc419277/granian-2.7.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb63d64c686799cea850c0c328d21adf75e323991a20be04923afc729432d2b5", size = 7001059, upload-time = "2026-04-23T11:54:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/58/63/2affbcecfe96f940744c2086ea3793935d5f6898207590a579c92fc8588f/granian-2.7.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f406648c47569e983f0c58bd0853bac30a2bcdc6227428255ee5cc65a8ee62b6", size = 6255487, upload-time = "2026-04-23T11:54:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/87/ac/31f7155a467020e7640e91af15ca3a70b0e7da210de42e3d3344e5eba8d0/granian-2.7.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bd56306eed06e293f4848c5ea997e1d019d1ad13b8252dde1f0bc773aca85ef", size = 6875068, upload-time = "2026-04-23T11:54:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/99/22/402cc903e5c4e82bd363177392d4e1dcab8b27c1f7006c5316c37c597056/granian-2.7.4-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:732639e612e6b6e8d481f399f367e8c9bbb6f0e1b7b0aa74db340c574ee3dd98", size = 6982487, upload-time = "2026-04-23T11:54:32.704Z" }, + { url = "https://files.pythonhosted.org/packages/d3/92/3878f977bda82fc3a66fc7e95a54366a7b82edd53e6c9fdb3ec053693280/granian-2.7.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:47b8fdbfb369d52bb3fb884514a6a3a7e4d8e81c65fd26e5232985f2b46ebe0f", size = 6990683, upload-time = "2026-04-23T11:54:34.301Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/a1239f3bc4e9034e07cb32403e6a6d26db01bba1c244dd654f6a76bf2612/granian-2.7.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:b679086082bfd7c1aa8c248ef673b715616a4ce58eec6fbeef8b83b30ac84283", size = 7148570, upload-time = "2026-04-23T11:54:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/fef781ea7356b21f671615dd0d53adc00fad81031a9ea506f80d1f46a43d/granian-2.7.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a29191e949a99ffae2807abb7a864f7493f7a744e4fe2ddd2b5cd8db9b71378d", size = 7006976, upload-time = "2026-04-23T11:54:38.135Z" }, + { url = "https://files.pythonhosted.org/packages/56/54/ae2979fc45c06fbb37f595ee10eb6b138b6056202163b8e274d140d3f87b/granian-2.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:07d26325cc69371ea2dc9d3a9cd0cc851c1c8e3dce40aca90e8c204547b5ba7e", size = 4027044, upload-time = "2026-04-23T11:54:39.957Z" }, + { url = "https://files.pythonhosted.org/packages/21/51/10344430e495bfa128dccc114957b33e712e971f91668788c08fe791df73/granian-2.7.4-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:4e093fe9511387313ad7ec9a76b0c78397cc584ef3dff47d46c336c5aee9cd8d", size = 6249290, upload-time = "2026-04-23T11:54:41.738Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/c7eda2e71a89a13e174598649f721c63ed3d908c0904b62621e8a433af0f/granian-2.7.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:227889f821526b8b60c5edf31b01fc987c4193bb0fc198c0998e0841e0cb719c", size = 5901799, upload-time = "2026-04-23T11:54:43.708Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/79e51f9f794389a9d6cab3d7c6b834b87d65fba72a43784eb5d2664a57a6/granian-2.7.4-cp313-cp313t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2b28d4aec5a9f2758a48da1897649a01b70ee1c00f2c4649db574527a3d00943", size = 6037594, upload-time = "2026-04-23T11:54:45.595Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d8/835873a407279435fa0c8e8ac52392d3ba5c9a652bb15c0036aa07d9c302/granian-2.7.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f708fea5024a40e0dfba1c17c1c4b09e02e00ac0ac9ac1e345b409f0c11b71e5", size = 6966672, upload-time = "2026-04-23T11:54:47.242Z" }, + { url = "https://files.pythonhosted.org/packages/92/5f/21eacdda27c38e4194de5f9bef36c4045058daf6d58533fadb7c54c70573/granian-2.7.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7006dfe9852cded794bc60008a168faf4dc2ecc18f1d74b5fde545685b699ec", size = 6563668, upload-time = "2026-04-23T11:54:49.751Z" }, + { url = "https://files.pythonhosted.org/packages/bd/06/9b19956d75277df44ee380e873a86b9890c431f2e2bcde32b3ba341f0efa/granian-2.7.4-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:77103af44034e30505fb5577b8214b0ad39cd6cbdc854ff980d4755faf93adaa", size = 6664285, upload-time = "2026-04-23T11:54:51.502Z" }, + { url = "https://files.pythonhosted.org/packages/85/33/740e0c9478be49c0778c4ea1773357680980e10e84b59bc19664033996dc/granian-2.7.4-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b23194e1e0652297086224212605edb4998442511637e732d6009506277f8ff9", size = 6820367, upload-time = "2026-04-23T11:54:53.506Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/3453fc1212268a01fee957122f2b1699af0efe50eca07ac570e11d1be12b/granian-2.7.4-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:f62941a4ffa1f1c2c5750cfc0b0ad96aa85d63b016125289779eef8888f5340d", size = 7132366, upload-time = "2026-04-23T11:54:55.123Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ca/8479e4d2a02f210ce68b5dc73c77953ec1dfd3769bf725d06e6ec420d502/granian-2.7.4-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:ea6f97d2ade676f1bf49b79088fa4b5640b8b9804b7470218486df3d4be50046", size = 6842094, upload-time = "2026-04-23T11:54:56.665Z" }, + { url = "https://files.pythonhosted.org/packages/0d/96/71f95c73220726aee3e908b3ad2745c4c44fbfba508cb5ed615a9d4d367f/granian-2.7.4-cp313-cp313t-win_amd64.whl", hash = "sha256:759140ceef02ef72e57a184461927d72bcc2ddd3664c3cbbf4def7516f818041", size = 3974523, upload-time = "2026-04-23T11:54:58.541Z" }, + { url = "https://files.pythonhosted.org/packages/98/5d/a0c3d8778cd8aa68131974d34c439a38a00a32953e71e3b549759a5e3cdb/granian-2.7.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c19ebe797d7383cbb3497c599b8201af71f9fff6b18deaf9965d106f61588ab8", size = 6322736, upload-time = "2026-04-23T11:55:00.292Z" }, + { url = "https://files.pythonhosted.org/packages/5e/99/211da053030574f2402c750f3e3e5dc587f5192eac4888affe6ca8894a9f/granian-2.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4cee0bdba9179537669c2fa0afab2ce89327a372f1b2a82f280798da321c996c", size = 6052103, upload-time = "2026-04-23T11:55:02.797Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/23ec1fd519a4c0db961b05d1821869ed6371cbaf8b3d3a0a85c04f89e6ca/granian-2.7.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4bc5b54845bfb5f87537483f25c8f8e6003c3c1b4b0eadf6b93a432d0604265", size = 7000868, upload-time = "2026-04-23T11:55:04.826Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/b8798c98c90d3293d9c85580ea6021f148d5ab73ab99d1f82a0e66f73131/granian-2.7.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b550fb98b89465c8192b6e506993de6bfb956838e715ffb58e944aec1afdae99", size = 6257266, upload-time = "2026-04-23T11:55:06.962Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4f/5574db17193d90a5831120a0ce2a2dc64a711110ccb9af5a3630260c3597/granian-2.7.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7100a6a6d3835fec2a207fef536a259dd42d9efdb5c46933cf6f9d55d5bfaad", size = 6849667, upload-time = "2026-04-23T11:55:08.862Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/90b85cc6a31cbee772fc8ee731479429a64169e389444a5fdd685d44a342/granian-2.7.4-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:034ac1bfe8c19b5a7916d35a1ca426845db9ac11215f1b367566aec3b6530549", size = 6902612, upload-time = "2026-04-23T11:55:10.888Z" }, + { url = "https://files.pythonhosted.org/packages/06/6c/ba203ca40bd406db0412bca70281e44712f941bc6aafb59a628f4811d517/granian-2.7.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:baf1c390a25d3d9840204c39e7b801c909e99e896ae2713d898c46b563cbf962", size = 6927025, upload-time = "2026-04-23T11:55:12.663Z" }, + { url = "https://files.pythonhosted.org/packages/ee/52/77e2abfba54523943eea275ebbe733a6d186fe646304fe25f6d22b243d03/granian-2.7.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:3bb99778ae05c1118cd694717d025cc0b85f5ee81f60cbcb2a8783692798db96", size = 7146800, upload-time = "2026-04-23T11:55:14.459Z" }, + { url = "https://files.pythonhosted.org/packages/1d/66/7209201856b7de8d3c643ba87e11272c4d651c216d05ea3fcbdce0da4ab0/granian-2.7.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13f0a39872afa81c6aaa8e29832371fd831373140f1f04de459ff862824f488b", size = 6999983, upload-time = "2026-04-23T11:55:16.236Z" }, + { url = "https://files.pythonhosted.org/packages/c3/45/bd1e521284714615996dcee48dad47d8b97ca2767a7e7cccd392f25fc176/granian-2.7.4-cp314-cp314-win_amd64.whl", hash = "sha256:97b5aeec98a9c6c0695bf8f068bd03aca83fc17c0d977a9c3a2e57bb5f10d47e", size = 3989433, upload-time = "2026-04-23T11:55:17.774Z" }, + { url = "https://files.pythonhosted.org/packages/45/a2/609f8f0dca7f596b5fb6e57b988b4b8f4d6579724b2720933c379d43301a/granian-2.7.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7b1aca6c654f0e61c9e493dd6d3ddb1698f47dc33ed04566a6635948b081b64", size = 6251034, upload-time = "2026-04-23T11:55:19.29Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/2eefa8ff477cce7b119ed2fe97fc1f3b2d108397d4755e83a5198149f2c8/granian-2.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4e0c8cc6850dec7180a26b6805b2c4cdbac4c1c48077fd7857a3cd8ff342d9d", size = 5912772, upload-time = "2026-04-23T11:55:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/ae/40/9a5070badaed4ceecf4082855985840c320f7232b8c1ddc93e1732c63265/granian-2.7.4-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:7e6b1f6e0fe873efa3393ef28803ff699a94254f2a7dc07422cc01d9849e2136", size = 6037318, upload-time = "2026-04-23T11:55:23.855Z" }, + { url = "https://files.pythonhosted.org/packages/95/52/1db412e63425cb12f5ca61877956583c6d12f21657b1a3e47eb3200e9c1b/granian-2.7.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dce110217825cff60f68da83280bc20471b10e004e720fa94b845e01925d8698", size = 6962778, upload-time = "2026-04-23T11:55:26.095Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/fcca39f617bf70e29ef903bb7a4d037970c637023484f2112d9ed6882516/granian-2.7.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:058f9a4ebfc7b9c2577569c6ecfd333628d0d045de272afaa65ee9933849778c", size = 6566618, upload-time = "2026-04-23T11:55:28.233Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/0da1bb552746d74275017e1ffc7fc419dd1a33345f132f6f5a90f9f41142/granian-2.7.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:7c05f74fa5b5dcedc9f035a7c10b8afd90a3d941975a370f1e07c3f3095dd883", size = 6670850, upload-time = "2026-04-23T11:55:29.945Z" }, + { url = "https://files.pythonhosted.org/packages/11/2a/d0d9cdb10d2760e2f47bd4600c8eef02e326f8f7e253a80ce4ba384265e6/granian-2.7.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:8b992bbc667e3c74de4ad48ac8d735c7cddf3f709fc2097f7dd230ecc46fd7b3", size = 6824752, upload-time = "2026-04-23T11:55:32.066Z" }, + { url = "https://files.pythonhosted.org/packages/3d/79/0432f92f9df6e54394e4dd1c159c0d4814d255a2d2541fa9a5c187d19152/granian-2.7.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:df05e0f85712b3e90ddf28cb8be358664b1afa8cb8f09978141ca70052dca3a7", size = 7130809, upload-time = "2026-04-23T11:55:33.807Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11cc0e08f59f03a3cd6a1fe46d7632a0f8690ef945a495b1303140bb7541/granian-2.7.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:dbc620f35b67cf6b03d2b6a24b9b442d1bf52961eaebadb2c3ff214d3d0c8dc4", size = 6845920, upload-time = "2026-04-23T11:55:35.583Z" }, + { url = "https://files.pythonhosted.org/packages/b4/49/bcbaaeec0f68d3d1a3dd1fdd21e4a6963d303ae18027c42b2b53f87d6b89/granian-2.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:b9df8aead4d71562753788264db23d32db34147bb73294ddd90833bef1f4cf35", size = 3981107, upload-time = "2026-04-23T11:55:37.597Z" }, ] [[package]]