Features: 1) Improved request processing in middleware by adding mutable QueryDict implementation; 2) Extended type annotations across various modules for enhanced type safety; 3) Refined JWT token lifetime configuration for environment-specific logic.

Fixes: 1) Addressed missing or incorrect imports and type hints with `# ty:ignore` markers; 2) Fixed search queryset error handling in filters module; 3) Resolved issues in viewsets with updated `@action` method usage.

Extra: Removed unused classes and dependencies (e.g., `BaseMutation`, `basedpyright`, and related packages); streamlined GraphQL mutation implementations; cleaned up unused arguments in model `save` methods.
This commit is contained in:
Egor Pavlovich Gorbunov 2025-12-19 15:17:17 +03:00
parent c3b4becc76
commit 13e7af52aa
26 changed files with 172 additions and 411 deletions

View file

@ -23,19 +23,17 @@ lint:
- "**/*.py"
- "pyproject.toml"
- ".pre-commit-config.yaml"
- "pyrightconfig.json"
when: on_success
- when: never
typecheck:
stage: typecheck
script:
- uv run pyright
- uv run ty
rules:
- changes:
- "**/*.py"
- "pyproject.toml"
- "pyrightconfig.json"
when: on_success
- when: never

View file

@ -99,7 +99,7 @@ class Post(NiceModel):
verbose_name = _("post")
verbose_name_plural = _("posts")
def save(self, **kwargs):
def save(self, *args, **kwargs):
if self.file:
raise ValueError(
_("markdown files are not supported yet - use markdown content instead")
@ -110,7 +110,7 @@ class Post(NiceModel):
"a markdown file or markdown content must be provided - mutually exclusive"
)
)
super().save(**kwargs)
super().save(*args, **kwargs)
class PostTag(NiceModel):

View file

@ -31,13 +31,13 @@ class NiceModel(Model):
def save(
self,
*,
*args,
force_insert: bool = False,
force_update: bool = False,
using: str | None = None,
update_fields: Collection[str] | None = None,
update_modified: bool = True,
) -> None:
) -> None: # ty:ignore[invalid-method-override]
self.update_modified = update_modified
return super().save(
force_insert=force_insert,

View file

@ -1,5 +1,5 @@
from contextlib import suppress
from typing import Any, ClassVar, Type
from typing import Any, Callable, ClassVar, Type
from constance.admin import Config
from constance.admin import ConstanceAdmin as BaseConstanceAdmin
@ -145,6 +145,7 @@ class FieldsetsMixin:
# noinspection PyUnresolvedReferences
class ActivationActionsMixin:
message_user: Callable
actions_on_top = True
actions_on_bottom = True
actions = [
@ -1086,10 +1087,8 @@ class ConstanceConfig:
_meta = Meta()
# noinspection PyTypeChecker
site.unregister([Config])
# noinspection PyTypeChecker
site.register([ConstanceConfig], BaseConstanceAdmin)
site.unregister([Config]) # ty:ignore[invalid-argument-type]
site.register([ConstanceConfig], BaseConstanceAdmin) # ty:ignore[invalid-argument-type]
site.site_title = settings.PROJECT_NAME
site.site_header = "eVibes"
site.index_title = settings.PROJECT_NAME

View file

@ -10,6 +10,7 @@ from django_elasticsearch_dsl import fields
from django_elasticsearch_dsl.registries import registry
from elasticsearch import NotFoundError
from elasticsearch.dsl import Q, Search
from elasticsearch.dsl.types import Hit
from rest_framework.request import Request
from engine.core.models import Brand, Category, Product
@ -199,7 +200,7 @@ def process_query(
minimum_should_match=1,
)
def build_search(idxs: list[str], size: int) -> Search:
def build_search(idxs: list[str], size: int) -> Search[Hit]:
return (
Search(index=idxs)
.query(query_base)

View file

@ -152,7 +152,7 @@ class ProductFilter(FilterSet):
data: dict[Any, Any] | None = None,
queryset: QuerySet[Product] | None = None,
*,
request: HttpRequest | Request | Context = None,
request: HttpRequest | Request | Context | None = None,
prefix: str | None = None,
) -> None:
super().__init__(data=data, queryset=queryset, request=request, prefix=prefix)
@ -516,12 +516,12 @@ class CategoryFilter(FilterSet):
if not value:
return queryset
uuids = [
category.get("uuid")
for category in process_query(query=value, indexes=("categories",))[
"categories"
]
]
s_result = process_query(query=value, indexes=("categories",))
if not s_result:
raise ValueError("Search is unprocessable")
uuids = [category.get("uuid") for category in s_result.get("categories", [])]
return queryset.filter(uuid__in=uuids)

View file

@ -1,12 +0,0 @@
from typing import Any
from graphene import Mutation
class BaseMutation(Mutation):
def __init__(self, *args: list[Any], **kwargs: dict[Any, Any]) -> None:
super().__init__(*args, **kwargs)
@staticmethod
def mutate(**kwargs: Any) -> None:
pass

View file

@ -1,179 +0,0 @@
from contextlib import suppress
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from graphene import UUID, Boolean, Field, InputObjectType, List, NonNull, String
from engine.core.graphene import BaseMutation
from engine.core.graphene.object_types import ProductType
from engine.core.models import (
Attribute,
AttributeGroup,
AttributeValue,
Brand,
Category,
Product,
ProductTag,
)
from engine.core.utils.messages import permission_denied_message
def resolve_attributes(product, attributes):
for attr_input in attributes:
attribute = None
attr_uuid = attr_input.get("attribute_uuid")
if attr_uuid:
with suppress(Attribute.DoesNotExist):
attribute = Attribute.objects.get(uuid=attr_uuid)
if attribute is None:
group_name = attr_input.get("group_name")
attribute_name = attr_input.get("attribute_name")
value_type = attr_input.get("value_type") or "string"
if group_name and attribute_name:
group, _ = AttributeGroup.objects.get_or_create(name=group_name)
attribute, _ = Attribute.objects.get_or_create(
group=group,
name=attribute_name,
defaults={"value_type": value_type},
)
if attribute.value_type != value_type:
attribute.value_type = value_type
attribute.save(update_fields=["value_type"])
if attribute is not None:
AttributeValue.objects.update_or_create(
product=product,
attribute=attribute,
defaults={"value": str(attr_input.get("value", ""))},
)
def resolve_tags(product, tag_uuids):
tags = list(ProductTag.objects.filter(uuid__in=tag_uuids))
if tags:
product.tags.set(tags)
class AttributeInput(InputObjectType):
attribute_uuid = UUID(required=False, name="attributeUuid")
group_name = String(required=False, name="groupName")
attribute_name = String(required=False, name="attributeName")
value_type = String(required=False, name="valueType")
value = String(required=True)
class ProductInput(InputObjectType):
name = NonNull(String)
description = String(required=False)
is_digital = Boolean(required=False, name="isDigital")
partnumber = String(required=False)
sku = String(required=False)
category_uuid = NonNull(UUID, name="categoryUuid")
brand_uuid = UUID(required=False, name="brandUuid")
tag_uuids = List(UUID, required=False, name="tagUuids")
attributes = List(NonNull(AttributeInput), required=False)
# noinspection PyUnusedLocal,PyTypeChecker
class CreateProduct(BaseMutation):
class Meta:
description = _("create a product")
class Arguments:
product_data = NonNull(ProductInput, name="productData")
product = Field(ProductType)
@staticmethod
def mutate(parent, info, product_data):
user = info.context.user
if not user.has_perm("core.add_product"):
raise PermissionDenied(permission_denied_message)
category = Category.objects.get(uuid=product_data["category_uuid"])
brand = None
if product_data.get("brand_uuid"):
with suppress(Brand.DoesNotExist):
brand = Brand.objects.get(uuid=product_data["brand_uuid"])
product = Product.objects.create(
name=product_data["name"],
description=product_data.get("description"),
is_digital=product_data.get("is_digital") or False,
partnumber=product_data.get("partnumber"),
sku=product_data.get("sku") or None,
category=category,
brand=brand,
)
resolve_tags(product, product_data.get("tag_uuids", []))
resolve_attributes(product, product_data.get("attributes", []))
return CreateProduct(product=product)
# noinspection PyUnusedLocal,PyTypeChecker
class UpdateProduct(BaseMutation):
class Meta:
description = _("create a product")
class Arguments:
product_uuid = UUID(required=True)
product_data = NonNull(ProductInput, name="productData")
product = Field(ProductType)
@staticmethod
def mutate(parent, info, product_uuid, product_data):
user = info.context.user
if not user.has_perm("core.change_product"):
raise PermissionDenied(permission_denied_message)
product = Product.objects.get(uuid=product_uuid)
updates = {}
for field_in, model_field in (
("name", "name"),
("description", "description"),
("is_digital", "is_digital"),
("partnumber", "partnumber"),
("sku", "sku"),
):
if field_in in product_data and product_data[field_in] is not None:
updates[model_field] = product_data[field_in]
if product_data.get("category_uuid"):
product.category = Category.objects.get(uuid=product_data["category_uuid"])
if product_data.get("brand_uuid") is not None:
if product_data.get("brand_uuid"):
product.brand = Brand.objects.get(uuid=product_data["brand_uuid"])
else:
product.brand = None
for k, v in updates.items():
setattr(product, k, v)
product.save()
resolve_tags(product, product_data.get("tag_uuids", []))
resolve_attributes(product, product_data.get("attributes"))
return UpdateProduct(product=product)
# noinspection PyUnusedLocal,PyTypeChecker
class DeleteProduct(BaseMutation):
class Meta:
description = _("create a product")
class Arguments:
product_uuid = UUID(required=True)
ok = Boolean()
@staticmethod
def mutate(parent, info, product_uuid):
user = info.context.user
if not user.has_perm("core.delete_product"):
raise PermissionDenied(permission_denied_message)
Product.objects.get(uuid=product_uuid).delete()
return DeleteProduct(ok=True)

View file

@ -6,11 +6,10 @@ from django.core.cache import cache
from django.core.exceptions import BadRequest, PermissionDenied
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from graphene import UUID, Boolean, Field, Int, List, String
from graphene import UUID, Boolean, Field, Int, List, Mutation, String
from graphene.types.generic import GenericScalar
from engine.core.elasticsearch import process_query
from engine.core.graphene import BaseMutation
from engine.core.graphene.object_types import (
AddressType,
BulkProductInput,
@ -32,7 +31,7 @@ logger = logging.getLogger(__name__)
# noinspection PyUnusedLocal
class CacheOperator(BaseMutation):
class CacheOperator(Mutation):
class Meta:
description = _("cache I/O")
@ -54,7 +53,7 @@ class CacheOperator(BaseMutation):
# noinspection PyUnusedLocal
class RequestCursedURL(BaseMutation):
class RequestCursedURL(Mutation):
class Meta:
description = _("request a CORSed URL")
@ -82,7 +81,7 @@ class RequestCursedURL(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class AddOrderProduct(BaseMutation):
class AddOrderProduct(Mutation):
class Meta:
description = _("add a product to the order")
@ -111,7 +110,7 @@ class AddOrderProduct(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class RemoveOrderProduct(BaseMutation):
class RemoveOrderProduct(Mutation):
class Meta:
description = _("remove a product from the order")
@ -140,7 +139,7 @@ class RemoveOrderProduct(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class RemoveAllOrderProducts(BaseMutation):
class RemoveAllOrderProducts(Mutation):
class Meta:
description = _("remove all products from the order")
@ -162,7 +161,7 @@ class RemoveAllOrderProducts(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class RemoveOrderProductsOfAKind(BaseMutation):
class RemoveOrderProductsOfAKind(Mutation):
class Meta:
description = _("remove a product from the order")
@ -185,7 +184,7 @@ class RemoveOrderProductsOfAKind(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class BuyOrder(BaseMutation):
class BuyOrder(Mutation):
class Meta:
description = _("buy an order")
@ -256,7 +255,7 @@ class BuyOrder(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class BulkOrderAction(BaseMutation):
class BulkOrderAction(Mutation):
class Meta:
description = _("perform an action on a list of products in the order")
@ -308,7 +307,7 @@ class BulkOrderAction(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class BulkWishlistAction(BaseMutation):
class BulkWishlistAction(Mutation):
class Meta:
description = _("perform an action on a list of products in the wishlist")
@ -349,7 +348,7 @@ class BulkWishlistAction(BaseMutation):
# noinspection PyUnusedLocal
class BuyUnregisteredOrder(BaseMutation):
class BuyUnregisteredOrder(Mutation):
class Meta:
description = _("purchase an order without account creation")
@ -397,7 +396,7 @@ class BuyUnregisteredOrder(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class AddWishlistProduct(BaseMutation):
class AddWishlistProduct(Mutation):
class Meta:
description = _("add a product to the wishlist")
@ -425,7 +424,7 @@ class AddWishlistProduct(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class RemoveWishlistProduct(BaseMutation):
class RemoveWishlistProduct(Mutation):
class Meta:
description = _("remove a product from the wishlist")
@ -453,7 +452,7 @@ class RemoveWishlistProduct(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class RemoveAllWishlistProducts(BaseMutation):
class RemoveAllWishlistProducts(Mutation):
class Meta:
description = _("remove all products from the wishlist")
@ -481,7 +480,7 @@ class RemoveAllWishlistProducts(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class BuyWishlist(BaseMutation):
class BuyWishlist(Mutation):
class Meta:
description = _("buy all products from the wishlist")
@ -531,7 +530,7 @@ class BuyWishlist(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class BuyProduct(BaseMutation):
class BuyProduct(Mutation):
class Meta:
description = _("buy a product")
@ -576,7 +575,7 @@ class BuyProduct(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class FeedbackProductAction(BaseMutation):
class FeedbackProductAction(Mutation):
class Meta:
description = _("add or delete a feedback for orderproduct")
@ -611,7 +610,7 @@ class FeedbackProductAction(BaseMutation):
# noinspection PyUnusedLocal,PyTypeChecker
class CreateAddress(BaseMutation):
class CreateAddress(Mutation):
class Arguments:
raw_data = String(
required=True, description=_("original address string provided by the user")
@ -628,7 +627,7 @@ class CreateAddress(BaseMutation):
# noinspection PyUnusedLocal
class DeleteAddress(BaseMutation):
class DeleteAddress(Mutation):
class Arguments:
uuid = UUID(required=True)
@ -655,7 +654,7 @@ class DeleteAddress(BaseMutation):
# noinspection PyUnusedLocal
class AutocompleteAddress(BaseMutation):
class AutocompleteAddress(Mutation):
class Arguments:
q = String()
limit = Int()
@ -676,7 +675,7 @@ class AutocompleteAddress(BaseMutation):
# noinspection PyUnusedLocal
class ContactUs(BaseMutation):
class ContactUs(Mutation):
class Arguments:
email = String(required=True)
name = String(required=True)
@ -707,7 +706,7 @@ class ContactUs(BaseMutation):
# noinspection PyArgumentList PyUnusedLocal
class Search(BaseMutation):
class Search(Mutation):
class Arguments:
query = String(required=True)

View file

@ -79,9 +79,6 @@ class AttributeGroup(ExportModelOperationsMixin("attribute_group"), NiceModel):
)
is_publicly_visible = True
attributes: QuerySet["Attribute"]
children: QuerySet["Self"]
parent = ForeignKey(
"self",
on_delete=CASCADE,
@ -164,15 +161,7 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel):
def __str__(self) -> str:
return self.name
def save(
self,
*,
force_insert: bool = False,
force_update: bool = False,
using: str | None = None,
update_fields: list[str] | tuple[str, ...] | None = None,
update_modified: bool = True,
) -> None:
def save(self, *args, **kwargs) -> None:
users = self.users.filter(is_active=True)
users = users.exclude(attributes__icontains="is_business")
if users.count() > 0:
@ -182,11 +171,11 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel):
user.attributes.update({"is_business": True})
user.save()
return super().save(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
update_modified=update_modified,
force_insert=kwargs.get("force_insert", False),
force_update=kwargs.get("force_update", False),
using=kwargs.get("using"),
update_fields=kwargs.get("update_fields"),
update_modified=kwargs.get("update_modified", True),
)
class Meta:
@ -704,7 +693,9 @@ class Product(ExportModelOperationsMixin("product"), NiceModel):
@cached_property
def discount_price(self) -> float | None:
return self.promos.first().discount_percent if self.promos.exists() else None
return (
self.promos.first().discount_percent if self.promos.exists() else None
) # ty:ignore[possibly-missing-attribute]
@property
def rating(self) -> float:

View file

@ -4,7 +4,6 @@ import os
import traceback
from contextlib import suppress
from datetime import date, timedelta
from typing import Any
import requests
from constance import config
@ -16,7 +15,6 @@ from django.core.exceptions import BadRequest
from django.db.models import Count, F, Sum
from django.http import (
FileResponse,
Http404,
HttpRequest,
HttpResponse,
HttpResponseRedirect,
@ -30,6 +28,7 @@ from django.utils.http import urlsafe_base64_decode
from django.utils.timezone import now as tz_now
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import cache_page
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.vary import vary_on_headers
from django_ratelimit.decorators import ratelimit
from drf_spectacular.utils import extend_schema_view
@ -404,14 +403,13 @@ class DownloadDigitalAssetView(APIView):
)
def favicon_view(
request: HttpRequest, *args: list[Any], **kwargs: dict[str, Any]
) -> HttpResponse | FileResponse | None:
@csrf_exempt
def favicon_view(request: HttpRequest) -> HttpResponse | FileResponse:
try:
favicon_path = os.path.join(settings.BASE_DIR, "static/favicon.png")
return FileResponse(open(favicon_path, "rb"), content_type="image/x-icon")
except FileNotFoundError as fnfe:
raise Http404(_("favicon not found")) from fnfe
except FileNotFoundError:
return HttpResponse(status=404, content=_("favicon not found"))
# noinspection PyTypeChecker

View file

@ -1,13 +1,13 @@
import graphene
from graphene import Mutation
from rest_framework.exceptions import PermissionDenied
from engine.core.graphene import BaseMutation
from engine.core.utils.messages import permission_denied_message
from engine.payments.graphene.object_types import TransactionType
from engine.payments.models import Transaction
class Deposit(BaseMutation):
class Deposit(Mutation):
class Arguments:
amount = graphene.Float(required=True)

View file

@ -11,11 +11,10 @@ from django.db import IntegrityError
from django.http import Http404
from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from graphene import UUID, Boolean, Field, List, String
from graphene import UUID, Boolean, Field, List, Mutation, String
from graphene.types.generic import GenericScalar
from graphene_file_upload.scalars import Upload
from engine.core.graphene import BaseMutation
from engine.core.utils.messages import permission_denied_message
from engine.core.utils.security import is_safe_key
from engine.vibes_auth.graphene.object_types import UserType
@ -31,7 +30,7 @@ from engine.vibes_auth.validators import is_valid_email, is_valid_phone_number
logger = logging.getLogger(__name__)
class CreateUser(BaseMutation):
class CreateUser(Mutation):
class Arguments:
email = String(required=True)
password = String(required=True)
@ -92,7 +91,7 @@ class CreateUser(BaseMutation):
raise BadRequest(str(e)) from e
class UpdateUser(BaseMutation):
class UpdateUser(Mutation):
class Arguments:
uuid = UUID(required=True)
email = String(required=False)
@ -187,7 +186,7 @@ class UpdateUser(BaseMutation):
raise BadRequest(str(e)) from e
class DeleteUser(BaseMutation):
class DeleteUser(Mutation):
class Arguments:
email = String()
uuid = UUID()
@ -212,7 +211,7 @@ class DeleteUser(BaseMutation):
raise PermissionDenied(permission_denied_message)
class ObtainJSONWebToken(BaseMutation):
class ObtainJSONWebToken(Mutation):
class Arguments:
email = String(required=True)
password = String(required=True)
@ -236,7 +235,7 @@ class ObtainJSONWebToken(BaseMutation):
raise PermissionDenied(f"invalid credentials provided: {e!s}") from e
class RefreshJSONWebToken(BaseMutation):
class RefreshJSONWebToken(Mutation):
class Arguments:
refresh_token = String(required=True)
@ -259,7 +258,7 @@ class RefreshJSONWebToken(BaseMutation):
raise PermissionDenied(f"invalid refresh token provided: {e!s}") from e
class VerifyJSONWebToken(BaseMutation):
class VerifyJSONWebToken(Mutation):
class Arguments:
token = String(required=True)
@ -281,7 +280,7 @@ class VerifyJSONWebToken(BaseMutation):
return VerifyJSONWebToken(token_is_valid=False, user=None, detail=detail)
class ActivateUser(BaseMutation):
class ActivateUser(Mutation):
class Arguments:
uid = String(required=True)
token = String(required=True)
@ -311,7 +310,7 @@ class ActivateUser(BaseMutation):
return ActivateUser(success=True)
class ResetPassword(BaseMutation):
class ResetPassword(Mutation):
class Arguments:
email = String(required=True)
@ -330,7 +329,7 @@ class ResetPassword(BaseMutation):
return ResetPassword(success=True)
class ConfirmResetPassword(BaseMutation):
class ConfirmResetPassword(Mutation):
class Arguments:
uid = String(required=True)
token = String(required=True)
@ -370,7 +369,7 @@ class ConfirmResetPassword(BaseMutation):
raise BadRequest(_(f"something went wrong: {e!s}")) from e
class UploadAvatar(BaseMutation):
class UploadAvatar(Mutation):
class Arguments:
file = Upload(required=True)

View file

@ -233,7 +233,7 @@ class TokenRefreshSerializer(Serializer):
if api_settings.ROTATE_REFRESH_TOKENS:
if api_settings.BLACKLIST_AFTER_ROTATION:
with suppress(AttributeError):
refresh.blacklist()
refresh.blacklist() # ty:ignore[possibly-missing-attribute]
refresh.set_jti()
refresh.set_exp()

View file

@ -2,7 +2,6 @@ import logging
import traceback
from contextlib import suppress
from secrets import compare_digest
from typing import Type
from django.conf import settings
from django.contrib.auth.password_validation import validate_password
@ -53,7 +52,7 @@ class UserViewSet(
queryset = User.objects.filter(is_active=True)
permission_classes = [AllowAny]
@action(detail=False, methods=["post"])
@action(detail=False, methods=("POST",))
@method_decorator(
ratelimit(key="ip", rate="4/h" if not settings.DEBUG else "888/h")
)
@ -65,7 +64,7 @@ class UserViewSet(
send_reset_password_email_task.delay(user_pk=str(user.uuid))
return Response(status=status.HTTP_200_OK)
@action(detail=True, methods=["put"], permission_classes=[IsAuthenticated])
@action(detail=True, methods=("PUT",), permission_classes=[IsAuthenticated])
@method_decorator(
ratelimit(key="ip", rate="3/h" if not settings.DEBUG else "888/h")
)
@ -81,24 +80,25 @@ class UserViewSet(
)
return Response(status=status.HTTP_400_BAD_REQUEST)
@action(detail=False, methods=["post"])
@action(detail=False, methods=("POST",))
@method_decorator(
ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")
)
def confirm_password_reset(self, request: Request, *args, **kwargs) -> Response:
try:
if not compare_digest(
request.data.get("password"), request.data.get("confirm_password")
str(request.data.get("password")),
str(request.data.get("confirm_password")),
):
return Response(
{"error": _("passwords do not match")},
status=status.HTTP_400_BAD_REQUEST,
)
uuid = urlsafe_base64_decode(request.data.get("uidb_64")).decode()
uuid = urlsafe_base64_decode(str(request.data.get("uidb_64"))).decode()
user = User.objects.get(pk=uuid)
validate_password(password=request.data.get("password"), user=user)
validate_password(password=str(request.data.get("password")), user=user)
password_reset_token = PasswordResetTokenGenerator()
if not password_reset_token.check_token(user, request.data.get("token")):
@ -139,18 +139,18 @@ class UserViewSet(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
@action(detail=False, methods=["post"])
@action(detail=False, methods=("POST",))
@method_decorator(
ratelimit(key="ip", rate="5/h" if not settings.DEBUG else "888/h")
)
def activate(self, request: Request) -> Response:
detail = ""
activation_error: Type[Exception] | None = None
activation_error: Exception | None = None
try:
uuid = urlsafe_base64_decode(request.data.get("uidb_64")).decode()
uuid = urlsafe_base64_decode(str(request.data.get("uidb_64"))).decode()
user = User.objects.get(pk=uuid)
if not user.check_token(
urlsafe_base64_decode(request.data.get("token")).decode()
urlsafe_base64_decode(str(request.data.get("token"))).decode()
):
return Response(
{"error": _("activation link is invalid!")},
@ -175,7 +175,7 @@ class UserViewSet(
detail = str(traceback.format_exc())
if user is None:
if settings.DEBUG:
raise Exception from activation_error
raise Exception from activation_error # ty:ignore[possibly-unresolved-reference]
return Response(
{"error": _("activation link is invalid!"), "detail": detail},
status=status.HTTP_400_BAD_REQUEST,
@ -187,7 +187,7 @@ class UserViewSet(
response_data["access"] = str(tokens.access_token)
return Response(response_data, status=status.HTTP_200_OK)
@action(detail=True, methods=["put"], permission_classes=[IsAuthenticated])
@action(detail=True, methods=("PUT",), permission_classes=[IsAuthenticated])
def merge_recently_viewed(self, request: Request, *args, **kwargs) -> Response:
user = self.get_object()
if request.user != user:

View file

@ -1,15 +0,0 @@
from typing import Any
from urllib.parse import urlparse
from storages.backends.ftp import FTPStorage
class AbsoluteFTPStorage(FTPStorage):
# noinspection PyProtectedMember
# noinspection PyUnresolvedReferences
def _get_config(self) -> Any:
cfg = super()._get_config()
url = urlparse(self.location)
cfg["path"] = url.path or cfg["path"]
return cfg

View file

@ -17,6 +17,7 @@ from django.http import (
HttpResponseForbidden,
HttpResponsePermanentRedirect,
JsonResponse,
QueryDict,
)
from django.middleware.common import CommonMiddleware
from django.middleware.locale import LocaleMiddleware
@ -177,10 +178,21 @@ class CamelCaseMiddleWare:
self.get_response = get_response
def __call__(self, request):
request.GET = underscoreize(
request.GET,
underscoreized_get = underscoreize(
{k: v for k, v in request.GET.lists()},
**JSON_UNDERSCOREIZE,
)
new_get = QueryDict(mutable=True)
for key, value in underscoreized_get.items():
if isinstance(value, list):
for val in value:
new_get.appendlist(key, val)
else:
new_get[key] = value
new_get._mutable = False
request.GET = new_get
response = self.get_response(request)
return response

View file

@ -10,6 +10,8 @@ class CustomPagination(PageNumberPagination):
)
def get_paginated_response(self, data: dict[str, Any]) -> Response:
if not self.page:
raise RuntimeError
return Response(
{
"links": {
@ -25,9 +27,7 @@ class CustomPagination(PageNumberPagination):
}
)
def get_paginated_response_schema(
self, data_schema: dict[str, Any]
) -> dict[str, Any]:
def get_paginated_response_schema(self, schema: dict[str, Any]) -> dict[str, Any]:
return {
"type": "object",
"properties": {
@ -63,6 +63,6 @@ class CustomPagination(PageNumberPagination):
"example": 100,
"description": "Total number of items",
},
"data": data_schema,
"data": schema,
},
}

View file

@ -348,7 +348,7 @@ LANGUAGE_URL_OVERRIDES: dict[str, str] = {
code.split("-")[0]: code for code, _ in LANGUAGES if "-" in code
}
CURRENCY_CODE: str = dict(CURRENCIES_BY_LANGUAGES).get(LANGUAGE_CODE)
CURRENCY_CODE: str = dict(CURRENCIES_BY_LANGUAGES).get(LANGUAGE_CODE, "")
MODELTRANSLATION_FALLBACK_LANGUAGES: tuple[str, ...] = (LANGUAGE_CODE, "en-us", "de-de")
@ -517,7 +517,7 @@ if getenv("DBBACKUP_HOST") and getenv("DBBACKUP_USER") and getenv("DBBACKUP_PASS
STORAGES.update(
{
"dbbackup": {
"BACKEND": "evibes.ftpstorage.AbsoluteFTPStorage",
"BACKEND": "storages.backends.ftp.FTPStorage",
"OPTIONS": {
"location": (
f"ftp://{getenv('DBBACKUP_USER')}:{getenv('DBBACKUP_PASS')}@{getenv('DBBACKUP_HOST')}:21/{raw_path}"

View file

@ -1,5 +1,6 @@
from datetime import timedelta
from os import getenv
from typing import Any
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
@ -12,7 +13,7 @@ from evibes.settings.base import (
SECRET_KEY,
)
REST_FRAMEWORK: dict[str, str | int | list[str] | tuple[str, ...] | dict[str, bool]] = {
REST_FRAMEWORK: dict[str, Any] = {
"DEFAULT_PAGINATION_CLASS": "evibes.pagination.CustomPagination",
"PAGE_SIZE": 30,
"DEFAULT_AUTHENTICATION_CLASSES": [
@ -30,11 +31,14 @@ REST_FRAMEWORK: dict[str, str | int | list[str] | tuple[str, ...] | dict[str, bo
},
}
JSON_UNDERSCOREIZE = REST_FRAMEWORK.get("JSON_UNDERSCOREIZE", {})
JSON_UNDERSCOREIZE: dict[str, Any] = REST_FRAMEWORK.get("JSON_UNDERSCOREIZE", ())
access_lifetime = timedelta(hours=8) if not DEBUG else timedelta(hours=88)
refresh_lifetime = timedelta(days=8)
SIMPLE_JWT: dict[str, timedelta | str | bool] = {
"ACCESS_TOKEN_LIFETIME": timedelta(hours=8) if not DEBUG else timedelta(hours=88),
"REFRESH_TOKEN_LIFETIME": timedelta(days=8),
"ACCESS_TOKEN_LIFETIME": access_lifetime,
"REFRESH_TOKEN_LIFETIME": refresh_lifetime,
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"UPDATE_LAST_LOGIN": True,
@ -90,15 +94,14 @@ The API supports multiple response formats:
Current API version: %(version)s
""") # noqa: E501, F405
_access_seconds = SIMPLE_JWT.get("ACCESS_TOKEN_LIFETIME").total_seconds()
if not DEBUG:
_access_lifetime = int(_access_seconds // 60)
_access_lifetime = int(access_lifetime.total_seconds() // 60)
_access_unit = "minutes"
else:
_access_lifetime = int(_access_seconds // 3600)
_access_lifetime = int(access_lifetime.total_seconds() // 3600)
_access_unit = "hours"
_refresh_hours = int(SIMPLE_JWT.get("REFRESH_TOKEN_LIFETIME").total_seconds() // 3600)
_refresh_hours = int(refresh_lifetime.total_seconds() // 3600)
SPECTACULAR_DESCRIPTION = format_lazy(
_SPECTACULAR_DESCRIPTION_TEMPLATE,

View file

@ -1,6 +1,8 @@
from django.db import models
from django_elasticsearch_dsl.registries import registry
from django_elasticsearch_dsl.signals import CelerySignalProcessor
from django_elasticsearch_dsl.signals import (
CelerySignalProcessor, # ty:ignore[possibly-missing-import]
)
class SelectiveSignalProcessor(CelerySignalProcessor):

View file

@ -48,6 +48,7 @@ urlpatterns = [
path(
r"favicon.ico",
favicon_view,
name="favicon",
),
path(
r"graphql/",

View file

@ -1,5 +1,5 @@
import re
from typing import Any, MutableMapping
from typing import Any, Collection, MutableMapping
from django.utils.module_loading import import_string
from drf_orjson_renderer.renderers import ORJSONRenderer
@ -43,12 +43,12 @@ def camelize(obj: Any) -> Any:
def camelize_serializer_fields(result, generator, request, public):
ignore_fields = JSON_UNDERSCOREIZE.get("ignore_fields") or ()
ignore_keys = JSON_UNDERSCOREIZE.get("ignore_keys") or ()
ignore_fields: Collection[Any] = JSON_UNDERSCOREIZE.get("ignore_fields", ())
ignore_keys: Collection[Any] = JSON_UNDERSCOREIZE.get("ignore_keys", ())
def has_middleware_installed():
try:
from djangorestframework_camel_case.middleware import CamelCaseMiddleWare
from evibes.middleware import CamelCaseMiddleWare
except ImportError:
return False
@ -104,7 +104,7 @@ def camelize_serializer_fields(result, generator, request, public):
class CamelCaseRenderer(ORJSONRenderer):
def render(
self, data: Any, media_type: str | None = None, renderer_context: Any = None
) -> bytes:
) -> bytes: # ty:ignore[invalid-method-override]
if data is None:
return b""

View file

@ -86,9 +86,8 @@ worker = [
"celery-prometheus-exporter==1.7.0",
]
linting = [
"ty==0.0.3",
"ruff==0.14.9",
"basedpyright>=1.36.1",
"pyright==1.1.407",
"celery-types>=0.23.0",
"django-stubs==5.2.8",
"djangorestframework-stubs==3.16.6",
@ -112,7 +111,15 @@ python-version = "3.13"
[tool.ruff]
line-length = 88
target-version = "py312"
exclude = ["media", "static", "storefront"]
exclude = [
"Dockerfiles",
"monitoring",
"scripts",
"static",
"storefront",
"tmp",
"media",
]
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "B", "Q", "I"]
@ -125,41 +132,25 @@ known-first-party = ["evibes", "engine"]
quote-style = "double"
indent-style = "space"
[tool.pyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"
useLibraryCodeForTypes = true
reportMissingTypeStubs = true
reportGeneralTypeIssues = false
reportRedeclaration = false
exclude = [
"**/__pycache__/**",
"**/.venv/**",
"**/.uv/**",
"media/**",
"static/**",
"storefront/**",
"**/migrations/**",
]
extraPaths = ["./evibes", "./engine"]
[tool.ty.environment]
python-version = "3.12"
[tool.basedpyright]
typeCheckingMode = "strict"
pythonVersion = "3.12"
useLibraryCodeForTypes = true
reportMissingTypeStubs = true
reportGeneralTypeIssues = false
reportRedeclaration = false
[tool.ty.terminal]
output-format = "concise"
[tool.ty.rules]
possibly-unresolved-reference = "warn"
[[tool.ty.overrides]]
exclude = [
"**/__pycache__/**",
"**/.venv/**",
"**/.uv/**",
"media/**",
"Dockerfiles/**",
"monitoring/**",
"scripts/**",
"static/**",
"storefront/**",
"**/migrations/**",
"tmp/**",
"media/**",
]
extraPaths = ["./evibes", "./engine"]
[tool.django-stubs]
django_settings_module = "evibes.settings.__init__"

81
uv.lock
View file

@ -257,18 +257,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
]
[[package]]
name = "basedpyright"
version = "1.36.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/29/d42d543a1637e692ac557bfc6d6fcf50e9a7061c1cb4da403378d6a70453/basedpyright-1.36.1.tar.gz", hash = "sha256:20c9a24e2a4c95d5b6d46c78a6b6c7e3dc7cbba227125256431d47c595b15fd4", size = 22834851, upload-time = "2025-12-11T14:55:47.463Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/7f/f0133313bffa303d32aa74468981eb6b2da7fadda6247c9aa0aeab8391b1/basedpyright-1.36.1-py3-none-any.whl", hash = "sha256:3d738484fe9681cdfe35dd98261f30a9a7aec64208bc91f8773a9aaa9b89dd16", size = 11881725, upload-time = "2025-12-11T14:55:43.805Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
@ -1420,12 +1408,11 @@ jupyter = [
{ name = "jupyter" },
]
linting = [
{ name = "basedpyright" },
{ name = "celery-types" },
{ name = "django-stubs" },
{ name = "djangorestframework-stubs" },
{ name = "pyright" },
{ name = "ruff" },
{ name = "ty" },
{ name = "types-docutils" },
{ name = "types-paramiko" },
{ name = "types-pillow" },
@ -1448,7 +1435,6 @@ worker = [
requires-dist = [
{ name = "aiogram", specifier = "==3.23.0" },
{ name = "aiosmtpd", specifier = "==1.4.6" },
{ name = "basedpyright", marker = "extra == 'linting'", specifier = ">=1.36.1" },
{ name = "celery", marker = "extra == 'worker'", specifier = "==5.6.0" },
{ name = "celery-prometheus-exporter", marker = "extra == 'worker'", specifier = "==1.7.0" },
{ name = "celery-types", marker = "extra == 'linting'", specifier = ">=0.23.0" },
@ -1514,7 +1500,6 @@ requires-dist = [
{ name = "pygraphviz", marker = "sys_platform != 'win32' and extra == 'graph'", specifier = "==1.14" },
{ name = "pyjwt", specifier = "==2.10.1" },
{ name = "pymdown-extensions", specifier = "==10.19.1" },
{ name = "pyright", marker = "extra == 'linting'", specifier = "==1.1.407" },
{ name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-django", specifier = "==4.11.1" },
{ name = "python-slugify", specifier = "==8.0.4" },
@ -1524,6 +1509,7 @@ requires-dist = [
{ name = "sentry-sdk", extras = ["django", "celery", "opentelemetry"], specifier = "==2.48.0" },
{ name = "six", specifier = "==1.17.0" },
{ name = "swapper", specifier = "==1.4.0" },
{ name = "ty", marker = "extra == 'linting'", specifier = "==0.0.3" },
{ name = "types-docutils", marker = "extra == 'linting'", specifier = "==0.22.3.20251115" },
{ name = "types-paramiko", marker = "extra == 'linting'", specifier = "==4.0.0.20250822" },
{ name = "types-pillow", marker = "extra == 'linting'", specifier = "==10.2.0.20240822" },
@ -2541,31 +2527,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
]
[[package]]
name = "nodeenv"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
[[package]]
name = "nodejs-wheel-binaries"
version = "24.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/35/d806c2ca66072e36dc340ccdbeb2af7e4f1b5bcc33f1481f00ceed476708/nodejs_wheel_binaries-24.12.0.tar.gz", hash = "sha256:f1b50aa25375e264697dec04b232474906b997c2630c8f499f4caf3692938435", size = 8058, upload-time = "2025-12-11T21:12:26.856Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/3b/9d6f044319cd5b1e98f07c41e2465b58cadc1c9c04a74c891578f3be6cb5/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:7564ddea0a87eff34e9b3ef71764cc2a476a8f09a5cccfddc4691148b0a47338", size = 55125859, upload-time = "2025-12-11T21:11:58.132Z" },
{ url = "https://files.pythonhosted.org/packages/48/a5/f5722bf15c014e2f476d7c76bce3d55c341d19122d8a5d86454db32a61a4/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:8ff929c4669e64613ceb07f5bbd758d528c3563820c75d5de3249eb452c0c0ab", size = 55309035, upload-time = "2025-12-11T21:12:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/a9/61/68d39a6f1b5df67805969fd2829ba7e80696c9af19537856ec912050a2be/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6ebacefa8891bc456ad3655e6bce0af7e20ba08662f79d9109986faeb703fd6f", size = 59661017, upload-time = "2025-12-11T21:12:05.268Z" },
{ url = "https://files.pythonhosted.org/packages/16/a1/31aad16f55a5e44ca7ea62d1367fc69f4b6e1dba67f58a0a41d0ed854540/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3292649a03682ccbfa47f7b04d3e4240e8c46ef04dc941b708f20e4e6a764f75", size = 60159770, upload-time = "2025-12-11T21:12:08.696Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5e/b7c569aa1862690ca4d4daf3a64cafa1ea6ce667a9e3ae3918c56e127d9b/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7fb83df312955ea355ba7f8cbd7055c477249a131d3cb43b60e4aeb8f8c730b1", size = 61653561, upload-time = "2025-12-11T21:12:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/71/87/567f58d7ba69ff0208be849b37be0f2c2e99c69e49334edd45ff44f00043/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2473c819448fedd7b036dde236b09f3c8bbf39fbbd0c1068790a0498800f498b", size = 62238331, upload-time = "2025-12-11T21:12:16.143Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9d/c6492188ce8de90093c6755a4a63bb6b2b4efb17094cb4f9a9a49c73ed3b/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_amd64.whl", hash = "sha256:2090d59f75a68079fabc9b86b14df8238b9aecb9577966dc142ce2a23a32e9bb", size = 41342076, upload-time = "2025-12-11T21:12:20.618Z" },
{ url = "https://files.pythonhosted.org/packages/df/af/cd3290a647df567645353feed451ef4feaf5844496ced69c4dcb84295ff4/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:d0c2273b667dd7e3f55e369c0085957b702144b1b04bfceb7ce2411e58333757", size = 39048104, upload-time = "2025-12-11T21:12:23.495Z" },
]
[[package]]
name = "notebook"
version = "7.5.1"
@ -3220,19 +3181,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/35/76/c34426d532e4dce7ff36e4d92cb20f4cbbd94b619964b93d24e8f5b5510f/pynacl-1.6.1-cp38-abi3-win_arm64.whl", hash = "sha256:5953e8b8cfadb10889a6e7bd0f53041a745d1b3d30111386a1bb37af171e6daf", size = 183970, upload-time = "2025-11-10T16:02:05.786Z" },
]
[[package]]
name = "pyright"
version = "1.1.407"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodeenv" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@ -3762,6 +3710,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
]
[[package]]
name = "ty"
version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/cd/aee86c0da3240960d6b7e807f3a41c89bae741495d81ca303200b0103dc9/ty-0.0.3.tar.gz", hash = "sha256:831259e22d3855436701472d4c0da200cd45041bc677eae79415d684f541de8a", size = 4769098, upload-time = "2025-12-18T02:16:49.773Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ef/2d0d18e8fe6b673d3e1ea642f18404d7edfa9d08310f7203e8f0e7dc862e/ty-0.0.3-py3-none-linux_armv6l.whl", hash = "sha256:cd035bb75acecb78ac1ba8c4cc696f57a586e29d36e84bd691bc3b5b8362794c", size = 9763890, upload-time = "2025-12-18T02:16:56.879Z" },
{ url = "https://files.pythonhosted.org/packages/bb/67/0ae31574619a7264df8cf8e641f246992db22ac1720c2a72953aa31cbe61/ty-0.0.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7708eaf73485e263efc7ef339f8e4487d3f5885779edbeec504fd72e4521c376", size = 9558276, upload-time = "2025-12-18T02:16:45.453Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f7/3b9c033e80910972fca3783e4a52ba9cb7cd5c8b6828a87986646d64082b/ty-0.0.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3113a633f46ec789f6df675b7afc5d3ab20c247c92ae4dbb9aa5b704768c18b2", size = 9094451, upload-time = "2025-12-18T02:17:01.155Z" },
{ url = "https://files.pythonhosted.org/packages/9a/29/9a90ed6bef00142a088965100b5e0a5d11805b9729c151ca598331bbd92b/ty-0.0.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a451f3f73a04bf18e551b1ebebb79b20fac5f09740a353f7e07b5f607b217c4f", size = 9568049, upload-time = "2025-12-18T02:16:28.643Z" },
{ url = "https://files.pythonhosted.org/packages/2f/ab/8daeb12912c2de8a3154db652931f4ad0d27c555faebcaf34af08bcfd0d2/ty-0.0.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f6e926b6de0becf0452e1afad75cb71f889a4777cd14269e5447d46c01b2770", size = 9547711, upload-time = "2025-12-18T02:16:54.464Z" },
{ url = "https://files.pythonhosted.org/packages/91/54/f5c1f293f647beda717fee2448cc927ac0d05f66bebe18647680a67e1d67/ty-0.0.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:160e7974150f9f359c31d5808214676d1baa05321ab5a7b29fb09f4906dbdb38", size = 9983225, upload-time = "2025-12-18T02:17:05.672Z" },
{ url = "https://files.pythonhosted.org/packages/95/34/065962cfa2e87c10db839512229940a366b8ca1caffa2254a277b1694e5a/ty-0.0.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:726576df31d4e76934ffc64f2939d4a9bc195c7427452c8c159261ad00bd1b5e", size = 10851148, upload-time = "2025-12-18T02:16:38.354Z" },
{ url = "https://files.pythonhosted.org/packages/54/27/e2a8cbfc33999eef882ccd1b816ed615293f96e96f6df60cd12f84b69ca2/ty-0.0.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5014cf4744c94d9ea7b43314199ddaf52564a80b3d006e4ba0fe982bc42f4e8b", size = 10564441, upload-time = "2025-12-18T02:17:03.584Z" },
{ url = "https://files.pythonhosted.org/packages/91/6d/dcce3e222e59477c1f2b3a012cc76428d7032248138cd5544ad7f1cda7bd/ty-0.0.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a9a51dc040f2718725f34ae6ef51fe8f8bd689e21bd3e82f4e71767034928de", size = 10358651, upload-time = "2025-12-18T02:16:26.091Z" },
{ url = "https://files.pythonhosted.org/packages/53/36/b6d0154b83a5997d607bf1238200271c17223f68aab2c778ded5424f9c1e/ty-0.0.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0e6188eddd3a228c449261bb398e8621d33b92c1fc03599afdfad4388327a48", size = 10120457, upload-time = "2025-12-18T02:16:51.864Z" },
{ url = "https://files.pythonhosted.org/packages/cc/46/05dc826674ee1a451406e4c253c71700a6f707bae88b706a4c9e9bba6919/ty-0.0.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5cc55e08d5d18edf1c5051af02456bd359716f07aae0a305e4cefe7735188540", size = 9551642, upload-time = "2025-12-18T02:16:33.518Z" },
{ url = "https://files.pythonhosted.org/packages/64/8a/f90b60d103fd5ec04ecbac091a64e607e6cd37cec6e718bba17cb2022644/ty-0.0.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:34b2d589412a81d1fd6d7fe461353068496c2bf1f7113742bd6d88d1d57ec3ad", size = 9572234, upload-time = "2025-12-18T02:16:31.013Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/5d3c6d34562d019ba7f3102b2a6d0c8e9e24ef39e70f09645c36a66765b7/ty-0.0.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8a065eb2959f141fe4adafc14d57463cfa34f6cc4844a4ed56b2dce1a53a419a", size = 9701682, upload-time = "2025-12-18T02:16:41.379Z" },
{ url = "https://files.pythonhosted.org/packages/ef/44/bda434f788b320c9550a48c549e4a8c507e3d8a6ccb04ba5bd098307ba1e/ty-0.0.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e7177421f830a493f98d22f86d940b5a38788866e6062f680881f19be35ba3bb", size = 10213714, upload-time = "2025-12-18T02:16:35.648Z" },
{ url = "https://files.pythonhosted.org/packages/53/a6/b76a787938026c3d209131e5773de32cf6fc41210e0dd97874aafa20f394/ty-0.0.3-py3-none-win32.whl", hash = "sha256:e3e590bf5f33cb118a53c6d5242eedf7924d45517a5ee676c7a16be3a1389d2f", size = 9160441, upload-time = "2025-12-18T02:16:43.404Z" },
{ url = "https://files.pythonhosted.org/packages/fe/db/da60eb8252768323aee0ce69a08b95011088c003f80204b12380fe562fd2/ty-0.0.3-py3-none-win_amd64.whl", hash = "sha256:5af25b1fed8a536ce8072a9ae6a70cd2b559aa5294d43f57071fbdcd31dd2b0e", size = 10034265, upload-time = "2025-12-18T02:16:47.602Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9c/9045cebdfc394c6f8c1e73a99d3aeda1bc639aace392e8ff4d695f1fab73/ty-0.0.3-py3-none-win_arm64.whl", hash = "sha256:29078b3100351a8b37339771615f13b8e4a4ff52b344d33f774f8d1a665a0ca5", size = 9513095, upload-time = "2025-12-18T02:16:59.073Z" },
]
[[package]]
name = "types-cffi"
version = "1.17.0.20250915"