From e5ec41a42f22aad4bb5cbb3e58fcd748c212c7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 14 Aug 2026 21:07:20 +0200 Subject: [PATCH 1/6] fix: bump required dep for vulnerabilities --- requirements-dev.txt | 6 +++--- requirements.txt | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index f9a4c596ab..ffb3299b3a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,11 +4,11 @@ boto3-stubs[essential]==1.38.23 google-auth-stubs==0.3.0 mypy[faster-cache]==1.16.0 pytest-alembic==0.12.1 -pytest-asyncio==1.3.0 +pytest-asyncio==1.4.0 pytest-xdist==3.8.0 pytest-cov==6.1.1 -pytest-mock==3.14.1 -pytest==9.0.1 +pytest-mock==3.15.1 +pytest==9.1.1 ruff==0.15.10 types-Authlib==1.5.0.20250516 types-fpdf2==2.8.3.20250516 diff --git a/requirements.txt b/requirements.txt index 7eb5aca243..d407f2c655 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ alembic==1.13.2 # database migrations anyio==4.13.0 arq==0.26.3 # Scheduler asyncpg==0.31.0 # PostgreSQL adapter for asynchronous operations -authlib==1.6.9 +authlib==1.7.2 bcrypt==4.1.3 # password hashing boto3==1.38.23 broadcaster==0.3.1 # Working with websockets with multiple workers. @@ -24,14 +24,14 @@ psycopg[binary]==3.2.13 # PostgreSQL adapter for *synchronous* opera pydantic-extra-types==2.10.5 pydantic-settings==2.3.4 pydantic==2.12.5 -pyjwt[crypto]==2.10.1 # generate and verify the JWT tokens, imported as `jwt` +pyjwt[crypto]==2.13.0 # generate and verify the JWT tokens, imported as `jwt` PyMuPDF==1.26.7 # PDF processing, imported as `fitz` -pypdf==6.4.0 -python-multipart==0.0.18 # a form data parser, as oauth flow requires form-data parameters +pypdf==6.14.2 +python-multipart==0.0.31 # a form data parser, as oauth flow requires form-data parameters redis==5.0.8 sqlalchemy-utils == 0.41.2 SQLAlchemy[asyncio]==2.0.44 # [asyncio] allows greenlet to be installed on Apple M1 devices. unidecode==1.3.8 uvicorn[standard]==0.30.6 -weasyprint==65.1 # HTML to PDF converter +weasyprint==69.0 # HTML to PDF converter xlsxwriter==3.2.0 \ No newline at end of file From 28f648a5247626fdb82202493924afad75922dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 14 Aug 2026 21:07:20 +0200 Subject: [PATCH 2/6] fix: bump all dependencies --- app/app.py | 8 ++-- app/core/permissions/factory_permissions.py | 29 +++++++++++++ app/utils/initialization.py | 3 +- requirements-dev.txt | 12 +++--- requirements.txt | 48 ++++++++++----------- 5 files changed, 64 insertions(+), 36 deletions(-) create mode 100644 app/core/permissions/factory_permissions.py diff --git a/app/app.py b/app/app.py index 1ec8db5540..0b7623fa45 100644 --- a/app/app.py +++ b/app/app.py @@ -16,7 +16,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from fastapi.routing import APIRoute +from fastapi.routing import APIRoute, iter_route_contexts from sqlalchemy.engine import Connection, Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -417,8 +417,8 @@ def use_route_path_as_operation_ids(app: FastAPI) -> None: See https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/ """ - for route in app.routes: - if isinstance(route, APIRoute): + for route in iter_route_contexts(app.routes): + if isinstance(route, APIRoute) and route.methods: # The operation_id should be unique. # It is possible to set multiple methods for the same endpoint method but it's not considered a good practice. method = "_".join(route.methods) @@ -740,7 +740,7 @@ async def validation_exception_handler( ) return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content=jsonable_encoder({"detail": exc.errors(), "body": exc.body}), ) diff --git a/app/core/permissions/factory_permissions.py b/app/core/permissions/factory_permissions.py new file mode 100644 index 0000000000..6617c6df08 --- /dev/null +++ b/app/core/permissions/factory_permissions.py @@ -0,0 +1,29 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups.groups_type import GroupType +from app.core.permissions import cruds_permissions, schemas_permissions +from app.core.utils.config import Settings +from app.module import permissions_list +from app.types.factory import Factory + + +class CorePermissionsFactory(Factory): + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + for permission in permissions_list: + await cruds_permissions.create_group_permission( + permission=schemas_permissions.CoreGroupPermission( + permission_name=permission, + group_id=GroupType.admin.value, + ), + db=db, + ) + await db.commit() + + @classmethod + async def should_run(cls, db: AsyncSession): + permissions = await cruds_permissions.get_permissions( + permissions_list, + db, + ) + return len(permissions) == 0 diff --git a/app/utils/initialization.py b/app/utils/initialization.py index 4c880567c9..10a8839898 100644 --- a/app/utils/initialization.py +++ b/app/utils/initialization.py @@ -2,6 +2,7 @@ import logging import os from collections.abc import Callable +from uuid import UUID import psutil import redis @@ -146,7 +147,7 @@ def set_core_data_crud_sync( def get_school_by_id_sync( - school_id: str, + school_id: UUID, db: Session, ) -> models_schools.CoreSchool | None: """ diff --git a/requirements-dev.txt b/requirements-dev.txt index ffb3299b3a..ff7684ecd2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,8 +1,8 @@ -r requirements.txt -aiosqlite==0.20.0 -boto3-stubs[essential]==1.38.23 +aiosqlite==0.22.1 +boto3-stubs[essential]==1.43.55 google-auth-stubs==0.3.0 -mypy[faster-cache]==1.16.0 +mypy[faster-cache]==2.3.0 pytest-alembic==0.12.1 pytest-asyncio==1.4.0 pytest-xdist==3.8.0 @@ -10,7 +10,5 @@ pytest-cov==6.1.1 pytest-mock==3.15.1 pytest==9.1.1 ruff==0.15.10 -types-Authlib==1.5.0.20250516 -types-fpdf2==2.8.3.20250516 -types-psutil==7.0.0.20250601 -types-redis==4.6.0.20241004 +types-Authlib==1.7.2.20260724 +types-psutil==7.2.2.20260518 diff --git a/requirements.txt b/requirements.txt index d407f2c655..c30e6c939a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,37 +1,37 @@ -alembic==1.13.2 # database migrations -anyio==4.13.0 -arq==0.26.3 # Scheduler +alembic==1.18.5 # database migrations +anyio==4.14.2 +arq==0.28.0 # Scheduler asyncpg==0.31.0 # PostgreSQL adapter for asynchronous operations authlib==1.7.2 -bcrypt==4.1.3 # password hashing -boto3==1.38.23 +bcrypt==5.0.0 # password hashing +boto3==1.43.55 broadcaster==0.3.1 # Working with websockets with multiple workers. calypsso==2.7.0 documenso-sdk==0.6.0 -Faker==37.1.0 -fastapi[standard]==0.122.0 -firebase-admin==7.1.0 # Firebase is used for push notification -google-api-python-client==2.187.0 -google-auth-oauthlib==1.2.1 -helloasso-python==1.0.5 +Faker==40.35.0 +fastapi[standard]==0.139.2 +firebase-admin==7.5.0 # Firebase is used for push notification +google-api-python-client==2.198.0 +google-auth-oauthlib==1.4.0 +helloasso-python==1.0.8 httpx==0.28.1 -icalendar==5.0.13 +icalendar==7.2.2 jellyfish==1.2.1 # String Matching Jinja2==3.1.6 # template engine for html files -phonenumbers==8.13.43 # Used for phone number validation -psutil==7.0.0 # psutil is used to determine the number of Hyperion workers -psycopg[binary]==3.2.13 # PostgreSQL adapter for *synchronous* operations at startup (database initializations & migrations) -pydantic-extra-types==2.10.5 -pydantic-settings==2.3.4 -pydantic==2.12.5 +phonenumbers==9.0.34 # Used for phone number validation +psutil==7.2.2 # psutil is used to determine the number of Hyperion workers +psycopg[binary]==3.3.4 # PostgreSQL adapter for *synchronous* operations at startup (database initializations & migrations) +pydantic-extra-types==2.11.1 +pydantic-settings==2.14.2 +pydantic==2.13.4 pyjwt[crypto]==2.13.0 # generate and verify the JWT tokens, imported as `jwt` -PyMuPDF==1.26.7 # PDF processing, imported as `fitz` +PyMuPDF==1.28.0 # PDF processing, imported as `fitz` pypdf==6.14.2 python-multipart==0.0.31 # a form data parser, as oauth flow requires form-data parameters redis==5.0.8 -sqlalchemy-utils == 0.41.2 -SQLAlchemy[asyncio]==2.0.44 # [asyncio] allows greenlet to be installed on Apple M1 devices. -unidecode==1.3.8 -uvicorn[standard]==0.30.6 +sqlalchemy-utils == 0.42.1 +SQLAlchemy[asyncio]==2.0.51 # [asyncio] allows greenlet to be installed on Apple M1 devices. +unidecode==1.4.0 +uvicorn[standard]==0.51.0 weasyprint==69.0 # HTML to PDF converter -xlsxwriter==3.2.0 \ No newline at end of file +xlsxwriter==3.2.9 \ No newline at end of file From 8d4ea92600a551074b4148a6842f490648bd10a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 14 Aug 2026 21:07:20 +0200 Subject: [PATCH 3/6] fix: missing factory use --- app/core/permissions/endpoints_permissions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/core/permissions/endpoints_permissions.py b/app/core/permissions/endpoints_permissions.py index 7398c15217..0ea32a6b44 100644 --- a/app/core/permissions/endpoints_permissions.py +++ b/app/core/permissions/endpoints_permissions.py @@ -12,6 +12,7 @@ from app.core.groups.groups_type import GroupType from app.core.permissions import cruds_permissions, schemas_permissions +from app.core.permissions.factory_permissions import CorePermissionsFactory from app.dependencies import ( get_db, is_user, @@ -30,7 +31,7 @@ root="permissions", tag="Permissions", router=router, - factory=None, + factory=CorePermissionsFactory(), ) hyperion_security_logger = logging.getLogger("hyperion.security") From 88644add28dd2d711ae9db1f93c046aed94f000a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 14 Aug 2026 21:07:20 +0200 Subject: [PATCH 4/6] refacto: use new fastapi custom route operation id method --- app/app.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/app.py b/app/app.py index 0b7623fa45..65ee708abe 100644 --- a/app/app.py +++ b/app/app.py @@ -16,7 +16,7 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from fastapi.routing import APIRoute, iter_route_contexts +from fastapi.routing import APIRoute from sqlalchemy.engine import Connection, Engine from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -408,21 +408,21 @@ async def initialize_notification_topics( ) -def use_route_path_as_operation_ids(app: FastAPI) -> None: +def use_route_path_as_operation_id(route: APIRoute) -> str: """ - Simplify operation IDs so that generated API clients have simpler function names. + Simplify operation ID so that generated API clients have simpler function names. Theses names may be used by API clients to generate function names. The operation_id will have the format "method_path", like "get_users_me". See https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/ """ - for route in iter_route_contexts(app.routes): - if isinstance(route, APIRoute) and route.methods: - # The operation_id should be unique. - # It is possible to set multiple methods for the same endpoint method but it's not considered a good practice. - method = "_".join(route.methods) - route.operation_id = method.lower() + route.path.replace("/", "_") + if route.methods: + # The operation_id should be unique. + # It is possible to set multiple methods for the same endpoint method but it's not considered a good practice. + method = "_".join(route.methods) + return method.lower() + route.path.replace("/", "_") + return route.name def init_db( @@ -652,9 +652,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[LifespanState]: title="Hyperion", version=settings.HYPERION_VERSION, lifespan=lifespan, + custom_generate_unique_id=use_route_path_as_operation_id, ) app.include_router(api.api_router) - use_route_path_as_operation_ids(app) app.add_middleware( CORSMiddleware, From f194f0808ff92e9a87f61f5b0862635f423308a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 14 Aug 2026 21:07:20 +0200 Subject: [PATCH 5/6] fix: permission factories and ignore warning --- app/app.py | 2 +- app/core/permissions/factory_permissions.py | 4 +++- app/modules/amap/endpoints_amap.py | 7 +++---- app/modules/raffle/endpoints_raffle.py | 4 ++-- app/utils/redis.py | 8 ++++---- pyproject.toml | 1 + 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/app.py b/app/app.py index 65ee708abe..35b4fc1d73 100644 --- a/app/app.py +++ b/app/app.py @@ -709,7 +709,7 @@ async def logging_middleware( # We test the ip address with the redis limiter process = True if redis_client and settings.ENABLE_RATE_LIMITER: # If redis is configured - process, log = limiter( + process, log = await limiter( redis_client, ip_address, settings.REDIS_LIMIT, diff --git a/app/core/permissions/factory_permissions.py b/app/core/permissions/factory_permissions.py index 6617c6df08..1a5144bae5 100644 --- a/app/core/permissions/factory_permissions.py +++ b/app/core/permissions/factory_permissions.py @@ -8,6 +8,8 @@ class CorePermissionsFactory(Factory): + depends_on = [] + @classmethod async def run(cls, db: AsyncSession, settings: Settings) -> None: for permission in permissions_list: @@ -26,4 +28,4 @@ async def should_run(cls, db: AsyncSession): permissions_list, db, ) - return len(permissions) == 0 + return not any(permission.groups for permission in permissions) diff --git a/app/modules/amap/endpoints_amap.py b/app/modules/amap/endpoints_amap.py index 1526fc7e45..b6b80c45f3 100644 --- a/app/modules/amap/endpoints_amap.py +++ b/app/modules/amap/endpoints_amap.py @@ -506,7 +506,7 @@ async def add_order_to_delievery( raise HTTPException(status_code=400, detail="You can't order nothing") redis_key = "amap_" + order.user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): @@ -624,7 +624,6 @@ async def edit_order_from_delivery( db_order = schemas_amap.OrderComplete( order_id=order_id, ordering_date=previous_order.ordering_date, - delivery_date=delivery.delivery_date, delivery_id=previous_order.delivery_id, user_id=previous_order.user_id, amount=amount, @@ -637,7 +636,7 @@ async def edit_order_from_delivery( raise HTTPException(status_code=404, detail="No cash found") redis_key = "amap_" + previous_order.user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): @@ -721,7 +720,7 @@ async def remove_order( redis_key = "amap_" + order.user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 75ea4573e7..598dc68b0e 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -517,7 +517,7 @@ async def buy_ticket( redis_key = "raffle_" + user.id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): @@ -985,7 +985,7 @@ async def edit_cash_by_id( redis_key = "raffle_" + user_id - if not isinstance(redis_client, Redis) or locker_get( + if not isinstance(redis_client, Redis) or await locker_get( redis_client=redis_client, key=redis_key, ): diff --git a/app/utils/redis.py b/app/utils/redis.py index 6765c034e5..98e26a2f97 100644 --- a/app/utils/redis.py +++ b/app/utils/redis.py @@ -1,10 +1,10 @@ import redis -def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): +async def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): """Simple fixed window rate limiter, returns a couple of booleans: the first is True if the request can be processed, False otherwise; the second indicates if an alert should be issued. key should be an ip address or a user id""" # Fixed window: see https://konghq.com/blog/how-to-design-a-scalable-rate-limiting-algorithm. - nb = redis_client.incr(key) + nb = await redis_client.incr(key) if nb == 1: redis_client.expire(key, window) elif nb == limit: @@ -17,8 +17,8 @@ def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): return True, False -def locker_get(redis_client: redis.Redis, key: str): - value = redis_client.get(key) +async def locker_get(redis_client: redis.Redis, key: str): + value = await redis_client.get(key) if value is None: return False return bool(int(value)) diff --git a/pyproject.toml b/pyproject.toml index 46c158cf4c..84a744f811 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,6 +182,7 @@ filterwarnings = [ "ignore:Module .* was previously imported, but not measured:coverage.exceptions.CoverageWarning", # idle xdist workers (no tests assigned) produce no coverage data; expected when running a subset of files. "ignore:No data was collected:coverage.exceptions.CoverageWarning", + "ignore::starlette.exceptions.StarletteDeprecationWarning" ] addopts = "-n 8 --dist=loadfile" # loadfile makes sure that tests are grouped by their containing file and distributed to available workers as whole units. From bb9f8232cde1993591832ba977c44557d9b5655e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Robert?= Date: Fri, 14 Aug 2026 21:07:20 +0200 Subject: [PATCH 6/6] fix: more --- app/app.py | 2 +- app/dependencies.py | 6 +++--- app/modules/amap/endpoints_amap.py | 14 +++++++------- app/modules/raffle/endpoints_raffle.py | 10 +++++----- app/utils/initialization.py | 13 +++++++------ app/utils/redis.py | 12 ++++++------ app/utils/state.py | 17 +++++++++-------- pyproject.toml | 1 - requirements.txt | 4 ++-- tests/commons.py | 2 +- tests/conftest.py | 2 +- 11 files changed, 42 insertions(+), 41 deletions(-) diff --git a/app/app.py b/app/app.py index 35b4fc1d73..71ecdb3f60 100644 --- a/app/app.py +++ b/app/app.py @@ -52,7 +52,7 @@ from app.utils.state import LifespanState if TYPE_CHECKING: - from redis import Redis + from redis.asyncio import Redis from app.types.factory import Factory diff --git a/app/dependencies.py b/app/dependencies.py index 55fa32a42f..87ad3ef975 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -91,7 +91,7 @@ async def init_state( SessionLocal = init_SessionLocal(engine) - redis_client = init_redis_client( + redis_client = await init_redis_client( settings=settings, hyperion_error_logger=hyperion_error_logger, ) @@ -135,7 +135,7 @@ async def disconnect_state( This methode should be called as a dependency as tests may need to run additional steps """ - disconnect_redis_client(GLOBAL_STATE["redis_client"]) + await disconnect_redis_client(GLOBAL_STATE["redis_client"]) await disconnect_scheduler(GLOBAL_STATE["scheduler"]) await disconnect_websocket_connection_manager(GLOBAL_STATE["ws_manager"]) @@ -229,7 +229,7 @@ async def get_unsafe_db() -> AsyncGenerator[AsyncSession]: yield db -def get_redis_client() -> redis.Redis | None: +def get_redis_client() -> redis.asyncio.Redis | None: """ Dependency that returns the redis client diff --git a/app/modules/amap/endpoints_amap.py b/app/modules/amap/endpoints_amap.py index b6b80c45f3..4746d34193 100644 --- a/app/modules/amap/endpoints_amap.py +++ b/app/modules/amap/endpoints_amap.py @@ -3,7 +3,7 @@ from datetime import UTC, datetime from fastapi import Depends, HTTPException, Response -from redis import Redis +from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups.groups_type import AccountType @@ -511,7 +511,7 @@ async def add_order_to_delievery( key=redis_key, ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_amap.add_order_to_delivery( @@ -548,7 +548,7 @@ async def add_order_to_delievery( **orderret.__dict__, ) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.patch( @@ -641,7 +641,7 @@ async def edit_order_from_delivery( key=redis_key, ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_amap.edit_order_with_products( @@ -669,7 +669,7 @@ async def edit_order_from_delivery( ) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.delete( @@ -725,7 +725,7 @@ async def remove_order( key=redis_key, ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_amap.remove_order( @@ -743,7 +743,7 @@ async def remove_order( return Response(status_code=204) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.post( diff --git a/app/modules/raffle/endpoints_raffle.py b/app/modules/raffle/endpoints_raffle.py index 598dc68b0e..a352bc60aa 100644 --- a/app/modules/raffle/endpoints_raffle.py +++ b/app/modules/raffle/endpoints_raffle.py @@ -3,7 +3,7 @@ from fastapi import Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse -from redis import Redis +from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups import cruds_groups @@ -523,7 +523,7 @@ async def buy_ticket( ): raise HTTPException(status_code=429, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: new_amount = balance.balance - pack_ticket.price @@ -545,7 +545,7 @@ async def buy_ticket( return tickets finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.get( @@ -990,7 +990,7 @@ async def edit_cash_by_id( key=redis_key, ): raise HTTPException(status_code=403, detail="Too fast !") - locker_set(redis_client=redis_client, key=redis_key, lock=True) + await locker_set(redis_client=redis_client, key=redis_key, lock=True) try: await cruds_raffle.edit_cash( @@ -999,7 +999,7 @@ async def edit_cash_by_id( db=db, ) finally: - locker_set(redis_client=redis_client, key=redis_key, lock=False) + await locker_set(redis_client=redis_client, key=redis_key, lock=False) @module.router.post( diff --git a/app/utils/initialization.py b/app/utils/initialization.py index 10a8839898..70797a79be 100644 --- a/app/utils/initialization.py +++ b/app/utils/initialization.py @@ -6,6 +6,7 @@ import psutil import redis +import redis.asyncio from pydantic import ValidationError from sqlalchemy import Connection, MetaData, delete, select from sqlalchemy.engine import Engine, create_engine @@ -292,7 +293,7 @@ def drop_db_sync(conn: Connection): async def use_lock_for_workers[**P, R]( job_function: Callable[P, R], key: str, - redis_client: redis.Redis | None, + redis_client: redis.asyncio.Redis | None, number_of_workers: int, logger: logging.Logger, unlock_key: str | None = None, @@ -332,7 +333,7 @@ async def use_lock_for_workers[**P, R]( ): await execute_async_or_sync_method(job_function, *args, **kwargs) - elif redis_client.set(key, "1", nx=True, ex=120): + elif await redis_client.set(key, "1", nx=True, ex=120): # We acquired the lock, we execute the function logger.info(f"Running {job_function.__name__}") @@ -340,19 +341,19 @@ async def use_lock_for_workers[**P, R]( if unlock_key is not None: # We set the unlock_key for other workers to resume operation - redis_client.set(unlock_key, "1") + await redis_client.set(unlock_key, "1") # After 60 seconds we remove the key for both performance and reloading issues # we assume other jobs won't take more than 60 seconds and will check this key before expiration - redis_client.expire(unlock_key, 60) + await redis_client.expire(unlock_key, 60) # After 60 seconds we remove the key for both performance and reloading issues # we assume other jobs won't take more than 60 seconds and will check this key before expiration - redis_client.expire(key, 60) + await redis_client.expire(key, 60) elif unlock_key: # As an `unlock_key` is provided, we will wait until an other worker has finished executing `job_function` - while redis_client.get(unlock_key) is None: + while await redis_client.get(unlock_key) is None: logger.debug(f"Waiting for {job_function.__name__} to finish") await asyncio.sleep(1) diff --git a/app/utils/redis.py b/app/utils/redis.py index 98e26a2f97..65320013ce 100644 --- a/app/utils/redis.py +++ b/app/utils/redis.py @@ -1,12 +1,12 @@ -import redis +from redis.asyncio import Redis -async def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): +async def limiter(redis_client: Redis, key: str, limit: int, window: int): """Simple fixed window rate limiter, returns a couple of booleans: the first is True if the request can be processed, False otherwise; the second indicates if an alert should be issued. key should be an ip address or a user id""" # Fixed window: see https://konghq.com/blog/how-to-design-a-scalable-rate-limiting-algorithm. nb = await redis_client.incr(key) if nb == 1: - redis_client.expire(key, window) + await redis_client.expire(key, window) elif nb == limit: return ( False, @@ -17,12 +17,12 @@ async def limiter(redis_client: redis.Redis, key: str, limit: int, window: int): return True, False -async def locker_get(redis_client: redis.Redis, key: str): +async def locker_get(redis_client: Redis, key: str): value = await redis_client.get(key) if value is None: return False return bool(int(value)) -def locker_set(redis_client: redis.Redis, key: str, lock: bool): - redis_client.set(key, int(lock)) +async def locker_set(redis_client: Redis, key: str, lock: bool): + await redis_client.set(key, int(lock)) diff --git a/app/utils/state.py b/app/utils/state.py index cbb8f4cc1a..0541220fbc 100644 --- a/app/utils/state.py +++ b/app/utils/state.py @@ -4,6 +4,7 @@ import calypsso import redis +import redis.asyncio from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -30,7 +31,7 @@ class GlobalState(TypedDict): # Database session creator SessionLocal: SessionLocalType # We may not have a Redis Client if it was not configured - redis_client: redis.Redis | None + redis_client: redis.asyncio.Redis | None scheduler: Scheduler ws_manager: WebsocketConnectionManager notification_manager: NotificationManager @@ -76,24 +77,24 @@ def init_SessionLocal(engine: AsyncEngine) -> SessionLocalType: ) -def init_redis_client( +async def init_redis_client( settings: Settings, hyperion_error_logger: logging.Logger, -) -> redis.Redis | None: +) -> redis.asyncio.Redis | None: """ Initialize the Redis client if the settings specify a Redis connection. Returns None if Redis is not configured. """ - redis_client: redis.Redis | None = None + redis_client: redis.asyncio.Redis | None = None if settings.REDIS_HOST is not None and settings.REDIS_HOST != "": try: - redis_client = redis.Redis( + redis_client = redis.asyncio.Redis( host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD, socket_keepalive=True, ) - redis_client.ping() # Test the connection + await redis_client.ping() # Test the connection except redis.exceptions.ConnectionError: hyperion_error_logger.exception( "Redis connection error: Check the Redis configuration or the Redis server", @@ -101,9 +102,9 @@ def init_redis_client( return redis_client -def disconnect_redis_client(redis_client: redis.Redis | None) -> None: +async def disconnect_redis_client(redis_client: redis.asyncio.Redis | None) -> None: if redis_client is not None: - redis_client.close() + await redis_client.close() async def init_scheduler( diff --git a/pyproject.toml b/pyproject.toml index 84a744f811..46c158cf4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,7 +182,6 @@ filterwarnings = [ "ignore:Module .* was previously imported, but not measured:coverage.exceptions.CoverageWarning", # idle xdist workers (no tests assigned) produce no coverage data; expected when running a subset of files. "ignore:No data was collected:coverage.exceptions.CoverageWarning", - "ignore::starlette.exceptions.StarletteDeprecationWarning" ] addopts = "-n 8 --dist=loadfile" # loadfile makes sure that tests are grouped by their containing file and distributed to available workers as whole units. diff --git a/requirements.txt b/requirements.txt index c30e6c939a..8c38790169 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ firebase-admin==7.5.0 # Firebase is used for push notification google-api-python-client==2.198.0 google-auth-oauthlib==1.4.0 helloasso-python==1.0.8 -httpx==0.28.1 +httpx2==2.3.0 icalendar==7.2.2 jellyfish==1.2.1 # String Matching Jinja2==3.1.6 # template engine for html files @@ -28,7 +28,7 @@ pyjwt[crypto]==2.13.0 # generate and verify the JWT tokens, impor PyMuPDF==1.28.0 # PDF processing, imported as `fitz` pypdf==6.14.2 python-multipart==0.0.31 # a form data parser, as oauth flow requires form-data parameters -redis==5.0.8 +redis==5.3.1 sqlalchemy-utils == 0.42.1 SQLAlchemy[asyncio]==2.0.51 # [asyncio] allows greenlet to be installed on Apple M1 devices. unidecode==1.4.0 diff --git a/tests/commons.py b/tests/commons.py index 9f67fb2ff6..177b85f8f6 100644 --- a/tests/commons.py +++ b/tests/commons.py @@ -57,7 +57,7 @@ async def override_init_state( SessionLocal = init_test_SessionLocal(engine=engine) - redis_client = init_redis_client( + redis_client = await init_redis_client( settings=settings, hyperion_error_logger=hyperion_error_logger, ) diff --git a/tests/conftest.py b/tests/conftest.py index b86931c24f..508b83952d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,6 +107,6 @@ async def test_example(client: TestClient): # locking logic to skip init_db on all but one worker, leaving DBs without tables. with ( patch("app.utils.initialization.get_number_of_workers", return_value=1), - TestClient(test_app, raise_server_exceptions=False) as client, + TestClient(test_app) as client, ): yield client