Refactor code structure for improved readability and maintainability

This commit is contained in:
JSC
2025-08-29 15:27:12 +02:00
parent dc89e45675
commit 2bdd109492
23 changed files with 652 additions and 719 deletions

View File

@@ -1,7 +1,9 @@
"""API endpoints for scheduled task management."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.database import get_db
@@ -9,12 +11,15 @@ from app.core.dependencies import (
get_admin_user,
get_current_active_user,
)
from app.core.services import get_global_scheduler_service
from app.models.scheduled_task import ScheduledTask, TaskStatus, TaskType
from app.models.user import User
from app.repositories.scheduled_task import ScheduledTaskRepository
from app.schemas.scheduler import (
ScheduledTaskCreate,
ScheduledTaskResponse,
ScheduledTaskUpdate,
TaskFilterParams,
)
from app.services.scheduler import SchedulerService
@@ -23,47 +28,21 @@ router = APIRouter(prefix="/scheduler")
def get_scheduler_service() -> SchedulerService:
"""Get the global scheduler service instance."""
from app.main import get_global_scheduler_service
return get_global_scheduler_service()
@router.post("/tasks", response_model=ScheduledTaskResponse)
async def create_task(
task_data: ScheduledTaskCreate,
current_user: User = Depends(get_current_active_user),
scheduler_service: SchedulerService = Depends(get_scheduler_service),
) -> ScheduledTask:
"""Create a new scheduled task."""
try:
task = await scheduler_service.create_task(
name=task_data.name,
task_type=task_data.task_type,
scheduled_at=task_data.scheduled_at,
parameters=task_data.parameters,
user_id=current_user.id,
timezone=task_data.timezone,
recurrence_type=task_data.recurrence_type,
cron_expression=task_data.cron_expression,
recurrence_count=task_data.recurrence_count,
expires_at=task_data.expires_at,
)
return task
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/tasks", response_model=list[ScheduledTaskResponse])
async def get_user_tasks(
status: TaskStatus | None = Query(None, description="Filter by task status"),
task_type: TaskType | None = Query(None, description="Filter by task type"),
limit: int | None = Query(50, description="Maximum number of tasks to return"),
offset: int | None = Query(0, description="Number of tasks to skip"),
current_user: User = Depends(get_current_active_user),
scheduler_service: SchedulerService = Depends(get_scheduler_service),
) -> list[ScheduledTask]:
"""Get user's scheduled tasks."""
return await scheduler_service.get_user_tasks(
user_id=current_user.id,
def get_task_filters(
status: Annotated[
TaskStatus | None, Query(description="Filter by task status"),
] = None,
task_type: Annotated[
TaskType | None, Query(description="Filter by task type"),
] = None,
limit: Annotated[int, Query(description="Maximum number of tasks to return")] = 50,
offset: Annotated[int, Query(description="Number of tasks to skip")] = 0,
) -> TaskFilterParams:
"""Create task filter parameters from query parameters."""
return TaskFilterParams(
status=status,
task_type=task_type,
limit=limit,
@@ -71,15 +50,45 @@ async def get_user_tasks(
)
@router.post("/tasks", response_model=ScheduledTaskResponse)
async def create_task(
task_data: ScheduledTaskCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
scheduler_service: Annotated[SchedulerService, Depends(get_scheduler_service)],
) -> ScheduledTask:
"""Create a new scheduled task."""
try:
return await scheduler_service.create_task(
task_data=task_data,
user_id=current_user.id,
)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@router.get("/tasks", response_model=list[ScheduledTaskResponse])
async def get_user_tasks(
filters: Annotated[TaskFilterParams, Depends(get_task_filters)],
current_user: Annotated[User, Depends(get_current_active_user)],
scheduler_service: Annotated[SchedulerService, Depends(get_scheduler_service)],
) -> list[ScheduledTask]:
"""Get user's scheduled tasks."""
return await scheduler_service.get_user_tasks(
user_id=current_user.id,
status=filters.status,
task_type=filters.task_type,
limit=filters.limit,
offset=filters.offset,
)
@router.get("/tasks/{task_id}", response_model=ScheduledTaskResponse)
async def get_task(
task_id: int,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_active_user)] = ...,
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
) -> ScheduledTask:
"""Get a specific scheduled task."""
from app.repositories.scheduled_task import ScheduledTaskRepository
repo = ScheduledTaskRepository(db_session)
task = await repo.get_by_id(task_id)
@@ -97,12 +106,10 @@ async def get_task(
async def update_task(
task_id: int,
task_update: ScheduledTaskUpdate,
current_user: User = Depends(get_current_active_user),
db_session: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_active_user)] = ...,
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
) -> ScheduledTask:
"""Update a scheduled task."""
from app.repositories.scheduled_task import ScheduledTaskRepository
repo = ScheduledTaskRepository(db_session)
task = await repo.get_by_id(task_id)
@@ -118,20 +125,19 @@ async def update_task(
for field, value in update_data.items():
setattr(task, field, value)
updated_task = await repo.update(task)
return updated_task
return await repo.update(task)
@router.delete("/tasks/{task_id}")
async def cancel_task(
task_id: int,
current_user: User = Depends(get_current_active_user),
scheduler_service: SchedulerService = Depends(get_scheduler_service),
db_session: AsyncSession = Depends(get_db),
current_user: Annotated[User, Depends(get_current_active_user)] = ...,
scheduler_service: Annotated[
SchedulerService, Depends(get_scheduler_service),
] = ...,
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
) -> dict:
"""Cancel a scheduled task."""
from app.repositories.scheduled_task import ScheduledTaskRepository
repo = ScheduledTaskRepository(db_session)
task = await repo.get_by_id(task_id)
@@ -152,20 +158,23 @@ async def cancel_task(
# Admin-only endpoints
@router.get("/admin/tasks", response_model=list[ScheduledTaskResponse])
async def get_all_tasks(
status: TaskStatus | None = Query(None, description="Filter by task status"),
task_type: TaskType | None = Query(None, description="Filter by task type"),
limit: int | None = Query(100, description="Maximum number of tasks to return"),
offset: int | None = Query(0, description="Number of tasks to skip"),
current_user: User = Depends(get_admin_user),
db_session: AsyncSession = Depends(get_db),
status: Annotated[
TaskStatus | None, Query(description="Filter by task status"),
] = None,
task_type: Annotated[
TaskType | None, Query(description="Filter by task type"),
] = None,
limit: Annotated[
int | None, Query(description="Maximum number of tasks to return"),
] = 100,
offset: Annotated[
int | None, Query(description="Number of tasks to skip"),
] = 0,
_: Annotated[User, Depends(get_admin_user)] = ...,
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
) -> list[ScheduledTask]:
"""Get all scheduled tasks (admin only)."""
from app.repositories.scheduled_task import ScheduledTaskRepository
repo = ScheduledTaskRepository(db_session)
# Get all tasks with pagination and filtering
from sqlmodel import select
# Build query with pagination and filtering
statement = select(ScheduledTask)
@@ -189,14 +198,16 @@ async def get_all_tasks(
@router.get("/admin/system-tasks", response_model=list[ScheduledTaskResponse])
async def get_system_tasks(
status: TaskStatus | None = Query(None, description="Filter by task status"),
task_type: TaskType | None = Query(None, description="Filter by task type"),
current_user: User = Depends(get_admin_user),
db_session: AsyncSession = Depends(get_db),
status: Annotated[
TaskStatus | None, Query(description="Filter by task status"),
] = None,
task_type: Annotated[
TaskType | None, Query(description="Filter by task type"),
] = None,
_: Annotated[User, Depends(get_admin_user)] = ...,
db_session: Annotated[AsyncSession, Depends(get_db)] = ...,
) -> list[ScheduledTask]:
"""Get system tasks (admin only)."""
from app.repositories.scheduled_task import ScheduledTaskRepository
repo = ScheduledTaskRepository(db_session)
return await repo.get_system_tasks(status=status, task_type=task_type)
@@ -204,23 +215,16 @@ async def get_system_tasks(
@router.post("/admin/system-tasks", response_model=ScheduledTaskResponse)
async def create_system_task(
task_data: ScheduledTaskCreate,
current_user: User = Depends(get_admin_user),
scheduler_service: SchedulerService = Depends(get_scheduler_service),
_: Annotated[User, Depends(get_admin_user)] = ...,
scheduler_service: Annotated[
SchedulerService, Depends(get_scheduler_service),
] = ...,
) -> ScheduledTask:
"""Create a system task (admin only)."""
try:
task = await scheduler_service.create_task(
name=task_data.name,
task_type=task_data.task_type,
scheduled_at=task_data.scheduled_at,
parameters=task_data.parameters,
return await scheduler_service.create_task(
task_data=task_data,
user_id=None, # System task
timezone=task_data.timezone,
recurrence_type=task_data.recurrence_type,
cron_expression=task_data.cron_expression,
recurrence_count=task_data.recurrence_count,
expires_at=task_data.expires_at,
)
return task
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
raise HTTPException(status_code=400, detail=str(e)) from e

23
app/core/services.py Normal file
View File

@@ -0,0 +1,23 @@
"""Global services container to avoid circular imports."""
from app.services.scheduler import SchedulerService
class AppServices:
"""Container for application services."""
def __init__(self) -> None:
"""Initialize the application services container."""
self.scheduler_service: SchedulerService | None = None
# Global service container
app_services = AppServices()
def get_global_scheduler_service() -> SchedulerService:
"""Get the global scheduler service instance."""
if app_services.scheduler_service is None:
msg = "Scheduler service not initialized"
raise RuntimeError(msg)
return app_services.scheduler_service

View File

@@ -9,6 +9,7 @@ from app.api import api_router
from app.core.config import settings
from app.core.database import get_session_factory, init_db
from app.core.logging import get_logger, setup_logging
from app.core.services import app_services
from app.middleware.logging import LoggingMiddleware
from app.services.extraction_processor import extraction_processor
from app.services.player import (
@@ -19,22 +20,10 @@ from app.services.player import (
from app.services.scheduler import SchedulerService
from app.services.socket import socket_manager
scheduler_service = None
def get_global_scheduler_service() -> SchedulerService:
"""Get the global scheduler service instance."""
global scheduler_service
if scheduler_service is None:
raise RuntimeError("Scheduler service not initialized")
return scheduler_service
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
"""Application lifespan context manager for setup and teardown."""
global scheduler_service
setup_logging()
logger = get_logger(__name__)
logger.info("Starting application")
@@ -53,20 +42,22 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
# Start the scheduler service
try:
player_service = get_player_service() # Get the initialized player service
scheduler_service = SchedulerService(get_session_factory(), player_service)
await scheduler_service.start()
app_services.scheduler_service = SchedulerService(
get_session_factory(), player_service,
)
await app_services.scheduler_service.start()
logger.info("Enhanced scheduler service started")
except Exception:
logger.exception("Failed to start scheduler service - continuing without it")
scheduler_service = None
app_services.scheduler_service = None
yield
logger.info("Shutting down application")
# Stop the scheduler service
if scheduler_service:
await scheduler_service.stop()
if app_services.scheduler_service:
await app_services.scheduler_service.stop()
logger.info("Scheduler service stopped")
# Stop the player service

View File

@@ -1,6 +1,6 @@
"""Scheduled task model for flexible task scheduling with timezone support."""
from datetime import datetime
from datetime import UTC, datetime
from enum import Enum
from typing import Any
@@ -42,7 +42,7 @@ class RecurrenceType(str, Enum):
class ScheduledTask(BaseModel, table=True):
"""Model for scheduled tasks with timezone support."""
__tablename__ = "scheduled_tasks"
__tablename__ = "scheduled_task"
id: int | None = Field(primary_key=True, default=None)
name: str = Field(max_length=255, description="Human-readable task name")
@@ -53,12 +53,12 @@ class ScheduledTask(BaseModel, table=True):
scheduled_at: datetime = Field(description="When the task should be executed (UTC)")
timezone: str = Field(
default="UTC",
description="Timezone for scheduling (e.g., 'America/New_York', 'Europe/Paris')",
description="Timezone for scheduling (e.g., 'America/New_York')",
)
recurrence_type: RecurrenceType = Field(default=RecurrenceType.NONE)
cron_expression: str | None = Field(
default=None,
description="Cron expression for custom recurrence (when recurrence_type is CRON)",
description="Cron expression for custom recurrence",
)
recurrence_count: int | None = Field(
default=None,
@@ -105,7 +105,7 @@ class ScheduledTask(BaseModel, table=True):
"""Check if the task has expired."""
if self.expires_at is None:
return False
return datetime.utcnow() > self.expires_at
return datetime.now(tz=UTC).replace(tzinfo=None) > self.expires_at
def is_recurring(self) -> bool:
"""Check if the task is recurring."""

View File

@@ -72,18 +72,22 @@ class BaseRepository[ModelType]:
logger.exception("Failed to get all %s", self.model.__name__)
raise
async def create(self, entity_data: dict[str, Any]) -> ModelType:
async def create(self, entity_data: dict[str, Any] | ModelType) -> ModelType:
"""Create a new entity.
Args:
entity_data: Dictionary of entity data
entity_data: Dictionary of entity data or model instance
Returns:
The created entity
"""
try:
entity = self.model(**entity_data)
if isinstance(entity_data, dict):
entity = self.model(**entity_data)
else:
# Already a model instance
entity = entity_data
self.session.add(entity)
await self.session.commit()
await self.session.refresh(entity)

View File

@@ -1,6 +1,6 @@
"""Repository for scheduled task operations."""
from datetime import datetime
from datetime import UTC, datetime
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -23,7 +23,7 @@ class ScheduledTaskRepository(BaseRepository[ScheduledTask]):
async def get_pending_tasks(self) -> list[ScheduledTask]:
"""Get all pending tasks that are ready to be executed."""
now = datetime.utcnow()
now = datetime.now(tz=UTC)
statement = select(ScheduledTask).where(
ScheduledTask.status == TaskStatus.PENDING,
ScheduledTask.is_active.is_(True),
@@ -90,7 +90,7 @@ class ScheduledTaskRepository(BaseRepository[ScheduledTask]):
async def get_recurring_tasks_due_for_next_execution(self) -> list[ScheduledTask]:
"""Get recurring tasks that need their next execution scheduled."""
now = datetime.utcnow()
now = datetime.now(tz=UTC)
statement = select(ScheduledTask).where(
ScheduledTask.recurrence_type != RecurrenceType.NONE,
ScheduledTask.is_active.is_(True),
@@ -102,7 +102,7 @@ class ScheduledTaskRepository(BaseRepository[ScheduledTask]):
async def get_expired_tasks(self) -> list[ScheduledTask]:
"""Get expired tasks that should be cleaned up."""
now = datetime.utcnow()
now = datetime.now(tz=UTC)
statement = select(ScheduledTask).where(
ScheduledTask.expires_at.is_not(None),
ScheduledTask.expires_at <= now,
@@ -152,7 +152,7 @@ class ScheduledTaskRepository(BaseRepository[ScheduledTask]):
) -> None:
"""Mark a task as completed and set next execution if recurring."""
task.status = TaskStatus.COMPLETED
task.last_executed_at = datetime.utcnow()
task.last_executed_at = datetime.now(tz=UTC)
task.executions_count += 1
task.error_message = None
@@ -170,7 +170,7 @@ class ScheduledTaskRepository(BaseRepository[ScheduledTask]):
"""Mark a task as failed with error message."""
task.status = TaskStatus.FAILED
task.error_message = error_message
task.last_executed_at = datetime.utcnow()
task.last_executed_at = datetime.now(tz=UTC)
# For non-recurring tasks, deactivate on failure
if not task.is_recurring():

View File

@@ -8,6 +8,15 @@ from pydantic import BaseModel, Field
from app.models.scheduled_task import RecurrenceType, TaskStatus, TaskType
class TaskFilterParams(BaseModel):
"""Query parameters for filtering tasks."""
status: TaskStatus | None = Field(default=None, description="Filter by task status")
task_type: TaskType | None = Field(default=None, description="Filter by task type")
limit: int = Field(default=50, description="Maximum number of tasks to return")
offset: int = Field(default=0, description="Number of tasks to skip")
class ScheduledTaskBase(BaseModel):
"""Base schema for scheduled tasks."""

View File

@@ -63,7 +63,7 @@ class PlayerState:
"""Convert player state to dictionary for serialization."""
return {
"status": self.status.value,
"mode": self.mode.value,
"mode": self.mode.value if isinstance(self.mode, PlayerMode) else self.mode,
"volume": self.volume,
"previous_volume": self.previous_volume,
"position": self.current_sound_position or 0,
@@ -401,8 +401,16 @@ class PlayerService:
if self.state.volume == 0 and self.state.previous_volume > 0:
await self.set_volume(self.state.previous_volume)
async def set_mode(self, mode: PlayerMode) -> None:
async def set_mode(self, mode: PlayerMode | str) -> None:
"""Set playback mode."""
if isinstance(mode, str):
# Convert string to PlayerMode enum
try:
mode = PlayerMode(mode)
except ValueError:
logger.error("Invalid player mode: %s", mode)
return
self.state.mode = mode
await self._broadcast_state()
logger.info("Playback mode set to: %s", mode.value)

View File

@@ -1,8 +1,8 @@
"""Enhanced scheduler service for flexible task scheduling with timezone support."""
from collections.abc import Callable
from datetime import datetime, timedelta
from typing import Any
from contextlib import suppress
from datetime import UTC, datetime, timedelta
import pytz
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -19,6 +19,7 @@ from app.models.scheduled_task import (
TaskType,
)
from app.repositories.scheduled_task import ScheduledTaskRepository
from app.schemas.scheduler import ScheduledTaskCreate
from app.services.credit import CreditService
from app.services.player import PlayerService
from app.services.task_handlers import TaskHandlerRegistry
@@ -57,7 +58,7 @@ class SchedulerService:
self.scheduler.add_job(
self._initialize_system_tasks,
"date",
run_date=datetime.utcnow() + timedelta(seconds=2),
run_date=datetime.now(tz=UTC) + timedelta(seconds=2),
id="initialize_system_tasks",
name="Initialize System Tasks",
replace_existing=True,
@@ -83,46 +84,43 @@ class SchedulerService:
async def create_task(
self,
name: str,
task_type: TaskType,
scheduled_at: datetime,
parameters: dict[str, Any] | None = None,
task_data: ScheduledTaskCreate,
user_id: int | None = None,
timezone: str = "UTC",
recurrence_type: RecurrenceType = RecurrenceType.NONE,
cron_expression: str | None = None,
recurrence_count: int | None = None,
expires_at: datetime | None = None,
) -> ScheduledTask:
"""Create a new scheduled task."""
"""Create a new scheduled task from schema data."""
async with self.db_session_factory() as session:
repo = ScheduledTaskRepository(session)
# Convert scheduled_at to UTC if it's in a different timezone
if timezone != "UTC":
tz = pytz.timezone(timezone)
scheduled_at = task_data.scheduled_at
if task_data.timezone != "UTC":
tz = pytz.timezone(task_data.timezone)
if scheduled_at.tzinfo is None:
# Assume the datetime is in the specified timezone
scheduled_at = tz.localize(scheduled_at)
scheduled_at = scheduled_at.astimezone(pytz.UTC).replace(tzinfo=None)
task_data = {
"name": name,
"task_type": task_type,
db_task_data = {
"name": task_data.name,
"task_type": task_data.task_type,
"scheduled_at": scheduled_at,
"timezone": timezone,
"parameters": parameters or {},
"timezone": task_data.timezone,
"parameters": task_data.parameters,
"user_id": user_id,
"recurrence_type": recurrence_type,
"cron_expression": cron_expression,
"recurrence_count": recurrence_count,
"expires_at": expires_at,
"recurrence_type": task_data.recurrence_type,
"cron_expression": task_data.cron_expression,
"recurrence_count": task_data.recurrence_count,
"expires_at": task_data.expires_at,
}
created_task = await repo.create(task_data)
created_task = await repo.create(db_task_data)
await self._schedule_apscheduler_job(created_task)
logger.info(f"Created scheduled task: {created_task.name} ({created_task.id})")
logger.info(
"Created scheduled task: %s (%s)",
created_task.name,
created_task.id,
)
return created_task
async def cancel_task(self, task_id: int) -> bool:
@@ -134,17 +132,16 @@ class SchedulerService:
if not task:
return False
task.status = TaskStatus.CANCELLED
task.is_active = False
await repo.update(task)
await repo.update(task, {
"status": TaskStatus.CANCELLED,
"is_active": False,
})
# Remove from APScheduler
try:
# Remove from APScheduler (job might not exist in scheduler)
with suppress(Exception):
self.scheduler.remove_job(str(task_id))
except Exception:
pass # Job might not exist in scheduler
logger.info(f"Cancelled task: {task.name} ({task_id})")
logger.info("Cancelled task: %s (%s)", task.name, task_id)
return True
async def get_user_tasks(
@@ -193,7 +190,7 @@ class SchedulerService:
if not daily_recharge_exists:
# Create daily credit recharge task
tomorrow_midnight = datetime.utcnow().replace(
tomorrow_midnight = datetime.now(tz=UTC).replace(
hour=0, minute=0, second=0, microsecond=0,
) + timedelta(days=1)
@@ -217,26 +214,29 @@ class SchedulerService:
for task in active_tasks:
await self._schedule_apscheduler_job(task)
logger.info(f"Loaded {len(active_tasks)} active tasks into scheduler")
logger.info("Loaded %s active tasks into scheduler", len(active_tasks))
async def _schedule_apscheduler_job(self, task: ScheduledTask) -> None:
"""Schedule a task in APScheduler."""
job_id = str(task.id)
# Remove existing job if it exists
try:
with suppress(Exception):
self.scheduler.remove_job(job_id)
except Exception:
pass
# Don't schedule if task is not active or already completed/failed
if not task.is_active or task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]:
inactive_statuses = [
TaskStatus.COMPLETED,
TaskStatus.FAILED,
TaskStatus.CANCELLED,
]
if not task.is_active or task.status in inactive_statuses:
return
# Create trigger based on recurrence type
trigger = self._create_trigger(task)
if not trigger:
logger.warning(f"Could not create trigger for task {task.id}")
logger.warning("Could not create trigger for task %s", task.id)
return
# Schedule the job
@@ -249,46 +249,51 @@ class SchedulerService:
replace_existing=True,
)
logger.debug(f"Scheduled APScheduler job for task {task.id}")
logger.debug("Scheduled APScheduler job for task %s", task.id)
def _create_trigger(self, task: ScheduledTask):
def _create_trigger(
self, task: ScheduledTask,
) -> DateTrigger | IntervalTrigger | CronTrigger | None:
"""Create APScheduler trigger based on task configuration."""
tz = pytz.timezone(task.timezone)
scheduled_time = task.scheduled_at
# Handle special cases first
if task.recurrence_type == RecurrenceType.NONE:
return DateTrigger(run_date=task.scheduled_at, timezone=tz)
return DateTrigger(run_date=scheduled_time, timezone=tz)
if task.recurrence_type == RecurrenceType.CRON and task.cron_expression:
return CronTrigger.from_crontab(task.cron_expression, timezone=tz)
if task.recurrence_type == RecurrenceType.HOURLY:
return IntervalTrigger(hours=1, start_date=task.scheduled_at, timezone=tz)
# Handle interval-based recurrence types
interval_configs = {
RecurrenceType.HOURLY: {"hours": 1},
RecurrenceType.DAILY: {"days": 1},
RecurrenceType.WEEKLY: {"weeks": 1},
}
if task.recurrence_type == RecurrenceType.DAILY:
return IntervalTrigger(days=1, start_date=task.scheduled_at, timezone=tz)
if task.recurrence_type in interval_configs:
config = interval_configs[task.recurrence_type]
return IntervalTrigger(start_date=scheduled_time, timezone=tz, **config)
if task.recurrence_type == RecurrenceType.WEEKLY:
return IntervalTrigger(weeks=1, start_date=task.scheduled_at, timezone=tz)
# Handle cron-based recurrence types
cron_configs = {
RecurrenceType.MONTHLY: {
"day": scheduled_time.day,
"hour": scheduled_time.hour,
"minute": scheduled_time.minute,
},
RecurrenceType.YEARLY: {
"month": scheduled_time.month,
"day": scheduled_time.day,
"hour": scheduled_time.hour,
"minute": scheduled_time.minute,
},
}
if task.recurrence_type == RecurrenceType.MONTHLY:
# Use cron trigger for monthly (more reliable than interval)
scheduled_time = task.scheduled_at
return CronTrigger(
day=scheduled_time.day,
hour=scheduled_time.hour,
minute=scheduled_time.minute,
timezone=tz,
)
if task.recurrence_type == RecurrenceType.YEARLY:
scheduled_time = task.scheduled_at
return CronTrigger(
month=scheduled_time.month,
day=scheduled_time.day,
hour=scheduled_time.hour,
minute=scheduled_time.minute,
timezone=tz,
)
if task.recurrence_type in cron_configs:
config = cron_configs[task.recurrence_type]
return CronTrigger(timezone=tz, **config)
return None
@@ -298,7 +303,7 @@ class SchedulerService:
# Prevent concurrent execution of the same task
if task_id_str in self._running_tasks:
logger.warning(f"Task {task_id} is already running, skipping execution")
logger.warning("Task %s is already running, skipping execution", task_id)
return
self._running_tasks.add(task_id_str)
@@ -310,20 +315,21 @@ class SchedulerService:
# Get fresh task data
task = await repo.get_by_id(task_id)
if not task:
logger.warning(f"Task {task_id} not found")
logger.warning("Task %s not found", task_id)
return
# Check if task is still active and pending
if not task.is_active or task.status != TaskStatus.PENDING:
logger.info(f"Task {task_id} is not active or not pending, skipping")
logger.info("Task %s not active or not pending, skipping", task_id)
return
# Check if task has expired
if task.is_expired():
logger.info(f"Task {task_id} has expired, marking as cancelled")
task.status = TaskStatus.CANCELLED
task.is_active = False
await repo.update(task)
logger.info("Task %s has expired, marking as cancelled", task_id)
await repo.update(task, {
"status": TaskStatus.CANCELLED,
"is_active": False,
})
return
# Mark task as running
@@ -332,7 +338,10 @@ class SchedulerService:
# Execute the task
try:
handler_registry = TaskHandlerRegistry(
session, self.db_session_factory, self.credit_service, self.player_service,
session,
self.db_session_factory,
self.credit_service,
self.player_service,
)
await handler_registry.execute_task(task)
@@ -352,14 +361,14 @@ class SchedulerService:
except Exception as e:
await repo.mark_as_failed(task, str(e))
logger.exception(f"Task {task_id} execution failed: {e!s}")
logger.exception("Task %s execution failed", task_id)
finally:
self._running_tasks.discard(task_id_str)
def _calculate_next_execution(self, task: ScheduledTask) -> datetime | None:
"""Calculate the next execution time for a recurring task."""
now = datetime.utcnow()
now = datetime.now(tz=UTC)
if task.recurrence_type == RecurrenceType.HOURLY:
return now + timedelta(hours=1)
@@ -376,7 +385,7 @@ class SchedulerService:
return None
async def _maintenance_job(self) -> None:
"""Periodic maintenance job to clean up expired tasks and handle scheduling issues."""
"""Periodic maintenance job to clean up expired tasks and handle scheduling."""
try:
async with self.db_session_factory() as session:
repo = ScheduledTaskRepository(session)
@@ -384,30 +393,33 @@ class SchedulerService:
# Handle expired tasks
expired_tasks = await repo.get_expired_tasks()
for task in expired_tasks:
task.status = TaskStatus.CANCELLED
task.is_active = False
await repo.update(task)
await repo.update(task, {
"status": TaskStatus.CANCELLED,
"is_active": False,
})
# Remove from scheduler
try:
with suppress(Exception):
self.scheduler.remove_job(str(task.id))
except Exception:
pass
if expired_tasks:
logger.info(f"Cleaned up {len(expired_tasks)} expired tasks")
logger.info("Cleaned up %s expired tasks", len(expired_tasks))
# Handle any missed recurring tasks
due_recurring = await repo.get_recurring_tasks_due_for_next_execution()
for task in due_recurring:
if task.should_repeat():
task.status = TaskStatus.PENDING
task.scheduled_at = task.next_execution_at or datetime.utcnow()
await repo.update(task)
next_scheduled_at = (
task.next_execution_at or datetime.now(tz=UTC)
)
await repo.update(task, {
"status": TaskStatus.PENDING,
"scheduled_at": next_scheduled_at,
})
await self._schedule_apscheduler_job(task)
if due_recurring:
logger.info(f"Rescheduled {len(due_recurring)} recurring tasks")
logger.info("Rescheduled %s recurring tasks", len(due_recurring))
except Exception:
logger.exception("Maintenance job failed")

View File

@@ -10,6 +10,7 @@ from app.repositories.playlist import PlaylistRepository
from app.repositories.sound import SoundRepository
from app.services.credit import CreditService
from app.services.player import PlayerService
from app.services.vlc_player import VLCPlayerService
logger = get_logger(__name__)
@@ -48,16 +49,23 @@ class TaskHandlerRegistry:
"""Execute a task based on its type."""
handler = self._handlers.get(task.task_type)
if not handler:
raise TaskExecutionError(f"No handler registered for task type: {task.task_type}")
msg = f"No handler registered for task type: {task.task_type}"
raise TaskExecutionError(msg)
logger.info(f"Executing task {task.id} ({task.task_type.value}): {task.name}")
logger.info(
"Executing task %s (%s): %s",
task.id,
task.task_type.value,
task.name,
)
try:
await handler(task)
logger.info(f"Task {task.id} executed successfully")
logger.info("Task %s executed successfully", task.id)
except Exception as e:
logger.exception(f"Task {task.id} execution failed: {e!s}")
raise TaskExecutionError(f"Task execution failed: {e!s}") from e
logger.exception("Task %s execution failed", task.id)
msg = f"Task execution failed: {e!s}"
raise TaskExecutionError(msg) from e
async def _handle_credit_recharge(self, task: ScheduledTask) -> None:
"""Handle credit recharge task."""
@@ -69,14 +77,15 @@ class TaskHandlerRegistry:
try:
user_id_int = int(user_id)
except (ValueError, TypeError) as e:
raise TaskExecutionError(f"Invalid user_id format: {user_id}") from e
msg = f"Invalid user_id format: {user_id}"
raise TaskExecutionError(msg) from e
stats = await self.credit_service.recharge_user_credits(user_id_int)
logger.info(f"Recharged credits for user {user_id}: {stats}")
logger.info("Recharged credits for user %s: %s", user_id, stats)
else:
# Recharge all users (system task)
stats = await self.credit_service.recharge_all_users_credits()
logger.info(f"Recharged credits for all users: {stats}")
logger.info("Recharged credits for all users: %s", stats)
async def _handle_play_sound(self, task: ScheduledTask) -> None:
"""Handle play sound task."""
@@ -84,41 +93,54 @@ class TaskHandlerRegistry:
sound_id = parameters.get("sound_id")
if not sound_id:
raise TaskExecutionError("sound_id parameter is required for PLAY_SOUND tasks")
msg = "sound_id parameter is required for PLAY_SOUND tasks"
raise TaskExecutionError(msg)
try:
# Handle both integer and string sound IDs
sound_id_int = int(sound_id)
except (ValueError, TypeError) as e:
raise TaskExecutionError(f"Invalid sound_id format: {sound_id}") from e
msg = f"Invalid sound_id format: {sound_id}"
raise TaskExecutionError(msg) from e
# Check if this is a user task (has user_id)
if task.user_id:
# User task: use credit-aware playback
from app.services.vlc_player import VLCPlayerService
vlc_service = VLCPlayerService(self.db_session_factory)
try:
result = await vlc_service.play_sound_with_credits(sound_id_int, task.user_id)
logger.info(f"Played sound {result.get('sound_name', sound_id)} via scheduled task for user {task.user_id} (credits deducted: {result.get('credits_deducted', 0)})")
result = await vlc_service.play_sound_with_credits(
sound_id_int, task.user_id,
)
logger.info(
(
"Played sound %s via scheduled task for user %s "
"(credits deducted: %s)"
),
result.get("sound_name", sound_id),
task.user_id,
result.get("credits_deducted", 0),
)
except Exception as e:
# Convert HTTP exceptions or credit errors to task execution errors
raise TaskExecutionError(f"Failed to play sound with credits: {e!s}") from e
msg = f"Failed to play sound with credits: {e!s}"
raise TaskExecutionError(msg) from e
else:
# System task: play without credit deduction
sound = await self.sound_repository.get_by_id(sound_id_int)
if not sound:
raise TaskExecutionError(f"Sound not found: {sound_id}")
msg = f"Sound not found: {sound_id}"
raise TaskExecutionError(msg)
from app.services.vlc_player import VLCPlayerService
vlc_service = VLCPlayerService(self.db_session_factory)
success = await vlc_service.play_sound(sound)
if not success:
raise TaskExecutionError(f"Failed to play sound {sound.filename}")
msg = f"Failed to play sound {sound.filename}"
raise TaskExecutionError(msg)
logger.info(f"Played sound {sound.filename} via scheduled system task")
logger.info("Played sound %s via scheduled system task", sound.filename)
async def _handle_play_playlist(self, task: ScheduledTask) -> None:
"""Handle play playlist task."""
@@ -128,31 +150,34 @@ class TaskHandlerRegistry:
shuffle = parameters.get("shuffle", False)
if not playlist_id:
raise TaskExecutionError("playlist_id parameter is required for PLAY_PLAYLIST tasks")
msg = "playlist_id parameter is required for PLAY_PLAYLIST tasks"
raise TaskExecutionError(msg)
try:
# Handle both integer and string playlist IDs
playlist_id_int = int(playlist_id)
except (ValueError, TypeError) as e:
raise TaskExecutionError(f"Invalid playlist_id format: {playlist_id}") from e
msg = f"Invalid playlist_id format: {playlist_id}"
raise TaskExecutionError(msg) from e
# Get the playlist from database
playlist = await self.playlist_repository.get_by_id(playlist_id_int)
if not playlist:
raise TaskExecutionError(f"Playlist not found: {playlist_id}")
msg = f"Playlist not found: {playlist_id}"
raise TaskExecutionError(msg)
# Load playlist in player
await self.player_service.load_playlist(playlist_id_int)
# Set play mode if specified
if play_mode in ["continuous", "loop", "loop_one", "random", "single"]:
self.player_service.set_mode(play_mode)
await self.player_service.set_mode(play_mode)
# Enable shuffle if requested
if shuffle:
self.player_service.set_shuffle(True)
await self.player_service.set_shuffle(shuffle=True)
# Start playing
await self.player_service.play()
logger.info(f"Started playing playlist {playlist.name} via scheduled task")
logger.info("Started playing playlist %s via scheduled task", playlist.name)

View File

@@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""Check current tasks in the database."""
import asyncio
from datetime import datetime
from app.core.database import get_session_factory
from app.repositories.scheduled_task import ScheduledTaskRepository
async def check_tasks():
session_factory = get_session_factory()
async with session_factory() as session:
repo = ScheduledTaskRepository(session)
# Get all tasks
all_tasks = await repo.get_all(limit=20)
print("All tasks in database:")
print("=" * 80)
for task in all_tasks:
print(f"ID: {task.id}")
print(f"Name: {task.name}")
print(f"Type: {task.task_type}")
print(f"Status: {task.status}")
print(f"Scheduled: {task.scheduled_at}")
print(f"Timezone: {task.timezone}")
print(f"Active: {task.is_active}")
print(f"User ID: {task.user_id}")
print(f"Executions: {task.executions_count}")
print(f"Last executed: {task.last_executed_at}")
print(f"Error: {task.error_message}")
print(f"Parameters: {task.parameters}")
print("-" * 40)
# Check specifically for pending tasks
print(f"\nCurrent time: {datetime.utcnow()}")
print("\nPending tasks:")
from app.models.scheduled_task import TaskStatus
pending_tasks = await repo.get_all(limit=10)
pending_tasks = [t for t in pending_tasks if t.status == TaskStatus.PENDING and t.is_active]
if not pending_tasks:
print("No pending tasks found")
else:
for task in pending_tasks:
time_diff = task.scheduled_at - datetime.utcnow()
print(f"- {task.name} (ID: {task.id}): scheduled for {task.scheduled_at} (in {time_diff})")
if __name__ == "__main__":
asyncio.run(check_tasks())

View File

@@ -1,37 +0,0 @@
#!/usr/bin/env python3
"""Create a test task with a future execution time."""
import asyncio
from datetime import datetime, timedelta
from app.core.database import get_session_factory
from app.models.scheduled_task import RecurrenceType, TaskType
from app.repositories.scheduled_task import ScheduledTaskRepository
async def create_future_task():
session_factory = get_session_factory()
# Create a task for 2 minutes from now
future_time = datetime.utcnow() + timedelta(minutes=2)
async with session_factory() as session:
repo = ScheduledTaskRepository(session)
task_data = {
"name": f"Future Task {future_time.strftime('%H:%M:%S')}",
"task_type": TaskType.PLAY_SOUND,
"scheduled_at": future_time,
"timezone": "UTC",
"parameters": {"sound_id": 1},
"user_id": 1,
"recurrence_type": RecurrenceType.NONE,
}
task = await repo.create(task_data)
print(f"Created task: {task.name} (ID: {task.id}) scheduled for {task.scheduled_at}")
print(f"Current time: {datetime.utcnow()}")
print(f"Task will execute in: {future_time - datetime.utcnow()}")
if __name__ == "__main__":
asyncio.run(create_future_task())

View File

@@ -8,29 +8,29 @@ dependencies = [
"aiosqlite==0.21.0",
"apscheduler==3.11.0",
"bcrypt==4.3.0",
"email-validator==2.2.0",
"email-validator==2.3.0",
"fastapi[standard]==0.116.1",
"ffmpeg-python==0.2.0",
"httpx==0.28.1",
"pydantic-settings==2.10.1",
"pyjwt==2.10.1",
"python-socketio==5.13.0",
"pytz==2024.1",
"pytz==2025.2",
"python-vlc==3.0.21203",
"sqlmodel==0.0.24",
"uvicorn[standard]==0.35.0",
"yt-dlp==2025.8.20",
"yt-dlp==2025.8.27",
]
[tool.uv]
dev-dependencies = [
"coverage==7.10.4",
"faker==37.5.3",
"coverage==7.10.5",
"faker==37.6.0",
"httpx==0.28.1",
"mypy==1.17.1",
"pytest==8.4.1",
"pytest-asyncio==1.1.0",
"ruff==0.12.10",
"ruff==0.12.11",
]
[tool.mypy]
@@ -69,6 +69,7 @@ ignore = ["D100", "D103", "TRY301"]
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
filterwarnings = [
"ignore:transaction already deassociated from connection:sqlalchemy.exc.SAWarning",
]

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env python3
"""Test creating a task via the scheduler service to simulate API call."""
import asyncio
from datetime import datetime, timedelta
from app.main import get_global_scheduler_service
from app.models.scheduled_task import RecurrenceType, TaskType
async def test_api_task_creation():
"""Test creating a task through the scheduler service (simulates API call)."""
try:
scheduler_service = get_global_scheduler_service()
# Create a task for 2 minutes from now
future_time = datetime.utcnow() + timedelta(minutes=2)
print(f"Creating task scheduled for: {future_time}")
print(f"Current time: {datetime.utcnow()}")
task = await scheduler_service.create_task(
name=f"API Test Task {future_time.strftime('%H:%M:%S')}",
task_type=TaskType.PLAY_SOUND,
scheduled_at=future_time,
parameters={"sound_id": 1},
user_id=1,
timezone="UTC",
recurrence_type=RecurrenceType.NONE,
)
print(f"Created task: {task.name} (ID: {task.id})")
print(f"Task will execute in: {future_time - datetime.utcnow()}")
print("Task should be automatically scheduled in APScheduler!")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
asyncio.run(test_api_task_creation())

View File

@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""Create a test task for scheduler testing."""
import asyncio
from datetime import datetime
from app.core.database import get_session_factory
from app.models.scheduled_task import RecurrenceType, TaskType
from app.repositories.scheduled_task import ScheduledTaskRepository
async def create_test_task():
session_factory = get_session_factory()
async with session_factory() as session:
repo = ScheduledTaskRepository(session)
task_data = {
"name": "Live Test Task",
"task_type": TaskType.PLAY_SOUND,
"scheduled_at": datetime(2025, 8, 28, 15, 21, 0), # 15:21:00 UTC
"timezone": "UTC",
"parameters": {"sound_id": 1},
"user_id": 1,
"recurrence_type": RecurrenceType.NONE,
}
task = await repo.create(task_data)
print(f"Created task: {task.name} (ID: {task.id}) scheduled for {task.scheduled_at}")
if __name__ == "__main__":
asyncio.run(create_test_task())

View File

@@ -358,15 +358,13 @@ def test_user_id(test_user: User):
@pytest.fixture
def test_sound_id():
"""Create a test sound ID."""
import uuid
return uuid.uuid4()
return 1
@pytest.fixture
def test_playlist_id():
"""Create a test playlist ID."""
import uuid
return uuid.uuid4()
return 1
@pytest.fixture

View File

@@ -20,7 +20,9 @@ class TestSchedulerService:
@pytest.fixture
def scheduler_service(self, mock_db_session_factory):
"""Create a scheduler service instance for testing."""
return SchedulerService(mock_db_session_factory)
from unittest.mock import MagicMock
mock_player_service = MagicMock()
return SchedulerService(mock_db_session_factory, mock_player_service)
@pytest.mark.asyncio
async def test_start_scheduler(self, scheduler_service) -> None:
@@ -31,20 +33,18 @@ class TestSchedulerService:
):
await scheduler_service.start()
# Verify job was added
mock_add_job.assert_called_once_with(
scheduler_service._daily_credit_recharge,
"cron",
hour=0,
minute=0,
id="daily_credit_recharge",
name="Daily Credit Recharge",
replace_existing=True,
)
# Verify scheduler was started
# Verify scheduler start was called
mock_start.assert_called_once()
# Verify jobs were added (2 calls: initialize_system_tasks and scheduler_maintenance)
assert mock_add_job.call_count == 2
# Check that the jobs are the expected ones
calls = mock_add_job.call_args_list
job_ids = [call[1]["id"] for call in calls]
assert "initialize_system_tasks" in job_ids
assert "scheduler_maintenance" in job_ids
@pytest.mark.asyncio
async def test_stop_scheduler(self, scheduler_service) -> None:
"""Test stopping the scheduler service."""
@@ -52,36 +52,3 @@ class TestSchedulerService:
await scheduler_service.stop()
mock_shutdown.assert_called_once()
@pytest.mark.asyncio
async def test_daily_credit_recharge_success(self, scheduler_service) -> None:
"""Test successful daily credit recharge task."""
mock_stats = {
"total_users": 10,
"recharged_users": 8,
"skipped_users": 2,
"total_credits_added": 500,
}
with patch.object(
scheduler_service.credit_service,
"recharge_all_users_credits",
) as mock_recharge:
mock_recharge.return_value = mock_stats
await scheduler_service._daily_credit_recharge()
mock_recharge.assert_called_once()
@pytest.mark.asyncio
async def test_daily_credit_recharge_failure(self, scheduler_service) -> None:
"""Test daily credit recharge task with failure."""
with patch.object(
scheduler_service.credit_service,
"recharge_all_users_credits",
) as mock_recharge:
mock_recharge.side_effect = Exception("Database error")
# Should not raise exception, just log it
await scheduler_service._daily_credit_recharge()
mock_recharge.assert_called_once()

View File

@@ -2,7 +2,7 @@
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
@@ -405,8 +405,17 @@ class TestVLCPlayerService:
async def test_record_play_count_success(self, vlc_service_with_db) -> None:
"""Test successful play count recording."""
# Mock session and repositories
mock_session = AsyncMock()
vlc_service_with_db.db_session_factory.return_value = mock_session
mock_session = MagicMock()
# Make async methods async mocks but keep sync methods as regular mocks
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.close = AsyncMock()
# Mock the context manager behavior
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_session)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
vlc_service_with_db.db_session_factory.return_value = mock_context_manager
mock_sound_repo = AsyncMock()
mock_user_repo = AsyncMock()
@@ -449,18 +458,18 @@ class TestVLCPlayerService:
# Verify sound repository calls
mock_sound_repo.get_by_id.assert_called_once_with(1)
mock_sound_repo.update.assert_called_once_with(
test_sound,
{"play_count": 1},
)
# Verify user repository calls
mock_user_repo.get_by_id.assert_called_once_with(1)
# Verify session operations
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
mock_session.close.assert_called_once()
# Verify session operations (called twice: once for sound, once for sound_played)
assert mock_session.add.call_count == 2
# Commit is called twice: once after updating sound, once after adding sound_played
assert mock_session.commit.call_count == 2
# Context manager handles session cleanup, so no explicit close() call
# Verify the sound's play count was incremented
assert test_sound.play_count == 1
# Verify socket broadcast
mock_socket.broadcast_to_all.assert_called_once_with(
@@ -488,8 +497,17 @@ class TestVLCPlayerService:
) -> None:
"""Test play count recording always creates a new SoundPlayed record."""
# Mock session and repositories
mock_session = AsyncMock()
vlc_service_with_db.db_session_factory.return_value = mock_session
mock_session = MagicMock()
# Make async methods async mocks but keep sync methods as regular mocks
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session.close = AsyncMock()
# Mock the context manager behavior
mock_context_manager = AsyncMock()
mock_context_manager.__aenter__ = AsyncMock(return_value=mock_session)
mock_context_manager.__aexit__ = AsyncMock(return_value=None)
vlc_service_with_db.db_session_factory.return_value = mock_context_manager
mock_sound_repo = AsyncMock()
mock_user_repo = AsyncMock()
@@ -530,17 +548,19 @@ class TestVLCPlayerService:
await vlc_service_with_db._record_play_count(1, "Test Sound")
# Verify sound play count was updated
mock_sound_repo.update.assert_called_once_with(
test_sound,
{"play_count": 6},
)
# Verify sound repository calls
mock_sound_repo.get_by_id.assert_called_once_with(1)
# Verify new SoundPlayed record was always added
mock_session.add.assert_called_once()
# Verify user repository calls
mock_user_repo.get_by_id.assert_called_once_with(1)
# Verify commit happened
mock_session.commit.assert_called_once()
# Verify session operations (called twice: once for sound, once for sound_played)
assert mock_session.add.call_count == 2
# Commit is called twice: once after updating sound, once after adding sound_played
assert mock_session.commit.call_count == 2
# Verify the sound's play count was incremented from 5 to 6
assert test_sound.play_count == 6
def test_uses_shared_sound_path_utility(self, vlc_service, sample_sound) -> None:
"""Test that VLC service uses the shared sound path utility."""

View File

@@ -1,7 +1,7 @@
"""Tests for scheduled task model."""
import uuid
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from app.models.scheduled_task import (
RecurrenceType,
@@ -19,7 +19,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="Test Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
)
assert task.name == "Test Task"
@@ -38,7 +38,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="User Task",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
user_id=user_id,
)
@@ -50,7 +50,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="System Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
)
assert task.user_id is None
@@ -61,7 +61,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="Recurring Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.DAILY,
recurrence_count=5,
)
@@ -74,7 +74,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="One-shot Task",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.NONE,
)
@@ -86,7 +86,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="Infinite Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.DAILY,
recurrence_count=None, # Infinite
)
@@ -103,7 +103,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="Limited Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.DAILY,
recurrence_count=3,
)
@@ -120,21 +120,21 @@ class TestScheduledTaskModel:
def test_task_expiration(self):
"""Test task expiration."""
# Non-expired task
# Non-expired task (using naive UTC datetimes)
task = ScheduledTask(
name="Valid Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
expires_at=datetime.utcnow() + timedelta(hours=2),
scheduled_at=datetime.now(tz=UTC).replace(tzinfo=None) + timedelta(hours=1),
expires_at=datetime.now(tz=UTC).replace(tzinfo=None) + timedelta(hours=2),
)
assert not task.is_expired()
# Expired task
# Expired task (using naive UTC datetimes)
expired_task = ScheduledTask(
name="Expired Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
expires_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC).replace(tzinfo=None) + timedelta(hours=1),
expires_at=datetime.now(tz=UTC).replace(tzinfo=None) - timedelta(hours=1),
)
assert expired_task.is_expired()
@@ -142,7 +142,7 @@ class TestScheduledTaskModel:
no_expiry_task = ScheduledTask(
name="No Expiry Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
)
assert not no_expiry_task.is_expired()
@@ -157,7 +157,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="Parametrized Task",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
parameters=parameters,
)
@@ -171,7 +171,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="NY Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
timezone="America/New_York",
)
@@ -184,7 +184,7 @@ class TestScheduledTaskModel:
task = ScheduledTask(
name="Cron Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.CRON,
cron_expression=cron_expr,
)

View File

@@ -1,7 +1,6 @@
"""Tests for scheduled task repository."""
import uuid
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -29,42 +28,42 @@ class TestScheduledTaskRepository:
repository: ScheduledTaskRepository,
) -> ScheduledTask:
"""Create a sample scheduled task."""
task = ScheduledTask(
name="Test Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
parameters={"test": "value"},
)
return await repository.create(task)
task_data = {
"name": "Test Task",
"task_type": TaskType.CREDIT_RECHARGE,
"scheduled_at": datetime.now(tz=UTC) + timedelta(hours=1),
"parameters": {"test": "value"},
}
return await repository.create(task_data)
@pytest.fixture
async def user_task(
self,
repository: ScheduledTaskRepository,
test_user_id: uuid.UUID,
test_user_id: int,
) -> ScheduledTask:
"""Create a user task."""
task = ScheduledTask(
name="User Task",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow() + timedelta(hours=2),
user_id=test_user_id,
parameters={"sound_id": str(uuid.uuid4())},
)
return await repository.create(task)
task_data = {
"name": "User Task",
"task_type": TaskType.PLAY_SOUND,
"scheduled_at": datetime.now(tz=UTC) + timedelta(hours=2),
"user_id": test_user_id,
"parameters": {"sound_id": "1"},
}
return await repository.create(task_data)
async def test_create_task(self, repository: ScheduledTaskRepository):
"""Test creating a scheduled task."""
task = ScheduledTask(
name="Test Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
timezone="America/New_York",
recurrence_type=RecurrenceType.DAILY,
parameters={"test": "value"},
)
task_data = {
"name": "Test Task",
"task_type": TaskType.CREDIT_RECHARGE,
"scheduled_at": datetime.now(tz=UTC) + timedelta(hours=1),
"timezone": "America/New_York",
"recurrence_type": RecurrenceType.DAILY,
"parameters": {"test": "value"},
}
created_task = await repository.create(task)
created_task = await repository.create(task_data)
assert created_task.id is not None
assert created_task.name == "Test Task"
@@ -85,7 +84,7 @@ class TestScheduledTaskRepository:
past_pending = ScheduledTask(
name="Past Pending",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) - timedelta(hours=1),
status=TaskStatus.PENDING,
)
await repository.create(past_pending)
@@ -93,7 +92,7 @@ class TestScheduledTaskRepository:
future_pending = ScheduledTask(
name="Future Pending",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
status=TaskStatus.PENDING,
)
await repository.create(future_pending)
@@ -101,7 +100,7 @@ class TestScheduledTaskRepository:
completed_task = ScheduledTask(
name="Completed",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) - timedelta(hours=1),
status=TaskStatus.COMPLETED,
)
await repository.create(completed_task)
@@ -109,7 +108,7 @@ class TestScheduledTaskRepository:
inactive_task = ScheduledTask(
name="Inactive",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) - timedelta(hours=1),
status=TaskStatus.PENDING,
is_active=False,
)
@@ -126,15 +125,15 @@ class TestScheduledTaskRepository:
self,
repository: ScheduledTaskRepository,
user_task: ScheduledTask,
test_user_id: uuid.UUID,
test_user_id: int,
):
"""Test getting tasks for a specific user."""
# Create another user's task
other_user_id = uuid.uuid4()
other_user_id = 999
other_task = ScheduledTask(
name="Other User Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
user_id=other_user_id,
)
await repository.create(other_task)
@@ -143,7 +142,7 @@ class TestScheduledTaskRepository:
system_task = ScheduledTask(
name="System Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
)
await repository.create(system_task)
@@ -156,7 +155,7 @@ class TestScheduledTaskRepository:
async def test_get_user_tasks_with_filters(
self,
repository: ScheduledTaskRepository,
test_user_id: uuid.UUID,
test_user_id: int,
):
"""Test getting user tasks with status and type filters."""
# Create tasks with different statuses and types
@@ -172,7 +171,7 @@ class TestScheduledTaskRepository:
name=name,
task_type=task_type,
status=status,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
user_id=test_user_id,
)
await repository.create(task)
@@ -224,10 +223,10 @@ class TestScheduledTaskRepository:
due_task = ScheduledTask(
name="Due Recurring",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) - timedelta(hours=1),
recurrence_type=RecurrenceType.DAILY,
status=TaskStatus.COMPLETED,
next_execution_at=datetime.utcnow() - timedelta(minutes=5),
next_execution_at=datetime.now(tz=UTC) - timedelta(minutes=5),
)
await repository.create(due_task)
@@ -235,10 +234,10 @@ class TestScheduledTaskRepository:
not_due_task = ScheduledTask(
name="Not Due Recurring",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) - timedelta(hours=1),
recurrence_type=RecurrenceType.DAILY,
status=TaskStatus.COMPLETED,
next_execution_at=datetime.utcnow() + timedelta(hours=1),
next_execution_at=datetime.now(tz=UTC) + timedelta(hours=1),
)
await repository.create(not_due_task)
@@ -246,7 +245,7 @@ class TestScheduledTaskRepository:
non_recurring = ScheduledTask(
name="Non-recurring",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) - timedelta(hours=1),
recurrence_type=RecurrenceType.NONE,
status=TaskStatus.COMPLETED,
)
@@ -266,8 +265,8 @@ class TestScheduledTaskRepository:
expired_task = ScheduledTask(
name="Expired Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
expires_at=datetime.utcnow() - timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
expires_at=datetime.now(tz=UTC) - timedelta(hours=1),
)
await repository.create(expired_task)
@@ -275,8 +274,8 @@ class TestScheduledTaskRepository:
valid_task = ScheduledTask(
name="Valid Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
expires_at=datetime.utcnow() + timedelta(hours=2),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
expires_at=datetime.now(tz=UTC) + timedelta(hours=2),
)
await repository.create(valid_task)
@@ -284,7 +283,7 @@ class TestScheduledTaskRepository:
no_expiry_task = ScheduledTask(
name="No Expiry",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
)
await repository.create(no_expiry_task)
@@ -296,7 +295,7 @@ class TestScheduledTaskRepository:
async def test_cancel_user_tasks(
self,
repository: ScheduledTaskRepository,
test_user_id: uuid.UUID,
test_user_id: int,
):
"""Test cancelling user tasks."""
# Create multiple user tasks
@@ -311,7 +310,7 @@ class TestScheduledTaskRepository:
name=name,
task_type=task_type,
status=status,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
user_id=test_user_id,
)
await repository.create(task)
@@ -338,14 +337,14 @@ class TestScheduledTaskRepository:
async def test_cancel_user_tasks_by_type(
self,
repository: ScheduledTaskRepository,
test_user_id: uuid.UUID,
test_user_id: int,
):
"""Test cancelling user tasks by type."""
# Create tasks of different types
credit_task = ScheduledTask(
name="Credit Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
user_id=test_user_id,
)
await repository.create(credit_task)
@@ -353,7 +352,7 @@ class TestScheduledTaskRepository:
sound_task = ScheduledTask(
name="Sound Task",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
user_id=test_user_id,
)
await repository.create(sound_task)
@@ -400,7 +399,7 @@ class TestScheduledTaskRepository:
):
"""Test marking task as completed."""
initial_count = sample_task.executions_count
next_execution = datetime.utcnow() + timedelta(days=1)
next_execution = datetime.now(tz=UTC) + timedelta(days=1)
await repository.mark_as_completed(sample_task, next_execution)
@@ -418,12 +417,12 @@ class TestScheduledTaskRepository:
task = ScheduledTask(
name="Recurring Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
recurrence_type=RecurrenceType.DAILY,
)
created_task = await repository.create(task)
next_execution = datetime.utcnow() + timedelta(days=1)
next_execution = datetime.now(tz=UTC).replace(tzinfo=None) + timedelta(days=1)
await repository.mark_as_completed(created_task, next_execution)
updated_task = await repository.get_by_id(created_task.id)
@@ -467,7 +466,7 @@ class TestScheduledTaskRepository:
task = ScheduledTask(
name="Recurring Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
recurrence_type=RecurrenceType.DAILY,
)
created_task = await repository.create(task)

View File

@@ -1,7 +1,7 @@
"""Tests for scheduler service."""
import uuid
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -13,6 +13,7 @@ from app.models.scheduled_task import (
TaskStatus,
TaskType,
)
from app.schemas.scheduler import ScheduledTaskCreate
from app.services.scheduler import SchedulerService
@@ -31,7 +32,8 @@ class TestSchedulerService:
mock_player_service,
) -> SchedulerService:
"""Create scheduler service fixture."""
session_factory = lambda: db_session
def session_factory():
return db_session
return SchedulerService(session_factory, mock_player_service)
@pytest.fixture
@@ -40,11 +42,16 @@ class TestSchedulerService:
return {
"name": "Test Task",
"task_type": TaskType.CREDIT_RECHARGE,
"scheduled_at": datetime.utcnow() + timedelta(hours=1),
"scheduled_at": datetime.now(tz=UTC) + timedelta(hours=1),
"parameters": {"test": "value"},
"timezone": "UTC",
}
def _create_task_schema(self, task_data: dict, **overrides) -> ScheduledTaskCreate:
"""Create ScheduledTaskCreate schema from dict."""
data = {**task_data, **overrides}
return ScheduledTaskCreate(**data)
async def test_create_task(
self,
scheduler_service: SchedulerService,
@@ -52,7 +59,8 @@ class TestSchedulerService:
):
"""Test creating a scheduled task."""
with patch.object(scheduler_service, "_schedule_apscheduler_job") as mock_schedule:
task = await scheduler_service.create_task(**sample_task_data)
schema = self._create_task_schema(sample_task_data)
task = await scheduler_service.create_task(task_data=schema)
assert task.id is not None
assert task.name == sample_task_data["name"]
@@ -69,9 +77,10 @@ class TestSchedulerService:
):
"""Test creating a user task."""
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
schema = self._create_task_schema(sample_task_data)
task = await scheduler_service.create_task(
task_data=schema,
user_id=test_user_id,
**sample_task_data,
)
assert task.user_id == test_user_id
@@ -84,7 +93,8 @@ class TestSchedulerService:
):
"""Test creating a system task."""
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(**sample_task_data)
schema = self._create_task_schema(sample_task_data)
task = await scheduler_service.create_task(task_data=schema)
assert task.user_id is None
assert task.is_system_task()
@@ -96,11 +106,12 @@ class TestSchedulerService:
):
"""Test creating a recurring task."""
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(
schema = self._create_task_schema(
sample_task_data,
recurrence_type=RecurrenceType.DAILY,
recurrence_count=5,
**sample_task_data,
)
task = await scheduler_service.create_task(task_data=schema)
assert task.recurrence_type == RecurrenceType.DAILY
assert task.recurrence_count == 5
@@ -113,13 +124,15 @@ class TestSchedulerService:
):
"""Test creating task with timezone conversion."""
# Use a specific datetime for testing
ny_time = datetime(2024, 1, 1, 12, 0, 0) # Noon in NY
sample_task_data["scheduled_at"] = ny_time
sample_task_data["timezone"] = "America/New_York"
ny_time = datetime(2024, 1, 1, 12, 0, 0) # Noon in NY # noqa: DTZ001
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(**sample_task_data)
schema = self._create_task_schema(
sample_task_data,
scheduled_at=ny_time,
timezone="America/New_York",
)
task = await scheduler_service.create_task(task_data=schema)
# The scheduled_at should be converted to UTC
assert task.timezone == "America/New_York"
@@ -135,7 +148,8 @@ class TestSchedulerService:
"""Test cancelling a task."""
# Create a task first
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(**sample_task_data)
schema = self._create_task_schema(sample_task_data)
task = await scheduler_service.create_task(task_data=schema)
# Mock the scheduler remove_job method
with patch.object(scheduler_service.scheduler, "remove_job") as mock_remove:
@@ -169,13 +183,15 @@ class TestSchedulerService:
"""Test getting user tasks."""
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
# Create user task
schema = self._create_task_schema(sample_task_data)
await scheduler_service.create_task(
task_data=schema,
user_id=test_user_id,
**sample_task_data,
)
# Create system task
await scheduler_service.create_task(**sample_task_data)
system_schema = self._create_task_schema(sample_task_data)
await scheduler_service.create_task(task_data=system_schema)
user_tasks = await scheduler_service.get_user_tasks(test_user_id)
@@ -196,10 +212,10 @@ class TestSchedulerService:
# Should create daily credit recharge task
mock_create.assert_called_once()
created_task = mock_create.call_args[0][0]
assert created_task.name == "Daily Credit Recharge"
assert created_task.task_type == TaskType.CREDIT_RECHARGE
assert created_task.recurrence_type == RecurrenceType.DAILY
created_task_data = mock_create.call_args[0][0]
assert created_task_data["name"] == "Daily Credit Recharge"
assert created_task_data["task_type"] == TaskType.CREDIT_RECHARGE
assert created_task_data["recurrence_type"] == RecurrenceType.DAILY
async def test_ensure_system_tasks_already_exist(
self,
@@ -209,7 +225,7 @@ class TestSchedulerService:
existing_task = ScheduledTask(
name="Existing Daily Credit Recharge",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
recurrence_type=RecurrenceType.DAILY,
is_active=True,
)
@@ -231,7 +247,7 @@ class TestSchedulerService:
task = ScheduledTask(
name="One Shot",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.NONE,
)
@@ -247,7 +263,7 @@ class TestSchedulerService:
task = ScheduledTask(
name="Daily",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.DAILY,
)
@@ -263,7 +279,7 @@ class TestSchedulerService:
task = ScheduledTask(
name="Cron",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow() + timedelta(hours=1),
scheduled_at=datetime.now(tz=UTC) + timedelta(hours=1),
recurrence_type=RecurrenceType.CRON,
cron_expression="0 9 * * *", # 9 AM daily
)
@@ -280,7 +296,7 @@ class TestSchedulerService:
task = ScheduledTask(
name="Monthly",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime(2024, 1, 15, 10, 30, 0), # 15th at 10:30 AM
scheduled_at=datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC), # 15th at 10:30 AM
recurrence_type=RecurrenceType.MONTHLY,
)
@@ -293,7 +309,7 @@ class TestSchedulerService:
scheduler_service: SchedulerService,
):
"""Test calculating next execution time."""
now = datetime.utcnow()
now = datetime.now(tz=UTC)
# Test different recurrence types
test_cases = [
@@ -313,7 +329,7 @@ class TestSchedulerService:
)
with patch("app.services.scheduler.datetime") as mock_datetime:
mock_datetime.utcnow.return_value = now
mock_datetime.now.return_value = now
next_execution = scheduler_service._calculate_next_execution(task)
assert next_execution is not None
@@ -328,14 +344,14 @@ class TestSchedulerService:
task = ScheduledTask(
name="One Shot",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
recurrence_type=RecurrenceType.NONE,
)
next_execution = scheduler_service._calculate_next_execution(task)
assert next_execution is None
@patch("app.services.task_handlers.TaskHandlerRegistry")
@patch("app.services.scheduler.TaskHandlerRegistry")
async def test_execute_task_success(
self,
mock_handler_class,
@@ -343,9 +359,11 @@ class TestSchedulerService:
sample_task_data: dict,
):
"""Test successful task execution."""
# Create task
# Create task ready for immediate execution
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(**sample_task_data)
ready_data = {**sample_task_data, "scheduled_at": datetime.now(tz=UTC) - timedelta(minutes=1)}
schema = self._create_task_schema(ready_data)
task = await scheduler_service.create_task(task_data=schema)
# Mock handler registry
mock_handler = AsyncMock()
@@ -365,7 +383,7 @@ class TestSchedulerService:
assert updated_task.status == TaskStatus.COMPLETED
assert updated_task.executions_count == 1
@patch("app.services.task_handlers.TaskHandlerRegistry")
@patch("app.services.scheduler.TaskHandlerRegistry")
async def test_execute_task_failure(
self,
mock_handler_class,
@@ -373,9 +391,11 @@ class TestSchedulerService:
sample_task_data: dict,
):
"""Test task execution failure."""
# Create task
# Create task ready for immediate execution
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(**sample_task_data)
ready_data = {**sample_task_data, "scheduled_at": datetime.now(tz=UTC) - timedelta(minutes=1)}
schema = self._create_task_schema(ready_data)
task = await scheduler_service.create_task(task_data=schema)
# Mock handler to raise exception
mock_handler = AsyncMock()
@@ -407,11 +427,12 @@ class TestSchedulerService:
sample_task_data: dict,
):
"""Test executing expired task."""
# Create expired task
sample_task_data["expires_at"] = datetime.utcnow() - timedelta(hours=1)
# Create expired task (stored as naive UTC datetime)
expires_at = datetime.now(tz=UTC).replace(tzinfo=None) - timedelta(hours=1)
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(**sample_task_data)
schema = self._create_task_schema(sample_task_data, expires_at=expires_at)
task = await scheduler_service.create_task(task_data=schema)
# Execute task
await scheduler_service._execute_task(task.id)
@@ -431,7 +452,8 @@ class TestSchedulerService:
):
"""Test prevention of concurrent task execution."""
with patch.object(scheduler_service, "_schedule_apscheduler_job"):
task = await scheduler_service.create_task(**sample_task_data)
schema = self._create_task_schema(sample_task_data)
task = await scheduler_service.create_task(task_data=schema)
# Add task to running set
scheduler_service._running_tasks.add(str(task.id))
@@ -443,7 +465,7 @@ class TestSchedulerService:
# Handler should not be called
mock_handler_class.assert_not_called()
@patch("app.repositories.scheduled_task.ScheduledTaskRepository")
@patch("app.services.scheduler.ScheduledTaskRepository")
async def test_maintenance_job_expired_tasks(
self,
mock_repo_class,
@@ -463,12 +485,13 @@ class TestSchedulerService:
await scheduler_service._maintenance_job()
# Should mark as cancelled and remove from scheduler
assert expired_task.status == TaskStatus.CANCELLED
assert expired_task.is_active is False
mock_repo.update.assert_called_with(expired_task)
mock_repo.update.assert_called_with(expired_task, {
"status": TaskStatus.CANCELLED,
"is_active": False,
})
mock_remove.assert_called_once_with(str(expired_task.id))
@patch("app.repositories.scheduled_task.ScheduledTaskRepository")
@patch("app.services.scheduler.ScheduledTaskRepository")
async def test_maintenance_job_due_recurring_tasks(
self,
mock_repo_class,
@@ -478,7 +501,7 @@ class TestSchedulerService:
# Mock due recurring task
due_task = MagicMock()
due_task.should_repeat.return_value = True
due_task.next_execution_at = datetime.utcnow() - timedelta(minutes=5)
due_task.next_execution_at = datetime.now(tz=UTC) - timedelta(minutes=5)
mock_repo = AsyncMock()
mock_repo.get_expired_tasks.return_value = []
@@ -489,7 +512,8 @@ class TestSchedulerService:
await scheduler_service._maintenance_job()
# Should reset to pending and reschedule
assert due_task.status == TaskStatus.PENDING
assert due_task.scheduled_at == due_task.next_execution_at
mock_repo.update.assert_called_with(due_task)
mock_repo.update.assert_called_with(due_task, {
"status": TaskStatus.PENDING,
"scheduled_at": due_task.next_execution_at,
})
mock_schedule.assert_called_once_with(due_task)

View File

@@ -1,6 +1,7 @@
"""Tests for task handlers."""
import uuid
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -21,7 +22,7 @@ class TestTaskHandlerRegistry:
@pytest.fixture
def mock_player_service(self):
"""Create mock player service."""
return MagicMock()
return AsyncMock()
@pytest.fixture
def task_registry(
@@ -31,8 +32,11 @@ class TestTaskHandlerRegistry:
mock_player_service,
) -> TaskHandlerRegistry:
"""Create task handler registry fixture."""
def mock_db_session_factory():
return db_session
return TaskHandlerRegistry(
db_session,
mock_db_session_factory,
mock_credit_service,
mock_player_service,
)
@@ -46,7 +50,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Unknown Task",
task_type="UNKNOWN_TYPE", # Invalid type
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
)
with pytest.raises(TaskExecutionError, match="No handler registered"):
@@ -61,7 +65,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Daily Credit Recharge",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={},
)
@@ -84,7 +88,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="User Credit Recharge",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"user_id": str(test_user_id)},
)
@@ -107,7 +111,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="User Credit Recharge",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"user_id": test_user_id}, # UUID object instead of string
)
@@ -118,13 +122,13 @@ class TestTaskHandlerRegistry:
async def test_handle_play_sound_success(
self,
task_registry: TaskHandlerRegistry,
test_sound_id: uuid.UUID,
test_sound_id: int,
):
"""Test successful play sound task handling."""
task = ScheduledTask(
name="Play Sound",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"sound_id": str(test_sound_id)},
)
@@ -134,8 +138,9 @@ class TestTaskHandlerRegistry:
mock_sound.filename = "test_sound.mp3"
with patch.object(task_registry.sound_repository, "get_by_id", return_value=mock_sound):
with patch("app.services.vlc_player.VLCPlayerService") as mock_vlc_class:
with patch("app.services.task_handlers.VLCPlayerService") as mock_vlc_class:
mock_vlc_service = AsyncMock()
mock_vlc_service.play_sound.return_value = True
mock_vlc_class.return_value = mock_vlc_service
await task_registry.execute_task(task)
@@ -151,7 +156,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Play Sound",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={}, # Missing sound_id
)
@@ -166,7 +171,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Play Sound",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"sound_id": "invalid-uuid"},
)
@@ -176,13 +181,13 @@ class TestTaskHandlerRegistry:
async def test_handle_play_sound_not_found(
self,
task_registry: TaskHandlerRegistry,
test_sound_id: uuid.UUID,
test_sound_id: int,
):
"""Test play sound task with non-existent sound."""
task = ScheduledTask(
name="Play Sound",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"sound_id": str(test_sound_id)},
)
@@ -193,13 +198,13 @@ class TestTaskHandlerRegistry:
async def test_handle_play_sound_uuid_parameter(
self,
task_registry: TaskHandlerRegistry,
test_sound_id: uuid.UUID,
test_sound_id: int,
):
"""Test play sound task with UUID parameter (not string)."""
task = ScheduledTask(
name="Play Sound",
task_type=TaskType.PLAY_SOUND,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"sound_id": test_sound_id}, # UUID object
)
@@ -207,7 +212,7 @@ class TestTaskHandlerRegistry:
mock_sound.filename = "test_sound.mp3"
with patch.object(task_registry.sound_repository, "get_by_id", return_value=mock_sound):
with patch("app.services.vlc_player.VLCPlayerService") as mock_vlc_class:
with patch("app.services.task_handlers.VLCPlayerService") as mock_vlc_class:
mock_vlc_service = AsyncMock()
mock_vlc_class.return_value = mock_vlc_service
@@ -219,13 +224,13 @@ class TestTaskHandlerRegistry:
self,
task_registry: TaskHandlerRegistry,
mock_player_service,
test_playlist_id: uuid.UUID,
test_playlist_id: int,
):
"""Test successful play playlist task handling."""
task = ScheduledTask(
name="Play Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={
"playlist_id": str(test_playlist_id),
"play_mode": "loop",
@@ -244,20 +249,20 @@ class TestTaskHandlerRegistry:
task_registry.playlist_repository.get_by_id.assert_called_once_with(test_playlist_id)
mock_player_service.load_playlist.assert_called_once_with(test_playlist_id)
mock_player_service.set_mode.assert_called_once_with("loop")
mock_player_service.set_shuffle.assert_called_once_with(True)
mock_player_service.set_shuffle.assert_called_once_with(shuffle=True)
mock_player_service.play.assert_called_once()
async def test_handle_play_playlist_minimal_parameters(
self,
task_registry: TaskHandlerRegistry,
mock_player_service,
test_playlist_id: uuid.UUID,
test_playlist_id: int,
):
"""Test play playlist task with minimal parameters."""
task = ScheduledTask(
name="Play Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"playlist_id": str(test_playlist_id)},
)
@@ -269,7 +274,7 @@ class TestTaskHandlerRegistry:
# Should use default values
mock_player_service.set_mode.assert_called_once_with("continuous")
mock_player_service.set_shuffle.assert_called_once_with(False)
mock_player_service.set_shuffle.assert_not_called()
async def test_handle_play_playlist_missing_playlist_id(
self,
@@ -279,7 +284,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Play Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={}, # Missing playlist_id
)
@@ -294,7 +299,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Play Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"playlist_id": "invalid-uuid"},
)
@@ -304,13 +309,13 @@ class TestTaskHandlerRegistry:
async def test_handle_play_playlist_not_found(
self,
task_registry: TaskHandlerRegistry,
test_playlist_id: uuid.UUID,
test_playlist_id: int,
):
"""Test play playlist task with non-existent playlist."""
task = ScheduledTask(
name="Play Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={"playlist_id": str(test_playlist_id)},
)
@@ -322,7 +327,7 @@ class TestTaskHandlerRegistry:
self,
task_registry: TaskHandlerRegistry,
mock_player_service,
test_playlist_id: uuid.UUID,
test_playlist_id: int,
):
"""Test play playlist task with various valid play modes."""
mock_playlist = MagicMock()
@@ -334,7 +339,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Play Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={
"playlist_id": str(test_playlist_id),
"play_mode": mode,
@@ -352,13 +357,13 @@ class TestTaskHandlerRegistry:
self,
task_registry: TaskHandlerRegistry,
mock_player_service,
test_playlist_id: uuid.UUID,
test_playlist_id: int,
):
"""Test play playlist task with invalid play mode."""
task = ScheduledTask(
name="Play Playlist",
task_type=TaskType.PLAY_PLAYLIST,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={
"playlist_id": str(test_playlist_id),
"play_mode": "invalid_mode",
@@ -386,7 +391,7 @@ class TestTaskHandlerRegistry:
task = ScheduledTask(
name="Failing Task",
task_type=TaskType.CREDIT_RECHARGE,
scheduled_at=datetime.utcnow(),
scheduled_at=datetime.now(tz=UTC),
parameters={},
)
@@ -403,13 +408,17 @@ class TestTaskHandlerRegistry:
mock_player_service,
):
"""Test task registry initialization."""
def mock_db_session_factory():
return db_session
registry = TaskHandlerRegistry(
db_session,
mock_db_session_factory,
mock_credit_service,
mock_player_service,
)
assert registry.db_session == db_session
assert registry.db_session_factory == mock_db_session_factory
assert registry.credit_service == mock_credit_service
assert registry.player_service == mock_player_service
assert registry.sound_repository is not None

192
uv.lock generated
View File

@@ -140,66 +140,66 @@ wheels = [
[[package]]
name = "coverage"
version = "7.10.4"
version = "7.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/4e/08b493f1f1d8a5182df0044acc970799b58a8d289608e0d891a03e9d269a/coverage-7.10.4.tar.gz", hash = "sha256:25f5130af6c8e7297fd14634955ba9e1697f47143f289e2a23284177c0061d27", size = 823798 }
sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/4a/781c9e4dd57cabda2a28e2ce5b00b6be416015265851060945a5ed4bd85e/coverage-7.10.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a1f0264abcabd4853d4cb9b3d164adbf1565da7dab1da1669e93f3ea60162d79", size = 216706 },
{ url = "https://files.pythonhosted.org/packages/6a/8c/51255202ca03d2e7b664770289f80db6f47b05138e06cce112b3957d5dfd/coverage-7.10.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:536cbe6b118a4df231b11af3e0f974a72a095182ff8ec5f4868c931e8043ef3e", size = 216939 },
{ url = "https://files.pythonhosted.org/packages/06/7f/df11131483698660f94d3c847dc76461369782d7a7644fcd72ac90da8fd0/coverage-7.10.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9a4c0d84134797b7bf3f080599d0cd501471f6c98b715405166860d79cfaa97e", size = 248429 },
{ url = "https://files.pythonhosted.org/packages/eb/fa/13ac5eda7300e160bf98f082e75f5c5b4189bf3a883dd1ee42dbedfdc617/coverage-7.10.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c155fc0f9cee8c9803ea0ad153ab6a3b956baa5d4cd993405dc0b45b2a0b9e0", size = 251178 },
{ url = "https://files.pythonhosted.org/packages/9a/bc/f63b56a58ad0bec68a840e7be6b7ed9d6f6288d790760647bb88f5fea41e/coverage-7.10.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5f2ab6e451d4b07855d8bcf063adf11e199bff421a4ba57f5bb95b7444ca62", size = 252313 },
{ url = "https://files.pythonhosted.org/packages/2b/b6/79338f1ea27b01266f845afb4485976211264ab92407d1c307babe3592a7/coverage-7.10.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:685b67d99b945b0c221be0780c336b303a7753b3e0ec0d618c795aada25d5e7a", size = 250230 },
{ url = "https://files.pythonhosted.org/packages/bc/93/3b24f1da3e0286a4dc5832427e1d448d5296f8287464b1ff4a222abeeeb5/coverage-7.10.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0c079027e50c2ae44da51c2e294596cbc9dbb58f7ca45b30651c7e411060fc23", size = 248351 },
{ url = "https://files.pythonhosted.org/packages/de/5f/d59412f869e49dcc5b89398ef3146c8bfaec870b179cc344d27932e0554b/coverage-7.10.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3749aa72b93ce516f77cf5034d8e3c0dfd45c6e8a163a602ede2dc5f9a0bb927", size = 249788 },
{ url = "https://files.pythonhosted.org/packages/cc/52/04a3b733f40a0cc7c4a5b9b010844111dbf906df3e868b13e1ce7b39ac31/coverage-7.10.4-cp312-cp312-win32.whl", hash = "sha256:fecb97b3a52fa9bcd5a7375e72fae209088faf671d39fae67261f37772d5559a", size = 219131 },
{ url = "https://files.pythonhosted.org/packages/83/dd/12909fc0b83888197b3ec43a4ac7753589591c08d00d9deda4158df2734e/coverage-7.10.4-cp312-cp312-win_amd64.whl", hash = "sha256:26de58f355626628a21fe6a70e1e1fad95702dafebfb0685280962ae1449f17b", size = 219939 },
{ url = "https://files.pythonhosted.org/packages/83/c7/058bb3220fdd6821bada9685eadac2940429ab3c97025ce53549ff423cc1/coverage-7.10.4-cp312-cp312-win_arm64.whl", hash = "sha256:67e8885408f8325198862bc487038a4980c9277d753cb8812510927f2176437a", size = 218572 },
{ url = "https://files.pythonhosted.org/packages/46/b0/4a3662de81f2ed792a4e425d59c4ae50d8dd1d844de252838c200beed65a/coverage-7.10.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b8e1d2015d5dfdbf964ecef12944c0c8c55b885bb5c0467ae8ef55e0e151233", size = 216735 },
{ url = "https://files.pythonhosted.org/packages/c5/e8/e2dcffea01921bfffc6170fb4406cffb763a3b43a047bbd7923566708193/coverage-7.10.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:25735c299439018d66eb2dccf54f625aceb78645687a05f9f848f6e6c751e169", size = 216982 },
{ url = "https://files.pythonhosted.org/packages/9d/59/cc89bb6ac869704d2781c2f5f7957d07097c77da0e8fdd4fd50dbf2ac9c0/coverage-7.10.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:715c06cb5eceac4d9b7cdf783ce04aa495f6aff657543fea75c30215b28ddb74", size = 247981 },
{ url = "https://files.pythonhosted.org/packages/aa/23/3da089aa177ceaf0d3f96754ebc1318597822e6387560914cc480086e730/coverage-7.10.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e017ac69fac9aacd7df6dc464c05833e834dc5b00c914d7af9a5249fcccf07ef", size = 250584 },
{ url = "https://files.pythonhosted.org/packages/ad/82/e8693c368535b4e5fad05252a366a1794d481c79ae0333ed943472fd778d/coverage-7.10.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad180cc40b3fccb0f0e8c702d781492654ac2580d468e3ffc8065e38c6c2408", size = 251856 },
{ url = "https://files.pythonhosted.org/packages/56/19/8b9cb13292e602fa4135b10a26ac4ce169a7fc7c285ff08bedd42ff6acca/coverage-7.10.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:becbdcd14f685fada010a5f792bf0895675ecf7481304fe159f0cd3f289550bd", size = 250015 },
{ url = "https://files.pythonhosted.org/packages/10/e7/e5903990ce089527cf1c4f88b702985bd65c61ac245923f1ff1257dbcc02/coverage-7.10.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0b485ca21e16a76f68060911f97ebbe3e0d891da1dbbce6af7ca1ab3f98b9097", size = 247908 },
{ url = "https://files.pythonhosted.org/packages/dd/c9/7d464f116df1df7fe340669af1ddbe1a371fc60f3082ff3dc837c4f1f2ab/coverage-7.10.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6c1d098ccfe8e1e0a1ed9a0249138899948afd2978cbf48eb1cc3fcd38469690", size = 249525 },
{ url = "https://files.pythonhosted.org/packages/ce/42/722e0cdbf6c19e7235c2020837d4e00f3b07820fd012201a983238cc3a30/coverage-7.10.4-cp313-cp313-win32.whl", hash = "sha256:8630f8af2ca84b5c367c3df907b1706621abe06d6929f5045fd628968d421e6e", size = 219173 },
{ url = "https://files.pythonhosted.org/packages/97/7e/aa70366f8275955cd51fa1ed52a521c7fcebcc0fc279f53c8c1ee6006dfe/coverage-7.10.4-cp313-cp313-win_amd64.whl", hash = "sha256:f68835d31c421736be367d32f179e14ca932978293fe1b4c7a6a49b555dff5b2", size = 219969 },
{ url = "https://files.pythonhosted.org/packages/ac/96/c39d92d5aad8fec28d4606556bfc92b6fee0ab51e4a548d9b49fb15a777c/coverage-7.10.4-cp313-cp313-win_arm64.whl", hash = "sha256:6eaa61ff6724ca7ebc5326d1fae062d85e19b38dd922d50903702e6078370ae7", size = 218601 },
{ url = "https://files.pythonhosted.org/packages/79/13/34d549a6177bd80fa5db758cb6fd3057b7ad9296d8707d4ab7f480b0135f/coverage-7.10.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:702978108876bfb3d997604930b05fe769462cc3000150b0e607b7b444f2fd84", size = 217445 },
{ url = "https://files.pythonhosted.org/packages/6a/c0/433da866359bf39bf595f46d134ff2d6b4293aeea7f3328b6898733b0633/coverage-7.10.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e8f978e8c5521d9c8f2086ac60d931d583fab0a16f382f6eb89453fe998e2484", size = 217676 },
{ url = "https://files.pythonhosted.org/packages/7e/d7/2b99aa8737f7801fd95222c79a4ebc8c5dd4460d4bed7ef26b17a60c8d74/coverage-7.10.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:df0ac2ccfd19351411c45e43ab60932b74472e4648b0a9edf6a3b58846e246a9", size = 259002 },
{ url = "https://files.pythonhosted.org/packages/08/cf/86432b69d57debaef5abf19aae661ba8f4fcd2882fa762e14added4bd334/coverage-7.10.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73a0d1aaaa3796179f336448e1576a3de6fc95ff4f07c2d7251d4caf5d18cf8d", size = 261178 },
{ url = "https://files.pythonhosted.org/packages/23/78/85176593f4aa6e869cbed7a8098da3448a50e3fac5cb2ecba57729a5220d/coverage-7.10.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:873da6d0ed6b3ffc0bc01f2c7e3ad7e2023751c0d8d86c26fe7322c314b031dc", size = 263402 },
{ url = "https://files.pythonhosted.org/packages/88/1d/57a27b6789b79abcac0cc5805b31320d7a97fa20f728a6a7c562db9a3733/coverage-7.10.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c6446c75b0e7dda5daa876a1c87b480b2b52affb972fedd6c22edf1aaf2e00ec", size = 260957 },
{ url = "https://files.pythonhosted.org/packages/fa/e5/3e5ddfd42835c6def6cd5b2bdb3348da2e34c08d9c1211e91a49e9fd709d/coverage-7.10.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6e73933e296634e520390c44758d553d3b573b321608118363e52113790633b9", size = 258718 },
{ url = "https://files.pythonhosted.org/packages/1a/0b/d364f0f7ef111615dc4e05a6ed02cac7b6f2ac169884aa57faeae9eb5fa0/coverage-7.10.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52073d4b08d2cb571234c8a71eb32af3c6923149cf644a51d5957ac128cf6aa4", size = 259848 },
{ url = "https://files.pythonhosted.org/packages/10/c6/bbea60a3b309621162e53faf7fac740daaf083048ea22077418e1ecaba3f/coverage-7.10.4-cp313-cp313t-win32.whl", hash = "sha256:e24afb178f21f9ceb1aefbc73eb524769aa9b504a42b26857243f881af56880c", size = 219833 },
{ url = "https://files.pythonhosted.org/packages/44/a5/f9f080d49cfb117ddffe672f21eab41bd23a46179a907820743afac7c021/coverage-7.10.4-cp313-cp313t-win_amd64.whl", hash = "sha256:be04507ff1ad206f4be3d156a674e3fb84bbb751ea1b23b142979ac9eebaa15f", size = 220897 },
{ url = "https://files.pythonhosted.org/packages/46/89/49a3fc784fa73d707f603e586d84a18c2e7796707044e9d73d13260930b7/coverage-7.10.4-cp313-cp313t-win_arm64.whl", hash = "sha256:f3e3ff3f69d02b5dad67a6eac68cc9c71ae343b6328aae96e914f9f2f23a22e2", size = 219160 },
{ url = "https://files.pythonhosted.org/packages/b5/22/525f84b4cbcff66024d29f6909d7ecde97223f998116d3677cfba0d115b5/coverage-7.10.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a59fe0af7dd7211ba595cf7e2867458381f7e5d7b4cffe46274e0b2f5b9f4eb4", size = 216717 },
{ url = "https://files.pythonhosted.org/packages/a6/58/213577f77efe44333a416d4bcb251471e7f64b19b5886bb515561b5ce389/coverage-7.10.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a6c35c5b70f569ee38dc3350cd14fdd0347a8b389a18bb37538cc43e6f730e6", size = 216994 },
{ url = "https://files.pythonhosted.org/packages/17/85/34ac02d0985a09472f41b609a1d7babc32df87c726c7612dc93d30679b5a/coverage-7.10.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acb7baf49f513554c4af6ef8e2bd6e8ac74e6ea0c7386df8b3eb586d82ccccc4", size = 248038 },
{ url = "https://files.pythonhosted.org/packages/47/4f/2140305ec93642fdaf988f139813629cbb6d8efa661b30a04b6f7c67c31e/coverage-7.10.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a89afecec1ed12ac13ed203238b560cbfad3522bae37d91c102e690b8b1dc46c", size = 250575 },
{ url = "https://files.pythonhosted.org/packages/f2/b5/41b5784180b82a083c76aeba8f2c72ea1cb789e5382157b7dc852832aea2/coverage-7.10.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:480442727f464407d8ade6e677b7f21f3b96a9838ab541b9a28ce9e44123c14e", size = 251927 },
{ url = "https://files.pythonhosted.org/packages/78/ca/c1dd063e50b71f5aea2ebb27a1c404e7b5ecf5714c8b5301f20e4e8831ac/coverage-7.10.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a89bf193707f4a17f1ed461504031074d87f035153239f16ce86dfb8f8c7ac76", size = 249930 },
{ url = "https://files.pythonhosted.org/packages/8d/66/d8907408612ffee100d731798e6090aedb3ba766ecf929df296c1a7ee4fb/coverage-7.10.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3ddd912c2fc440f0fb3229e764feec85669d5d80a988ff1b336a27d73f63c818", size = 247862 },
{ url = "https://files.pythonhosted.org/packages/29/db/53cd8ec8b1c9c52d8e22a25434785bfc2d1e70c0cfb4d278a1326c87f741/coverage-7.10.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a538944ee3a42265e61c7298aeba9ea43f31c01271cf028f437a7b4075592cf", size = 249360 },
{ url = "https://files.pythonhosted.org/packages/4f/75/5ec0a28ae4a0804124ea5a5becd2b0fa3adf30967ac656711fb5cdf67c60/coverage-7.10.4-cp314-cp314-win32.whl", hash = "sha256:fd2e6002be1c62476eb862b8514b1ba7e7684c50165f2a8d389e77da6c9a2ebd", size = 219449 },
{ url = "https://files.pythonhosted.org/packages/9d/ab/66e2ee085ec60672bf5250f11101ad8143b81f24989e8c0e575d16bb1e53/coverage-7.10.4-cp314-cp314-win_amd64.whl", hash = "sha256:ec113277f2b5cf188d95fb66a65c7431f2b9192ee7e6ec9b72b30bbfb53c244a", size = 220246 },
{ url = "https://files.pythonhosted.org/packages/37/3b/00b448d385f149143190846217797d730b973c3c0ec2045a7e0f5db3a7d0/coverage-7.10.4-cp314-cp314-win_arm64.whl", hash = "sha256:9744954bfd387796c6a091b50d55ca7cac3d08767795b5eec69ad0f7dbf12d38", size = 218825 },
{ url = "https://files.pythonhosted.org/packages/ee/2e/55e20d3d1ce00b513efb6fd35f13899e1c6d4f76c6cbcc9851c7227cd469/coverage-7.10.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5af4829904dda6aabb54a23879f0f4412094ba9ef153aaa464e3c1b1c9bc98e6", size = 217462 },
{ url = "https://files.pythonhosted.org/packages/47/b3/aab1260df5876f5921e2c57519e73a6f6eeacc0ae451e109d44ee747563e/coverage-7.10.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7bba5ed85e034831fac761ae506c0644d24fd5594727e174b5a73aff343a7508", size = 217675 },
{ url = "https://files.pythonhosted.org/packages/67/23/1cfe2aa50c7026180989f0bfc242168ac7c8399ccc66eb816b171e0ab05e/coverage-7.10.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d57d555b0719834b55ad35045de6cc80fc2b28e05adb6b03c98479f9553b387f", size = 259176 },
{ url = "https://files.pythonhosted.org/packages/9d/72/5882b6aeed3f9de7fc4049874fd7d24213bf1d06882f5c754c8a682606ec/coverage-7.10.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ba62c51a72048bb1ea72db265e6bd8beaabf9809cd2125bbb5306c6ce105f214", size = 261341 },
{ url = "https://files.pythonhosted.org/packages/1b/70/a0c76e3087596ae155f8e71a49c2c534c58b92aeacaf4d9d0cbbf2dde53b/coverage-7.10.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0acf0c62a6095f07e9db4ec365cc58c0ef5babb757e54745a1aa2ea2a2564af1", size = 263600 },
{ url = "https://files.pythonhosted.org/packages/cb/5f/27e4cd4505b9a3c05257fb7fc509acbc778c830c450cb4ace00bf2b7bda7/coverage-7.10.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1033bf0f763f5cf49ffe6594314b11027dcc1073ac590b415ea93463466deec", size = 261036 },
{ url = "https://files.pythonhosted.org/packages/02/d6/cf2ae3a7f90ab226ea765a104c4e76c5126f73c93a92eaea41e1dc6a1892/coverage-7.10.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:92c29eff894832b6a40da1789b1f252305af921750b03ee4535919db9179453d", size = 258794 },
{ url = "https://files.pythonhosted.org/packages/9e/b1/39f222eab0d78aa2001cdb7852aa1140bba632db23a5cfd832218b496d6c/coverage-7.10.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:822c4c830989c2093527e92acd97be4638a44eb042b1bdc0e7a278d84a070bd3", size = 259946 },
{ url = "https://files.pythonhosted.org/packages/74/b2/49d82acefe2fe7c777436a3097f928c7242a842538b190f66aac01f29321/coverage-7.10.4-cp314-cp314t-win32.whl", hash = "sha256:e694d855dac2e7cf194ba33653e4ba7aad7267a802a7b3fc4347d0517d5d65cd", size = 220226 },
{ url = "https://files.pythonhosted.org/packages/06/b0/afb942b6b2fc30bdbc7b05b087beae11c2b0daaa08e160586cf012b6ad70/coverage-7.10.4-cp314-cp314t-win_amd64.whl", hash = "sha256:efcc54b38ef7d5bfa98050f220b415bc5bb3d432bd6350a861cf6da0ede2cdcd", size = 221346 },
{ url = "https://files.pythonhosted.org/packages/d8/66/e0531c9d1525cb6eac5b5733c76f27f3053ee92665f83f8899516fea6e76/coverage-7.10.4-cp314-cp314t-win_arm64.whl", hash = "sha256:6f3a3496c0fa26bfac4ebc458747b778cff201c8ae94fa05e1391bab0dbc473c", size = 219368 },
{ url = "https://files.pythonhosted.org/packages/bb/78/983efd23200921d9edb6bd40512e1aa04af553d7d5a171e50f9b2b45d109/coverage-7.10.4-py3-none-any.whl", hash = "sha256:065d75447228d05121e5c938ca8f0e91eed60a1eb2d1258d42d5084fecfc3302", size = 208365 },
{ url = "https://files.pythonhosted.org/packages/27/8e/40d75c7128f871ea0fd829d3e7e4a14460cad7c3826e3b472e6471ad05bd/coverage-7.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9", size = 217077 },
{ url = "https://files.pythonhosted.org/packages/18/a8/f333f4cf3fb5477a7f727b4d603a2eb5c3c5611c7fe01329c2e13b23b678/coverage-7.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b", size = 217310 },
{ url = "https://files.pythonhosted.org/packages/ec/2c/fbecd8381e0a07d1547922be819b4543a901402f63930313a519b937c668/coverage-7.10.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c", size = 248802 },
{ url = "https://files.pythonhosted.org/packages/3f/bc/1011da599b414fb6c9c0f34086736126f9ff71f841755786a6b87601b088/coverage-7.10.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a", size = 251550 },
{ url = "https://files.pythonhosted.org/packages/4c/6f/b5c03c0c721c067d21bc697accc3642f3cef9f087dac429c918c37a37437/coverage-7.10.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6", size = 252684 },
{ url = "https://files.pythonhosted.org/packages/f9/50/d474bc300ebcb6a38a1047d5c465a227605d6473e49b4e0d793102312bc5/coverage-7.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a", size = 250602 },
{ url = "https://files.pythonhosted.org/packages/4a/2d/548c8e04249cbba3aba6bd799efdd11eee3941b70253733f5d355d689559/coverage-7.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a", size = 248724 },
{ url = "https://files.pythonhosted.org/packages/e2/96/a7c3c0562266ac39dcad271d0eec8fc20ab576e3e2f64130a845ad2a557b/coverage-7.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34", size = 250158 },
{ url = "https://files.pythonhosted.org/packages/f3/75/74d4be58c70c42ef0b352d597b022baf12dbe2b43e7cb1525f56a0fb1d4b/coverage-7.10.5-cp312-cp312-win32.whl", hash = "sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf", size = 219493 },
{ url = "https://files.pythonhosted.org/packages/4f/08/364e6012d1d4d09d1e27437382967efed971d7613f94bca9add25f0c1f2b/coverage-7.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f", size = 220302 },
{ url = "https://files.pythonhosted.org/packages/db/d5/7c8a365e1f7355c58af4fe5faf3f90cc8e587590f5854808d17ccb4e7077/coverage-7.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8", size = 218936 },
{ url = "https://files.pythonhosted.org/packages/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106 },
{ url = "https://files.pythonhosted.org/packages/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353 },
{ url = "https://files.pythonhosted.org/packages/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350 },
{ url = "https://files.pythonhosted.org/packages/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955 },
{ url = "https://files.pythonhosted.org/packages/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230 },
{ url = "https://files.pythonhosted.org/packages/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387 },
{ url = "https://files.pythonhosted.org/packages/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280 },
{ url = "https://files.pythonhosted.org/packages/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894 },
{ url = "https://files.pythonhosted.org/packages/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536 },
{ url = "https://files.pythonhosted.org/packages/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330 },
{ url = "https://files.pythonhosted.org/packages/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961 },
{ url = "https://files.pythonhosted.org/packages/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819 },
{ url = "https://files.pythonhosted.org/packages/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040 },
{ url = "https://files.pythonhosted.org/packages/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374 },
{ url = "https://files.pythonhosted.org/packages/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551 },
{ url = "https://files.pythonhosted.org/packages/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776 },
{ url = "https://files.pythonhosted.org/packages/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326 },
{ url = "https://files.pythonhosted.org/packages/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090 },
{ url = "https://files.pythonhosted.org/packages/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217 },
{ url = "https://files.pythonhosted.org/packages/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194 },
{ url = "https://files.pythonhosted.org/packages/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258 },
{ url = "https://files.pythonhosted.org/packages/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521 },
{ url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090 },
{ url = "https://files.pythonhosted.org/packages/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365 },
{ url = "https://files.pythonhosted.org/packages/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413 },
{ url = "https://files.pythonhosted.org/packages/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943 },
{ url = "https://files.pythonhosted.org/packages/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301 },
{ url = "https://files.pythonhosted.org/packages/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302 },
{ url = "https://files.pythonhosted.org/packages/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237 },
{ url = "https://files.pythonhosted.org/packages/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726 },
{ url = "https://files.pythonhosted.org/packages/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825 },
{ url = "https://files.pythonhosted.org/packages/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618 },
{ url = "https://files.pythonhosted.org/packages/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199 },
{ url = "https://files.pythonhosted.org/packages/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833 },
{ url = "https://files.pythonhosted.org/packages/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048 },
{ url = "https://files.pythonhosted.org/packages/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549 },
{ url = "https://files.pythonhosted.org/packages/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715 },
{ url = "https://files.pythonhosted.org/packages/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969 },
{ url = "https://files.pythonhosted.org/packages/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408 },
{ url = "https://files.pythonhosted.org/packages/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168 },
{ url = "https://files.pythonhosted.org/packages/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317 },
{ url = "https://files.pythonhosted.org/packages/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600 },
{ url = "https://files.pythonhosted.org/packages/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714 },
{ url = "https://files.pythonhosted.org/packages/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735 },
{ url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736 },
]
[[package]]
@@ -213,27 +213,27 @@ wheels = [
[[package]]
name = "email-validator"
version = "2.2.0"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 }
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 },
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 },
]
[[package]]
name = "faker"
version = "37.5.3"
version = "37.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/5d/7797a74e8e31fa227f0303239802c5f09b6722bdb6638359e7b6c8f30004/faker-37.5.3.tar.gz", hash = "sha256:8315d8ff4d6f4f588bd42ffe63abd599886c785073e26a44707e10eeba5713dc", size = 1907147 }
sdist = { url = "https://files.pythonhosted.org/packages/24/cd/f7679c20f07d9e2013123b7f7e13809a3450a18d938d58e86081a486ea15/faker-37.6.0.tar.gz", hash = "sha256:0f8cc34f30095184adf87c3c24c45b38b33ad81c35ef6eb0a3118f301143012c", size = 1907960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/bf/d06dd96e7afa72069dbdd26ed0853b5e8bd7941e2c0819a9b21d6e6fc052/faker-37.5.3-py3-none-any.whl", hash = "sha256:386fe9d5e6132a915984bf887fcebcc72d6366a25dd5952905b31b141a17016d", size = 1949261 },
{ url = "https://files.pythonhosted.org/packages/61/7d/8b50e4ac772719777be33661f4bde320793400a706f5eb214e4de46f093c/faker-37.6.0-py3-none-any.whl", hash = "sha256:3c5209b23d7049d596a51db5d76403a0ccfea6fc294ffa2ecfef6a8843b1e6a7", size = 1949837 },
]
[[package]]
@@ -744,11 +744,11 @@ wheels = [
[[package]]
name = "pytz"
version = "2024.1"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/26/9f1f00a5d021fff16dee3de13d43e5e978f3d58928e129c3a62cf7eb9738/pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812", size = 316214 }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/3d/a121f284241f08268b21359bd425f7d4825cffc5ac5cd0e1b3d82ffd2b10/pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319", size = 505474 },
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 },
]
[[package]]
@@ -852,28 +852,28 @@ wheels = [
[[package]]
name = "ruff"
version = "0.12.10"
version = "0.12.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/eb/8c073deb376e46ae767f4961390d17545e8535921d2f65101720ed8bd434/ruff-0.12.10.tar.gz", hash = "sha256:189ab65149d11ea69a2d775343adf5f49bb2426fc4780f65ee33b423ad2e47f9", size = 5310076 }
sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/e7/560d049d15585d6c201f9eeacd2fd130def3741323e5ccf123786e0e3c95/ruff-0.12.10-py3-none-linux_armv6l.whl", hash = "sha256:8b593cb0fb55cc8692dac7b06deb29afda78c721c7ccfed22db941201b7b8f7b", size = 11935161 },
{ url = "https://files.pythonhosted.org/packages/d1/b0/ad2464922a1113c365d12b8f80ed70fcfb39764288ac77c995156080488d/ruff-0.12.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ebb7333a45d56efc7c110a46a69a1b32365d5c5161e7244aaf3aa20ce62399c1", size = 12660884 },
{ url = "https://files.pythonhosted.org/packages/d7/f1/97f509b4108d7bae16c48389f54f005b62ce86712120fd8b2d8e88a7cb49/ruff-0.12.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d59e58586829f8e4a9920788f6efba97a13d1fa320b047814e8afede381c6839", size = 11872754 },
{ url = "https://files.pythonhosted.org/packages/12/ad/44f606d243f744a75adc432275217296095101f83f966842063d78eee2d3/ruff-0.12.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:822d9677b560f1fdeab69b89d1f444bf5459da4aa04e06e766cf0121771ab844", size = 12092276 },
{ url = "https://files.pythonhosted.org/packages/06/1f/ed6c265e199568010197909b25c896d66e4ef2c5e1c3808caf461f6f3579/ruff-0.12.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b4a64f4062a50c75019c61c7017ff598cb444984b638511f48539d3a1c98db", size = 11734700 },
{ url = "https://files.pythonhosted.org/packages/63/c5/b21cde720f54a1d1db71538c0bc9b73dee4b563a7dd7d2e404914904d7f5/ruff-0.12.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2c6f4064c69d2542029b2a61d39920c85240c39837599d7f2e32e80d36401d6e", size = 13468783 },
{ url = "https://files.pythonhosted.org/packages/02/9e/39369e6ac7f2a1848f22fb0b00b690492f20811a1ac5c1fd1d2798329263/ruff-0.12.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:059e863ea3a9ade41407ad71c1de2badfbe01539117f38f763ba42a1206f7559", size = 14436642 },
{ url = "https://files.pythonhosted.org/packages/e3/03/5da8cad4b0d5242a936eb203b58318016db44f5c5d351b07e3f5e211bb89/ruff-0.12.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bef6161e297c68908b7218fa6e0e93e99a286e5ed9653d4be71e687dff101cf", size = 13859107 },
{ url = "https://files.pythonhosted.org/packages/19/19/dd7273b69bf7f93a070c9cec9494a94048325ad18fdcf50114f07e6bf417/ruff-0.12.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f1345fbf8fb0531cd722285b5f15af49b2932742fc96b633e883da8d841896b", size = 12886521 },
{ url = "https://files.pythonhosted.org/packages/c0/1d/b4207ec35e7babaee62c462769e77457e26eb853fbdc877af29417033333/ruff-0.12.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f68433c4fbc63efbfa3ba5db31727db229fa4e61000f452c540474b03de52a9", size = 13097528 },
{ url = "https://files.pythonhosted.org/packages/ff/00/58f7b873b21114456e880b75176af3490d7a2836033779ca42f50de3b47a/ruff-0.12.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:141ce3d88803c625257b8a6debf4a0473eb6eed9643a6189b68838b43e78165a", size = 13080443 },
{ url = "https://files.pythonhosted.org/packages/12/8c/9e6660007fb10189ccb78a02b41691288038e51e4788bf49b0a60f740604/ruff-0.12.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f3fc21178cd44c98142ae7590f42ddcb587b8e09a3b849cbc84edb62ee95de60", size = 11896759 },
{ url = "https://files.pythonhosted.org/packages/67/4c/6d092bb99ea9ea6ebda817a0e7ad886f42a58b4501a7e27cd97371d0ba54/ruff-0.12.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7d1a4e0bdfafcd2e3e235ecf50bf0176f74dd37902f241588ae1f6c827a36c56", size = 11701463 },
{ url = "https://files.pythonhosted.org/packages/59/80/d982c55e91df981f3ab62559371380616c57ffd0172d96850280c2b04fa8/ruff-0.12.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e67d96827854f50b9e3e8327b031647e7bcc090dbe7bb11101a81a3a2cbf1cc9", size = 12691603 },
{ url = "https://files.pythonhosted.org/packages/ad/37/63a9c788bbe0b0850611669ec6b8589838faf2f4f959647f2d3e320383ae/ruff-0.12.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ae479e1a18b439c59138f066ae79cc0f3ee250712a873d00dbafadaad9481e5b", size = 13164356 },
{ url = "https://files.pythonhosted.org/packages/47/d4/1aaa7fb201a74181989970ebccd12f88c0fc074777027e2a21de5a90657e/ruff-0.12.10-py3-none-win32.whl", hash = "sha256:9de785e95dc2f09846c5e6e1d3a3d32ecd0b283a979898ad427a9be7be22b266", size = 11896089 },
{ url = "https://files.pythonhosted.org/packages/ad/14/2ad38fd4037daab9e023456a4a40ed0154e9971f8d6aed41bdea390aabd9/ruff-0.12.10-py3-none-win_amd64.whl", hash = "sha256:7837eca8787f076f67aba2ca559cefd9c5cbc3a9852fd66186f4201b87c1563e", size = 13004616 },
{ url = "https://files.pythonhosted.org/packages/24/3c/21cf283d67af33a8e6ed242396863af195a8a6134ec581524fd22b9811b6/ruff-0.12.10-py3-none-win_arm64.whl", hash = "sha256:cc138cc06ed9d4bfa9d667a65af7172b47840e1a98b02ce7011c391e54635ffc", size = 12074225 },
{ url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885 },
{ url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364 },
{ url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111 },
{ url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060 },
{ url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848 },
{ url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288 },
{ url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633 },
{ url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430 },
{ url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133 },
{ url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082 },
{ url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490 },
{ url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928 },
{ url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513 },
{ url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154 },
{ url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653 },
{ url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270 },
{ url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600 },
{ url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290 },
]
[[package]]
@@ -914,7 +914,7 @@ requires-dist = [
{ name = "aiosqlite", specifier = "==0.21.0" },
{ name = "apscheduler", specifier = "==3.11.0" },
{ name = "bcrypt", specifier = "==4.3.0" },
{ name = "email-validator", specifier = "==2.2.0" },
{ name = "email-validator", specifier = "==2.3.0" },
{ name = "fastapi", extras = ["standard"], specifier = "==0.116.1" },
{ name = "ffmpeg-python", specifier = "==0.2.0" },
{ name = "httpx", specifier = "==0.28.1" },
@@ -922,21 +922,21 @@ requires-dist = [
{ name = "pyjwt", specifier = "==2.10.1" },
{ name = "python-socketio", specifier = "==5.13.0" },
{ name = "python-vlc", specifier = "==3.0.21203" },
{ name = "pytz", specifier = "==2024.1" },
{ name = "pytz", specifier = "==2025.2" },
{ name = "sqlmodel", specifier = "==0.0.24" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.35.0" },
{ name = "yt-dlp", specifier = "==2025.8.20" },
{ name = "yt-dlp", specifier = "==2025.8.27" },
]
[package.metadata.requires-dev]
dev = [
{ name = "coverage", specifier = "==7.10.4" },
{ name = "faker", specifier = "==37.5.3" },
{ name = "coverage", specifier = "==7.10.5" },
{ name = "faker", specifier = "==37.6.0" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "mypy", specifier = "==1.17.1" },
{ name = "pytest", specifier = "==8.4.1" },
{ name = "pytest-asyncio", specifier = "==1.1.0" },
{ name = "ruff", specifier = "==0.12.10" },
{ name = "ruff", specifier = "==0.12.11" },
]
[[package]]
@@ -1259,9 +1259,9 @@ wheels = [
[[package]]
name = "yt-dlp"
version = "2025.8.20"
version = "2025.8.27"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/af/d3c81af35ae2aef148d0ff78f001650ce5a7ca73fbd3b271eb9aab4c56ee/yt_dlp-2025.8.20.tar.gz", hash = "sha256:da873bcf424177ab5c3b701fa94ea4cdac17bf3aec5ef37b91f530c90def7bcf", size = 3037484 }
sdist = { url = "https://files.pythonhosted.org/packages/f4/d4/d9dd231b03f09fdfb5f0fe70f30de0b5f59454aa54fa6b2b2aea49404988/yt_dlp-2025.8.27.tar.gz", hash = "sha256:ed74768d2a93b29933ab14099da19497ef571637f7aa375140dd3d882b9c1854", size = 3038374 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/e8/ebd888100684c10799897296e3061c19ba5559b641f8da218bf48229a815/yt_dlp-2025.8.20-py3-none-any.whl", hash = "sha256:073c97e2a3f9cd0fa6a76142c4ef46ca62b2575c37eaf80d8c3718fd6f3277eb", size = 3266841 },
{ url = "https://files.pythonhosted.org/packages/cb/9c/b69fc0c800f80b94ea2f8eff1d1f473fecee6aa337681d297ba7c7c5d3fd/yt_dlp-2025.8.27-py3-none-any.whl", hash = "sha256:0b8fd3bb7c54bc2e7ecb5cdac7d64c30e2503ea4d3dd9ae24d4f09e22aaa95f4", size = 3267059 },
]