Compare commits
11 Commits
734cade8d9
...
f-a-f
| Author | SHA1 | Date | |
|---|---|---|---|
| 383efed319 | |||
|
|
ade4689ab7 | ||
| 3a796dd966 | |||
| e5ff673a54 | |||
| fd6b698077 | |||
| f848bbf0f2 | |||
| e03747f526 | |||
| b86dfe7373 | |||
| d663394106 | |||
| 595abd6cd3 | |||
| faa04a0d01 |
@@ -22,4 +22,4 @@ ENV PYTHONUNBUFFERED=1
|
||||
ENV PORT=8080
|
||||
|
||||
# Run the application
|
||||
CMD ["uv", "run", "uvicorn", "capa_de_integracion.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["uv", "run", "uvicorn", "capa_de_integracion.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "4", "--limit-concurrency", "1000", "--backlog", "2048"]
|
||||
|
||||
66
scripts/load_test.py → locustfile.py
Executable file → Normal file
66
scripts/load_test.py → locustfile.py
Executable file → Normal file
@@ -1,40 +1,29 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = [
|
||||
# "locust>=2.32.5",
|
||||
# ]
|
||||
# ///
|
||||
"""Locust load testing script for capa-de-integracion service.
|
||||
"""Locust load testing for capa-de-integracion service.
|
||||
|
||||
Usage:
|
||||
# Run with web UI (default port 8089)
|
||||
uv run scripts/load_test.py
|
||||
locust --host http://localhost:8080
|
||||
|
||||
# Run headless with specific users and spawn rate
|
||||
uv run scripts/load_test.py --headless -u 100 -r 10
|
||||
|
||||
# Run against specific host
|
||||
uv run scripts/load_test.py --host http://localhost:8080
|
||||
locust --host http://localhost:8080 --headless -u 100 -r 10
|
||||
|
||||
# Run for specific duration
|
||||
uv run scripts/load_test.py --headless -u 50 -r 5 --run-time 5m
|
||||
locust --host http://localhost:8080 --headless -u 50 -r 5 --run-time 5m
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
from locust import HttpUser, between, task
|
||||
|
||||
|
||||
class ConversationUser(HttpUser):
|
||||
"""Simulate users interacting with the conversation API."""
|
||||
|
||||
wait_time = between(1, 3) # Wait 1-3 seconds between tasks
|
||||
host = "http://localhost:8080"
|
||||
wait_time = between(1, 3)
|
||||
|
||||
# Sample data for realistic load testing
|
||||
phone_numbers = [
|
||||
f"555-{1000 + i:04d}" for i in range(100)
|
||||
] # 100 unique phone numbers
|
||||
]
|
||||
|
||||
conversation_messages = [
|
||||
"Hola",
|
||||
@@ -57,15 +46,21 @@ class ConversationUser(HttpUser):
|
||||
|
||||
screen_contexts = [
|
||||
"home",
|
||||
"card_management",
|
||||
"account_details",
|
||||
"transfers",
|
||||
"help_center",
|
||||
"pagos",
|
||||
"transferencia",
|
||||
"prestamos",
|
||||
"inversiones",
|
||||
"lealtad",
|
||||
"finanzas",
|
||||
"capsulas",
|
||||
"descubre",
|
||||
"retiro-sin-tarjeta",
|
||||
"detalle-tdc",
|
||||
"detalle-tdd",
|
||||
]
|
||||
|
||||
def on_start(self):
|
||||
"""Called when a simulated user starts."""
|
||||
# Assign a phone number to this user for the session
|
||||
self.phone = random.choice(self.phone_numbers)
|
||||
self.nombre = f"Usuario_{self.phone.replace('-', '')}"
|
||||
|
||||
@@ -173,8 +168,7 @@ class ConversationFlowUser(HttpUser):
|
||||
"""Simulate realistic conversation flows with multiple interactions."""
|
||||
|
||||
wait_time = between(2, 5)
|
||||
host = "http://localhost:8080"
|
||||
weight = 2 # This user class will be 2x more likely to be chosen
|
||||
weight = 2
|
||||
|
||||
def on_start(self):
|
||||
"""Initialize user session."""
|
||||
@@ -184,17 +178,15 @@ class ConversationFlowUser(HttpUser):
|
||||
@task
|
||||
def complete_conversation_flow(self):
|
||||
"""Simulate a complete conversation flow."""
|
||||
# Step 1: Start with quick replies
|
||||
screen_payload = {
|
||||
"usuario": {
|
||||
"telefono": self.phone,
|
||||
"nombre": self.nombre,
|
||||
},
|
||||
"pantallaContexto": "help_center",
|
||||
"pantallaContexto": "home",
|
||||
}
|
||||
self.client.post("/api/v1/quick-replies/screen", json=screen_payload)
|
||||
|
||||
# Step 2: Have a conversation
|
||||
conversation_steps = [
|
||||
"Hola, necesito ayuda",
|
||||
"¿Cómo puedo verificar mi saldo?",
|
||||
@@ -209,23 +201,7 @@ class ConversationFlowUser(HttpUser):
|
||||
"nickname": self.nombre,
|
||||
},
|
||||
"canal": "mobile",
|
||||
"pantallaContexto": "help_center",
|
||||
"pantallaContexto": "home",
|
||||
}
|
||||
self.client.post("/api/v1/dialogflow/detect-intent", json=payload)
|
||||
# Small delay between messages
|
||||
self.wait()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Set default host if not provided via command line
|
||||
if "--host" not in sys.argv and "HOST" not in os.environ:
|
||||
os.environ["HOST"] = "http://localhost:8080"
|
||||
|
||||
# Import and run locust
|
||||
from locust import main as locust_main
|
||||
|
||||
# Run locust with command line arguments
|
||||
sys.exit(locust_main.main())
|
||||
@@ -34,6 +34,7 @@ build-backend = "uv_build"
|
||||
dev = [
|
||||
"fakeredis>=2.34.0",
|
||||
"inline-snapshot>=0.32.1",
|
||||
"locust>=2.43.3",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cov>=7.0.0",
|
||||
@@ -48,7 +49,7 @@ exclude = ["tests", "scripts"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ['ALL']
|
||||
ignore = ['D203', 'D213']
|
||||
ignore = ['D203', 'D213', 'COM812']
|
||||
|
||||
[tool.ty.src]
|
||||
include = ["src"]
|
||||
@@ -73,7 +74,7 @@ filterwarnings = [
|
||||
]
|
||||
|
||||
env = [
|
||||
"FIRESTORE_EMULATOR_HOST=[::1]:8911",
|
||||
"FIRESTORE_EMULATOR_HOST=[::1]:8462",
|
||||
"GCP_PROJECT_ID=test-project",
|
||||
"GCP_LOCATION=us-central1",
|
||||
"GCP_FIRESTORE_DATABASE_ID=(default)",
|
||||
|
||||
@@ -9,7 +9,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
class Settings(BaseSettings):
|
||||
"""Application configuration from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# GCP General
|
||||
gcp_project_id: str
|
||||
@@ -18,17 +18,20 @@ class Settings(BaseSettings):
|
||||
# RAG
|
||||
rag_endpoint_url: str
|
||||
rag_echo_enabled: bool = Field(
|
||||
default=False, alias="RAG_ECHO_ENABLED",
|
||||
default=False,
|
||||
alias="RAG_ECHO_ENABLED",
|
||||
)
|
||||
|
||||
# Firestore
|
||||
firestore_database_id: str = Field(..., alias="GCP_FIRESTORE_DATABASE_ID")
|
||||
firestore_host: str = Field(
|
||||
default="firestore.googleapis.com", alias="GCP_FIRESTORE_HOST",
|
||||
default="firestore.googleapis.com",
|
||||
alias="GCP_FIRESTORE_HOST",
|
||||
)
|
||||
firestore_port: int = Field(default=443, alias="GCP_FIRESTORE_PORT")
|
||||
firestore_importer_enabled: bool = Field(
|
||||
default=False, alias="GCP_FIRESTORE_IMPORTER_ENABLE",
|
||||
default=False,
|
||||
alias="GCP_FIRESTORE_IMPORTER_ENABLE",
|
||||
)
|
||||
|
||||
# Redis
|
||||
@@ -41,10 +44,12 @@ class Settings(BaseSettings):
|
||||
|
||||
# Conversation Context
|
||||
conversation_context_message_limit: int = Field(
|
||||
default=60, alias="CONVERSATION_CONTEXT_MESSAGE_LIMIT",
|
||||
default=60,
|
||||
alias="CONVERSATION_CONTEXT_MESSAGE_LIMIT",
|
||||
)
|
||||
conversation_context_days_limit: int = Field(
|
||||
default=30, alias="CONVERSATION_CONTEXT_DAYS_LIMIT",
|
||||
default=30,
|
||||
alias="CONVERSATION_CONTEXT_DAYS_LIMIT",
|
||||
)
|
||||
|
||||
# Logging
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Dependency injection and service lifecycle management."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
|
||||
from capa_de_integracion.services.rag import (
|
||||
@@ -14,10 +16,13 @@ from .services import (
|
||||
DLPService,
|
||||
NotificationManagerService,
|
||||
QuickReplyContentService,
|
||||
QuickReplySessionService,
|
||||
)
|
||||
from .services.firestore_service import FirestoreService
|
||||
from .services.quick_reply_session_service import QuickReplySessionService
|
||||
from .services.redis_service import RedisService
|
||||
from .services.conversation import get_background_tasks as conv_bg_tasks
|
||||
from .services.notifications import get_background_tasks as notif_bg_tasks
|
||||
from .services.storage import FirestoreService, RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -107,6 +112,12 @@ async def startup_services() -> None:
|
||||
|
||||
async def shutdown_services() -> None:
|
||||
"""Close all service connections on shutdown."""
|
||||
# Drain in-flight background tasks before closing connections
|
||||
all_tasks = conv_bg_tasks() | notif_bg_tasks()
|
||||
if all_tasks:
|
||||
logger.info("Draining %d background tasks before shutdown…", len(all_tasks))
|
||||
await asyncio.gather(*all_tasks, return_exceptions=True)
|
||||
|
||||
# Close Redis
|
||||
redis = get_redis_service()
|
||||
await redis.close()
|
||||
|
||||
@@ -71,11 +71,25 @@ async def health_check() -> dict[str, str]:
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for CLI."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Capa de Integración server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)") # noqa: S104
|
||||
parser.add_argument("--port", type=int, default=8080, help="Bind port (default: 8080)")
|
||||
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
|
||||
parser.add_argument("--limit-concurrency", type=int, default=None, help="Max concurrent connections per worker")
|
||||
parser.add_argument("--backlog", type=int, default=2048, help="TCP listen backlog (default: 2048)")
|
||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (dev only)")
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run(
|
||||
"capa_de_integracion.main:app",
|
||||
host="0.0.0.0", # noqa: S104
|
||||
port=8080,
|
||||
reload=True,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
workers=args.workers,
|
||||
limit_concurrency=args.limit_concurrency,
|
||||
backlog=args.backlog,
|
||||
reload=args.reload,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class ConversationEntry(BaseModel):
|
||||
entity: Literal["user", "assistant"]
|
||||
type: str = Field(..., alias="type") # "INICIO", "CONVERSACION", "LLM"
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC), alias="timestamp",
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="timestamp",
|
||||
)
|
||||
text: str = Field(..., alias="text")
|
||||
parameters: dict[str, Any] | None = Field(None, alias="parameters")
|
||||
@@ -67,10 +68,12 @@ class ConversationSession(BaseModel):
|
||||
user_id: str = Field(..., alias="userId")
|
||||
telefono: str = Field(..., alias="telefono")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC), alias="createdAt",
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="createdAt",
|
||||
)
|
||||
last_modified: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC), alias="lastModified",
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
alias="lastModified",
|
||||
)
|
||||
last_message: str | None = Field(None, alias="lastMessage")
|
||||
pantalla_contexto: str | None = Field(None, alias="pantallaContexto")
|
||||
|
||||
@@ -13,7 +13,9 @@ class Notification(BaseModel):
|
||||
"""
|
||||
|
||||
id_notificacion: str = Field(
|
||||
..., alias="idNotificacion", description="Unique notification ID",
|
||||
...,
|
||||
alias="idNotificacion",
|
||||
description="Unique notification ID",
|
||||
)
|
||||
telefono: str = Field(..., alias="telefono", description="User phone number")
|
||||
timestamp_creacion: datetime = Field(
|
||||
@@ -38,7 +40,9 @@ class Notification(BaseModel):
|
||||
description="Session parameters for Dialogflow",
|
||||
)
|
||||
status: str = Field(
|
||||
default="active", alias="status", description="Notification status",
|
||||
default="active",
|
||||
alias="status",
|
||||
description="Notification status",
|
||||
)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
@@ -69,16 +73,18 @@ class Notification(BaseModel):
|
||||
New Notification instance with current timestamp
|
||||
|
||||
"""
|
||||
return cls.model_validate({
|
||||
"idNotificacion": id_notificacion,
|
||||
"telefono": telefono,
|
||||
"timestampCreacion": datetime.now(UTC),
|
||||
"texto": texto,
|
||||
"nombreEventoDialogflow": nombre_evento_dialogflow,
|
||||
"codigoIdiomaDialogflow": codigo_idioma_dialogflow,
|
||||
"parametros": parametros or {},
|
||||
"status": status,
|
||||
})
|
||||
return cls.model_validate(
|
||||
{
|
||||
"idNotificacion": id_notificacion,
|
||||
"telefono": telefono,
|
||||
"timestampCreacion": datetime.now(UTC),
|
||||
"texto": texto,
|
||||
"nombreEventoDialogflow": nombre_evento_dialogflow,
|
||||
"codigoIdiomaDialogflow": codigo_idioma_dialogflow,
|
||||
"parametros": parametros or {},
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class NotificationSession(BaseModel):
|
||||
@@ -111,7 +117,9 @@ class ExternalNotificationRequest(BaseModel):
|
||||
texto: str = Field(..., min_length=1)
|
||||
telefono: str = Field(..., alias="telefono", description="User phone number")
|
||||
parametros_ocultos: dict[str, Any] | None = Field(
|
||||
None, alias="parametrosOcultos", description="Hidden parameters (metadata)",
|
||||
None,
|
||||
alias="parametrosOcultos",
|
||||
description="Hidden parameters (metadata)",
|
||||
)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
@@ -17,9 +17,12 @@ router = APIRouter(prefix="/api/v1/dialogflow", tags=["conversation"])
|
||||
@router.post("/detect-intent")
|
||||
async def detect_intent(
|
||||
request: ConversationRequest,
|
||||
conversation_manager: Annotated[ConversationManagerService, Depends(
|
||||
get_conversation_manager,
|
||||
)],
|
||||
conversation_manager: Annotated[
|
||||
ConversationManagerService,
|
||||
Depends(
|
||||
get_conversation_manager,
|
||||
),
|
||||
],
|
||||
) -> DetectIntentResponse:
|
||||
"""Detect user intent and manage conversation.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from capa_de_integracion.dependencies import get_notification_manager
|
||||
from capa_de_integracion.models.notification import ExternalNotificationRequest
|
||||
from capa_de_integracion.services.notification_manager import NotificationManagerService
|
||||
from capa_de_integracion.services import NotificationManagerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/dialogflow", tags=["notifications"])
|
||||
@@ -16,9 +16,12 @@ router = APIRouter(prefix="/api/v1/dialogflow", tags=["notifications"])
|
||||
@router.post("/notification", status_code=200)
|
||||
async def process_notification(
|
||||
request: ExternalNotificationRequest,
|
||||
notification_manager: Annotated[NotificationManagerService, Depends(
|
||||
get_notification_manager,
|
||||
)],
|
||||
notification_manager: Annotated[
|
||||
NotificationManagerService,
|
||||
Depends(
|
||||
get_notification_manager,
|
||||
),
|
||||
],
|
||||
) -> None:
|
||||
"""Process push notification from external system.
|
||||
|
||||
@@ -36,7 +39,7 @@ async def process_notification(
|
||||
notification_manager: Notification manager service instance
|
||||
|
||||
Returns:
|
||||
None (204 No Content)
|
||||
None (200 OK with empty body)
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if validation fails, 500 for internal errors
|
||||
|
||||
@@ -10,9 +10,7 @@ from capa_de_integracion.dependencies import (
|
||||
get_quick_reply_session_service,
|
||||
)
|
||||
from capa_de_integracion.models.quick_replies import QuickReplyScreen
|
||||
from capa_de_integracion.services.quick_reply_session_service import (
|
||||
QuickReplySessionService,
|
||||
)
|
||||
from capa_de_integracion.services import QuickReplySessionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/quick-replies", tags=["quick-replies"])
|
||||
@@ -45,7 +43,8 @@ class QuickReplyScreenResponse(BaseModel):
|
||||
async def start_quick_reply_session(
|
||||
request: QuickReplyScreenRequest,
|
||||
quick_reply_session_service: Annotated[
|
||||
QuickReplySessionService, Depends(get_quick_reply_session_service),
|
||||
QuickReplySessionService,
|
||||
Depends(get_quick_reply_session_service),
|
||||
],
|
||||
) -> QuickReplyScreenResponse:
|
||||
"""Start a quick reply FAQ session for a specific screen.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Services module."""
|
||||
|
||||
from .conversation_manager import ConversationManagerService
|
||||
from .dlp_service import DLPService
|
||||
from .notification_manager import NotificationManagerService
|
||||
from .quick_reply_content import QuickReplyContentService
|
||||
from .quick_reply_session_service import QuickReplySessionService
|
||||
from capa_de_integracion.services.conversation import ConversationManagerService
|
||||
from capa_de_integracion.services.dlp import DLPService
|
||||
from capa_de_integracion.services.notifications import NotificationManagerService
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.quick_reply.session import QuickReplySessionService
|
||||
|
||||
__all__ = [
|
||||
"ConversationManagerService",
|
||||
|
||||
634
src/capa_de_integracion/services/conversation.py
Normal file
634
src/capa_de_integracion/services/conversation.py
Normal file
@@ -0,0 +1,634 @@
|
||||
"""Conversation manager service for orchestrating user conversations."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models import (
|
||||
ConversationEntry,
|
||||
ConversationRequest,
|
||||
ConversationSession,
|
||||
DetectIntentResponse,
|
||||
QueryResult,
|
||||
)
|
||||
from capa_de_integracion.models.notification import NotificationSession
|
||||
from capa_de_integracion.services.dlp import DLPService
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.rag import RAGServiceBase
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Keep references to background tasks to prevent garbage collection
|
||||
_background_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
|
||||
def get_background_tasks() -> set[asyncio.Task[None]]:
|
||||
"""Return the set of pending background tasks (for graceful shutdown)."""
|
||||
return _background_tasks
|
||||
|
||||
MSG_EMPTY_MESSAGE = "Message cannot be empty"
|
||||
|
||||
|
||||
class ConversationManagerService:
|
||||
"""Central orchestrator for managing user conversations."""
|
||||
|
||||
SESSION_RESET_THRESHOLD_MINUTES = 30
|
||||
SCREEN_CONTEXT_TIMEOUT_MINUTES = 10
|
||||
CONV_HISTORY_PARAM = "conversation_history"
|
||||
HISTORY_PARAM = "historial"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
rag_service: RAGServiceBase,
|
||||
redis_service: RedisService,
|
||||
firestore_service: FirestoreService,
|
||||
dlp_service: DLPService,
|
||||
) -> None:
|
||||
"""Initialize conversation manager."""
|
||||
self.settings = settings
|
||||
self.rag_service = rag_service
|
||||
self.redis_service = redis_service
|
||||
self.firestore_service = firestore_service
|
||||
self.dlp_service = dlp_service
|
||||
self.quick_reply_service = QuickReplyContentService(settings)
|
||||
|
||||
logger.info("ConversationManagerService initialized successfully")
|
||||
|
||||
def _validate_message(self, mensaje: str) -> None:
|
||||
"""Validate message is not empty.
|
||||
|
||||
Args:
|
||||
mensaje: Message text to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If message is empty or whitespace
|
||||
|
||||
"""
|
||||
if not mensaje or not mensaje.strip():
|
||||
raise ValueError(MSG_EMPTY_MESSAGE)
|
||||
|
||||
async def manage_conversation(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
) -> DetectIntentResponse:
|
||||
"""Manage conversation flow and return response.
|
||||
|
||||
Orchestrates:
|
||||
1. Validation
|
||||
2. Security (DLP obfuscation)
|
||||
3. Session management
|
||||
4. Quick reply path (if applicable)
|
||||
5. Standard RAG path (fallback)
|
||||
|
||||
Args:
|
||||
request: External conversation request from client
|
||||
|
||||
Returns:
|
||||
Detect intent response from Dialogflow
|
||||
|
||||
"""
|
||||
try:
|
||||
# Step 1: Validate message is not empty
|
||||
self._validate_message(request.mensaje)
|
||||
|
||||
# Step 2+3: Apply DLP security and obtain session in parallel
|
||||
telefono = request.usuario.telefono
|
||||
obfuscated_message, session = await asyncio.gather(
|
||||
self.dlp_service.get_obfuscated_string(
|
||||
request.mensaje,
|
||||
self.settings.dlp_template_complete_flow,
|
||||
),
|
||||
self._obtain_or_create_session(telefono),
|
||||
)
|
||||
request.mensaje = obfuscated_message
|
||||
|
||||
# Step 4: Try quick reply path first
|
||||
response = await self._handle_quick_reply_path(request, session)
|
||||
if response:
|
||||
return response
|
||||
|
||||
# Step 5: Fall through to standard conversation path
|
||||
return await self._handle_standard_conversation(request, session)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error managing conversation")
|
||||
raise
|
||||
|
||||
async def _obtain_or_create_session(self, telefono: str) -> ConversationSession:
|
||||
"""Get existing session or create new one.
|
||||
|
||||
Checks Redis → Firestore → Creates new session with auto-caching.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
Returns:
|
||||
ConversationSession instance
|
||||
|
||||
"""
|
||||
# Try Redis first
|
||||
session = await self.redis_service.get_session(telefono)
|
||||
if session:
|
||||
return session
|
||||
|
||||
# Try Firestore if Redis miss
|
||||
session = await self.firestore_service.get_session_by_phone(telefono)
|
||||
if session:
|
||||
# Cache to Redis for subsequent requests
|
||||
await self.redis_service.save_session(session)
|
||||
return session
|
||||
|
||||
# Create new session if both miss
|
||||
session_id = str(uuid4())
|
||||
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
|
||||
session = await self.firestore_service.create_session(
|
||||
session_id,
|
||||
user_id,
|
||||
telefono,
|
||||
)
|
||||
|
||||
# Auto-cache to Redis
|
||||
await self.redis_service.save_session(session)
|
||||
|
||||
return session
|
||||
|
||||
async def _save_conversation_turn(
|
||||
self,
|
||||
session_id: str,
|
||||
user_text: str,
|
||||
assistant_text: str,
|
||||
entry_type: str,
|
||||
canal: str | None = None,
|
||||
) -> None:
|
||||
"""Save user and assistant messages to Firestore.
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
user_text: User message text
|
||||
assistant_text: Assistant response text
|
||||
entry_type: Type of conversation entry ("CONVERSACION" or "LLM")
|
||||
canal: Communication channel
|
||||
|
||||
"""
|
||||
# Save user and assistant entries in parallel.
|
||||
# Use a single timestamp for both, but offset the assistant entry by 1µs
|
||||
# to avoid Firestore document ID collision (save_entry uses isoformat()
|
||||
# as the document ID).
|
||||
now = datetime.now(UTC)
|
||||
user_entry = ConversationEntry(
|
||||
entity="user",
|
||||
type=entry_type,
|
||||
timestamp=now,
|
||||
text=user_text,
|
||||
parameters=None,
|
||||
canal=canal,
|
||||
)
|
||||
assistant_entry = ConversationEntry(
|
||||
entity="assistant",
|
||||
type=entry_type,
|
||||
timestamp=now + timedelta(microseconds=1),
|
||||
text=assistant_text,
|
||||
parameters=None,
|
||||
canal=canal,
|
||||
)
|
||||
await asyncio.gather(
|
||||
self.firestore_service.save_entry(session_id, user_entry),
|
||||
self.firestore_service.save_entry(session_id, assistant_entry),
|
||||
)
|
||||
|
||||
async def _update_session_after_turn(
|
||||
self,
|
||||
session: ConversationSession,
|
||||
last_message: str,
|
||||
) -> None:
|
||||
"""Update session metadata and sync to storage.
|
||||
|
||||
Updates last_message, last_modified timestamp, and saves to
|
||||
both Firestore and Redis for dual-storage consistency.
|
||||
|
||||
Args:
|
||||
session: Session to update (modified in place)
|
||||
last_message: Latest message text
|
||||
|
||||
"""
|
||||
session.last_message = last_message
|
||||
session.last_modified = datetime.now(UTC)
|
||||
await asyncio.gather(
|
||||
self.firestore_service.save_session(session),
|
||||
self.redis_service.save_session(session),
|
||||
)
|
||||
|
||||
async def _handle_quick_reply_path(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
session: ConversationSession,
|
||||
) -> DetectIntentResponse | None:
|
||||
"""Handle conversation when pantalla_contexto is active and valid.
|
||||
|
||||
Args:
|
||||
request: User conversation request
|
||||
session: Current conversation session
|
||||
|
||||
Returns:
|
||||
DetectIntentResponse if handled, None if fall through to standard path
|
||||
|
||||
"""
|
||||
# Check if pantalla_contexto exists
|
||||
if not session.pantalla_contexto:
|
||||
return None
|
||||
|
||||
# Check if pantalla_contexto is stale
|
||||
if not self._is_pantalla_context_valid(session.last_modified):
|
||||
logger.info(
|
||||
"Detected STALE 'pantallaContexto'. "
|
||||
"Ignoring and proceeding with normal flow.",
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Detected 'pantallaContexto' in session: %s. "
|
||||
"Delegating to QuickReplies flow.",
|
||||
session.pantalla_contexto,
|
||||
)
|
||||
|
||||
response = await self._manage_quick_reply_conversation(
|
||||
request,
|
||||
session.pantalla_contexto,
|
||||
)
|
||||
|
||||
if not response:
|
||||
return None
|
||||
|
||||
# Extract response text
|
||||
response_text = (
|
||||
response.query_result.response_text if response.query_result else ""
|
||||
) or ""
|
||||
|
||||
# Fire-and-forget: persist conversation turn and update session
|
||||
async def _post_response() -> None:
|
||||
try:
|
||||
await asyncio.gather(
|
||||
self._save_conversation_turn(
|
||||
session_id=session.session_id,
|
||||
user_text=request.mensaje,
|
||||
assistant_text=response_text,
|
||||
entry_type="CONVERSACION",
|
||||
canal=getattr(request, "canal", None),
|
||||
),
|
||||
self._update_session_after_turn(session, response_text),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error in quick-reply post-response work")
|
||||
|
||||
task = asyncio.create_task(_post_response())
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
return response
|
||||
|
||||
async def _handle_standard_conversation(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
session: ConversationSession,
|
||||
) -> DetectIntentResponse:
|
||||
"""Handle standard RAG-based conversation flow.
|
||||
|
||||
Loads history, notifications, queries RAG service, and persists results.
|
||||
|
||||
Args:
|
||||
request: User conversation request
|
||||
session: Current conversation session
|
||||
|
||||
Returns:
|
||||
DetectIntentResponse with RAG response
|
||||
|
||||
"""
|
||||
telefono = request.usuario.telefono
|
||||
nickname = request.usuario.nickname
|
||||
|
||||
logger.info(
|
||||
"Primary Check (Redis): Looking up session for phone: %s",
|
||||
telefono,
|
||||
)
|
||||
|
||||
# Load conversation history and notifications in parallel
|
||||
session_age = datetime.now(UTC) - session.created_at
|
||||
load_history = session_age > timedelta(
|
||||
minutes=self.SESSION_RESET_THRESHOLD_MINUTES,
|
||||
)
|
||||
|
||||
if load_history:
|
||||
entries, notifications = await asyncio.gather(
|
||||
self.firestore_service.get_entries(
|
||||
session.session_id,
|
||||
limit=self.settings.conversation_context_message_limit,
|
||||
),
|
||||
self._get_active_notifications(telefono),
|
||||
)
|
||||
logger.info(
|
||||
"Session is %s minutes old. Loaded %s conversation entries.",
|
||||
session_age.total_seconds() / 60,
|
||||
len(entries),
|
||||
)
|
||||
else:
|
||||
entries = []
|
||||
notifications = await self._get_active_notifications(telefono)
|
||||
logger.info(
|
||||
"Session is only %s minutes old. Skipping history load.",
|
||||
session_age.total_seconds() / 60,
|
||||
)
|
||||
|
||||
logger.info("Retrieved %s active notifications", len(notifications))
|
||||
|
||||
# Prepare current user message
|
||||
messages = await self._prepare_rag_messages(request.mensaje)
|
||||
|
||||
# Extract notification texts for RAG
|
||||
notification_texts = (
|
||||
[n.texto for n in notifications if n.texto and n.texto.strip()]
|
||||
if notifications
|
||||
else None
|
||||
)
|
||||
|
||||
# Format conversation history for RAG
|
||||
conversation_history = (
|
||||
self._format_conversation_history(session, entries) if entries else None
|
||||
)
|
||||
|
||||
# Query RAG service with separated fields
|
||||
logger.info("Sending query to RAG service")
|
||||
assistant_response = await self.rag_service.query(
|
||||
messages=messages,
|
||||
notifications=notification_texts,
|
||||
conversation_history=conversation_history,
|
||||
user_nickname=nickname or None,
|
||||
)
|
||||
logger.info(
|
||||
"Received response from RAG service: %s...",
|
||||
assistant_response[:100],
|
||||
)
|
||||
|
||||
# Build response object first, then fire-and-forget persistence
|
||||
response = DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(
|
||||
responseText=assistant_response,
|
||||
parameters=None,
|
||||
),
|
||||
quick_replies=None,
|
||||
)
|
||||
|
||||
# Fire-and-forget: persist conversation and update session
|
||||
async def _post_response() -> None:
|
||||
try:
|
||||
coros = [
|
||||
self._save_conversation_turn(
|
||||
session_id=session.session_id,
|
||||
user_text=request.mensaje,
|
||||
assistant_text=assistant_response,
|
||||
entry_type="LLM",
|
||||
canal=getattr(request, "canal", None),
|
||||
),
|
||||
self._update_session_after_turn(session, assistant_response),
|
||||
]
|
||||
if notifications:
|
||||
coros.append(self._mark_notifications_as_processed(telefono))
|
||||
await asyncio.gather(*coros)
|
||||
except Exception:
|
||||
logger.exception("Error in post-response background work")
|
||||
|
||||
task = asyncio.create_task(_post_response())
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
return response
|
||||
|
||||
def _is_pantalla_context_valid(self, last_modified: datetime) -> bool:
|
||||
"""Check if pantallaContexto is still valid (not stale)."""
|
||||
time_diff = datetime.now(UTC) - last_modified
|
||||
return time_diff < timedelta(minutes=self.SCREEN_CONTEXT_TIMEOUT_MINUTES)
|
||||
|
||||
async def _manage_quick_reply_conversation(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
screen_id: str,
|
||||
) -> DetectIntentResponse | None:
|
||||
"""Handle conversation within Quick Replies context."""
|
||||
quick_reply_screen = await self.quick_reply_service.get_quick_replies(screen_id)
|
||||
|
||||
# If no questions available, delegate to normal conversation flow
|
||||
if not quick_reply_screen.preguntas:
|
||||
logger.warning("No quick replies found for screen: %s.", screen_id)
|
||||
return None
|
||||
|
||||
# Match user message to a quick reply question
|
||||
user_message_lower = request.mensaje.lower().strip()
|
||||
matched_answer = None
|
||||
|
||||
for pregunta in quick_reply_screen.preguntas:
|
||||
# Simple matching: check if question title matches user message
|
||||
if pregunta.titulo.lower().strip() == user_message_lower:
|
||||
matched_answer = pregunta.respuesta
|
||||
logger.info("Matched quick reply: %s", pregunta.titulo)
|
||||
break
|
||||
|
||||
# If no match, delegate to normal flow
|
||||
if not matched_answer:
|
||||
logger.warning(
|
||||
"No matching quick reply found for message: '%s'. Falling back to RAG.",
|
||||
request.mensaje,
|
||||
)
|
||||
return None
|
||||
|
||||
# Create response with the matched quick reply answer
|
||||
return DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText=matched_answer, parameters=None),
|
||||
quick_replies=quick_reply_screen,
|
||||
)
|
||||
|
||||
async def _get_active_notifications(self, telefono: str) -> list:
|
||||
"""Retrieve active notifications for a user from Redis or Firestore.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
Returns:
|
||||
List of active Notification objects
|
||||
|
||||
"""
|
||||
try:
|
||||
# Try Redis first
|
||||
notification_session = await self.redis_service.get_notification_session(
|
||||
telefono,
|
||||
)
|
||||
|
||||
# If not in Redis, try Firestore
|
||||
if not notification_session:
|
||||
# Firestore uses phone as document ID for notifications
|
||||
doc_ref = self.firestore_service.db.collection(
|
||||
self.firestore_service.notifications_collection,
|
||||
).document(telefono)
|
||||
doc = await doc_ref.get()
|
||||
|
||||
if doc.exists:
|
||||
data = doc.to_dict()
|
||||
notification_session = NotificationSession.model_validate(data)
|
||||
|
||||
# Filter for active notifications only
|
||||
if notification_session and notification_session.notificaciones:
|
||||
active_notifications = [
|
||||
notif
|
||||
for notif in notification_session.notificaciones
|
||||
if notif.status == "active"
|
||||
]
|
||||
else:
|
||||
active_notifications = []
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error retrieving notifications for %s", telefono)
|
||||
return []
|
||||
else:
|
||||
return active_notifications
|
||||
|
||||
async def _prepare_rag_messages(
|
||||
self,
|
||||
user_message: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Prepare current user message for RAG service.
|
||||
|
||||
Args:
|
||||
user_message: Current user message
|
||||
|
||||
Returns:
|
||||
List with single user message
|
||||
|
||||
"""
|
||||
# Only include the current user message - no system messages
|
||||
return [{"role": "user", "content": user_message}]
|
||||
|
||||
async def _mark_notifications_as_processed(self, telefono: str) -> None:
|
||||
"""Mark all notifications for a user as processed.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
"""
|
||||
try:
|
||||
# Update status in Firestore
|
||||
await self.firestore_service.update_notification_status(
|
||||
telefono,
|
||||
"processed",
|
||||
)
|
||||
|
||||
# Update or delete from Redis
|
||||
await self.redis_service.delete_notification_session(telefono)
|
||||
|
||||
logger.info("Marked notifications as processed for %s", telefono)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error marking notifications as processed for %s",
|
||||
telefono,
|
||||
)
|
||||
|
||||
def _format_conversation_history(
|
||||
self,
|
||||
session: ConversationSession, # noqa: ARG002
|
||||
entries: list[ConversationEntry],
|
||||
) -> str:
|
||||
"""Format conversation history with business rule limits.
|
||||
|
||||
Applies limits:
|
||||
- Date: 30 days maximum
|
||||
- Count: 60 messages maximum
|
||||
- Size: 50KB maximum
|
||||
|
||||
Args:
|
||||
session: Conversation session
|
||||
entries: List of conversation entries
|
||||
|
||||
Returns:
|
||||
Formatted conversation text
|
||||
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
# Filter by date (30 days)
|
||||
cutoff_date = datetime.now(UTC) - timedelta(
|
||||
days=self.settings.conversation_context_days_limit,
|
||||
)
|
||||
recent_entries = [
|
||||
e for e in entries if e.timestamp and e.timestamp >= cutoff_date
|
||||
]
|
||||
|
||||
# Sort by timestamp (oldest first) and limit count
|
||||
recent_entries.sort(key=lambda e: e.timestamp)
|
||||
limited_entries = recent_entries[
|
||||
-self.settings.conversation_context_message_limit :
|
||||
]
|
||||
|
||||
# Format with size truncation (50KB)
|
||||
return self._format_entries_with_size_limit(limited_entries)
|
||||
|
||||
def _format_entries_with_size_limit(self, entries: list[ConversationEntry]) -> str:
|
||||
"""Format entries with 50KB size limit.
|
||||
|
||||
Builds from newest to oldest, stopping at size limit.
|
||||
|
||||
Args:
|
||||
entries: List of conversation entries
|
||||
|
||||
Returns:
|
||||
Formatted text, truncated if necessary
|
||||
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
max_bytes = 50 * 1024 # 50KB
|
||||
formatted_messages = [self._format_entry(entry) for entry in entries]
|
||||
|
||||
# Build from newest to oldest
|
||||
text_block = []
|
||||
current_size = 0
|
||||
|
||||
for message in reversed(formatted_messages):
|
||||
message_line = message + "\n"
|
||||
message_bytes = len(message_line.encode("utf-8"))
|
||||
|
||||
if current_size + message_bytes > max_bytes:
|
||||
break
|
||||
|
||||
text_block.insert(0, message_line)
|
||||
current_size += message_bytes
|
||||
|
||||
return "".join(text_block).strip()
|
||||
|
||||
def _format_entry(self, entry: ConversationEntry) -> str:
|
||||
"""Format a single conversation entry.
|
||||
|
||||
Args:
|
||||
entry: Conversation entry
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "User: hello", "Assistant: hi there")
|
||||
|
||||
"""
|
||||
# Map entity to prefix (fixed bug from Java port!)
|
||||
prefix = "User: " if entry.entity == "user" else "Assistant: "
|
||||
|
||||
# Clean content if needed
|
||||
content = entry.text
|
||||
if entry.entity == "assistant":
|
||||
# Remove trailing JSON artifacts like {...}
|
||||
content = re.sub(r"\s*\{.*\}\s*$", "", content).strip()
|
||||
|
||||
return prefix + content
|
||||
@@ -1,498 +0,0 @@
|
||||
"""Conversation manager service for orchestrating user conversations."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models import (
|
||||
ConversationEntry,
|
||||
ConversationRequest,
|
||||
ConversationSession,
|
||||
DetectIntentResponse,
|
||||
QueryResult,
|
||||
)
|
||||
from capa_de_integracion.models.notification import NotificationSession
|
||||
from capa_de_integracion.services.rag import RAGServiceBase
|
||||
|
||||
from .dlp_service import DLPService
|
||||
from .firestore_service import FirestoreService
|
||||
from .quick_reply_content import QuickReplyContentService
|
||||
from .redis_service import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationManagerService:
|
||||
"""Central orchestrator for managing user conversations."""
|
||||
|
||||
SESSION_RESET_THRESHOLD_MINUTES = 30
|
||||
SCREEN_CONTEXT_TIMEOUT_MINUTES = 10
|
||||
CONV_HISTORY_PARAM = "conversation_history"
|
||||
HISTORY_PARAM = "historial"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
rag_service: RAGServiceBase,
|
||||
redis_service: RedisService,
|
||||
firestore_service: FirestoreService,
|
||||
dlp_service: DLPService,
|
||||
) -> None:
|
||||
"""Initialize conversation manager."""
|
||||
self.settings = settings
|
||||
self.rag_service = rag_service
|
||||
self.redis_service = redis_service
|
||||
self.firestore_service = firestore_service
|
||||
self.dlp_service = dlp_service
|
||||
self.quick_reply_service = QuickReplyContentService(settings)
|
||||
|
||||
logger.info("ConversationManagerService initialized successfully")
|
||||
|
||||
async def manage_conversation( # noqa: PLR0915
|
||||
self, request: ConversationRequest,
|
||||
) -> DetectIntentResponse:
|
||||
"""Manage conversation flow and return response.
|
||||
|
||||
Args:
|
||||
request: External conversation request from client
|
||||
|
||||
Returns:
|
||||
Detect intent response from Dialogflow
|
||||
|
||||
"""
|
||||
try:
|
||||
# Step 1: DLP obfuscation
|
||||
obfuscated_message = await self.dlp_service.get_obfuscated_string(
|
||||
request.mensaje,
|
||||
self.settings.dlp_template_complete_flow,
|
||||
)
|
||||
request.mensaje = obfuscated_message
|
||||
telefono = request.usuario.telefono
|
||||
|
||||
# Step 2. Fetch session in Redis -> Firestore -> Create new session
|
||||
session = await self.redis_service.get_session(telefono)
|
||||
if not session:
|
||||
session = await self.firestore_service.get_session_by_phone(telefono)
|
||||
if not session:
|
||||
session_id = str(uuid4())
|
||||
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
|
||||
session = await self.firestore_service.create_session(
|
||||
session_id, user_id, telefono,
|
||||
)
|
||||
await self.redis_service.save_session(session)
|
||||
|
||||
# Step 2: Check for pantallaContexto in existing session
|
||||
if session.pantalla_contexto:
|
||||
# Check if pantallaContexto is stale (10 minutes)
|
||||
if self._is_pantalla_context_valid(session.last_modified):
|
||||
logger.info(
|
||||
"Detected 'pantallaContexto' in session: %s. "
|
||||
"Delegating to QuickReplies flow.",
|
||||
session.pantalla_contexto,
|
||||
)
|
||||
response = await self._manage_quick_reply_conversation(
|
||||
request, session.pantalla_contexto,
|
||||
)
|
||||
if response:
|
||||
# Save user message to Firestore
|
||||
user_entry = ConversationEntry(
|
||||
entity="user",
|
||||
type="CONVERSACION",
|
||||
timestamp=datetime.now(UTC),
|
||||
text=request.mensaje,
|
||||
parameters=None,
|
||||
canal=getattr(request, "canal", None),
|
||||
)
|
||||
await self.firestore_service.save_entry(
|
||||
session.session_id, user_entry,
|
||||
)
|
||||
|
||||
# Save quick reply response to Firestore
|
||||
response_text = (
|
||||
response.query_result.response_text
|
||||
if response.query_result
|
||||
else ""
|
||||
) or ""
|
||||
assistant_entry = ConversationEntry(
|
||||
entity="assistant",
|
||||
type="CONVERSACION",
|
||||
timestamp=datetime.now(UTC),
|
||||
text=response_text,
|
||||
parameters=None,
|
||||
canal=getattr(request, "canal", None),
|
||||
)
|
||||
await self.firestore_service.save_entry(
|
||||
session.session_id, assistant_entry,
|
||||
)
|
||||
|
||||
# Update session with last message and timestamp
|
||||
session.last_message = response_text
|
||||
session.last_modified = datetime.now(UTC)
|
||||
await self.firestore_service.save_session(session)
|
||||
await self.redis_service.save_session(session)
|
||||
|
||||
return response
|
||||
else:
|
||||
logger.info(
|
||||
"Detected STALE 'pantallaContexto'. "
|
||||
"Ignoring and proceeding with normal flow.",
|
||||
)
|
||||
|
||||
# Step 3: Continue with standard conversation flow
|
||||
nickname = request.usuario.nickname
|
||||
|
||||
logger.info(
|
||||
"Primary Check (Redis): Looking up session for phone: %s",
|
||||
telefono,
|
||||
)
|
||||
|
||||
# Step 3a: Load conversation history from Firestore
|
||||
entries = await self.firestore_service.get_entries(
|
||||
session.session_id,
|
||||
limit=self.settings.conversation_context_message_limit,
|
||||
)
|
||||
logger.info("Loaded %s conversation entries from Firestore", len(entries))
|
||||
|
||||
# Step 3b: Retrieve active notifications for this user
|
||||
notifications = await self._get_active_notifications(telefono)
|
||||
logger.info("Retrieved %s active notifications", len(notifications))
|
||||
|
||||
# Step 3c: Prepare context for RAG service
|
||||
messages = await self._prepare_rag_messages(
|
||||
session=session,
|
||||
entries=entries,
|
||||
notifications=notifications,
|
||||
user_message=request.mensaje,
|
||||
nickname=nickname or "Usuario",
|
||||
)
|
||||
|
||||
# Step 3d: Query RAG service
|
||||
logger.info("Sending query to RAG service")
|
||||
assistant_response = await self.rag_service.query(messages)
|
||||
logger.info(
|
||||
"Received response from RAG service: %s...",
|
||||
assistant_response[:100],
|
||||
)
|
||||
|
||||
# Step 3e: Save user message to Firestore
|
||||
user_entry = ConversationEntry(
|
||||
entity="user",
|
||||
type="CONVERSACION",
|
||||
timestamp=datetime.now(UTC),
|
||||
text=request.mensaje,
|
||||
parameters=None,
|
||||
canal=getattr(request, "canal", None),
|
||||
)
|
||||
await self.firestore_service.save_entry(session.session_id, user_entry)
|
||||
logger.info("Saved user message to Firestore")
|
||||
|
||||
# Step 3f: Save assistant response to Firestore
|
||||
assistant_entry = ConversationEntry(
|
||||
entity="assistant",
|
||||
type="LLM",
|
||||
timestamp=datetime.now(UTC),
|
||||
text=assistant_response,
|
||||
parameters=None,
|
||||
canal=getattr(request, "canal", None),
|
||||
)
|
||||
await self.firestore_service.save_entry(session.session_id, assistant_entry)
|
||||
logger.info("Saved assistant response to Firestore")
|
||||
|
||||
# Step 3g: Update session with last message and timestamp
|
||||
session.last_message = assistant_response
|
||||
session.last_modified = datetime.now(UTC)
|
||||
await self.firestore_service.save_session(session)
|
||||
await self.redis_service.save_session(session)
|
||||
logger.info("Updated session in Firestore and Redis")
|
||||
|
||||
# Step 3h: Mark notifications as processed if any were included
|
||||
if notifications:
|
||||
await self._mark_notifications_as_processed(telefono)
|
||||
logger.info("Marked %s notifications as processed", len(notifications))
|
||||
|
||||
# Step 3i: Return response object
|
||||
return DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(
|
||||
responseText=assistant_response,
|
||||
parameters=None,
|
||||
),
|
||||
quick_replies=None,
|
||||
)
|
||||
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error managing conversation")
|
||||
raise
|
||||
|
||||
def _is_pantalla_context_valid(self, last_modified: datetime) -> bool:
|
||||
"""Check if pantallaContexto is still valid (not stale)."""
|
||||
time_diff = datetime.now(UTC) - last_modified
|
||||
return time_diff < timedelta(minutes=self.SCREEN_CONTEXT_TIMEOUT_MINUTES)
|
||||
|
||||
async def _manage_quick_reply_conversation(
|
||||
self,
|
||||
request: ConversationRequest,
|
||||
screen_id: str,
|
||||
) -> DetectIntentResponse | None:
|
||||
"""Handle conversation within Quick Replies context."""
|
||||
quick_reply_screen = await self.quick_reply_service.get_quick_replies(screen_id)
|
||||
|
||||
# If no questions available, delegate to normal conversation flow
|
||||
if not quick_reply_screen.preguntas:
|
||||
logger.warning("No quick replies found for screen: %s.", screen_id)
|
||||
return None
|
||||
|
||||
# Match user message to a quick reply question
|
||||
user_message_lower = request.mensaje.lower().strip()
|
||||
matched_answer = None
|
||||
|
||||
for pregunta in quick_reply_screen.preguntas:
|
||||
# Simple matching: check if question title matches user message
|
||||
if pregunta.titulo.lower().strip() == user_message_lower:
|
||||
matched_answer = pregunta.respuesta
|
||||
logger.info("Matched quick reply: %s", pregunta.titulo)
|
||||
break
|
||||
|
||||
# If no match, use first question as default or delegate to normal flow
|
||||
if not matched_answer:
|
||||
logger.warning(
|
||||
"No matching quick reply found for message: '%s'.",
|
||||
request.mensaje,
|
||||
)
|
||||
|
||||
# Create response with the matched quick reply answer
|
||||
return DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText=matched_answer, parameters=None),
|
||||
quick_replies=quick_reply_screen,
|
||||
)
|
||||
|
||||
|
||||
async def _get_active_notifications(self, telefono: str) -> list:
|
||||
"""Retrieve active notifications for a user from Redis or Firestore.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
Returns:
|
||||
List of active Notification objects
|
||||
|
||||
"""
|
||||
try:
|
||||
# Try Redis first
|
||||
notification_session = await self.redis_service.get_notification_session(
|
||||
telefono,
|
||||
)
|
||||
|
||||
# If not in Redis, try Firestore
|
||||
if not notification_session:
|
||||
# Firestore uses phone as document ID for notifications
|
||||
doc_ref = self.firestore_service.db.collection(
|
||||
self.firestore_service.notifications_collection,
|
||||
).document(telefono)
|
||||
doc = await doc_ref.get()
|
||||
|
||||
if doc.exists:
|
||||
data = doc.to_dict()
|
||||
notification_session = NotificationSession.model_validate(data)
|
||||
|
||||
# Filter for active notifications only
|
||||
if notification_session and notification_session.notificaciones:
|
||||
active_notifications = [
|
||||
notif
|
||||
for notif in notification_session.notificaciones
|
||||
if notif.status == "active"
|
||||
]
|
||||
else:
|
||||
active_notifications = []
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error retrieving notifications for %s", telefono)
|
||||
return []
|
||||
else:
|
||||
return active_notifications
|
||||
|
||||
async def _prepare_rag_messages(
|
||||
self,
|
||||
session: ConversationSession,
|
||||
entries: list[ConversationEntry],
|
||||
notifications: list,
|
||||
user_message: str,
|
||||
nickname: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""Prepare messages in OpenAI format for RAG service.
|
||||
|
||||
Args:
|
||||
session: Current conversation session
|
||||
entries: Conversation history entries
|
||||
notifications: Active notifications
|
||||
user_message: Current user message
|
||||
nickname: User's nickname
|
||||
|
||||
Returns:
|
||||
List of messages in OpenAI format [{"role": "...", "content": "..."}]
|
||||
|
||||
"""
|
||||
messages = []
|
||||
|
||||
# Add system message with conversation history if available
|
||||
if entries:
|
||||
conversation_context = self._format_conversation_history(session, entries)
|
||||
if conversation_context:
|
||||
messages.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"Historial de conversación:\n"
|
||||
f"{conversation_context}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Add system message with notifications if available
|
||||
if notifications:
|
||||
# Simple Pythonic join - no mapper needed!
|
||||
notifications_text = "\n".join(
|
||||
n.texto for n in notifications if n.texto and n.texto.strip()
|
||||
)
|
||||
if notifications_text:
|
||||
messages.append(
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"Notificaciones pendientes para el usuario:\n"
|
||||
f"{notifications_text}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Add system message with user context
|
||||
user_context = f"Usuario: {nickname}" if nickname else "Usuario anónimo"
|
||||
messages.append({"role": "system", "content": user_context})
|
||||
|
||||
# Add current user message
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
return messages
|
||||
|
||||
async def _mark_notifications_as_processed(self, telefono: str) -> None:
|
||||
"""Mark all notifications for a user as processed.
|
||||
|
||||
Args:
|
||||
telefono: User phone number
|
||||
|
||||
"""
|
||||
try:
|
||||
# Update status in Firestore
|
||||
await self.firestore_service.update_notification_status(
|
||||
telefono, "processed",
|
||||
)
|
||||
|
||||
# Update or delete from Redis
|
||||
await self.redis_service.delete_notification_session(telefono)
|
||||
|
||||
logger.info("Marked notifications as processed for %s", telefono)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error marking notifications as processed for %s",
|
||||
telefono,
|
||||
)
|
||||
|
||||
def _format_conversation_history(
|
||||
self,
|
||||
session: ConversationSession, # noqa: ARG002
|
||||
entries: list[ConversationEntry],
|
||||
) -> str:
|
||||
"""Format conversation history with business rule limits.
|
||||
|
||||
Applies limits:
|
||||
- Date: 30 days maximum
|
||||
- Count: 60 messages maximum
|
||||
- Size: 50KB maximum
|
||||
|
||||
Args:
|
||||
session: Conversation session
|
||||
entries: List of conversation entries
|
||||
|
||||
Returns:
|
||||
Formatted conversation text
|
||||
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
# Filter by date (30 days)
|
||||
cutoff_date = datetime.now(UTC) - timedelta(
|
||||
days=self.settings.conversation_context_days_limit,
|
||||
)
|
||||
recent_entries = [
|
||||
e for e in entries if e.timestamp and e.timestamp >= cutoff_date
|
||||
]
|
||||
|
||||
# Sort by timestamp (oldest first) and limit count
|
||||
recent_entries.sort(key=lambda e: e.timestamp)
|
||||
limited_entries = recent_entries[
|
||||
-self.settings.conversation_context_message_limit :
|
||||
]
|
||||
|
||||
# Format with size truncation (50KB)
|
||||
return self._format_entries_with_size_limit(limited_entries)
|
||||
|
||||
def _format_entries_with_size_limit(self, entries: list[ConversationEntry]) -> str:
|
||||
"""Format entries with 50KB size limit.
|
||||
|
||||
Builds from newest to oldest, stopping at size limit.
|
||||
|
||||
Args:
|
||||
entries: List of conversation entries
|
||||
|
||||
Returns:
|
||||
Formatted text, truncated if necessary
|
||||
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
|
||||
max_bytes = 50 * 1024 # 50KB
|
||||
formatted_messages = [self._format_entry(entry) for entry in entries]
|
||||
|
||||
# Build from newest to oldest
|
||||
text_block = []
|
||||
current_size = 0
|
||||
|
||||
for message in reversed(formatted_messages):
|
||||
message_line = message + "\n"
|
||||
message_bytes = len(message_line.encode("utf-8"))
|
||||
|
||||
if current_size + message_bytes > max_bytes:
|
||||
break
|
||||
|
||||
text_block.insert(0, message_line)
|
||||
current_size += message_bytes
|
||||
|
||||
return "".join(text_block).strip()
|
||||
|
||||
def _format_entry(self, entry: ConversationEntry) -> str:
|
||||
"""Format a single conversation entry.
|
||||
|
||||
Args:
|
||||
entry: Conversation entry
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "User: hello", "Assistant: hi there")
|
||||
|
||||
"""
|
||||
# Map entity to prefix (fixed bug from Java port!)
|
||||
prefix = "User: " if entry.entity == "user" else "Assistant: "
|
||||
|
||||
# Clean content if needed
|
||||
content = entry.text
|
||||
if entry.entity == "assistant":
|
||||
# Remove trailing JSON artifacts like {...}
|
||||
content = re.sub(r"\s*\{.*\}\s*$", "", content).strip()
|
||||
|
||||
return prefix + content
|
||||
@@ -33,10 +33,17 @@ class DLPService:
|
||||
self.settings = settings
|
||||
self.project_id = settings.gcp_project_id
|
||||
self.location = settings.gcp_location
|
||||
self.dlp_client = dlp_v2.DlpServiceAsyncClient()
|
||||
self._dlp_client: dlp_v2.DlpServiceAsyncClient | None = None
|
||||
|
||||
logger.info("DLP Service initialized")
|
||||
|
||||
@property
|
||||
def dlp_client(self) -> dlp_v2.DlpServiceAsyncClient:
|
||||
"""Lazily create the async DLP client (requires a running event loop)."""
|
||||
if self._dlp_client is None:
|
||||
self._dlp_client = dlp_v2.DlpServiceAsyncClient()
|
||||
return self._dlp_client
|
||||
|
||||
async def get_obfuscated_string(self, text: str, template_id: str) -> str:
|
||||
"""Inspect text for sensitive data and obfuscate findings.
|
||||
|
||||
@@ -96,7 +103,7 @@ class DLPService:
|
||||
obfuscated_text = text
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error during DLP inspection. Returning original text.")
|
||||
logger.warning("DLP inspection failed. Returning original text.")
|
||||
return text
|
||||
else:
|
||||
return obfuscated_text
|
||||
@@ -140,7 +147,6 @@ class DLPService:
|
||||
# Clean up consecutive DIRECCION tags
|
||||
return self._clean_direccion(text)
|
||||
|
||||
|
||||
def _get_replacement(self, info_type: str, quote: str) -> str | None:
|
||||
"""Get replacement text for a given info type.
|
||||
|
||||
@@ -9,15 +9,22 @@ from capa_de_integracion.models.notification import (
|
||||
ExternalNotificationRequest,
|
||||
Notification,
|
||||
)
|
||||
|
||||
from .dlp_service import DLPService
|
||||
from .firestore_service import FirestoreService
|
||||
from .redis_service import RedisService
|
||||
from capa_de_integracion.services.dlp import DLPService
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PREFIX_PO_PARAM = "notification_po_"
|
||||
|
||||
# Keep references to background tasks to prevent garbage collection
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def get_background_tasks() -> set[asyncio.Task]:
|
||||
"""Return the set of pending background tasks (for graceful shutdown)."""
|
||||
return _background_tasks
|
||||
|
||||
|
||||
class NotificationManagerService:
|
||||
"""Manages notification processing and integration with conversations.
|
||||
@@ -53,7 +60,8 @@ class NotificationManagerService:
|
||||
logger.info("NotificationManagerService initialized")
|
||||
|
||||
async def process_notification(
|
||||
self, external_request: ExternalNotificationRequest,
|
||||
self,
|
||||
external_request: ExternalNotificationRequest,
|
||||
) -> None:
|
||||
"""Process a push notification from external system.
|
||||
|
||||
@@ -127,6 +135,8 @@ class NotificationManagerService:
|
||||
)
|
||||
|
||||
# Fire and forget - don't await
|
||||
_task = asyncio.create_task(save_notification_to_firestore())
|
||||
task = asyncio.create_task(save_notification_to_firestore())
|
||||
# Store reference to prevent premature garbage collection
|
||||
del _task
|
||||
_background_tasks.add(task)
|
||||
# Remove from set when done to prevent memory leak
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
9
src/capa_de_integracion/services/quick_reply/__init__.py
Normal file
9
src/capa_de_integracion/services/quick_reply/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Quick reply services."""
|
||||
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.quick_reply.session import QuickReplySessionService
|
||||
|
||||
__all__ = [
|
||||
"QuickReplyContentService",
|
||||
"QuickReplySessionService",
|
||||
]
|
||||
161
src/capa_de_integracion/services/quick_reply/content.py
Normal file
161
src/capa_de_integracion/services/quick_reply/content.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Quick reply content service for loading FAQ screens."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models.quick_replies import (
|
||||
QuickReplyQuestions,
|
||||
QuickReplyScreen,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuickReplyContentService:
|
||||
"""Service for loading quick reply screen content from JSON files."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Initialize quick reply content service.
|
||||
|
||||
Args:
|
||||
settings: Application settings
|
||||
|
||||
"""
|
||||
self.settings = settings
|
||||
self.quick_replies_path = settings.base_path / "quick_replies"
|
||||
self._cache: dict[str, QuickReplyScreen] = {}
|
||||
|
||||
logger.info(
|
||||
"QuickReplyContentService initialized with path: %s",
|
||||
self.quick_replies_path,
|
||||
)
|
||||
|
||||
# Preload all quick reply files into memory
|
||||
self._preload_cache()
|
||||
|
||||
def _validate_file(self, file_path: Path, screen_id: str) -> None:
|
||||
"""Validate that the quick reply file exists."""
|
||||
if not file_path.exists():
|
||||
logger.warning("Quick reply file not found: %s", file_path)
|
||||
msg = f"Quick reply file not found for screen_id: {screen_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def _parse_quick_reply_data(self, data: dict) -> QuickReplyScreen:
|
||||
"""Parse JSON data into QuickReplyScreen model.
|
||||
|
||||
Args:
|
||||
data: JSON data dictionary
|
||||
|
||||
Returns:
|
||||
Parsed QuickReplyScreen object
|
||||
|
||||
"""
|
||||
preguntas_data = data.get("preguntas", [])
|
||||
preguntas = [
|
||||
QuickReplyQuestions(
|
||||
titulo=q.get("titulo", ""),
|
||||
descripcion=q.get("descripcion"),
|
||||
respuesta=q.get("respuesta", ""),
|
||||
)
|
||||
for q in preguntas_data
|
||||
]
|
||||
|
||||
return QuickReplyScreen(
|
||||
header=data.get("header"),
|
||||
body=data.get("body"),
|
||||
button=data.get("button"),
|
||||
header_section=data.get("header_section"),
|
||||
preguntas=preguntas,
|
||||
)
|
||||
|
||||
def _preload_cache(self) -> None:
|
||||
"""Preload all quick reply files into memory cache at startup.
|
||||
|
||||
This method runs synchronously at initialization to load all
|
||||
quick reply JSON files. Blocking here is acceptable since it
|
||||
only happens once at startup.
|
||||
|
||||
"""
|
||||
if not self.quick_replies_path.exists():
|
||||
logger.warning(
|
||||
"Quick replies directory not found: %s",
|
||||
self.quick_replies_path,
|
||||
)
|
||||
return
|
||||
|
||||
loaded_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for file_path in self.quick_replies_path.glob("*.json"):
|
||||
screen_id = file_path.stem
|
||||
try:
|
||||
# Blocking I/O is OK at startup
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
data = json.loads(content)
|
||||
quick_reply = self._parse_quick_reply_data(data)
|
||||
|
||||
self._cache[screen_id] = quick_reply
|
||||
loaded_count += 1
|
||||
|
||||
logger.debug(
|
||||
"Cached %s quick replies for screen: %s",
|
||||
len(quick_reply.preguntas),
|
||||
screen_id,
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Invalid JSON in file: %s", file_path)
|
||||
failed_count += 1
|
||||
except Exception:
|
||||
logger.exception("Failed to load quick reply file: %s", file_path)
|
||||
failed_count += 1
|
||||
|
||||
logger.info(
|
||||
"Quick reply cache initialized: %s screens loaded, %s failed",
|
||||
loaded_count,
|
||||
failed_count,
|
||||
)
|
||||
|
||||
async def get_quick_replies(self, screen_id: str) -> QuickReplyScreen:
|
||||
"""Get quick reply screen content by ID from in-memory cache.
|
||||
|
||||
This method is non-blocking as it retrieves data from the
|
||||
in-memory cache populated at startup.
|
||||
|
||||
Args:
|
||||
screen_id: Screen identifier (e.g., "pagos", "home")
|
||||
|
||||
Returns:
|
||||
Quick reply screen data
|
||||
|
||||
Raises:
|
||||
ValueError: If the quick reply is not found in cache
|
||||
|
||||
"""
|
||||
if not screen_id or not screen_id.strip():
|
||||
logger.warning("screen_id is null or empty. Returning empty quick replies")
|
||||
return QuickReplyScreen(
|
||||
header="empty",
|
||||
body=None,
|
||||
button=None,
|
||||
header_section=None,
|
||||
preguntas=[],
|
||||
)
|
||||
|
||||
# Non-blocking: just a dictionary lookup
|
||||
quick_reply = self._cache.get(screen_id)
|
||||
|
||||
if quick_reply is None:
|
||||
logger.warning("Quick reply not found in cache for screen: %s", screen_id)
|
||||
msg = f"Quick reply not found for screen_id: {screen_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
logger.info(
|
||||
"Retrieved %s quick replies for screen: %s from cache",
|
||||
len(quick_reply.preguntas),
|
||||
screen_id,
|
||||
)
|
||||
|
||||
return quick_reply
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Quick reply session service for managing FAQ sessions."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from capa_de_integracion.models.quick_replies import QuickReplyScreen
|
||||
from capa_de_integracion.services.firestore_service import FirestoreService
|
||||
from capa_de_integracion.services.quick_reply_content import QuickReplyContentService
|
||||
from capa_de_integracion.services.redis_service import RedisService
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,19 +88,27 @@ class QuickReplySessionService:
|
||||
"""
|
||||
self._validate_phone(telefono)
|
||||
|
||||
# Get or create session
|
||||
session = await self.firestore_service.get_session_by_phone(telefono)
|
||||
# Get or create session (check Redis first for consistency)
|
||||
session = await self.redis_service.get_session(telefono)
|
||||
if not session:
|
||||
session = await self.firestore_service.get_session_by_phone(telefono)
|
||||
|
||||
if session:
|
||||
session_id = session.session_id
|
||||
await self.firestore_service.update_pantalla_contexto(
|
||||
session_id, pantalla_contexto,
|
||||
session_id,
|
||||
pantalla_contexto,
|
||||
)
|
||||
session.pantalla_contexto = pantalla_contexto
|
||||
session.last_modified = datetime.now(UTC)
|
||||
else:
|
||||
session_id = str(uuid4())
|
||||
user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}"
|
||||
session = await self.firestore_service.create_session(
|
||||
session_id, user_id, telefono, pantalla_contexto,
|
||||
session_id,
|
||||
user_id,
|
||||
telefono,
|
||||
pantalla_contexto,
|
||||
)
|
||||
|
||||
# Cache session in Redis
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Quick reply content service for loading FAQ screens."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models.quick_replies import (
|
||||
QuickReplyQuestions,
|
||||
QuickReplyScreen,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuickReplyContentService:
|
||||
"""Service for loading quick reply screen content from JSON files."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
"""Initialize quick reply content service.
|
||||
|
||||
Args:
|
||||
settings: Application settings
|
||||
|
||||
"""
|
||||
self.settings = settings
|
||||
self.quick_replies_path = settings.base_path / "quick_replies"
|
||||
|
||||
logger.info(
|
||||
"QuickReplyContentService initialized with path: %s",
|
||||
self.quick_replies_path,
|
||||
)
|
||||
|
||||
def _validate_file(self, file_path: Path, screen_id: str) -> None:
|
||||
"""Validate that the quick reply file exists."""
|
||||
if not file_path.exists():
|
||||
logger.warning("Quick reply file not found: %s", file_path)
|
||||
msg = f"Quick reply file not found for screen_id: {screen_id}"
|
||||
raise ValueError(msg)
|
||||
|
||||
async def get_quick_replies(self, screen_id: str) -> QuickReplyScreen:
|
||||
"""Load quick reply screen content by ID.
|
||||
|
||||
Args:
|
||||
screen_id: Screen identifier (e.g., "pagos", "home")
|
||||
|
||||
Returns:
|
||||
Quick reply DTO
|
||||
|
||||
Raises:
|
||||
ValueError: If the quick reply file is not found
|
||||
|
||||
"""
|
||||
if not screen_id or not screen_id.strip():
|
||||
logger.warning("screen_id is null or empty. Returning empty quick replies")
|
||||
return QuickReplyScreen(
|
||||
header="empty",
|
||||
body=None,
|
||||
button=None,
|
||||
header_section=None,
|
||||
preguntas=[],
|
||||
)
|
||||
|
||||
file_path = self.quick_replies_path / f"{screen_id}.json"
|
||||
|
||||
try:
|
||||
self._validate_file(file_path, screen_id)
|
||||
|
||||
# Use Path.read_text() for async-friendly file reading
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
data = json.loads(content)
|
||||
|
||||
# Parse questions
|
||||
preguntas_data = data.get("preguntas", [])
|
||||
preguntas = [
|
||||
QuickReplyQuestions(
|
||||
titulo=q.get("titulo", ""),
|
||||
descripcion=q.get("descripcion"),
|
||||
respuesta=q.get("respuesta", ""),
|
||||
)
|
||||
for q in preguntas_data
|
||||
]
|
||||
|
||||
quick_reply = QuickReplyScreen(
|
||||
header=data.get("header"),
|
||||
body=data.get("body"),
|
||||
button=data.get("button"),
|
||||
header_section=data.get("header_section"),
|
||||
preguntas=preguntas,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Successfully loaded %s quick replies for screen: %s",
|
||||
len(preguntas),
|
||||
screen_id,
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.exception("Error parsing JSON file %s", file_path)
|
||||
msg = f"Invalid JSON format in quick reply file for screen_id: {screen_id}"
|
||||
raise ValueError(msg) from e
|
||||
except Exception as e:
|
||||
logger.exception("Error loading quick replies for screen %s", screen_id)
|
||||
msg = f"Error loading quick replies for screen_id: {screen_id}"
|
||||
raise ValueError(msg) from e
|
||||
else:
|
||||
return quick_reply
|
||||
@@ -17,7 +17,22 @@ class Message(BaseModel):
|
||||
class RAGRequest(BaseModel):
|
||||
"""Request model for RAG endpoint."""
|
||||
|
||||
messages: list[Message] = Field(..., description="Conversation history")
|
||||
messages: list[Message] = Field(
|
||||
...,
|
||||
description="Current conversation messages (user and assistant only)",
|
||||
)
|
||||
notifications: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Active notifications for the user",
|
||||
)
|
||||
conversation_history: str | None = Field(
|
||||
default=None,
|
||||
description="Formatted conversation history",
|
||||
)
|
||||
user_nickname: str | None = Field(
|
||||
default=None,
|
||||
description="User's nickname or display name",
|
||||
)
|
||||
|
||||
|
||||
class RAGResponse(BaseModel):
|
||||
@@ -34,12 +49,21 @@ class RAGServiceBase(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def query(self, messages: list[dict[str, str]]) -> str:
|
||||
"""Send conversation history to RAG endpoint and get response.
|
||||
async def query(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
notifications: list[str] | None = None,
|
||||
conversation_history: str | None = None,
|
||||
user_nickname: str | None = None,
|
||||
) -> str:
|
||||
"""Send conversation to RAG endpoint and get response.
|
||||
|
||||
Args:
|
||||
messages: OpenAI-style conversation history
|
||||
messages: Current conversation messages (user/assistant only)
|
||||
e.g., [{"role": "user", "content": "Hello"}, ...]
|
||||
notifications: Active notifications for the user (optional)
|
||||
conversation_history: Formatted conversation history (optional)
|
||||
user_nickname: User's nickname or display name (optional)
|
||||
|
||||
Returns:
|
||||
Response string from RAG endpoint
|
||||
|
||||
@@ -28,12 +28,21 @@ class EchoRAGService(RAGServiceBase):
|
||||
self.prefix = prefix
|
||||
logger.info("EchoRAGService initialized with prefix: %r", prefix)
|
||||
|
||||
async def query(self, messages: list[dict[str, str]]) -> str:
|
||||
async def query(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
notifications: list[str] | None = None, # noqa: ARG002
|
||||
conversation_history: str | None = None, # noqa: ARG002
|
||||
user_nickname: str | None = None, # noqa: ARG002
|
||||
) -> str:
|
||||
"""Echo back the last user message with a prefix.
|
||||
|
||||
Args:
|
||||
messages: OpenAI-style conversation history
|
||||
messages: Current conversation messages (user/assistant only)
|
||||
e.g., [{"role": "user", "content": "Hello"}, ...]
|
||||
notifications: Active notifications for the user (optional, ignored)
|
||||
conversation_history: Formatted conversation history (optional, ignored)
|
||||
user_nickname: User's nickname or display name (optional, ignored)
|
||||
|
||||
Returns:
|
||||
The last user message content with prefix
|
||||
|
||||
@@ -61,12 +61,21 @@ class HTTPRAGService(RAGServiceBase):
|
||||
timeout,
|
||||
)
|
||||
|
||||
async def query(self, messages: list[dict[str, str]]) -> str:
|
||||
"""Send conversation history to RAG endpoint and get response.
|
||||
async def query(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
notifications: list[str] | None = None,
|
||||
conversation_history: str | None = None,
|
||||
user_nickname: str | None = None,
|
||||
) -> str:
|
||||
"""Send conversation to RAG endpoint and get response.
|
||||
|
||||
Args:
|
||||
messages: OpenAI-style conversation history
|
||||
messages: Current conversation messages (user/assistant only)
|
||||
e.g., [{"role": "user", "content": "Hello"}, ...]
|
||||
notifications: Active notifications for the user (optional)
|
||||
conversation_history: Formatted conversation history (optional)
|
||||
user_nickname: User's nickname or display name (optional)
|
||||
|
||||
Returns:
|
||||
Response string from RAG endpoint
|
||||
@@ -79,10 +88,22 @@ class HTTPRAGService(RAGServiceBase):
|
||||
try:
|
||||
# Validate and construct request
|
||||
message_objects = [Message(**msg) for msg in messages]
|
||||
request = RAGRequest(messages=message_objects)
|
||||
request = RAGRequest(
|
||||
messages=message_objects,
|
||||
notifications=notifications,
|
||||
conversation_history=conversation_history,
|
||||
user_nickname=user_nickname,
|
||||
)
|
||||
|
||||
# Make async HTTP POST request
|
||||
logger.debug("Sending RAG request with %s messages", len(messages))
|
||||
logger.debug(
|
||||
"Sending RAG request with %s messages, %s notifications, "
|
||||
"history: %s, user: %s",
|
||||
len(messages),
|
||||
len(notifications) if notifications else 0,
|
||||
"yes" if conversation_history else "no",
|
||||
user_nickname or "anonymous",
|
||||
)
|
||||
|
||||
response = await self._client.post(
|
||||
self.endpoint_url,
|
||||
|
||||
9
src/capa_de_integracion/services/storage/__init__.py
Normal file
9
src/capa_de_integracion/services/storage/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Storage services."""
|
||||
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
__all__ = [
|
||||
"FirestoreService",
|
||||
"RedisService",
|
||||
]
|
||||
@@ -80,7 +80,6 @@ class FirestoreService:
|
||||
query = (
|
||||
self.db.collection(self.conversations_collection)
|
||||
.where(filter=FieldFilter("telefono", "==", telefono))
|
||||
.order_by("lastModified", direction=firestore.Query.DESCENDING)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@@ -96,14 +95,13 @@ class FirestoreService:
|
||||
return session
|
||||
|
||||
logger.debug("No session found in Firestore for phone: %s", telefono)
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error querying session by phone %s from Firestore:",
|
||||
telefono,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
async def save_session(self, session: ConversationSession) -> bool:
|
||||
"""Save conversation session to Firestore."""
|
||||
@@ -183,7 +181,9 @@ class FirestoreService:
|
||||
return True
|
||||
|
||||
async def get_entries(
|
||||
self, session_id: str, limit: int = 10,
|
||||
self,
|
||||
session_id: str,
|
||||
limit: int = 10,
|
||||
) -> list[ConversationEntry]:
|
||||
"""Retrieve recent conversation entries from Firestore."""
|
||||
try:
|
||||
@@ -192,7 +192,8 @@ class FirestoreService:
|
||||
|
||||
# Get entries ordered by timestamp descending
|
||||
query = entries_ref.order_by(
|
||||
"timestamp", direction=firestore.Query.DESCENDING,
|
||||
"timestamp",
|
||||
direction=firestore.Query.DESCENDING,
|
||||
).limit(limit)
|
||||
|
||||
docs = query.stream()
|
||||
@@ -206,7 +207,9 @@ class FirestoreService:
|
||||
# Reverse to get chronological order
|
||||
entries.reverse()
|
||||
logger.debug(
|
||||
"Retrieved %s entries for session: %s", len(entries), session_id,
|
||||
"Retrieved %s entries for session: %s",
|
||||
len(entries),
|
||||
session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
@@ -240,7 +243,9 @@ class FirestoreService:
|
||||
return True
|
||||
|
||||
async def update_pantalla_contexto(
|
||||
self, session_id: str, pantalla_contexto: str | None,
|
||||
self,
|
||||
session_id: str,
|
||||
pantalla_contexto: str | None,
|
||||
) -> bool:
|
||||
"""Update the pantallaContexto field for a conversation session.
|
||||
|
||||
@@ -286,7 +291,8 @@ class FirestoreService:
|
||||
# ====== Notification Methods ======
|
||||
|
||||
def _notification_ref(
|
||||
self, notification_id: str,
|
||||
self,
|
||||
notification_id: str,
|
||||
) -> firestore.AsyncDocumentReference:
|
||||
"""Get Firestore document reference for notification."""
|
||||
return self.db.collection(self.notifications_collection).document(
|
||||
@@ -104,12 +104,12 @@ class RedisService:
|
||||
phone_key = self._phone_to_session_key(session.telefono)
|
||||
|
||||
try:
|
||||
# Save session data
|
||||
# Save session data and phone mapping in a single pipeline
|
||||
data = session.model_dump_json(by_alias=False)
|
||||
await self.redis.setex(key, self.session_ttl, data)
|
||||
|
||||
# Save phone-to-session mapping
|
||||
await self.redis.setex(phone_key, self.session_ttl, session.session_id)
|
||||
async with self.redis.pipeline(transaction=False) as pipe:
|
||||
pipe.setex(key, self.session_ttl, data)
|
||||
pipe.setex(phone_key, self.session_ttl, session.session_id)
|
||||
await pipe.execute()
|
||||
|
||||
logger.debug(
|
||||
"Saved session to Redis: %s for phone: %s",
|
||||
@@ -329,7 +329,8 @@ class RedisService:
|
||||
return True
|
||||
|
||||
async def get_notification_session(
|
||||
self, session_id: str,
|
||||
self,
|
||||
session_id: str,
|
||||
) -> NotificationSession | None:
|
||||
"""Retrieve notification session from Redis."""
|
||||
if not self.redis:
|
||||
@@ -383,8 +384,10 @@ class RedisService:
|
||||
|
||||
try:
|
||||
logger.info("Deleting notification session for phone %s", phone_number)
|
||||
await self.redis.delete(notification_key)
|
||||
await self.redis.delete(phone_key)
|
||||
async with self.redis.pipeline(transaction=False) as pipe:
|
||||
pipe.delete(notification_key)
|
||||
pipe.delete(phone_key)
|
||||
await pipe.execute()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error deleting notification session for phone %s:",
|
||||
@@ -10,8 +10,7 @@ import pytest_asyncio
|
||||
from fakeredis import aioredis as fakeredis
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.services.firestore_service import FirestoreService
|
||||
from capa_de_integracion.services.redis_service import RedisService
|
||||
from capa_de_integracion.services.storage import FirestoreService, RedisService
|
||||
|
||||
# Configure pytest-asyncio
|
||||
pytest_plugins = ("pytest_asyncio",)
|
||||
|
||||
792
tests/services/test_conversation_service.py
Normal file
792
tests/services/test_conversation_service.py
Normal file
@@ -0,0 +1,792 @@
|
||||
"""Unit tests for ConversationManagerService."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models import (
|
||||
ConversationEntry,
|
||||
ConversationRequest,
|
||||
ConversationSession,
|
||||
DetectIntentResponse,
|
||||
QueryResult,
|
||||
User,
|
||||
)
|
||||
from capa_de_integracion.services.conversation import ConversationManagerService
|
||||
from capa_de_integracion.services.dlp import DLPService
|
||||
from capa_de_integracion.services.rag import RAGServiceBase
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings() -> Settings:
|
||||
"""Create mock settings."""
|
||||
settings = Mock(spec=Settings)
|
||||
settings.dlp_template_complete_flow = "test_template"
|
||||
settings.conversation_context_message_limit = 60
|
||||
settings.conversation_context_days_limit = 30
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_dlp() -> DLPService:
|
||||
"""Create mock DLP service."""
|
||||
dlp = Mock(spec=DLPService)
|
||||
dlp.get_obfuscated_string = AsyncMock(return_value="obfuscated message")
|
||||
return dlp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_rag() -> RAGServiceBase:
|
||||
"""Create mock RAG service."""
|
||||
rag = Mock(spec=RAGServiceBase)
|
||||
rag.query = AsyncMock(return_value="RAG response")
|
||||
return rag
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis() -> RedisService:
|
||||
"""Create mock Redis service."""
|
||||
redis = Mock(spec=RedisService)
|
||||
redis.get_session = AsyncMock(return_value=None)
|
||||
redis.save_session = AsyncMock()
|
||||
redis.get_notification_session = AsyncMock(return_value=None)
|
||||
redis.delete_notification_session = AsyncMock()
|
||||
return redis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_firestore() -> FirestoreService:
|
||||
"""Create mock Firestore service."""
|
||||
firestore = Mock(spec=FirestoreService)
|
||||
firestore.get_session_by_phone = AsyncMock(return_value=None)
|
||||
firestore.create_session = AsyncMock()
|
||||
firestore.save_session = AsyncMock()
|
||||
firestore.save_entry = AsyncMock()
|
||||
firestore.get_entries = AsyncMock(return_value=[])
|
||||
firestore.update_notification_status = AsyncMock()
|
||||
# Mock db.collection for notifications
|
||||
mock_doc = Mock()
|
||||
mock_doc.exists = False
|
||||
mock_doc_ref = Mock()
|
||||
mock_doc_ref.get = AsyncMock(return_value=mock_doc)
|
||||
mock_collection = Mock()
|
||||
mock_collection.document = Mock(return_value=mock_doc_ref)
|
||||
firestore.db = Mock()
|
||||
firestore.db.collection = Mock(return_value=mock_collection)
|
||||
firestore.notifications_collection = "notifications"
|
||||
return firestore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_service(
|
||||
mock_settings: Settings,
|
||||
mock_rag: RAGServiceBase,
|
||||
mock_redis: RedisService,
|
||||
mock_firestore: FirestoreService,
|
||||
mock_dlp: DLPService,
|
||||
) -> ConversationManagerService:
|
||||
"""Create conversation service with mocked dependencies."""
|
||||
with patch(
|
||||
"capa_de_integracion.services.conversation.QuickReplyContentService"
|
||||
):
|
||||
service = ConversationManagerService(
|
||||
settings=mock_settings,
|
||||
rag_service=mock_rag,
|
||||
redis_service=mock_redis,
|
||||
firestore_service=mock_firestore,
|
||||
dlp_service=mock_dlp,
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_session() -> ConversationSession:
|
||||
"""Create a sample conversation session."""
|
||||
return ConversationSession(
|
||||
session_id="test_session_123",
|
||||
user_id="user_by_phone_1234567890",
|
||||
telefono="1234567890",
|
||||
last_modified=datetime.now(UTC),
|
||||
last_message="Hello",
|
||||
pantalla_contexto=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_request() -> ConversationRequest:
|
||||
"""Create a sample conversation request."""
|
||||
return ConversationRequest(
|
||||
mensaje="Hello, I need help",
|
||||
usuario=User(
|
||||
telefono="1234567890",
|
||||
nickname="TestUser",
|
||||
),
|
||||
canal="whatsapp",
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Session Management
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestSessionManagement:
|
||||
"""Tests for session management methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obtain_session_from_redis(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_redis: RedisService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test obtaining session from Redis."""
|
||||
mock_redis.get_session = AsyncMock(return_value=sample_session)
|
||||
|
||||
result = await conversation_service._obtain_or_create_session("1234567890")
|
||||
|
||||
assert result == sample_session
|
||||
mock_redis.get_session.assert_awaited_once_with("1234567890")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_obtain_session_from_firestore_when_redis_miss(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_redis: RedisService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test obtaining session from Firestore when Redis misses."""
|
||||
mock_redis.get_session = AsyncMock(return_value=None)
|
||||
mock_firestore.get_session_by_phone = AsyncMock(return_value=sample_session)
|
||||
|
||||
result = await conversation_service._obtain_or_create_session("1234567890")
|
||||
|
||||
assert result == sample_session
|
||||
mock_redis.get_session.assert_awaited_once()
|
||||
mock_firestore.get_session_by_phone.assert_awaited_once_with("1234567890")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_new_session_when_both_miss(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_redis: RedisService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test creating new session when both Redis and Firestore miss."""
|
||||
mock_redis.get_session = AsyncMock(return_value=None)
|
||||
mock_firestore.get_session_by_phone = AsyncMock(return_value=None)
|
||||
mock_firestore.create_session = AsyncMock(return_value=sample_session)
|
||||
|
||||
result = await conversation_service._obtain_or_create_session("1234567890")
|
||||
|
||||
assert result == sample_session
|
||||
mock_firestore.create_session.assert_awaited_once()
|
||||
# Verify the session was auto-cached to Redis
|
||||
mock_redis.save_session.assert_awaited_once_with(sample_session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_auto_cached_to_redis(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_redis: RedisService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test that newly created session is auto-cached to Redis."""
|
||||
mock_redis.get_session = AsyncMock(return_value=None)
|
||||
mock_firestore.get_session_by_phone = AsyncMock(return_value=None)
|
||||
mock_firestore.create_session = AsyncMock(return_value=sample_session)
|
||||
|
||||
await conversation_service._obtain_or_create_session("1234567890")
|
||||
|
||||
mock_redis.save_session.assert_awaited_once_with(sample_session)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Entry Persistence
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestEntryPersistence:
|
||||
"""Tests for conversation entry persistence methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_conversation_turn_with_conversacion_type(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
) -> None:
|
||||
"""Test saving conversation turn with CONVERSACION type."""
|
||||
await conversation_service._save_conversation_turn(
|
||||
session_id="test_session",
|
||||
user_text="Hello",
|
||||
assistant_text="Hi there",
|
||||
entry_type="CONVERSACION",
|
||||
canal="whatsapp",
|
||||
)
|
||||
|
||||
assert mock_firestore.save_entry.await_count == 2
|
||||
# Verify user entry
|
||||
user_call = mock_firestore.save_entry.await_args_list[0]
|
||||
assert user_call[0][0] == "test_session"
|
||||
user_entry = user_call[0][1]
|
||||
assert user_entry.entity == "user"
|
||||
assert user_entry.text == "Hello"
|
||||
assert user_entry.type == "CONVERSACION"
|
||||
assert user_entry.canal == "whatsapp"
|
||||
# Verify assistant entry
|
||||
assistant_call = mock_firestore.save_entry.await_args_list[1]
|
||||
assistant_entry = assistant_call[0][1]
|
||||
assert assistant_entry.entity == "assistant"
|
||||
assert assistant_entry.text == "Hi there"
|
||||
assert assistant_entry.type == "CONVERSACION"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_conversation_turn_with_llm_type(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
) -> None:
|
||||
"""Test saving conversation turn with LLM type."""
|
||||
await conversation_service._save_conversation_turn(
|
||||
session_id="test_session",
|
||||
user_text="What's the weather?",
|
||||
assistant_text="It's sunny",
|
||||
entry_type="LLM",
|
||||
canal="telegram",
|
||||
)
|
||||
|
||||
assert mock_firestore.save_entry.await_count == 2
|
||||
assistant_call = mock_firestore.save_entry.await_args_list[1]
|
||||
assistant_entry = assistant_call[0][1]
|
||||
assert assistant_entry.type == "LLM"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_conversation_turn_with_canal(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
) -> None:
|
||||
"""Test saving conversation turn with canal parameter."""
|
||||
await conversation_service._save_conversation_turn(
|
||||
session_id="test_session",
|
||||
user_text="Test",
|
||||
assistant_text="Response",
|
||||
entry_type="CONVERSACION",
|
||||
canal="sms",
|
||||
)
|
||||
|
||||
user_call = mock_firestore.save_entry.await_args_list[0]
|
||||
user_entry = user_call[0][1]
|
||||
assert user_entry.canal == "sms"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_conversation_turn_without_canal(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
) -> None:
|
||||
"""Test saving conversation turn without canal parameter."""
|
||||
await conversation_service._save_conversation_turn(
|
||||
session_id="test_session",
|
||||
user_text="Test",
|
||||
assistant_text="Response",
|
||||
entry_type="CONVERSACION",
|
||||
)
|
||||
|
||||
user_call = mock_firestore.save_entry.await_args_list[0]
|
||||
user_entry = user_call[0][1]
|
||||
assert user_entry.canal is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Session Updates
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestSessionUpdates:
|
||||
"""Tests for session update methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_session_sets_last_message(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test that update_session sets last_message."""
|
||||
await conversation_service._update_session_after_turn(
|
||||
session=sample_session,
|
||||
last_message="New message",
|
||||
)
|
||||
|
||||
assert sample_session.last_message == "New message"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_session_sets_timestamp(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test that update_session sets timestamp."""
|
||||
old_timestamp = sample_session.last_modified
|
||||
await conversation_service._update_session_after_turn(
|
||||
session=sample_session,
|
||||
last_message="New message",
|
||||
)
|
||||
|
||||
assert sample_session.last_modified > old_timestamp
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_session_saves_to_firestore(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test that update_session saves to Firestore."""
|
||||
await conversation_service._update_session_after_turn(
|
||||
session=sample_session,
|
||||
last_message="New message",
|
||||
)
|
||||
|
||||
mock_firestore.save_session.assert_awaited_once_with(sample_session)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_session_saves_to_redis(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_redis: RedisService,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test that update_session saves to Redis."""
|
||||
await conversation_service._update_session_after_turn(
|
||||
session=sample_session,
|
||||
last_message="New message",
|
||||
)
|
||||
|
||||
mock_redis.save_session.assert_awaited_once_with(sample_session)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Quick Reply Path
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestQuickReplyPath:
|
||||
"""Tests for quick reply path handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_reply_path_with_valid_context(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test quick reply path with valid pantalla_contexto."""
|
||||
sample_session.pantalla_contexto = "screen_123"
|
||||
sample_session.last_modified = datetime.now(UTC)
|
||||
|
||||
# Mock quick reply service
|
||||
mock_response = DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Quick reply response"),
|
||||
)
|
||||
conversation_service._manage_quick_reply_conversation = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
result = await conversation_service._handle_quick_reply_path(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
assert result == mock_response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_reply_path_with_stale_context_returns_none(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test quick reply path with stale pantalla_contexto returns None."""
|
||||
sample_session.pantalla_contexto = "screen_123"
|
||||
# Set timestamp to 11 minutes ago (stale)
|
||||
sample_session.last_modified = datetime.now(UTC) - timedelta(minutes=11)
|
||||
|
||||
result = await conversation_service._handle_quick_reply_path(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_reply_path_without_context_returns_none(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test quick reply path without pantalla_contexto returns None."""
|
||||
sample_session.pantalla_contexto = None
|
||||
|
||||
result = await conversation_service._handle_quick_reply_path(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_reply_path_saves_entries(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test quick reply path saves conversation entries."""
|
||||
sample_session.pantalla_contexto = "screen_123"
|
||||
sample_session.last_modified = datetime.now(UTC)
|
||||
|
||||
mock_response = DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Quick reply response"),
|
||||
)
|
||||
conversation_service._manage_quick_reply_conversation = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
await conversation_service._handle_quick_reply_path(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete
|
||||
assert mock_firestore.save_entry.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_reply_path_updates_session(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_redis: RedisService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test quick reply path updates session."""
|
||||
sample_session.pantalla_contexto = "screen_123"
|
||||
sample_session.last_modified = datetime.now(UTC)
|
||||
|
||||
mock_response = DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Quick reply response"),
|
||||
)
|
||||
conversation_service._manage_quick_reply_conversation = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
await conversation_service._handle_quick_reply_path(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete
|
||||
mock_firestore.save_session.assert_awaited_once()
|
||||
mock_redis.save_session.assert_awaited_once()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Standard Conversation Path
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestStandardConversation:
|
||||
"""Tests for standard conversation flow."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_conversation_loads_history(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test standard conversation loads history for old sessions."""
|
||||
# Make session older than 30 minutes to trigger history loading
|
||||
old_session = ConversationSession(
|
||||
session_id=sample_session.session_id,
|
||||
user_id=sample_session.user_id,
|
||||
telefono=sample_session.telefono,
|
||||
created_at=datetime.now(UTC) - timedelta(minutes=45),
|
||||
last_modified=sample_session.last_modified,
|
||||
last_message=sample_session.last_message,
|
||||
pantalla_contexto=sample_session.pantalla_contexto,
|
||||
)
|
||||
|
||||
await conversation_service._handle_standard_conversation(
|
||||
request=sample_request,
|
||||
session=old_session,
|
||||
)
|
||||
|
||||
mock_firestore.get_entries.assert_awaited_once_with(
|
||||
old_session.session_id,
|
||||
limit=60,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_conversation_queries_rag(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_rag: RAGServiceBase,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test standard conversation queries RAG service."""
|
||||
await conversation_service._handle_standard_conversation(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
mock_rag.query.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_conversation_saves_entries(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test standard conversation saves entries."""
|
||||
await conversation_service._handle_standard_conversation(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete
|
||||
assert mock_firestore.save_entry.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_conversation_updates_session(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
mock_redis: RedisService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test standard conversation updates session."""
|
||||
await conversation_service._handle_standard_conversation(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete
|
||||
# save_session is called in _update_session_after_turn
|
||||
assert mock_firestore.save_session.await_count >= 1
|
||||
assert mock_redis.save_session.await_count >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_conversation_marks_notifications_processed(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test standard conversation marks notifications as processed."""
|
||||
# Mock that there are active notifications
|
||||
conversation_service._get_active_notifications = AsyncMock(
|
||||
return_value=[Mock(texto="Test notification")]
|
||||
)
|
||||
|
||||
await conversation_service._handle_standard_conversation(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete
|
||||
mock_firestore.update_notification_status.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_standard_conversation_without_notifications(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_firestore: FirestoreService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test standard conversation without notifications."""
|
||||
conversation_service._get_active_notifications = AsyncMock(return_value=[])
|
||||
|
||||
await conversation_service._handle_standard_conversation(
|
||||
request=sample_request,
|
||||
session=sample_session,
|
||||
)
|
||||
|
||||
mock_firestore.update_notification_status.assert_not_awaited()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test Orchestration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestOrchestration:
|
||||
"""Tests for main orchestration logic."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_conversation_applies_dlp(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
mock_dlp: DLPService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test manage_conversation applies DLP obfuscation."""
|
||||
conversation_service._obtain_or_create_session = AsyncMock(
|
||||
return_value=sample_session
|
||||
)
|
||||
conversation_service._handle_standard_conversation = AsyncMock(
|
||||
return_value=DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Response"),
|
||||
)
|
||||
)
|
||||
|
||||
await conversation_service.manage_conversation(sample_request)
|
||||
|
||||
mock_dlp.get_obfuscated_string.assert_awaited_once()
|
||||
assert sample_request.mensaje == "obfuscated message"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_conversation_obtains_session(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test manage_conversation obtains session."""
|
||||
conversation_service._obtain_or_create_session = AsyncMock(
|
||||
return_value=sample_session
|
||||
)
|
||||
conversation_service._handle_standard_conversation = AsyncMock(
|
||||
return_value=DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Response"),
|
||||
)
|
||||
)
|
||||
|
||||
await conversation_service.manage_conversation(sample_request)
|
||||
|
||||
conversation_service._obtain_or_create_session.assert_awaited_once_with(
|
||||
"1234567890"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_conversation_uses_quick_reply_path_when_valid(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test manage_conversation uses quick reply path when valid."""
|
||||
sample_session.pantalla_contexto = "screen_123"
|
||||
sample_session.last_modified = datetime.now(UTC)
|
||||
|
||||
conversation_service._obtain_or_create_session = AsyncMock(
|
||||
return_value=sample_session
|
||||
)
|
||||
mock_response = DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Quick reply"),
|
||||
)
|
||||
conversation_service._handle_quick_reply_path = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
conversation_service._handle_standard_conversation = AsyncMock()
|
||||
|
||||
result = await conversation_service.manage_conversation(sample_request)
|
||||
|
||||
assert result == mock_response
|
||||
conversation_service._handle_quick_reply_path.assert_awaited_once()
|
||||
conversation_service._handle_standard_conversation.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_conversation_uses_standard_path_when_no_context(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test manage_conversation uses standard path when no context."""
|
||||
sample_session.pantalla_contexto = None
|
||||
|
||||
conversation_service._obtain_or_create_session = AsyncMock(
|
||||
return_value=sample_session
|
||||
)
|
||||
mock_response = DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Standard response"),
|
||||
)
|
||||
conversation_service._handle_standard_conversation = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
result = await conversation_service.manage_conversation(sample_request)
|
||||
|
||||
assert result == mock_response
|
||||
conversation_service._handle_standard_conversation.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_conversation_uses_standard_path_when_stale_context(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
sample_session: ConversationSession,
|
||||
) -> None:
|
||||
"""Test manage_conversation uses standard path when context is stale."""
|
||||
sample_session.pantalla_contexto = "screen_123"
|
||||
sample_session.last_modified = datetime.now(UTC) - timedelta(minutes=11)
|
||||
|
||||
conversation_service._obtain_or_create_session = AsyncMock(
|
||||
return_value=sample_session
|
||||
)
|
||||
mock_response = DetectIntentResponse(
|
||||
responseId=str(uuid4()),
|
||||
queryResult=QueryResult(responseText="Standard response"),
|
||||
)
|
||||
conversation_service._handle_quick_reply_path = AsyncMock(return_value=None)
|
||||
conversation_service._handle_standard_conversation = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
result = await conversation_service.manage_conversation(sample_request)
|
||||
|
||||
assert result == mock_response
|
||||
conversation_service._handle_standard_conversation.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_conversation_handles_exceptions(
|
||||
self,
|
||||
conversation_service: ConversationManagerService,
|
||||
sample_request: ConversationRequest,
|
||||
) -> None:
|
||||
"""Test manage_conversation handles exceptions properly."""
|
||||
conversation_service._obtain_or_create_session = AsyncMock(
|
||||
side_effect=Exception("Test error")
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="Test error"):
|
||||
await conversation_service.manage_conversation(sample_request)
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from google.cloud.dlp_v2 import types
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.services.dlp_service import DLPService
|
||||
from capa_de_integracion.services import DLPService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -21,7 +21,7 @@ def mock_settings():
|
||||
@pytest.fixture
|
||||
def service(mock_settings):
|
||||
"""Create DLPService instance with mocked client."""
|
||||
with patch("capa_de_integracion.services.dlp_service.dlp_v2.DlpServiceAsyncClient"):
|
||||
with patch("capa_de_integracion.services.dlp.dlp_v2.DlpServiceAsyncClient"):
|
||||
return DLPService(mock_settings)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from inline_snapshot import snapshot
|
||||
|
||||
from capa_de_integracion.models import ConversationEntry, ConversationSession
|
||||
from capa_de_integracion.models.notification import Notification
|
||||
from capa_de_integracion.services.firestore_service import FirestoreService
|
||||
from capa_de_integracion.services.storage import FirestoreService
|
||||
|
||||
|
||||
@pytest.mark.vcr
|
||||
@@ -120,10 +120,8 @@ class TestSessionManagement:
|
||||
|
||||
mock_collection = MagicMock()
|
||||
mock_where = MagicMock()
|
||||
mock_order = MagicMock()
|
||||
mock_collection.where.return_value = mock_where
|
||||
mock_where.order_by.return_value = mock_order
|
||||
mock_order.limit.return_value = mock_query
|
||||
mock_where.limit.return_value = mock_query
|
||||
|
||||
original_collection = clean_firestore.db.collection
|
||||
clean_firestore.db.collection = MagicMock(return_value=mock_collection)
|
||||
|
||||
@@ -6,10 +6,8 @@ import pytest
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models.notification import ExternalNotificationRequest
|
||||
from capa_de_integracion.services.dlp_service import DLPService
|
||||
from capa_de_integracion.services.firestore_service import FirestoreService
|
||||
from capa_de_integracion.services.notification_manager import NotificationManagerService
|
||||
from capa_de_integracion.services.redis_service import RedisService
|
||||
from capa_de_integracion.services import DLPService, NotificationManagerService
|
||||
from capa_de_integracion.services.storage import FirestoreService, RedisService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -157,3 +155,33 @@ async def test_process_notification_generates_unique_id(service, mock_redis):
|
||||
notification2 = mock_redis.save_or_append_notification.call_args[0][0]
|
||||
|
||||
assert notification1.id_notificacion != notification2.id_notificacion
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_notification_firestore_exception_handling(
|
||||
service, mock_redis, mock_firestore
|
||||
):
|
||||
"""Test that Firestore exceptions are handled gracefully in background task."""
|
||||
import asyncio
|
||||
|
||||
# Make Firestore save fail
|
||||
mock_firestore.save_or_append_notification = AsyncMock(
|
||||
side_effect=Exception("Firestore connection error")
|
||||
)
|
||||
|
||||
request = ExternalNotificationRequest(
|
||||
telefono="555-1234",
|
||||
texto="Test notification",
|
||||
parametros_ocultos=None,
|
||||
)
|
||||
|
||||
await service.process_notification(request)
|
||||
|
||||
# Redis should succeed
|
||||
mock_redis.save_or_append_notification.assert_called_once()
|
||||
|
||||
# Give the background task time to execute
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Firestore should have been attempted (and failed)
|
||||
mock_firestore.save_or_append_notification.assert_called_once()
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
"""Tests for QuickReplyContentService."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models.quick_replies import QuickReplyScreen
|
||||
from capa_de_integracion.services.quick_reply_content import QuickReplyContentService
|
||||
from capa_de_integracion.services import QuickReplyContentService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_settings():
|
||||
def mock_settings(tmp_path):
|
||||
"""Create mock settings for testing."""
|
||||
settings = Mock(spec=Settings)
|
||||
settings.base_path = Path("/tmp/test_resources")
|
||||
# Create the quick_replies directory
|
||||
quick_replies_dir = tmp_path / "quick_replies"
|
||||
quick_replies_dir.mkdir()
|
||||
settings.base_path = tmp_path
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_settings):
|
||||
"""Create QuickReplyContentService instance."""
|
||||
"""Create QuickReplyContentService instance with empty cache."""
|
||||
return QuickReplyContentService(mock_settings)
|
||||
|
||||
|
||||
@@ -59,22 +61,19 @@ async def test_get_quick_replies_whitespace_screen_id(service):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_quick_replies_file_not_found(service, tmp_path):
|
||||
"""Test get_quick_replies raises error when file not found."""
|
||||
# Set service to use a temp directory where the file won't exist
|
||||
service.quick_replies_path = tmp_path / "nonexistent_dir"
|
||||
|
||||
with pytest.raises(ValueError, match="Error loading quick replies"):
|
||||
async def test_get_quick_replies_file_not_found(service):
|
||||
"""Test get_quick_replies raises error when screen not in cache."""
|
||||
# Cache is empty (no files loaded), so any screen_id should raise ValueError
|
||||
with pytest.raises(ValueError, match="Quick reply not found for screen_id"):
|
||||
await service.get_quick_replies("nonexistent")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_quick_replies_success(service, tmp_path):
|
||||
"""Test get_quick_replies successfully loads file."""
|
||||
# Create test JSON file
|
||||
async def test_get_quick_replies_success(tmp_path):
|
||||
"""Test get_quick_replies successfully retrieves from cache."""
|
||||
# Create test JSON file BEFORE initializing service
|
||||
quick_replies_dir = tmp_path / "quick_replies"
|
||||
quick_replies_dir.mkdir()
|
||||
service.quick_replies_path = quick_replies_dir
|
||||
|
||||
test_data = {
|
||||
"header": "Test Header",
|
||||
@@ -97,6 +96,11 @@ async def test_get_quick_replies_success(service, tmp_path):
|
||||
test_file = quick_replies_dir / "test_screen.json"
|
||||
test_file.write_text(json.dumps(test_data), encoding="utf-8")
|
||||
|
||||
# Initialize service - it will preload the file into cache
|
||||
settings = Mock(spec=Settings)
|
||||
settings.base_path = tmp_path
|
||||
service = QuickReplyContentService(settings)
|
||||
|
||||
result = await service.get_quick_replies("test_screen")
|
||||
|
||||
assert isinstance(result, QuickReplyScreen)
|
||||
@@ -114,25 +118,30 @@ async def test_get_quick_replies_success(service, tmp_path):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_quick_replies_invalid_json(service, tmp_path):
|
||||
"""Test get_quick_replies raises error for invalid JSON."""
|
||||
async def test_get_quick_replies_invalid_json(tmp_path):
|
||||
"""Test that invalid JSON files are skipped during cache preload."""
|
||||
quick_replies_dir = tmp_path / "quick_replies"
|
||||
quick_replies_dir.mkdir()
|
||||
service.quick_replies_path = quick_replies_dir
|
||||
|
||||
# Create invalid JSON file
|
||||
test_file = quick_replies_dir / "invalid.json"
|
||||
test_file.write_text("{ invalid json }", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid JSON format"):
|
||||
# Initialize service - invalid file should be logged but not crash
|
||||
settings = Mock(spec=Settings)
|
||||
settings.base_path = tmp_path
|
||||
service = QuickReplyContentService(settings)
|
||||
|
||||
# Requesting the invalid screen should raise ValueError (not in cache)
|
||||
with pytest.raises(ValueError, match="Quick reply not found for screen_id"):
|
||||
await service.get_quick_replies("invalid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_quick_replies_minimal_data(service, tmp_path):
|
||||
"""Test get_quick_replies with minimal data."""
|
||||
async def test_get_quick_replies_minimal_data(tmp_path):
|
||||
"""Test get_quick_replies with minimal data from cache."""
|
||||
quick_replies_dir = tmp_path / "quick_replies"
|
||||
quick_replies_dir.mkdir()
|
||||
service.quick_replies_path = quick_replies_dir
|
||||
|
||||
test_data = {
|
||||
"preguntas": [],
|
||||
@@ -141,6 +150,11 @@ async def test_get_quick_replies_minimal_data(service, tmp_path):
|
||||
test_file = quick_replies_dir / "minimal.json"
|
||||
test_file.write_text(json.dumps(test_data), encoding="utf-8")
|
||||
|
||||
# Initialize service - it will preload the file
|
||||
settings = Mock(spec=Settings)
|
||||
settings.base_path = tmp_path
|
||||
service = QuickReplyContentService(settings)
|
||||
|
||||
result = await service.get_quick_replies("minimal")
|
||||
|
||||
assert isinstance(result, QuickReplyScreen)
|
||||
@@ -168,3 +182,103 @@ async def test_validate_file_not_exists(service, tmp_path):
|
||||
|
||||
with pytest.raises(ValueError, match="Quick reply file not found"):
|
||||
service._validate_file(test_file, "test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_preload_multiple_files(tmp_path):
|
||||
"""Test that cache preloads multiple files correctly."""
|
||||
quick_replies_dir = tmp_path / "quick_replies"
|
||||
quick_replies_dir.mkdir()
|
||||
|
||||
# Create multiple test files
|
||||
for screen_id in ["home", "pagos", "transferencia"]:
|
||||
test_data = {
|
||||
"header": f"{screen_id} header",
|
||||
"preguntas": [
|
||||
{"titulo": f"Q1 for {screen_id}", "respuesta": "Answer 1"},
|
||||
],
|
||||
}
|
||||
test_file = quick_replies_dir / f"{screen_id}.json"
|
||||
test_file.write_text(json.dumps(test_data), encoding="utf-8")
|
||||
|
||||
# Initialize service
|
||||
settings = Mock(spec=Settings)
|
||||
settings.base_path = tmp_path
|
||||
service = QuickReplyContentService(settings)
|
||||
|
||||
# Verify all screens are in cache
|
||||
home = await service.get_quick_replies("home")
|
||||
assert home.header == "home header"
|
||||
|
||||
pagos = await service.get_quick_replies("pagos")
|
||||
assert pagos.header == "pagos header"
|
||||
|
||||
transferencia = await service.get_quick_replies("transferencia")
|
||||
assert transferencia.header == "transferencia header"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_preload_with_mixed_valid_invalid(tmp_path):
|
||||
"""Test cache preload handles mix of valid and invalid files."""
|
||||
quick_replies_dir = tmp_path / "quick_replies"
|
||||
quick_replies_dir.mkdir()
|
||||
|
||||
# Create valid file
|
||||
valid_data = {"header": "valid", "preguntas": []}
|
||||
(quick_replies_dir / "valid.json").write_text(
|
||||
json.dumps(valid_data), encoding="utf-8",
|
||||
)
|
||||
|
||||
# Create invalid file
|
||||
(quick_replies_dir / "invalid.json").write_text(
|
||||
"{ invalid }", encoding="utf-8",
|
||||
)
|
||||
|
||||
# Initialize service - should not crash
|
||||
settings = Mock(spec=Settings)
|
||||
settings.base_path = tmp_path
|
||||
service = QuickReplyContentService(settings)
|
||||
|
||||
# Valid file should be in cache
|
||||
valid = await service.get_quick_replies("valid")
|
||||
assert valid.header == "valid"
|
||||
|
||||
# Invalid file should not be in cache
|
||||
with pytest.raises(ValueError, match="Quick reply not found"):
|
||||
await service.get_quick_replies("invalid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cache_preload_handles_generic_exception(tmp_path, monkeypatch):
|
||||
"""Test cache preload handles generic exceptions during file processing."""
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
quick_replies_dir = tmp_path / "quick_replies"
|
||||
quick_replies_dir.mkdir()
|
||||
|
||||
# Create valid JSON file
|
||||
valid_data = {"header": "test", "preguntas": []}
|
||||
(quick_replies_dir / "test.json").write_text(
|
||||
json.dumps(valid_data), encoding="utf-8",
|
||||
)
|
||||
|
||||
# Mock _parse_quick_reply_data to raise generic exception
|
||||
def mock_parse_error(*args, **kwargs):
|
||||
raise ValueError("Simulated parsing error")
|
||||
|
||||
settings = Mock(spec=Settings)
|
||||
settings.base_path = tmp_path
|
||||
|
||||
# Initialize service and patch the parsing method
|
||||
with monkeypatch.context() as m:
|
||||
service = QuickReplyContentService(settings)
|
||||
m.setattr(service, "_parse_quick_reply_data", mock_parse_error)
|
||||
|
||||
# Manually call _preload_cache to trigger the exception
|
||||
service._cache.clear()
|
||||
service._preload_cache()
|
||||
|
||||
# The file should not be in cache due to the exception
|
||||
with pytest.raises(ValueError, match="Quick reply not found"):
|
||||
await service.get_quick_replies("test")
|
||||
|
||||
205
tests/services/test_quick_reply_session.py
Normal file
205
tests/services/test_quick_reply_session.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Tests for QuickReplySessionService."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from capa_de_integracion.models.conversation import ConversationSession
|
||||
from capa_de_integracion.models.quick_replies import QuickReplyScreen
|
||||
from capa_de_integracion.services import QuickReplySessionService
|
||||
from capa_de_integracion.services.quick_reply.content import QuickReplyContentService
|
||||
from capa_de_integracion.services.storage.firestore import FirestoreService
|
||||
from capa_de_integracion.services.storage.redis import RedisService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
"""Create mock Redis service."""
|
||||
redis = Mock(spec=RedisService)
|
||||
redis.get_session = AsyncMock()
|
||||
redis.save_session = AsyncMock()
|
||||
return redis
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_firestore():
|
||||
"""Create mock Firestore service."""
|
||||
firestore = Mock(spec=FirestoreService)
|
||||
firestore.get_session_by_phone = AsyncMock()
|
||||
firestore.create_session = AsyncMock()
|
||||
firestore.update_pantalla_contexto = AsyncMock()
|
||||
return firestore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_content():
|
||||
"""Create mock QuickReplyContentService."""
|
||||
content = Mock(spec=QuickReplyContentService)
|
||||
content.get_quick_replies = AsyncMock()
|
||||
return content
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_redis, mock_firestore, mock_content):
|
||||
"""Create QuickReplySessionService instance."""
|
||||
return QuickReplySessionService(
|
||||
redis_service=mock_redis,
|
||||
firestore_service=mock_firestore,
|
||||
quick_reply_content_service=mock_content,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_phone_empty_string(service):
|
||||
"""Test phone validation with empty string."""
|
||||
with pytest.raises(ValueError, match="Phone number is required"):
|
||||
service._validate_phone("")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_phone_whitespace(service):
|
||||
"""Test phone validation with whitespace only."""
|
||||
with pytest.raises(ValueError, match="Phone number is required"):
|
||||
service._validate_phone(" ")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_session_new_user(service, mock_firestore, mock_redis, mock_content):
|
||||
"""Test starting a quick reply session for a new user."""
|
||||
# Setup mocks
|
||||
mock_redis.get_session.return_value = None # No session in Redis
|
||||
mock_firestore.get_session_by_phone.return_value = None # No existing session
|
||||
|
||||
# Mock create_session to return a session with the ID that was passed in
|
||||
def create_session_side_effect(session_id, user_id, telefono, pantalla_contexto):
|
||||
return ConversationSession.create(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
telefono=telefono,
|
||||
pantalla_contexto=pantalla_contexto,
|
||||
)
|
||||
|
||||
mock_firestore.create_session.side_effect = create_session_side_effect
|
||||
|
||||
test_quick_replies = QuickReplyScreen(
|
||||
header="Home Screen",
|
||||
body=None,
|
||||
button=None,
|
||||
header_section=None,
|
||||
preguntas=[],
|
||||
)
|
||||
mock_content.get_quick_replies.return_value = test_quick_replies
|
||||
|
||||
# Execute
|
||||
result = await service.start_quick_reply_session(
|
||||
telefono="555-1234",
|
||||
_nombre="John",
|
||||
pantalla_contexto="home",
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result.session_id is not None # Session ID should be generated
|
||||
assert result.quick_replies.header == "Home Screen"
|
||||
|
||||
mock_redis.get_session.assert_called_once_with("555-1234")
|
||||
mock_firestore.get_session_by_phone.assert_called_once_with("555-1234")
|
||||
mock_firestore.create_session.assert_called_once()
|
||||
|
||||
# Verify create_session was called with correct parameters
|
||||
call_args = mock_firestore.create_session.call_args
|
||||
assert call_args[0][2] == "555-1234" # telefono
|
||||
assert call_args[0][3] == "home" # pantalla_contexto
|
||||
|
||||
mock_redis.save_session.assert_called_once()
|
||||
mock_content.get_quick_replies.assert_called_once_with("home")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_session_existing_user(service, mock_firestore, mock_redis, mock_content):
|
||||
"""Test starting a quick reply session for an existing user."""
|
||||
# Setup mocks - existing session
|
||||
test_session_id = "existing-session-123"
|
||||
test_session = ConversationSession.create(
|
||||
session_id=test_session_id,
|
||||
user_id="user_by_phone_5551234",
|
||||
telefono="555-1234",
|
||||
pantalla_contexto="old_screen",
|
||||
)
|
||||
mock_redis.get_session.return_value = None # Not in Redis cache
|
||||
mock_firestore.get_session_by_phone.return_value = test_session
|
||||
|
||||
test_quick_replies = QuickReplyScreen(
|
||||
header="Payments Screen",
|
||||
body=None,
|
||||
button=None,
|
||||
header_section=None,
|
||||
preguntas=[],
|
||||
)
|
||||
mock_content.get_quick_replies.return_value = test_quick_replies
|
||||
|
||||
# Execute
|
||||
result = await service.start_quick_reply_session(
|
||||
telefono="555-1234",
|
||||
_nombre="John",
|
||||
pantalla_contexto="pagos",
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert result.session_id == test_session_id
|
||||
assert result.quick_replies.header == "Payments Screen"
|
||||
|
||||
mock_redis.get_session.assert_called_once_with("555-1234")
|
||||
mock_firestore.get_session_by_phone.assert_called_once_with("555-1234")
|
||||
mock_firestore.update_pantalla_contexto.assert_called_once_with(
|
||||
test_session_id,
|
||||
"pagos",
|
||||
)
|
||||
mock_firestore.create_session.assert_not_called()
|
||||
mock_redis.save_session.assert_called_once()
|
||||
mock_content.get_quick_replies.assert_called_once_with("pagos")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_session_updates_last_modified_on_existing(
|
||||
service, mock_firestore, mock_redis, mock_content
|
||||
):
|
||||
"""Test that last_modified is refreshed when updating pantalla_contexto.
|
||||
|
||||
Ensures quick reply context won't be incorrectly marked as stale
|
||||
when the session was idle before the user opened a quick reply screen.
|
||||
"""
|
||||
stale_time = datetime.now(UTC) - timedelta(minutes=20)
|
||||
test_session = ConversationSession.create(
|
||||
session_id="session-123",
|
||||
user_id="user_by_phone_5551234",
|
||||
telefono="555-1234",
|
||||
pantalla_contexto=None,
|
||||
)
|
||||
test_session.last_modified = stale_time
|
||||
|
||||
mock_redis.get_session.return_value = test_session
|
||||
mock_content.get_quick_replies.return_value = QuickReplyScreen(
|
||||
header="H", body=None, button=None, header_section=None, preguntas=[]
|
||||
)
|
||||
|
||||
await service.start_quick_reply_session(
|
||||
telefono="555-1234",
|
||||
_nombre="John",
|
||||
pantalla_contexto="pagos",
|
||||
)
|
||||
|
||||
saved_session = mock_redis.save_session.call_args[0][0]
|
||||
assert saved_session.last_modified > stale_time
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_session_invalid_phone(service):
|
||||
"""Test starting session with invalid phone number."""
|
||||
with pytest.raises(ValueError, match="Phone number is required"):
|
||||
await service.start_quick_reply_session(
|
||||
telefono="",
|
||||
_nombre="John",
|
||||
pantalla_contexto="home",
|
||||
)
|
||||
@@ -201,6 +201,25 @@ class TestHTTPRAGService:
|
||||
|
||||
mock_client.aclose.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_generic_exception(self):
|
||||
"""Test HTTP RAG service handles generic exceptions during processing."""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status = Mock()
|
||||
# Make json() raise a generic exception
|
||||
mock_response.json = Mock(side_effect=ValueError("Invalid response format"))
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client_class:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client_class.return_value = mock_client
|
||||
|
||||
service = HTTPRAGService(endpoint_url="http://test.example.com/rag")
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid response format"):
|
||||
await service.query(messages)
|
||||
|
||||
|
||||
class TestRAGModels:
|
||||
"""Tests for RAG data models."""
|
||||
|
||||
@@ -9,7 +9,7 @@ from inline_snapshot import snapshot
|
||||
from capa_de_integracion.config import Settings
|
||||
from capa_de_integracion.models import ConversationEntry, ConversationSession
|
||||
from capa_de_integracion.models.notification import Notification, NotificationSession
|
||||
from capa_de_integracion.services.redis_service import RedisService
|
||||
from capa_de_integracion.services.storage import RedisService
|
||||
|
||||
|
||||
class TestConnectionManagement:
|
||||
|
||||
@@ -11,6 +11,7 @@ from capa_de_integracion.dependencies import (
|
||||
get_firestore_service,
|
||||
get_notification_manager,
|
||||
get_quick_reply_content_service,
|
||||
get_quick_reply_session_service,
|
||||
get_rag_service,
|
||||
get_redis_service,
|
||||
init_services,
|
||||
@@ -22,10 +23,10 @@ from capa_de_integracion.services import (
|
||||
DLPService,
|
||||
NotificationManagerService,
|
||||
QuickReplyContentService,
|
||||
QuickReplySessionService,
|
||||
)
|
||||
from capa_de_integracion.services.firestore_service import FirestoreService
|
||||
from capa_de_integracion.services.rag import EchoRAGService, HTTPRAGService
|
||||
from capa_de_integracion.services.redis_service import RedisService
|
||||
from capa_de_integracion.services.storage import FirestoreService, RedisService
|
||||
|
||||
|
||||
def test_get_redis_service():
|
||||
@@ -77,6 +78,21 @@ def test_get_quick_reply_content_service():
|
||||
assert service is service2
|
||||
|
||||
|
||||
def test_get_quick_reply_session_service():
|
||||
"""Test get_quick_reply_session_service returns QuickReplySessionService."""
|
||||
get_quick_reply_session_service.cache_clear()
|
||||
get_redis_service.cache_clear()
|
||||
get_firestore_service.cache_clear()
|
||||
get_quick_reply_content_service.cache_clear()
|
||||
|
||||
service = get_quick_reply_session_service()
|
||||
assert isinstance(service, QuickReplySessionService)
|
||||
|
||||
# Should return same instance (cached)
|
||||
service2 = get_quick_reply_session_service()
|
||||
assert service is service2
|
||||
|
||||
|
||||
def test_get_notification_manager():
|
||||
"""Test get_notification_manager returns NotificationManagerService."""
|
||||
get_notification_manager.cache_clear()
|
||||
|
||||
@@ -47,14 +47,15 @@ def test_app_has_routers():
|
||||
|
||||
def test_main_entry_point():
|
||||
"""Test main entry point calls uvicorn.run."""
|
||||
with patch("capa_de_integracion.main.uvicorn.run") as mock_run:
|
||||
with patch("capa_de_integracion.main.uvicorn.run") as mock_run, \
|
||||
patch("sys.argv", ["capa-de-integracion"]):
|
||||
main()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
call_kwargs = mock_run.call_args.kwargs
|
||||
assert call_kwargs["host"] == "0.0.0.0"
|
||||
assert call_kwargs["port"] == 8080
|
||||
assert call_kwargs["reload"] is True
|
||||
assert call_kwargs["workers"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -8,6 +8,11 @@ from capa_de_integracion.models import ConversationRequest, DetectIntentResponse
|
||||
from capa_de_integracion.models.notification import ExternalNotificationRequest
|
||||
from capa_de_integracion.models.quick_replies import QuickReplyScreen
|
||||
from capa_de_integracion.routers import conversation, notification, quick_replies
|
||||
from capa_de_integracion.routers.quick_replies import (
|
||||
QuickReplyScreenRequest,
|
||||
QuickReplyUser,
|
||||
)
|
||||
from capa_de_integracion.services.quick_reply.session import QuickReplySessionResponse
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -136,3 +141,79 @@ async def test_process_notification_general_error():
|
||||
await notification.process_notification(request, mock_manager)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_quick_reply_session_success():
|
||||
"""Test quick reply session endpoint with success."""
|
||||
mock_service = Mock()
|
||||
mock_result = QuickReplySessionResponse(
|
||||
session_id="test-session-123",
|
||||
quick_replies=QuickReplyScreen(
|
||||
header="Test Header",
|
||||
body=None,
|
||||
button=None,
|
||||
header_section=None,
|
||||
preguntas=[],
|
||||
),
|
||||
)
|
||||
mock_service.start_quick_reply_session = AsyncMock(return_value=mock_result)
|
||||
|
||||
request = QuickReplyScreenRequest(
|
||||
usuario=QuickReplyUser(telefono="555-1234", nombre="John"),
|
||||
pantallaContexto="home",
|
||||
)
|
||||
|
||||
response = await quick_replies.start_quick_reply_session(request, mock_service)
|
||||
|
||||
assert response.response_id == "test-session-123"
|
||||
assert response.quick_replies.header == "Test Header"
|
||||
mock_service.start_quick_reply_session.assert_called_once_with(
|
||||
telefono="555-1234",
|
||||
_nombre="John",
|
||||
pantalla_contexto="home",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_quick_reply_session_value_error():
|
||||
"""Test quick reply session with ValueError."""
|
||||
mock_service = Mock()
|
||||
mock_service.start_quick_reply_session = AsyncMock(
|
||||
side_effect=ValueError("Invalid screen"),
|
||||
)
|
||||
|
||||
request = QuickReplyScreenRequest(
|
||||
usuario=QuickReplyUser(telefono="555-1234", nombre="John"),
|
||||
pantallaContexto="invalid",
|
||||
)
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await quick_replies.start_quick_reply_session(request, mock_service)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "Invalid screen" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_quick_reply_session_general_error():
|
||||
"""Test quick reply session with general Exception."""
|
||||
mock_service = Mock()
|
||||
mock_service.start_quick_reply_session = AsyncMock(
|
||||
side_effect=RuntimeError("Database error"),
|
||||
)
|
||||
|
||||
request = QuickReplyScreenRequest(
|
||||
usuario=QuickReplyUser(telefono="555-1234", nombre="John"),
|
||||
pantallaContexto="home",
|
||||
)
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await quick_replies.start_quick_reply_session(request, mock_service)
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "Internal server error" in str(exc_info.value.detail)
|
||||
|
||||
601
uv.lock
generated
601
uv.lock
generated
@@ -163,6 +163,62 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bidict"
|
||||
version = "0.23.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "capa-de-integracion"
|
||||
version = "0.1.0"
|
||||
@@ -187,6 +243,7 @@ dependencies = [
|
||||
dev = [
|
||||
{ name = "fakeredis" },
|
||||
{ name = "inline-snapshot" },
|
||||
{ name = "locust" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
@@ -217,6 +274,7 @@ requires-dist = [
|
||||
dev = [
|
||||
{ name = "fakeredis", specifier = ">=2.34.0" },
|
||||
{ name = "inline-snapshot", specifier = ">=0.32.1" },
|
||||
{ name = "locust", specifier = ">=2.43.3" },
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||
{ name = "pytest-cov", specifier = ">=7.0.0" },
|
||||
@@ -370,6 +428,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "configargparse"
|
||||
version = "1.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/4d/6c9ef746dfcc2a32e26f3860bb4a011c008c392b83eabdfb598d1a8bbe5d/configargparse-1.7.1.tar.gz", hash = "sha256:79c2ddae836a1e5914b71d58e4b9adbd9f7779d4e6351a637b7d2d9b6c46d3d9", size = 43958, upload-time = "2025-05-23T14:26:17.369Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/28/d28211d29bcc3620b1fece85a65ce5bb22f18670a03cd28ea4b75ede270c/configargparse-1.7.1-py3-none-any.whl", hash = "sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6", size = 25607, upload-time = "2025-05-23T14:26:15.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
@@ -563,6 +630,49 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-cors"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask-login"
|
||||
version = "0.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
@@ -652,6 +762,102 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gevent"
|
||||
version = "25.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
|
||||
{ name = "greenlet", marker = "platform_python_implementation == 'CPython'" },
|
||||
{ name = "zope-event" },
|
||||
{ name = "zope-interface" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/49/e55930ba5259629eb28ac7ee1abbca971996a9165f902f0249b561602f24/gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86", size = 2955991, upload-time = "2025-09-17T14:52:30.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/88/63dc9e903980e1da1e16541ec5c70f2b224ec0a8e34088cb42794f1c7f52/gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692", size = 1808503, upload-time = "2025-09-17T15:41:25.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/8d/7236c3a8f6ef7e94c22e658397009596fa90f24c7d19da11ad7ab3a9248e/gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2", size = 1890001, upload-time = "2025-09-17T15:49:01.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/63/0d7f38c4a2085ecce26b50492fc6161aa67250d381e26d6a7322c309b00f/gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74", size = 1855335, upload-time = "2025-09-17T15:49:20.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/18/da5211dfc54c7a57e7432fd9a6ffeae1ce36fe5a313fa782b1c96529ea3d/gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51", size = 2109046, upload-time = "2025-09-17T15:15:13.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/5a/7bb5ec8e43a2c6444853c4a9f955f3e72f479d7c24ea86c95fb264a2de65/gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5", size = 1827099, upload-time = "2025-09-17T15:52:41.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/d4/b63a0a60635470d7d986ef19897e893c15326dd69e8fb342c76a4f07fe9e/gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f", size = 2172623, upload-time = "2025-09-17T15:24:12.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "geventhttpclient"
|
||||
version = "2.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "brotli" },
|
||||
{ name = "certifi" },
|
||||
{ name = "gevent" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/48/4bca27d59960fc1f41b783ea7d6aa2477f8ff573eced7914ec57e61d7059/geventhttpclient-2.3.7.tar.gz", hash = "sha256:06c28d3d1aabddbaaf61721401a0e5852b216a1845ef2580f3819161e44e9b1c", size = 83708, upload-time = "2025-12-07T19:48:53.153Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/597634914f0346faf5eb4f371f885add9873081cea921070d826c99b18f7/geventhttpclient-2.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0b1564f10fd46bf4fce9bf8b1c6952e2f1c7b88c62c86f2c45f7866bd341ba4b", size = 69756, upload-time = "2025-12-07T19:48:04.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/05/fe01ea721d5491f868ab1ed82e12306947c121a77583944234b8b840c17a/geventhttpclient-2.3.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4085d23c5b86993cdcef6a00e788cea4bcf6fedb2f2eb7c22c057716a02dc343", size = 51396, upload-time = "2025-12-07T19:48:04.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/74/1c654bfeca910f7bd3998080e4f9c53799c396ec0558236b229fd706b54b/geventhttpclient-2.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:531dbf14baad90ad319db4d34afd91d01a3d14d947f26666b03f49c6c2082a8f", size = 51136, upload-time = "2025-12-07T19:48:05.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/a8/2bae3d6af26e345f3f53185885bbad19d902fa9364e255b5632f3de08d39/geventhttpclient-2.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264de1e0902c93d7911b3235430f297a8a551e1bc8dd29692f8620f606d4cecf", size = 114992, upload-time = "2025-12-07T19:48:06.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/cb/65f59ebced7cfc0f7840a132a73aa67a57368034c37882a5212655f989df/geventhttpclient-2.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b9a3a4938b5cc47f9330443e0bdd3fcdb850e6147147810fd88235b7bc5c4e8", size = 115664, upload-time = "2025-12-07T19:48:07.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/0f/076fba4792c00ace47d274f329cf4e1748faea30a79ff98b1c1dd780937d/geventhttpclient-2.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fbad11254abdecf5edab4dae22642824aca5cbd258a2d14a79d8d9ab72223f9e", size = 121684, upload-time = "2025-12-07T19:48:08.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/48/f4d7418229ca7ae3ca1163c6c415675e536def90944ea16f5fb2f586663b/geventhttpclient-2.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:383d6f95683a2fe1009d6d4660631e1c8f04043876c48c06c2e0ad64e516db5d", size = 111581, upload-time = "2025-12-07T19:48:08.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/5e/f1c17fce2b25b1782dd697f63df63709aaf03a904f46f21e9f631e6eea02/geventhttpclient-2.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f9ef048b05c53085cfbd86277a00f18e99c614ce62b2b47ec3d85a76fdccb38", size = 118459, upload-time = "2025-12-07T19:48:10.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/c9/b3b980afed693be43700322976953d3bc87e3fc843102584c493cf6cbce6/geventhttpclient-2.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:602de0f6e20e06078f87ca8011d658d80e07873b3c2c1aaa581cac5fc4d0762b", size = 112238, upload-time = "2025-12-07T19:48:10.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/5c/04e46bccb8d4e5880bb0be379479374a6645cab8af9b14c0ccbbbedc68dd/geventhttpclient-2.3.7-cp312-cp312-win32.whl", hash = "sha256:0daa0afff191d52740dbbba62f589a352eedd52d82a83e4944ec97a0337505fa", size = 48371, upload-time = "2025-12-07T19:48:11.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/c5/8d2e1608644018232c77bf8d1e15525c307417a9cdefa3ed467aa9b39c04/geventhttpclient-2.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:80199b556a6e226283a909a82090ed22408aa0572c8bfaa5d3c90aafa5df0a8b", size = 49008, upload-time = "2025-12-07T19:48:12.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/23/a7ff5039df13c116dffbe98a6536e576e33d4fa32235e939670d734a7438/geventhttpclient-2.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:df22102bd2975f15ab7063cd329887d343c6ef1a848f58c0f57cbefb1b9dd07b", size = 69761, upload-time = "2025-12-07T19:48:13.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/df/f2e0d7b5ad37eec393f57f097cce88086cd416f163b1e6a386e91be04b10/geventhttpclient-2.3.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0175078426fb0083881ee4a34d4a8adc9fdf558eb9165ecde5a3a8599730d26e", size = 51397, upload-time = "2025-12-07T19:48:14.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/09/23f129f9e07c4c1fdca678da1b2357b7cb834854084fcd2b888e909d99fd/geventhttpclient-2.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0985fd1d24f41f0ba0c1f88785a932e1284d80f97fa3218d305d0a2937c335ab", size = 51133, upload-time = "2025-12-07T19:48:15.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/e4/4c8a5b41aed136f40798b763008470654c33d3040cac084c5230048be9a8/geventhttpclient-2.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ceb81f286abb196f67922d76c879a6c79aa85b9447e3d3891143ba2e07d9e10e", size = 115010, upload-time = "2025-12-07T19:48:16.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/67/bb02f50937c23ba94834de35ea6f29f6dc4fddde5832837d9de4a2311ff6/geventhttpclient-2.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46ef540dca5b29103e58e86876a647f2d5edcad52c0db3cb3daa0a293f892a09", size = 115701, upload-time = "2025-12-07T19:48:17.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/45/a77ade5a89fa4fbf431cc11d4a417425b19967e2ec288ed091be1159672f/geventhttpclient-2.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c98dadee94f5bbd29d44352f6a573a926238afa4c52b9eb6cf1a0d9497550727", size = 121693, upload-time = "2025-12-07T19:48:17.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/df/cda48df32398f8d2158e19795e710c2ded42bff6c44f1001b058f9b18f3f/geventhttpclient-2.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:09961922a68e97cf33b118130b16219da4a8c9c50f521fbf61d7769036e53d87", size = 111674, upload-time = "2025-12-07T19:48:18.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/11/64f44b73dc275b8bf458ca60aa610a109eef2b30e5e334d5c38c58447958/geventhttpclient-2.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c2ca897e5c6291fb713544c60c99761d7ebb1f1ee1f122da3b6e44d1a67943dc", size = 118455, upload-time = "2025-12-07T19:48:19.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/ca/64fee96694bfb899c0276a4033f77f7bea21ba2be2d39c099dbada1fac82/geventhttpclient-2.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfcaf1ace1f82272061405e0f14b765883bc774071f0ab9364f93370f6968377", size = 112262, upload-time = "2025-12-07T19:48:20.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/91/c339d7770fdd278c7a5012229fa800a3662c08ad90dbeb54346e147c9713/geventhttpclient-2.3.7-cp313-cp313-win32.whl", hash = "sha256:3a6c3cd6e0583be68c18e33afa1fb6c86bc46b5fcce85fb7b4ef23f02bc4ee25", size = 48366, upload-time = "2025-12-07T19:48:21.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/27/a1ec008ece77000bb9c56a92fd5c844ecf13943198fe3978d27e890ece5c/geventhttpclient-2.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:37ffa13c2a3b5311c92cd9355cb6ba077e74c2e5d34cd692e25b42549fa350d5", size = 48997, upload-time = "2025-12-07T19:48:22.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/35/2d9e36d9ee5e06056cca682fc65d4c8c37512433507bb65e7895cf0385ec/geventhttpclient-2.3.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:12e7374a196aa82933b6577f41e7934177685e3d878b3c33ea0863105e01082f", size = 70037, upload-time = "2025-12-07T19:48:23.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/b3/191191959f3f3753d33984d38fd002d753909552552bf2fdcfa88e072caf/geventhttpclient-2.3.7-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:59745cc2b1bd1da99547761188e6c24387acc9f316f40b2dcfd53a8497eff866", size = 51519, upload-time = "2025-12-07T19:48:23.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/71/cc24182c2bbc4a10ef66171d0ded95dbb96df17cc76cd189a492d4d72e57/geventhttpclient-2.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad06347ff320ba0072112c26c908b16451674d469b74d0758ac1a9a2f1e719e9", size = 51177, upload-time = "2025-12-07T19:48:24.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/60/0dea10fb568a39ab524d9acfdd87886c4f6fdc8f44fb058f9d135ce68a0c/geventhttpclient-2.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63b616e6ad33f56c5c3a05685ce09b21cd68984d961cf85545b7e734920567a6", size = 115040, upload-time = "2025-12-07T19:48:25.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/2a/019e334c3e6e3ad5b91fc64a6abd0034bef8c62d2cc4e95e87ac174af6c4/geventhttpclient-2.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e525a2cfe8d73f62e94745613bbf29432ddb168c6eb1b57f5335198d43c97542", size = 115766, upload-time = "2025-12-07T19:48:26.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a1/a0226602fe1dc98f5feebb204443fdffaf4c070d35409991bf01b41d921f/geventhttpclient-2.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51c19b5b2043d5fed8225aba7d6438f193ca7eb2c74693ee79d840e466c92d59", size = 121766, upload-time = "2025-12-07T19:48:27.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/5f/31329c6e842ced2cbb7e0881343574a71ece5fbf5c9e09c6f16204148ade/geventhttpclient-2.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:88caf6ba4d69f433f5eddbbe6909d4f9c41a1974322fadce6ce1215cdabe9b58", size = 111756, upload-time = "2025-12-07T19:48:28.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/f2/dafae6a5447ac4ed86100c784e550c8979b2b4c9818ffaa7c39c487ca244/geventhttpclient-2.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:847df15b38330fe2c845390977100fde79e4e799b14a0e389a7c942f246e7ea1", size = 118496, upload-time = "2025-12-07T19:48:29.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/36/1af8173e5242a09eb1fea92277faa272206d5ad040a438893a3d070c880d/geventhttpclient-2.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e86f5b6f555c7264b5c9b37fd7e697c665692b8615356f33b686edcea415847a", size = 112209, upload-time = "2025-12-07T19:48:30.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/23/26880ea96c649b57740235a134e5c2d27da97768bdbb4613d0a0b297428f/geventhttpclient-2.3.7-cp314-cp314-win32.whl", hash = "sha256:ff9ab5a001d82e70a9368c24b6f1d1c7150aa0351a38d0fdeaf82e961a94ea78", size = 49013, upload-time = "2025-12-07T19:48:31.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9d/045d49b6fb2b014b8e5b870a3d09c471cf4a80ca29c56ae0b1b5db43126f/geventhttpclient-2.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:c4905a3810fb59c62748bc867ea564641e8933dc4095504deb21ac355b501836", size = 49499, upload-time = "2025-12-07T19:48:32.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7c/49d30cf202b129bacaacecbbcebe491e58b9ad9b669bd85e3653b6592227/geventhttpclient-2.3.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:eb1283aff6cb409875491d777b88954744f87763b5a978ad95263c57dbb2a517", size = 70427, upload-time = "2025-12-07T19:48:33.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/66/68c714f8c92acc3f94e00ad7fcd7db5dfd35e3fe259e4238af59c97ee288/geventhttpclient-2.3.7-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:089fb07dd8aec37d66deceb3b970b717ee37cdd563054f30edc817646463491b", size = 51704, upload-time = "2025-12-07T19:48:34.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/de/c889782fd36223f114b2ee42b5f3b9c4ac317fbab15a7f0a732a7f781754/geventhttpclient-2.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b95b6c133b6793792cca71a8c744fc6f7a5e9176d55485d6bf2fe0a7422f7905", size = 51388, upload-time = "2025-12-07T19:48:35.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/dbb6c156d7846ef86fe4c9ec528a75c752b22c7898944400f417b76606b1/geventhttpclient-2.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b6157b5c875a19ad2547c226ec53d427e943f9fde6f6fe2e83b73edd0286df3", size = 117942, upload-time = "2025-12-07T19:48:35.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b6/42899b7840b4c389fa175dace26111494beab59e5145bfb3bf6d63aa04fd/geventhttpclient-2.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a5c641fde195078212979469e371290625c367666969fce0c53caea1fc65503", size = 119588, upload-time = "2025-12-07T19:48:36.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f7/5f408cdc1c74c39dc43bacca67f60bf429cf559aeb6f76abf05959980a56/geventhttpclient-2.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6d975972e95014f57642fc893c4b04f6009093306b3bdba45729062c892a6b6a", size = 125396, upload-time = "2025-12-07T19:48:37.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/69/6f27ed81ebd4aeaa0a9067cb3cb92a63c349d29e9c1e276e4ae42cfc960b/geventhttpclient-2.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9beb5a9d9049223393148490274e8839a0bcb3c081a23c0136e23c1a5fbeb85", size = 115218, upload-time = "2025-12-07T19:48:38.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/2c/2ba34727cc2bb409d202d439e5c3b9030bdc9e351eb73684091f16e580f0/geventhttpclient-2.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f1f7247ed6531134387c8173e2cfaa832c4a908adbf867e042c317a534ea363c", size = 121872, upload-time = "2025-12-07T19:48:39.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b5/b90ca3c67596e8c72439f320c6f3b59f22c8045d2ebbf30036740c71fc7d/geventhttpclient-2.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0dae49bc6226510be2c714e78b10efa8c0e852628a1c0b345e463c81405ff", size = 115005, upload-time = "2025-12-07T19:48:40.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/00/171ed8cfbfd8e6db2509acfa1610d880a2d44d4dc0488dff3c4001f0ced2/geventhttpclient-2.3.7-cp314-cp314t-win32.whl", hash = "sha256:77a9ce7c4aaa5f6b0c2256ee8ee9c3bf3a1bc59a97422f0071869670704ec7f8", size = 49372, upload-time = "2025-12-07T19:48:41.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/d2/6c99ec3d9e369ddc27adc758a82b6485d28ac797669be3571afa74757cae/geventhttpclient-2.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:607b7a1c4d03a94ec1a2f4e7891039fde84fcd816f2d921a28c11759427f068f", size = 49914, upload-time = "2025-12-07T19:48:42.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "google-ai-generativelanguage"
|
||||
version = "0.6.15"
|
||||
@@ -965,6 +1171,49 @@ grpc = [
|
||||
{ name = "grpcio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpc-google-iam-v1"
|
||||
version = "0.14.3"
|
||||
@@ -1206,6 +1455,53 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/9d/2684ba7147a877e6a8c4b456665334cc0fecd7fa782eeaf7fad40ae8502e/inline_snapshot-0.32.1-py3-none-any.whl", hash = "sha256:55d0848daa42154ea08e6b71096d8fb568d0f8b681be9605ea17f4c37e86dd82", size = 84269, upload-time = "2026-02-17T12:06:17.898Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "locust"
|
||||
version = "2.43.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "configargparse" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-cors" },
|
||||
{ name = "flask-login" },
|
||||
{ name = "gevent" },
|
||||
{ name = "geventhttpclient" },
|
||||
{ name = "msgpack" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pytest" },
|
||||
{ name = "python-engineio" },
|
||||
{ name = "python-socketio", extra = ["client"] },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyzmq" },
|
||||
{ name = "requests" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/c5/7d7bd50ac744bc209a4bcbeb74660d7ae450a44441737efe92ee9d8ea6a7/locust-2.43.3.tar.gz", hash = "sha256:b5d2c48f8f7d443e3abdfdd6ec2f7aebff5cd74fab986bcf1e95b375b5c5a54b", size = 1445349, upload-time = "2026-02-12T09:55:34.591Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d2/dc5379876d3a481720803653ea4d219f0c26f2d2b37c9243baaa16d0bc79/locust-2.43.3-py3-none-any.whl", hash = "sha256:e032c119b54a9d984cb74a936ee83cfd7d68b3c76c8f308af63d04f11396b553", size = 1463473, upload-time = "2026-02-12T09:55:31.727Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
@@ -1218,6 +1514,69 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
@@ -1227,6 +1586,50 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msgpack"
|
||||
version = "1.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.7.1"
|
||||
@@ -1454,6 +1857,34 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.2"
|
||||
@@ -1692,6 +2123,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-engineio"
|
||||
version = "4.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "simple-websocket" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/12/bdef9dbeedbe2cdeba2a2056ad27b1fb081557d34b69a97f574843462cae/python_engineio-4.13.1.tar.gz", hash = "sha256:0a853fcef52f5b345425d8c2b921ac85023a04dfcf75d7b74696c61e940fd066", size = 92348, upload-time = "2026-02-06T23:38:06.12Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.22"
|
||||
@@ -1701,6 +2144,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-socketio"
|
||||
version = "5.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bidict" },
|
||||
{ name = "python-engineio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/81/cf8284f45e32efa18d3848ed82cdd4dcc1b657b082458fbe01ad3e1f2f8d/python_socketio-5.16.1.tar.gz", hash = "sha256:f863f98eacce81ceea2e742f6388e10ca3cdd0764be21d30d5196470edf5ea89", size = 128508, upload-time = "2026-02-06T23:42:07Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
client = [
|
||||
{ name = "requests" },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@@ -1747,6 +2225,49 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyzmq"
|
||||
version = "27.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "implementation_name == 'pypy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.2.0"
|
||||
@@ -1826,6 +2347,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/07/5bda6a85b220c64c65686bc85bd0bbb23b29c62b3a9f9433fa55f17cda93/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416", size = 10874604, upload-time = "2026-02-12T23:09:05.515Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simple-websocket"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -2089,6 +2622,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
@@ -2120,6 +2662,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.1.1"
|
||||
@@ -2174,6 +2728,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wsproto"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.22.0"
|
||||
@@ -2267,3 +2833,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zope-event"
|
||||
version = "6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zope-interface"
|
||||
version = "8.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a0/1e1fabbd2e9c53ef92b69df6d14f4adc94ec25583b1380336905dc37e9a0/zope_interface-8.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:624b6787fc7c3e45fa401984f6add2c736b70a7506518c3b537ffaacc4b29d4c", size = 208785, upload-time = "2026-01-09T08:05:17.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/2a/88d098a06975c722a192ef1fb7d623d1b57c6a6997cf01a7aabb45ab1970/zope_interface-8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc9ded9e97a0ed17731d479596ed1071e53b18e6fdb2fc33af1e43f5fd2d3aaa", size = 208976, upload-time = "2026-01-09T08:05:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/e8/757398549fdfd2f8c89f32c82ae4d2f0537ae2a5d2f21f4a2f711f5a059f/zope_interface-8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:532367553e4420c80c0fc0cabcc2c74080d495573706f66723edee6eae53361d", size = 259411, upload-time = "2026-01-09T08:05:20.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/af/502601f0395ce84dff622f63cab47488657a04d0065547df42bee3a680ff/zope_interface-8.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2bf9cf275468bafa3c72688aad8cfcbe3d28ee792baf0b228a1b2d93bd1d541a", size = 264859, upload-time = "2026-01-09T08:05:22.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/0c/d2f765b9b4814a368a7c1b0ac23b68823c6789a732112668072fe596945d/zope_interface-8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0009d2d3c02ea783045d7804da4fd016245e5c5de31a86cebba66dd6914d59a2", size = 264398, upload-time = "2026-01-09T08:05:23.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/47/45188fb101fa060b20e6090e500682398ab415e516a0c228fbb22bc7def2/zope_interface-8.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:6068322004a0158c80dfd4708dfb103a899635408c67c3b10e9acec4dbacefec", size = 209170, upload-time = "2026-01-09T08:05:26.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/03/f6b9336c03c2b48403c4eb73a1ec961d94dc2fb5354c583dfb5fa05fd41f/zope_interface-8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2499de92e8275d0dd68f84425b3e19e9268cd1fa8507997900fa4175f157733c", size = 209229, upload-time = "2026-01-09T08:05:28.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/b1/65fe1dca708569f302ade02e6cdca309eab6752bc9f80105514f5b708651/zope_interface-8.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f777e68c76208503609c83ca021a6864902b646530a1a39abb9ed310d1100664", size = 259393, upload-time = "2026-01-09T08:05:29.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/a5/97b49cfceb6ed53d3dcfb3f3ebf24d83b5553194f0337fbbb3a9fec6cf78/zope_interface-8.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b05a919fdb0ed6ea942e5a7800e09a8b6cdae6f98fee1bef1c9d1a3fc43aaa0", size = 264863, upload-time = "2026-01-09T08:05:31.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/02/0b7a77292810efe3a0586a505b077ebafd5114e10c6e6e659f0c8e387e1f/zope_interface-8.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ccc62b5712dd7bd64cfba3ee63089fb11e840f5914b990033beeae3b2180b6cb", size = 264369, upload-time = "2026-01-09T08:05:32.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1d/0d1ff3846302ed1b5bbf659316d8084b30106770a5f346b7ff4e9f540f80/zope_interface-8.2-cp313-cp313-win_amd64.whl", hash = "sha256:34f877d1d3bb7565c494ed93828fa6417641ca26faf6e8f044e0d0d500807028", size = 212447, upload-time = "2026-01-09T08:05:35.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/da/3c89de3917751446728b8898b4d53318bc2f8f6bf8196e150a063c59905e/zope_interface-8.2-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:46c7e4e8cbc698398a67e56ca985d19cb92365b4aafbeb6a712e8c101090f4cb", size = 209223, upload-time = "2026-01-09T08:05:36.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/7f/62d00ec53f0a6e5df0c984781e6f3999ed265129c4c3413df8128d1e0207/zope_interface-8.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a87fc7517f825a97ff4a4ca4c8a950593c59e0f8e7bfe1b6f898a38d5ba9f9cf", size = 209366, upload-time = "2026-01-09T08:05:38.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a2/f241986315174be8e00aabecfc2153cf8029c1327cab8ed53a9d979d7e08/zope_interface-8.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:ccf52f7d44d669203c2096c1a0c2c15d52e36b2e7a9413df50f48392c7d4d080", size = 261037, upload-time = "2026-01-09T08:05:39.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/cc/b321c51d6936ede296a1b8860cf173bee2928357fe1fff7f97234899173f/zope_interface-8.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aae807efc7bd26302eb2fea05cd6de7d59269ed6ae23a6de1ee47add6de99b8c", size = 264219, upload-time = "2026-01-09T08:05:41.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/fb/5f5e7b40a2f4efd873fe173624795ca47eaa22e29051270c981361b45209/zope_interface-8.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05a0e42d6d830f547e114de2e7cd15750dc6c0c78f8138e6c5035e51ddfff37c", size = 264390, upload-time = "2026-01-09T08:05:42.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/82/3f2bc594370bc3abd58e5f9085d263bf682a222f059ed46275cde0570810/zope_interface-8.2-cp314-cp314-win_amd64.whl", hash = "sha256:561ce42390bee90bae51cf1c012902a8033b2aaefbd0deed81e877562a116d48", size = 212585, upload-time = "2026-01-09T08:05:44.419Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user