Compare commits
19 Commits
88a91aa6d7
...
df3bfda594
| Author | SHA1 | Date |
|---|---|---|
|
|
df3bfda594 | |
|
|
7f5a75e0a5 | |
|
|
e30e7304df | |
|
|
1665d11d66 | |
|
|
c7b77fb532 | |
|
|
fea30e79fd | |
|
|
0e1eae75dd | |
|
|
2e9a43263f | |
|
|
3039a65ee4 | |
|
|
f29ac29649 | |
|
|
fc88eedfa2 | |
|
|
72dcd09771 | |
|
|
6d2961cee2 | |
|
|
b48d218a1d | |
|
|
7e2646337f | |
|
|
2ac4b75d96 | |
|
|
1e16e0e3eb | |
|
|
f6da65044a | |
|
|
4a06bfdde4 |
|
|
@ -6,10 +6,12 @@ __pycache__/
|
|||
|
||||
# Environment variables
|
||||
.env
|
||||
.env*
|
||||
|
||||
# Claude AI related files
|
||||
.claudeignore
|
||||
# .claude/ 폴더는 커밋 대상 (에이전트 설정 포함)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# VSCode settings
|
||||
.vscode/
|
||||
|
|
@ -36,3 +38,11 @@ static/
|
|||
# Log files
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.13
|
||||
3.14
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ app/
|
|||
| ------ | -------------------------- | ----------------------------- |
|
||||
| POST | `/song/generate` | Suno AI를 이용한 노래 생성 요청 |
|
||||
| GET | `/song/status/{task_id}` | 노래 생성 상태 조회 (폴링) |
|
||||
| GET | `/song/download/{task_id}` | 생성된 노래 MP3 다운로드 |
|
||||
|
||||
## 환경 설정
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from contextlib import asynccontextmanager
|
|||
from fastapi import FastAPI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||
logger = get_logger("core")
|
||||
|
||||
|
||||
|
|
@ -24,6 +24,7 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
await create_db_tables()
|
||||
logger.info("Database tables created (DEBUG mode)")
|
||||
await NvMapPwScraper.initiate_scraper()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Database initialization timed out")
|
||||
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
|
||||
|
|
|
|||
|
|
@ -92,18 +92,18 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
pool = engine.pool
|
||||
|
||||
# 커넥션 풀 상태 로깅 (디버깅용)
|
||||
logger.debug(
|
||||
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
# logger.debug(
|
||||
# f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
# f"overflow: {pool.overflow()}"
|
||||
# )
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
logger.debug(
|
||||
f"[get_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
# logger.debug(
|
||||
# f"[get_session] Session acquired in "
|
||||
# f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
# )
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
|
|
@ -115,10 +115,10 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
logger.debug(
|
||||
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
# logger.debug(
|
||||
# f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
|
||||
# f"pool_out: {pool.checkedout()}"
|
||||
# )
|
||||
|
||||
|
||||
# 백그라운드 태스크용 세션 제너레이터
|
||||
|
|
@ -126,18 +126,18 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
start_time = time.perf_counter()
|
||||
pool = background_engine.pool
|
||||
|
||||
logger.debug(
|
||||
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
f"overflow: {pool.overflow()}"
|
||||
)
|
||||
# logger.debug(
|
||||
# f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
|
||||
# f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
|
||||
# f"overflow: {pool.overflow()}"
|
||||
# )
|
||||
|
||||
async with BackgroundSessionLocal() as session:
|
||||
acquire_time = time.perf_counter()
|
||||
logger.debug(
|
||||
f"[get_background_session] Session acquired in "
|
||||
f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
)
|
||||
# logger.debug(
|
||||
# f"[get_background_session] Session acquired in "
|
||||
# f"{(acquire_time - start_time)*1000:.1f}ms"
|
||||
# )
|
||||
try:
|
||||
yield session
|
||||
except Exception as e:
|
||||
|
|
@ -150,11 +150,11 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
|||
raise e
|
||||
finally:
|
||||
total_time = time.perf_counter() - start_time
|
||||
logger.debug(
|
||||
f"[get_background_session] RELEASE - "
|
||||
f"duration: {total_time*1000:.1f}ms, "
|
||||
f"pool_out: {pool.checkedout()}"
|
||||
)
|
||||
# logger.debug(
|
||||
# f"[get_background_session] RELEASE - "
|
||||
# f"duration: {total_time*1000:.1f}ms, "
|
||||
# f"pool_out: {pool.checkedout()}"
|
||||
# )
|
||||
|
||||
|
||||
# 앱 종료 시 엔진 리소스 정리 함수
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.home.models import Image, Project
|
||||
from app.home.models import Image, Project, UserProject
|
||||
|
||||
|
||||
class ProjectAdmin(ModelView, model=Project):
|
||||
|
|
@ -100,3 +100,44 @@ class ImageAdmin(ModelView, model=Image):
|
|||
"img_url": "이미지 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
||||
|
||||
class UserProjectAdmin(ModelView, model=UserProject):
|
||||
name = "사용자-프로젝트"
|
||||
name_plural = "사용자-프로젝트 목록"
|
||||
icon = "fa-solid fa-link"
|
||||
category = "프로젝트 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
"user",
|
||||
"project",
|
||||
]
|
||||
|
||||
column_searchable_list = [
|
||||
UserProject.user_id,
|
||||
UserProject.project_id,
|
||||
]
|
||||
|
||||
column_sortable_list = [
|
||||
UserProject.id,
|
||||
UserProject.user_id,
|
||||
UserProject.project_id,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"project_id": "프로젝트 ID",
|
||||
"user": "사용자",
|
||||
"project": "프로젝트",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.database.session import get_session, AsyncSessionLocal
|
||||
from app.home.models import Image
|
||||
from app.home.schemas.home_schema import (
|
||||
AutoCompleteRequest,
|
||||
CrawlingRequest,
|
||||
CrawlingResponse,
|
||||
ErrorResponse,
|
||||
|
|
@ -23,10 +24,11 @@ from app.home.schemas.home_schema import (
|
|||
ProcessedInfo,
|
||||
)
|
||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||
from app.utils.common import generate_task_id
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||
from app.utils.prompts.prompts import marketing_prompt
|
||||
from config import MEDIA_ROOT
|
||||
|
||||
|
|
@ -62,7 +64,8 @@ KOREAN_CITIES = [
|
|||
]
|
||||
# fmt: on
|
||||
|
||||
router = APIRouter(tags=["Home"])
|
||||
# router = APIRouter(tags=["Home"])
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _extract_region_from_address(road_address: str | None) -> str:
|
||||
|
|
@ -105,28 +108,69 @@ def _extract_region_from_address(road_address: str | None) -> str:
|
|||
tags=["Crawling"],
|
||||
)
|
||||
async def crawling(request_body: CrawlingRequest):
|
||||
"""네이버 지도 장소 크롤링"""
|
||||
return await _crawling_logic(request_body.url)
|
||||
|
||||
@router.post(
|
||||
"/autocomplete",
|
||||
summary="네이버 자동완성 크롤링",
|
||||
description="""
|
||||
네이버 검색 API 정보를 활용하여 Place ID를 추출한 뒤 자동으로 크롤링합니다.
|
||||
|
||||
## 요청 필드
|
||||
- **title**: 네이버 검색 API Place 결과물 title (필수)
|
||||
- **address**: 네이버 검색 API Place 결과물 지번주소 (필수)
|
||||
- **roadAddress**:네이버 검색 API Place 결과물 도로명주소
|
||||
|
||||
## 반환 정보
|
||||
- **image_list**: 장소 이미지 URL 목록
|
||||
- **image_count**: 이미지 개수
|
||||
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
|
||||
""",
|
||||
response_model=CrawlingResponse,
|
||||
response_description="크롤링 결과",
|
||||
responses={
|
||||
200: {"description": "크롤링 성공", "model": CrawlingResponse},
|
||||
400: {
|
||||
"description": "잘못된 URL",
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
502: {
|
||||
"description": "크롤링 실패",
|
||||
"model": ErrorResponse,
|
||||
},
|
||||
},
|
||||
tags=["Crawling"],
|
||||
)
|
||||
async def autocomplete_crawling(request_body: AutoCompleteRequest):
|
||||
url = await _autocomplete_logic(request_body.dict())
|
||||
return await _crawling_logic(url)
|
||||
|
||||
async def _crawling_logic(url:str):
|
||||
request_start = time.perf_counter()
|
||||
logger.info("[crawling] ========== START ==========")
|
||||
logger.info(f"[crawling] URL: {request_body.url[:80]}...")
|
||||
logger.info(f"[crawling] URL: {url[:80]}...")
|
||||
|
||||
# ========== Step 1: 네이버 지도 크롤링 ==========
|
||||
step1_start = time.perf_counter()
|
||||
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
|
||||
|
||||
try:
|
||||
scraper = NvMapScraper(request_body.url)
|
||||
scraper = NvMapScraper(url)
|
||||
await scraper.scrap()
|
||||
except GraphQLException as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)")
|
||||
logger.error(
|
||||
f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)")
|
||||
logger.error(
|
||||
f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.exception("[crawling] Step 1 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
|
|
@ -135,7 +179,9 @@ async def crawling(request_body: CrawlingRequest):
|
|||
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
|
||||
logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
|
||||
logger.info(
|
||||
f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== Step 2: 정보 가공 ==========
|
||||
step2_start = time.perf_counter()
|
||||
|
|
@ -156,7 +202,9 @@ async def crawling(request_body: CrawlingRequest):
|
|||
)
|
||||
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
|
||||
logger.info(
|
||||
f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== Step 3: ChatGPT 마케팅 분석 ==========
|
||||
step3_start = time.perf_counter()
|
||||
|
|
@ -167,14 +215,16 @@ async def crawling(request_body: CrawlingRequest):
|
|||
step3_1_start = time.perf_counter()
|
||||
chatgpt_service = ChatgptService()
|
||||
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
|
||||
logger.debug(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)")
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# Step 3-2: 프롬프트 생성
|
||||
# step3_2_start = time.perf_counter()
|
||||
input_marketing_data = {
|
||||
"customer_name" : customer_name,
|
||||
"region" : region,
|
||||
"detail_region_info" : road_address or ""
|
||||
"customer_name": customer_name,
|
||||
"region": region,
|
||||
"detail_region_info": road_address or "",
|
||||
}
|
||||
# prompt = chatgpt_service.build_market_analysis_prompt()
|
||||
# prompt1 = marketing_prompt.build_prompt(input_marketing_data)
|
||||
|
|
@ -182,15 +232,22 @@ async def crawling(request_body: CrawlingRequest):
|
|||
|
||||
# Step 3-3: GPT API 호출
|
||||
step3_3_start = time.perf_counter()
|
||||
structured_report = await chatgpt_service.generate_structured_output(marketing_prompt, input_marketing_data)
|
||||
structured_report = await chatgpt_service.generate_structured_output(
|
||||
marketing_prompt, input_marketing_data
|
||||
)
|
||||
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
|
||||
logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)")
|
||||
logger.debug(f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)")
|
||||
|
||||
logger.info(
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
|
||||
step3_4_start = time.perf_counter()
|
||||
logger.debug(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}")
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}"
|
||||
)
|
||||
|
||||
# 요약 Deprecated / 20250115 / Selling points를 첫 prompt에서 추출 중
|
||||
# parsed = await chatgpt_service.parse_marketing_analysis(
|
||||
|
|
@ -199,6 +256,23 @@ async def crawling(request_body: CrawlingRequest):
|
|||
|
||||
# marketing_analysis = MarketingAnalysis(**parsed)
|
||||
|
||||
logger.debug(
|
||||
f"[crawling] structured_report 구조 확인:\n"
|
||||
f"{'='*60}\n"
|
||||
f"[report] type: {type(structured_report.get('report'))}\n"
|
||||
f"{'-'*60}\n"
|
||||
f"{structured_report.get('report')}\n"
|
||||
f"{'='*60}\n"
|
||||
f"[tags] type: {type(structured_report.get('tags'))}\n"
|
||||
f"{'-'*60}\n"
|
||||
f"{structured_report.get('tags')}\n"
|
||||
f"{'='*60}\n"
|
||||
f"[selling_points] type: {type(structured_report.get('selling_points'))}\n"
|
||||
f"{'-'*60}\n"
|
||||
f"{structured_report.get('selling_points')}\n"
|
||||
f"{'='*60}"
|
||||
)
|
||||
|
||||
marketing_analysis = MarketingAnalysis(
|
||||
report=structured_report["report"],
|
||||
tags=structured_report["tags"],
|
||||
|
|
@ -209,20 +283,38 @@ async def crawling(request_body: CrawlingRequest):
|
|||
# print(sp['keywords'])
|
||||
# print(sp['description'])
|
||||
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
|
||||
logger.debug(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)")
|
||||
logger.debug(
|
||||
f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
|
||||
logger.info(
|
||||
f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
except ChatGPTResponseError as e:
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(
|
||||
f"[crawling] Step 3 FAILED - ChatGPT Error: status={e.status}, "
|
||||
f"code={e.error_code}, message={e.error_message} ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
marketing_analysis = None
|
||||
gpt_status = "failed"
|
||||
|
||||
except Exception as e:
|
||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||
logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)")
|
||||
logger.error(
|
||||
f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.exception("[crawling] Step 3 상세 오류:")
|
||||
# GPT 실패 시에도 크롤링 결과는 반환
|
||||
marketing_analysis = None
|
||||
gpt_status = "failed"
|
||||
else:
|
||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||
logger.warning(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
|
||||
logger.warning(
|
||||
f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)"
|
||||
)
|
||||
|
||||
# ========== 완료 ==========
|
||||
total_elapsed = (time.perf_counter() - request_start) * 1000
|
||||
|
|
@ -231,19 +323,37 @@ async def crawling(request_body: CrawlingRequest):
|
|||
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
|
||||
if scraper.base_info:
|
||||
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
|
||||
if 'step3_elapsed' in locals():
|
||||
if "step3_elapsed" in locals():
|
||||
logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
|
||||
if 'step3_3_elapsed' in locals():
|
||||
if "step3_3_elapsed" in locals():
|
||||
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
|
||||
|
||||
return {
|
||||
"status": gpt_status if 'gpt_status' in locals() else "completed",
|
||||
"image_list": scraper.image_link_list,
|
||||
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
|
||||
"processed_info": processed_info,
|
||||
"marketing_analysis": marketing_analysis
|
||||
"marketing_analysis": marketing_analysis,
|
||||
}
|
||||
|
||||
|
||||
async def _autocomplete_logic(autocomplete_item:dict):
|
||||
step1_start = time.perf_counter()
|
||||
try:
|
||||
async with NvMapPwScraper() as pw_scraper:
|
||||
new_url = await pw_scraper.get_place_id_url(autocomplete_item)
|
||||
except Exception as e:
|
||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||
logger.error(
|
||||
f"[crawling] Autocomplete FAILED - 자동완성 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)"
|
||||
)
|
||||
logger.exception("[crawling] Autocomplete 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="자동완성 place id 추출 실패",
|
||||
)
|
||||
return new_url
|
||||
|
||||
def _extract_image_name(url: str, index: int) -> str:
|
||||
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
|
||||
try:
|
||||
|
|
@ -598,6 +708,15 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
|
|||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
||||
},
|
||||
tags=["Image-Blob"],
|
||||
openapi_extra={
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"encoding": {"files": {"contentType": "application/octet-stream"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
async def upload_images_blob(
|
||||
images_json: Optional[str] = Form(
|
||||
|
|
@ -606,7 +725,8 @@ async def upload_images_blob(
|
|||
examples=[IMAGES_JSON_EXAMPLE],
|
||||
),
|
||||
files: Optional[list[UploadFile]] = File(
|
||||
default=None, description="이미지 바이너리 파일 목록"
|
||||
default=None,
|
||||
description="이미지 바이너리 파일 목록",
|
||||
),
|
||||
) -> ImageUploadResponse:
|
||||
"""이미지 업로드 (URL + Azure Blob Storage)
|
||||
|
|
@ -674,9 +794,11 @@ async def upload_images_blob(
|
|||
)
|
||||
|
||||
stage1_time = time.perf_counter()
|
||||
logger.info(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
||||
logger.info(
|
||||
f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
|
||||
f"files: {len(valid_files_data)}, "
|
||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms")
|
||||
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
|
||||
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
|
||||
|
|
@ -695,8 +817,10 @@ async def upload_images_blob(
|
|||
)
|
||||
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
||||
|
||||
logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
|
||||
f"{filename} ({len(file_content)} bytes)")
|
||||
logger.debug(
|
||||
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
|
||||
f"{filename} ({len(file_content)} bytes)"
|
||||
)
|
||||
|
||||
# Azure Blob Storage에 직접 업로드
|
||||
upload_success = await uploader.upload_image_bytes(file_content, filename)
|
||||
|
|
@ -705,15 +829,21 @@ async def upload_images_blob(
|
|||
blob_url = uploader.public_url
|
||||
blob_upload_results.append((original_name, blob_url))
|
||||
img_order += 1
|
||||
logger.debug(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS")
|
||||
logger.debug(
|
||||
f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS"
|
||||
)
|
||||
else:
|
||||
skipped_files.append(filename)
|
||||
logger.warning(f"[upload_images_blob] File {idx+1}/{total_files} FAILED")
|
||||
logger.warning(
|
||||
f"[upload_images_blob] File {idx + 1}/{total_files} FAILED"
|
||||
)
|
||||
|
||||
stage2_time = time.perf_counter()
|
||||
logger.info(f"[upload_images_blob] Stage 2 done - blob uploads: "
|
||||
logger.info(
|
||||
f"[upload_images_blob] Stage 2 done - blob uploads: "
|
||||
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
|
||||
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
|
||||
f"elapsed: {(stage2_time - stage1_time) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
|
||||
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
|
||||
|
|
@ -724,9 +854,7 @@ async def upload_images_blob(
|
|||
async with AsyncSessionLocal() as session:
|
||||
# URL 이미지 저장
|
||||
for url_item in url_images:
|
||||
img_name = (
|
||||
url_item.name or _extract_image_name(url_item.url, img_order)
|
||||
)
|
||||
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
||||
|
||||
image = Image(
|
||||
task_id=task_id,
|
||||
|
|
@ -772,9 +900,11 @@ async def upload_images_blob(
|
|||
|
||||
await session.commit()
|
||||
stage3_time = time.perf_counter()
|
||||
logger.info(f"[upload_images_blob] Stage 3 done - "
|
||||
logger.info(
|
||||
f"[upload_images_blob] Stage 3 done - "
|
||||
f"saved: {len(result_images)}, "
|
||||
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
|
||||
f"elapsed: {(stage3_time - stage2_time) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
|
||||
|
|
@ -784,8 +914,10 @@ async def upload_images_blob(
|
|||
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
|
||||
logger.error(
|
||||
f"[upload_images_blob] Stage 3 EXCEPTION - "
|
||||
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
|
||||
)
|
||||
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
|
|
@ -796,8 +928,10 @@ async def upload_images_blob(
|
|||
image_urls = [img.img_url for img in result_images]
|
||||
|
||||
total_time = time.perf_counter() - request_start
|
||||
logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
|
||||
logger.info(
|
||||
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||
f"total: {saved_count}, total_time: {total_time * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
return ImageUploadResponse(
|
||||
task_id=task_id,
|
||||
|
|
|
|||
|
|
@ -122,6 +122,22 @@ class CrawlingRequest(BaseModel):
|
|||
|
||||
url: str = Field(..., description="네이버 지도 장소 URL")
|
||||
|
||||
class AutoCompleteRequest(BaseModel):
|
||||
"""자동완성 요청 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
'title': '<b>스테이</b>,<b>머뭄</b>',
|
||||
'address': '전북특별자치도 군산시 신흥동 63-18',
|
||||
'roadAddress': '전북특별자치도 군산시 절골길 18',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
title: str = Field(..., description="네이버 검색 place API Title")
|
||||
address: str = Field(..., description="네이버 검색 place API 지번주소")
|
||||
roadAddress: Optional[str] = Field(None, description="네이버 검색 place API 도로명주소")
|
||||
|
||||
class ProcessedInfo(BaseModel):
|
||||
"""가공된 장소 정보 스키마"""
|
||||
|
|
@ -151,13 +167,37 @@ class MarketingAnalysis(BaseModel):
|
|||
class CrawlingResponse(BaseModel):
|
||||
"""크롤링 응답 스키마"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"status": "completed",
|
||||
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
|
||||
"image_count": 2,
|
||||
"processed_info": {
|
||||
"customer_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "전북특별자치도 군산시 절골길 18"
|
||||
},
|
||||
"marketing_analysis": {
|
||||
"report": "마케팅 분석 리포트...",
|
||||
"tags": ["힐링", "감성숙소"],
|
||||
"facilities": ["조식", "주차"]
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
status: str = Field(
|
||||
default="completed",
|
||||
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
||||
)
|
||||
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||
image_count: int = Field(..., description="이미지 개수")
|
||||
processed_info: Optional[ProcessedInfo] = Field(
|
||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||
)
|
||||
marketing_analysis: Optional[MarketingAnalysis] = Field(
|
||||
None, description="마케팅 분석 결과 (report, tags, facilities)"
|
||||
None, description="마케팅 분석 결과 (report, tags, facilities). 실패 시 null"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -108,15 +108,33 @@ class LyricStatusResponse(BaseModel):
|
|||
Usage:
|
||||
GET /lyric/status/{task_id}
|
||||
Returns the current processing status of a lyric generation task.
|
||||
|
||||
Status Values:
|
||||
- processing: 가사 생성 진행 중
|
||||
- completed: 가사 생성 완료
|
||||
- failed: ChatGPT API 오류 또는 생성 실패
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"examples": [
|
||||
{
|
||||
"summary": "성공",
|
||||
"value": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "completed",
|
||||
"message": "가사 생성이 완료되었습니다.",
|
||||
}
|
||||
},
|
||||
{
|
||||
"summary": "실패",
|
||||
"value": {
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"status": "failed",
|
||||
"message": "가사 생성에 실패했습니다.",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -131,11 +149,18 @@ class LyricDetailResponse(BaseModel):
|
|||
Usage:
|
||||
GET /lyric/{task_id}
|
||||
Returns the generated lyric content for a specific task.
|
||||
|
||||
Note:
|
||||
- status가 "failed"인 경우 lyric_result에 에러 메시지가 저장됩니다.
|
||||
- 에러 메시지 형식: "ChatGPT Error: {message}" 또는 "Error: {message}"
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"examples": [
|
||||
{
|
||||
"summary": "성공",
|
||||
"value": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
|
|
@ -143,14 +168,27 @@ class LyricDetailResponse(BaseModel):
|
|||
"lyric_result": "인스타 감성의 스테이 머뭄\n군산 신흥동 말랭이 마을에서\n여유로운 하루를 보내며\n추억을 만들어가요",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
},
|
||||
{
|
||||
"summary": "실패",
|
||||
"value": {
|
||||
"id": 1,
|
||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
||||
"project_id": 1,
|
||||
"status": "failed",
|
||||
"lyric_result": "ChatGPT Error: Response incomplete: max_output_tokens",
|
||||
"created_at": "2024-01-15T12:00:00",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
id: int = Field(..., description="가사 ID")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
project_id: int = Field(..., description="프로젝트 ID")
|
||||
status: str = Field(..., description="처리 상태")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사")
|
||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||
|
||||
from app.database.session import BackgroundSessionLocal
|
||||
from app.lyric.models import Lyric
|
||||
from app.utils.chatgpt_prompt import ChatgptService
|
||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||
from app.utils.prompts.prompts import Prompt
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
|
|
@ -130,6 +130,14 @@ async def generate_lyric_background(
|
|||
logger.debug(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
|
||||
logger.debug(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
|
||||
|
||||
except ChatGPTResponseError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(
|
||||
f"[generate_lyric_background] ChatGPT ERROR - task_id: {task_id}, "
|
||||
f"status: {e.status}, code: {e.error_code}, message: {e.error_message} ({elapsed:.1f}ms)"
|
||||
)
|
||||
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}")
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
elapsed = (time.perf_counter() - task_start) * 1000
|
||||
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ Song API Router
|
|||
엔드포인트 목록:
|
||||
- POST /song/generate/{task_id}: 노래 생성 요청 (task_id로 Project/Lyric 연결)
|
||||
- GET /song/status/{song_id}: Suno API 노래 생성 상태 조회
|
||||
- GET /song/download/{task_id}: 노래 다운로드 상태 조회 (DB polling)
|
||||
|
||||
사용 예시:
|
||||
from app.song.api.routers.v1.song import router
|
||||
|
|
@ -14,28 +13,20 @@ Song API Router
|
|||
"""
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.dependencies.pagination import (
|
||||
PaginationParams,
|
||||
get_pagination_params,
|
||||
)
|
||||
from app.home.models import Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song, SongTimestamp
|
||||
|
||||
from app.song.schemas.song_schema import (
|
||||
DownloadSongResponse,
|
||||
GenerateSongRequest,
|
||||
GenerateSongResponse,
|
||||
PollingSongResponse,
|
||||
SongListItem,
|
||||
)
|
||||
from app.song.worker.song_task import download_and_upload_song_by_suno_task_id
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.utils.suno import SunoService
|
||||
|
||||
logger = get_logger("song")
|
||||
|
|
@ -128,7 +119,9 @@ async def generate_song(
|
|||
project = project_result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
logger.warning(f"[generate_song] Project NOT FOUND - task_id: {task_id}")
|
||||
logger.warning(
|
||||
f"[generate_song] Project NOT FOUND - task_id: {task_id}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||
|
|
@ -143,6 +136,13 @@ async def generate_song(
|
|||
.limit(1)
|
||||
)
|
||||
lyric = lyric_result.scalar_one_or_none()
|
||||
logger.debug(
|
||||
f"[generate_song] Lyric query result - "
|
||||
f"id: {lyric.id if lyric else None}, "
|
||||
f"project_id: {lyric.project_id if lyric else None}, "
|
||||
f"task_id: {lyric.task_id if lyric else None}, "
|
||||
f"lyric_result: {lyric.lyric_result if lyric else None}"
|
||||
)
|
||||
|
||||
if not lyric:
|
||||
logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
|
||||
|
|
@ -156,13 +156,25 @@ async def generate_song(
|
|||
logger.info(
|
||||
f"[generate_song] Queries completed - task_id: {task_id}, "
|
||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
||||
f"elapsed: {(query_time - request_start)*1000:.1f}ms"
|
||||
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
# Song 테이블에 초기 데이터 저장
|
||||
song_prompt = (
|
||||
f"[Lyrics]\n{request_body.lyrics}\n\n[Genre]\n{request_body.genre}"
|
||||
)
|
||||
logger.debug(
|
||||
f"[generate_song] Lyrics comparison - task_id: {task_id}\n"
|
||||
f"{'=' * 60}\n"
|
||||
f"[lyric.lyric_result]\n"
|
||||
f"{'-' * 60}\n"
|
||||
f"{lyric.lyric_result}\n"
|
||||
f"{'=' * 60}\n"
|
||||
f"[song_prompt]\n"
|
||||
f"{'-' * 60}\n"
|
||||
f"{song_prompt}\n"
|
||||
f"{'=' * 60}"
|
||||
)
|
||||
|
||||
song = Song(
|
||||
project_id=project_id,
|
||||
|
|
@ -181,7 +193,7 @@ async def generate_song(
|
|||
logger.info(
|
||||
f"[generate_song] Stage 1 DONE - Song saved - "
|
||||
f"task_id: {task_id}, song_id: {song_id}, "
|
||||
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms"
|
||||
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
# 세션이 여기서 자동으로 닫힘
|
||||
|
||||
|
|
@ -218,7 +230,7 @@ async def generate_song(
|
|||
logger.info(
|
||||
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
|
||||
f"suno_task_id: {suno_task_id}, "
|
||||
f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
|
||||
f"elapsed: {(stage2_time - stage2_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -264,12 +276,12 @@ async def generate_song(
|
|||
total_time = stage3_time - request_start
|
||||
logger.info(
|
||||
f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
|
||||
f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
|
||||
f"elapsed: {(stage3_time - stage3_start) * 1000:.1f}ms"
|
||||
)
|
||||
logger.info(
|
||||
f"[generate_song] SUCCESS - task_id: {task_id}, "
|
||||
f"suno_task_id: {suno_task_id}, "
|
||||
f"total_time: {total_time*1000:.1f}ms"
|
||||
f"total_time: {total_time * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
return GenerateSongResponse(
|
||||
|
|
@ -325,7 +337,6 @@ GET /song/status/abc123...
|
|||
## 참고
|
||||
- 이 엔드포인트는 Suno API의 상태를 반환합니다
|
||||
- SUCCESS 응답 시 백그라운드에서 MP3 다운로드 → Azure Blob Storage 업로드가 시작됩니다
|
||||
- 최종 완료 상태는 `/song/download/{task_id}` 엔드포인트에서 확인하세요
|
||||
- Song 테이블 상태: processing → uploading → completed
|
||||
""",
|
||||
response_model=PollingSongResponse,
|
||||
|
|
@ -349,9 +360,17 @@ async def get_song_status(
|
|||
try:
|
||||
suno_service = SunoService()
|
||||
result = await suno_service.get_task_status(suno_task_id)
|
||||
logger.debug(f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}")
|
||||
logger.debug(
|
||||
f"[get_song_status] Suno API raw response - song_id: {suno_task_id}, result: {result}"
|
||||
)
|
||||
parsed_response = suno_service.parse_status_response(result)
|
||||
logger.info(f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}")
|
||||
logger.info(
|
||||
f"[get_song_status] Suno API response - song_id: {suno_task_id}, status: {parsed_response.status}"
|
||||
)
|
||||
|
||||
if parsed_response.status == "TEXT_SUCCESS" and result:
|
||||
parsed_response.status = "processing"
|
||||
return parsed_response
|
||||
|
||||
# SUCCESS 상태인 경우 백그라운드에서 MP3 다운로드 및 Blob 업로드 진행
|
||||
if parsed_response.status == "SUCCESS" and result:
|
||||
|
|
@ -365,13 +384,15 @@ async def get_song_status(
|
|||
first_clip = clips_data[0]
|
||||
audio_url = first_clip.get("audioUrl")
|
||||
clip_duration = first_clip.get("duration")
|
||||
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}")
|
||||
logger.debug(
|
||||
f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
|
||||
)
|
||||
|
||||
if audio_url:
|
||||
# song_id로 Song 조회
|
||||
song_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.suno_task_id == suno_task_id)
|
||||
.where(Song.suno_task_id == song_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
|
@ -388,22 +409,33 @@ async def get_song_status(
|
|||
|
||||
# 상태를 uploading으로 변경 (중복 호출 방지)
|
||||
song.status = "uploading"
|
||||
song.suno_audio_id = first_clip.get('id')
|
||||
song.suno_audio_id = first_clip.get("id")
|
||||
await session.commit()
|
||||
logger.info(f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}")
|
||||
logger.info(
|
||||
f"[get_song_status] Song status changed to uploading - song_id: {suno_task_id}"
|
||||
)
|
||||
|
||||
# 백그라운드 태스크로 MP3 다운로드 및 Blob 업로드 실행
|
||||
background_tasks.add_task(
|
||||
download_and_upload_song_by_suno_task_id,
|
||||
suno_task_id=suno_task_id,
|
||||
suno_task_id=song_id,
|
||||
audio_url=audio_url,
|
||||
store_name=store_name,
|
||||
duration=clip_duration,
|
||||
)
|
||||
logger.info(f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}")
|
||||
logger.info(
|
||||
f"[get_song_status] Background task scheduled - song_id: {suno_task_id}, store_name: {store_name}"
|
||||
)
|
||||
|
||||
suno_audio_id = first_clip.get('id')
|
||||
word_data = await suno_service.get_lyric_timestamp(suno_task_id, suno_audio_id)
|
||||
suno_audio_id = first_clip.get("id")
|
||||
word_data = await suno_service.get_lyric_timestamp(
|
||||
suno_task_id, suno_audio_id
|
||||
)
|
||||
logger.debug(
|
||||
f"[get_song_status] word_data from get_lyric_timestamp - "
|
||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||
f"word_data: {word_data}"
|
||||
)
|
||||
lyric_result = await session.execute(
|
||||
select(Lyric)
|
||||
.where(Lyric.task_id == song.task_id)
|
||||
|
|
@ -413,28 +445,74 @@ async def get_song_status(
|
|||
lyric = lyric_result.scalar_one_or_none()
|
||||
gt_lyric = lyric.lyric_result
|
||||
lyric_line_list = gt_lyric.split("\n")
|
||||
sentences = [lyric_line.strip(',. ') for lyric_line in lyric_line_list if lyric_line and lyric_line != "---"]
|
||||
sentences = [
|
||||
lyric_line.strip(",. ")
|
||||
for lyric_line in lyric_line_list
|
||||
if lyric_line and lyric_line != "---"
|
||||
]
|
||||
logger.debug(
|
||||
f"[get_song_status] sentences from lyric - "
|
||||
f"sentences: {sentences}"
|
||||
)
|
||||
|
||||
timestamped_lyrics = suno_service.align_lyrics(
|
||||
word_data, sentences
|
||||
)
|
||||
logger.debug(
|
||||
f"[get_song_status] sentences from lyric - "
|
||||
f"sentences: {sentences}"
|
||||
)
|
||||
|
||||
timestamped_lyrics = suno_service.align_lyrics(word_data, sentences)
|
||||
# TODO : DB upload timestamped_lyrics
|
||||
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
|
||||
for order_idx, timestamped_lyric in enumerate(
|
||||
timestamped_lyrics
|
||||
):
|
||||
song_timestamp = SongTimestamp(
|
||||
suno_audio_id = suno_audio_id,
|
||||
order_idx = order_idx,
|
||||
lyric_line = timestamped_lyric["text"],
|
||||
start_time = timestamped_lyric["start_sec"],
|
||||
end_time = timestamped_lyric["end_sec"]
|
||||
suno_audio_id=suno_audio_id,
|
||||
order_idx=order_idx,
|
||||
lyric_line=timestamped_lyric["text"],
|
||||
start_time=timestamped_lyric["start_sec"],
|
||||
end_time=timestamped_lyric["end_sec"],
|
||||
)
|
||||
session.add(song_timestamp)
|
||||
|
||||
await session.commit()
|
||||
parsed_response.status = "processing"
|
||||
|
||||
elif song and song.status == "uploading":
|
||||
logger.info(f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}")
|
||||
logger.info(
|
||||
f"[get_song_status] SKIPPED - Song is already uploading, song_id: {suno_task_id}"
|
||||
)
|
||||
parsed_response.status = "uploading"
|
||||
elif song and song.status == "completed":
|
||||
logger.info(f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}")
|
||||
logger.info(
|
||||
f"[get_song_status] SKIPPED - Song already completed, song_id: {suno_task_id}"
|
||||
)
|
||||
parsed_response.song_result_url = song.song_result_url
|
||||
else:
|
||||
# audio_url이 없는 경우 에러 반환
|
||||
logger.error(
|
||||
f"[get_song_status] ERROR - audio_url not found in clips_data, song_id: {suno_task_id}"
|
||||
)
|
||||
return PollingSongResponse(
|
||||
success=False,
|
||||
status="error",
|
||||
message="Suno API 응답에서 audio_url을 찾을 수 없습니다.",
|
||||
error_message="audio_url not found in Suno API response",
|
||||
)
|
||||
else:
|
||||
# clips_data가 없는 경우 에러 반환
|
||||
logger.error(
|
||||
f"[get_song_status] ERROR - clips_data not found, song_id: {suno_task_id}"
|
||||
)
|
||||
return PollingSongResponse(
|
||||
success=False,
|
||||
status="error",
|
||||
message="Suno API 응답에서 클립 데이터를 찾을 수 없습니다.",
|
||||
error_message="clips_data not found in Suno API response",
|
||||
)
|
||||
|
||||
logger.info(f"[get_song_status] SUCCESS - song_id: {suno_task_id}")
|
||||
logger.info(f"[get_song_status] END - song_id: {suno_task_id}")
|
||||
return parsed_response
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -447,261 +525,3 @@ async def get_song_status(
|
|||
message="상태 조회에 실패했습니다.",
|
||||
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/download/{task_id}",
|
||||
summary="노래 다운로드 상태 조회 (DB Polling)",
|
||||
description="""
|
||||
task_id를 기반으로 Song 테이블의 상태를 조회하고,
|
||||
completed인 경우 Project 정보와 노래 URL을 반환합니다.
|
||||
|
||||
## 경로 파라미터
|
||||
- **task_id**: 프로젝트 task_id (필수)
|
||||
|
||||
## 반환 정보
|
||||
- **success**: 조회 성공 여부
|
||||
- **status**: DB 처리 상태 (processing, uploading, completed, failed, not_found, error)
|
||||
- **message**: 응답 메시지
|
||||
- **store_name**: 업체명 (completed 시)
|
||||
- **region**: 지역명 (completed 시)
|
||||
- **detail_region_info**: 상세 지역 정보 (completed 시)
|
||||
- **task_id**: 작업 고유 식별자
|
||||
- **language**: 언어 (completed 시)
|
||||
- **song_result_url**: 노래 결과 URL (completed 시, Azure Blob Storage URL)
|
||||
- **created_at**: 생성 일시 (completed 시)
|
||||
- **error_message**: 에러 메시지 (실패 시)
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /song/download/019123ab-cdef-7890-abcd-ef1234567890
|
||||
```
|
||||
|
||||
## 상태 값 (DB 상태)
|
||||
- **processing**: Suno API에서 노래 생성 중
|
||||
- **uploading**: MP3 다운로드 및 Azure Blob 업로드 중
|
||||
- **completed**: 모든 작업 완료, Blob URL 사용 가능
|
||||
- **failed**: 노래 생성 또는 업로드 실패
|
||||
- **not_found**: task_id에 해당하는 Song 없음
|
||||
- **error**: 조회 중 오류 발생
|
||||
|
||||
## 참고
|
||||
- 이 엔드포인트는 DB의 Song 테이블 상태를 반환합니다
|
||||
- completed 상태인 경우 Project 정보와 함께 song_result_url (Azure Blob URL)을 반환합니다
|
||||
- song_result_url 형식: {AZURE_BLOB_BASE_URL}/{task_id}/song/{store_name}.mp3
|
||||
""",
|
||||
response_model=DownloadSongResponse,
|
||||
responses={
|
||||
200: {"description": "조회 성공 (모든 상태에서 200 반환)"},
|
||||
},
|
||||
)
|
||||
async def download_song(
|
||||
task_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DownloadSongResponse:
|
||||
"""task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다."""
|
||||
logger.info(f"[download_song] START - task_id: {task_id}")
|
||||
try:
|
||||
# task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택)
|
||||
song_result = await session.execute(
|
||||
select(Song)
|
||||
.where(Song.task_id == task_id)
|
||||
.order_by(Song.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
song = song_result.scalar_one_or_none()
|
||||
|
||||
if not song:
|
||||
logger.warning(f"[download_song] Song NOT FOUND - task_id: {task_id}")
|
||||
return DownloadSongResponse(
|
||||
success=False,
|
||||
status="not_found",
|
||||
message=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
|
||||
error_message="Song not found",
|
||||
)
|
||||
|
||||
logger.info(f"[download_song] Song found - task_id: {task_id}, status: {song.status}")
|
||||
|
||||
# processing 상태인 경우
|
||||
if song.status == "processing":
|
||||
logger.info(f"[download_song] PROCESSING - task_id: {task_id}")
|
||||
return DownloadSongResponse(
|
||||
success=True,
|
||||
status="processing",
|
||||
message="노래 생성이 진행 중입니다.",
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# uploading 상태인 경우
|
||||
if song.status == "uploading":
|
||||
logger.info(f"[download_song] UPLOADING - task_id: {task_id}")
|
||||
return DownloadSongResponse(
|
||||
success=True,
|
||||
status="uploading",
|
||||
message="노래 파일을 업로드 중입니다.",
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# failed 상태인 경우
|
||||
if song.status == "failed":
|
||||
logger.warning(f"[download_song] FAILED - task_id: {task_id}")
|
||||
return DownloadSongResponse(
|
||||
success=False,
|
||||
status="failed",
|
||||
message="노래 생성에 실패했습니다.",
|
||||
task_id=task_id,
|
||||
error_message="Song generation failed",
|
||||
)
|
||||
|
||||
# completed 상태인 경우 - Project 정보 조회
|
||||
project_result = await session.execute(
|
||||
select(Project).where(Project.id == song.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
|
||||
logger.info(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}")
|
||||
return DownloadSongResponse(
|
||||
success=True,
|
||||
status="completed",
|
||||
message="노래 다운로드가 완료되었습니다.",
|
||||
store_name=project.store_name if project else None,
|
||||
region=project.region if project else None,
|
||||
detail_region_info=project.detail_region_info if project else None,
|
||||
task_id=task_id,
|
||||
language=project.language if project else None,
|
||||
song_result_url=song.song_result_url,
|
||||
created_at=song.created_at,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
return DownloadSongResponse(
|
||||
success=False,
|
||||
status="error",
|
||||
message="노래 다운로드 조회에 실패했습니다.",
|
||||
error_message=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"s/",
|
||||
summary="생성된 노래 목록 조회",
|
||||
description="""
|
||||
완료된 노래 목록을 페이지네이션하여 조회합니다.
|
||||
|
||||
## 쿼리 파라미터
|
||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
||||
|
||||
## 반환 정보
|
||||
- **items**: 노래 목록 (store_name, region, task_id, language, song_result_url, created_at)
|
||||
- **total**: 전체 데이터 수
|
||||
- **page**: 현재 페이지
|
||||
- **page_size**: 페이지당 데이터 수
|
||||
- **total_pages**: 전체 페이지 수
|
||||
- **has_next**: 다음 페이지 존재 여부
|
||||
- **has_prev**: 이전 페이지 존재 여부
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
GET /songs/?page=1&page_size=10
|
||||
```
|
||||
|
||||
## 참고
|
||||
- status가 'completed'인 노래만 반환됩니다.
|
||||
- created_at 기준 내림차순 정렬됩니다.
|
||||
""",
|
||||
response_model=PaginatedResponse[SongListItem],
|
||||
responses={
|
||||
200: {"description": "노래 목록 조회 성공"},
|
||||
},
|
||||
)
|
||||
async def get_songs(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> PaginatedResponse[SongListItem]:
|
||||
"""완료된 노래 목록을 페이지네이션하여 반환합니다."""
|
||||
logger.info(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}")
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
# 서브쿼리: task_id별 최신 Song의 id 조회 (completed 상태, created_at 기준)
|
||||
from sqlalchemy import and_
|
||||
|
||||
# task_id별 최신 created_at 조회
|
||||
latest_subquery = (
|
||||
select(
|
||||
Song.task_id,
|
||||
func.max(Song.created_at).label("max_created_at")
|
||||
)
|
||||
.where(Song.status == "completed")
|
||||
.group_by(Song.task_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# 전체 개수 조회 (task_id별 최신 1개만)
|
||||
count_query = select(func.count()).select_from(latest_subquery)
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 데이터 조회 (completed 상태, task_id별 created_at 기준 최신 1개만, 최신순)
|
||||
query = (
|
||||
select(Song)
|
||||
.join(
|
||||
latest_subquery,
|
||||
and_(
|
||||
Song.task_id == latest_subquery.c.task_id,
|
||||
Song.created_at == latest_subquery.c.max_created_at
|
||||
)
|
||||
)
|
||||
.where(Song.status == "completed")
|
||||
.order_by(Song.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(pagination.page_size)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
songs = result.scalars().all()
|
||||
|
||||
# Project 정보 일괄 조회 (N+1 문제 해결)
|
||||
project_ids = [s.project_id for s in songs if s.project_id]
|
||||
projects_map: dict = {}
|
||||
if project_ids:
|
||||
projects_result = await session.execute(
|
||||
select(Project).where(Project.id.in_(project_ids))
|
||||
)
|
||||
projects_map = {p.id: p for p in projects_result.scalars().all()}
|
||||
|
||||
# SongListItem으로 변환
|
||||
items = []
|
||||
for song in songs:
|
||||
project = projects_map.get(song.project_id)
|
||||
|
||||
item = SongListItem(
|
||||
store_name=project.store_name if project else None,
|
||||
region=project.region if project else None,
|
||||
task_id=song.task_id,
|
||||
language=song.language,
|
||||
song_result_url=song.song_result_url,
|
||||
created_at=song.created_at,
|
||||
)
|
||||
items.append(item)
|
||||
|
||||
response = PaginatedResponse.create(
|
||||
items=items,
|
||||
total=total,
|
||||
page=pagination.page,
|
||||
page_size=pagination.page_size,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, "
|
||||
f"page_size: {pagination.page_size}, items_count: {len(items)}"
|
||||
)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[get_songs] EXCEPTION - error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"노래 목록 조회에 실패했습니다: {str(e)}",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.song.models import Song
|
||||
from app.song.models import Song, SongTimestamp
|
||||
|
||||
|
||||
class SongAdmin(ModelView, model=Song):
|
||||
|
|
@ -67,3 +67,59 @@ class SongAdmin(ModelView, model=Song):
|
|||
"song_result_url": "결과 URL",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
||||
|
||||
class SongTimestampAdmin(ModelView, model=SongTimestamp):
|
||||
name = "노래 타임스탬프"
|
||||
name_plural = "노래 타임스탬프 목록"
|
||||
icon = "fa-solid fa-clock"
|
||||
category = "노래 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"suno_audio_id",
|
||||
"order_idx",
|
||||
"lyric_line",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"suno_audio_id",
|
||||
"order_idx",
|
||||
"lyric_line",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at"]
|
||||
|
||||
column_searchable_list = [
|
||||
SongTimestamp.suno_audio_id,
|
||||
SongTimestamp.lyric_line,
|
||||
]
|
||||
|
||||
column_default_sort = (SongTimestamp.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SongTimestamp.id,
|
||||
SongTimestamp.suno_audio_id,
|
||||
SongTimestamp.order_idx,
|
||||
SongTimestamp.start_time,
|
||||
SongTimestamp.end_time,
|
||||
SongTimestamp.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"suno_audio_id": "Suno 오디오 ID",
|
||||
"order_idx": "순서",
|
||||
"lyric_line": "가사",
|
||||
"start_time": "시작 시간",
|
||||
"end_time": "종료 시간",
|
||||
"created_at": "생성일시",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,9 +79,30 @@ class GenerateSongResponse(BaseModel):
|
|||
}
|
||||
"""
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"success": True,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"song_id": "abc123...",
|
||||
"message": "노래 생성 요청이 접수되었습니다. song_id로 상태를 조회하세요.",
|
||||
"error_message": None,
|
||||
},
|
||||
{
|
||||
"success": False,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"song_id": None,
|
||||
"message": "노래 생성 요청에 실패했습니다.",
|
||||
"error_message": "Suno API connection error",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
success: bool = Field(..., description="요청 성공 여부")
|
||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project/Lyric task_id)")
|
||||
song_id: Optional[str] = Field(None, description="Suno API 작업 ID")
|
||||
song_id: Optional[str] = Field(None, description="Suno API 작업 ID (상태 조회에 사용)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
|
@ -124,6 +145,7 @@ class PollingSongResponse(BaseModel):
|
|||
상태 값 (Suno API 응답):
|
||||
- PENDING: Suno API 대기 중
|
||||
- processing: Suno API에서 노래 생성 중
|
||||
- uploading: MP3 다운로드 및 Azure Blob 업로드 중
|
||||
- SUCCESS: Suno API 노래 생성 완료 (백그라운드 Blob 업로드 시작)
|
||||
- TEXT_SUCCESS: Suno API 노래 생성 완료
|
||||
- failed: Suno API 노래 생성 실패
|
||||
|
|
@ -133,13 +155,15 @@ class PollingSongResponse(BaseModel):
|
|||
- 백그라운드에서 MP3 파일 다운로드 및 Azure Blob 업로드 시작
|
||||
- Song 테이블의 status가 uploading으로 변경
|
||||
- 업로드 완료 시 status가 completed로 변경, song_result_url에 Blob URL 저장
|
||||
- completed 상태인 경우 song_result_url 반환
|
||||
|
||||
Example Response (Pending):
|
||||
{
|
||||
"success": true,
|
||||
"status": "PENDING",
|
||||
"message": "노래 생성 대기 중입니다.",
|
||||
"error_message": null
|
||||
"error_message": null,
|
||||
"song_result_url": null
|
||||
}
|
||||
|
||||
Example Response (Processing):
|
||||
|
|
@ -147,15 +171,26 @@ class PollingSongResponse(BaseModel):
|
|||
"success": true,
|
||||
"status": "processing",
|
||||
"message": "노래를 생성하고 있습니다.",
|
||||
"error_message": null
|
||||
"error_message": null,
|
||||
"song_result_url": null
|
||||
}
|
||||
|
||||
Example Response (Success):
|
||||
Example Response (Uploading):
|
||||
{
|
||||
"success": true,
|
||||
"status": "uploading",
|
||||
"message": "노래 생성이 완료되었습니다.",
|
||||
"error_message": null,
|
||||
"song_result_url": null
|
||||
}
|
||||
|
||||
Example Response (Success - Completed):
|
||||
{
|
||||
"success": true,
|
||||
"status": "SUCCESS",
|
||||
"message": "노래 생성이 완료되었습니다.",
|
||||
"error_message": null
|
||||
"error_message": null,
|
||||
"song_result_url": "https://blob.azure.com/.../song.mp3"
|
||||
}
|
||||
|
||||
Example Response (Failure):
|
||||
|
|
@ -163,131 +198,42 @@ class PollingSongResponse(BaseModel):
|
|||
"success": false,
|
||||
"status": "error",
|
||||
"message": "상태 조회에 실패했습니다.",
|
||||
"error_message": "ConnectionError: ..."
|
||||
"error_message": "ConnectionError: ...",
|
||||
"song_result_url": null
|
||||
}
|
||||
"""
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"success": True,
|
||||
"status": "processing",
|
||||
"message": "노래를 생성하고 있습니다.",
|
||||
"error_message": None,
|
||||
"song_result_url": None,
|
||||
},
|
||||
{
|
||||
"success": True,
|
||||
"status": "SUCCESS",
|
||||
"message": "노래 생성이 완료되었습니다.",
|
||||
"error_message": None,
|
||||
"song_result_url": "https://blob.azure.com/.../song.mp3",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: Optional[str] = Field(
|
||||
None, description="Suno API 작업 상태 (PENDING, processing, SUCCESS, TEXT_SUCCESS, failed, error)"
|
||||
None,
|
||||
description="작업 상태 (PENDING, processing, uploading, SUCCESS, TEXT_SUCCESS, failed, error)",
|
||||
)
|
||||
message: str = Field(..., description="상태 메시지")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
|
||||
|
||||
class SongListItem(BaseModel):
|
||||
"""노래 목록 아이템 스키마
|
||||
|
||||
Usage:
|
||||
GET /songs 응답의 개별 노래 정보
|
||||
|
||||
Example:
|
||||
{
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": "Korean",
|
||||
"song_result_url": "http://localhost:8000/media/2025-01-15/스테이머뭄.mp3",
|
||||
"created_at": "2025-01-15T12:00:00"
|
||||
}
|
||||
"""
|
||||
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
task_id: str = Field(..., description="작업 고유 식별자")
|
||||
language: Optional[str] = Field(None, description="언어")
|
||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
|
||||
|
||||
class DownloadSongResponse(BaseModel):
|
||||
"""노래 다운로드 응답 스키마 (DB Polling)
|
||||
|
||||
Usage:
|
||||
GET /song/download/{task_id}
|
||||
DB의 Song 테이블 상태를 조회하고 완료 시 Project 정보와 노래 URL을 반환합니다.
|
||||
|
||||
Note:
|
||||
상태 값 (DB 상태):
|
||||
- processing: Suno API에서 노래 생성 중 (song_result_url은 null)
|
||||
- uploading: MP3 다운로드 및 Azure Blob 업로드 중 (song_result_url은 null)
|
||||
- completed: 모든 작업 완료 (song_result_url에 Azure Blob URL 포함)
|
||||
- failed: 노래 생성 또는 업로드 실패
|
||||
- not_found: task_id에 해당하는 Song 없음
|
||||
- error: 조회 중 오류 발생
|
||||
|
||||
Example Response (Processing):
|
||||
{
|
||||
"success": true,
|
||||
"status": "processing",
|
||||
"message": "노래 생성이 진행 중입니다.",
|
||||
"store_name": null,
|
||||
"region": null,
|
||||
"detail_region_info": null,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": null,
|
||||
"song_result_url": null,
|
||||
"created_at": null,
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Uploading):
|
||||
{
|
||||
"success": true,
|
||||
"status": "uploading",
|
||||
"message": "노래 파일을 업로드 중입니다.",
|
||||
"store_name": null,
|
||||
"region": null,
|
||||
"detail_region_info": null,
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": null,
|
||||
"song_result_url": null,
|
||||
"created_at": null,
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Completed):
|
||||
{
|
||||
"success": true,
|
||||
"status": "completed",
|
||||
"message": "노래 다운로드가 완료되었습니다.",
|
||||
"store_name": "스테이 머뭄",
|
||||
"region": "군산",
|
||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||
"language": "Korean",
|
||||
"song_result_url": "https://blob.azure.com/.../song.mp3",
|
||||
"created_at": "2025-01-15T12:00:00",
|
||||
"error_message": null
|
||||
}
|
||||
|
||||
Example Response (Not Found):
|
||||
{
|
||||
"success": false,
|
||||
"status": "not_found",
|
||||
"message": "task_id 'xxx'에 해당하는 Song을 찾을 수 없습니다.",
|
||||
"store_name": null,
|
||||
"region": null,
|
||||
"detail_region_info": null,
|
||||
"task_id": null,
|
||||
"language": null,
|
||||
"song_result_url": null,
|
||||
"created_at": null,
|
||||
"error_message": "Song not found"
|
||||
}
|
||||
"""
|
||||
|
||||
success: bool = Field(..., description="조회 성공 여부")
|
||||
status: str = Field(..., description="DB 처리 상태 (processing, uploading, completed, failed, not_found, error)")
|
||||
message: str = Field(..., description="응답 메시지")
|
||||
store_name: Optional[str] = Field(None, description="업체명")
|
||||
region: Optional[str] = Field(None, description="지역명")
|
||||
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
|
||||
task_id: Optional[str] = Field(None, description="작업 고유 식별자")
|
||||
language: Optional[str] = Field(None, description="언어")
|
||||
song_result_url: Optional[str] = Field(None, description="노래 결과 URL")
|
||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
|
||||
song_result_url: Optional[str] = Field(
|
||||
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -78,7 +78,9 @@ async def kakao_callback(
|
|||
if forwarded_for:
|
||||
ip_address = forwarded_for.split(",")[0].strip()
|
||||
|
||||
logger.debug(f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}")
|
||||
logger.debug(
|
||||
f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}"
|
||||
)
|
||||
|
||||
result = await auth_service.kakao_login(
|
||||
code=code,
|
||||
|
|
@ -89,11 +91,13 @@ async def kakao_callback(
|
|||
|
||||
# 프론트엔드로 토큰과 함께 리다이렉트
|
||||
redirect_url = (
|
||||
f"https://{prj_settings.PROJECT_DOMAIN}"
|
||||
f"{prj_settings.PROJECT_DOMAIN}"
|
||||
f"?access_token={result.access_token}"
|
||||
f"&refresh_token={result.refresh_token}"
|
||||
)
|
||||
logger.info(f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}...")
|
||||
logger.info(
|
||||
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
|
||||
)
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
|
||||
|
||||
|
|
@ -139,7 +143,9 @@ async def kakao_verify(
|
|||
if forwarded_for:
|
||||
ip_address = forwarded_for.split(",")[0].strip()
|
||||
|
||||
logger.debug(f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}")
|
||||
logger.debug(
|
||||
f"[ROUTER] 클라이언트 정보 - ip: {ip_address}, user_agent: {user_agent}"
|
||||
)
|
||||
|
||||
result = await auth_service.kakao_login(
|
||||
code=body.code,
|
||||
|
|
@ -148,7 +154,9 @@ async def kakao_verify(
|
|||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}")
|
||||
logger.info(
|
||||
f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
from sqladmin import ModelView
|
||||
|
||||
from app.user.models import RefreshToken, SocialAccount, User
|
||||
|
||||
|
||||
class UserAdmin(ModelView, model=User):
|
||||
name = "사용자"
|
||||
name_plural = "사용자 목록"
|
||||
icon = "fa-solid fa-user"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"kakao_id",
|
||||
"email",
|
||||
"nickname",
|
||||
"role",
|
||||
"is_active",
|
||||
"is_deleted",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"kakao_id",
|
||||
"email",
|
||||
"nickname",
|
||||
"profile_image_url",
|
||||
"thumbnail_image_url",
|
||||
"phone",
|
||||
"name",
|
||||
"birth_date",
|
||||
"gender",
|
||||
"is_active",
|
||||
"is_admin",
|
||||
"role",
|
||||
"is_deleted",
|
||||
"deleted_at",
|
||||
"last_login_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user_projects",
|
||||
"refresh_tokens",
|
||||
"social_accounts",
|
||||
]
|
||||
|
||||
column_searchable_list = [
|
||||
User.kakao_id,
|
||||
User.email,
|
||||
User.nickname,
|
||||
User.phone,
|
||||
User.name,
|
||||
]
|
||||
|
||||
column_default_sort = (User.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
User.id,
|
||||
User.kakao_id,
|
||||
User.email,
|
||||
User.nickname,
|
||||
User.role,
|
||||
User.is_active,
|
||||
User.is_deleted,
|
||||
User.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"kakao_id": "카카오 ID",
|
||||
"email": "이메일",
|
||||
"nickname": "닉네임",
|
||||
"profile_image_url": "프로필 이미지",
|
||||
"thumbnail_image_url": "썸네일 이미지",
|
||||
"phone": "전화번호",
|
||||
"name": "실명",
|
||||
"birth_date": "생년월일",
|
||||
"gender": "성별",
|
||||
"is_active": "활성화",
|
||||
"is_admin": "관리자",
|
||||
"role": "권한",
|
||||
"is_deleted": "삭제됨",
|
||||
"deleted_at": "삭제일시",
|
||||
"last_login_at": "마지막 로그인",
|
||||
"created_at": "생성일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
||||
|
||||
class RefreshTokenAdmin(ModelView, model=RefreshToken):
|
||||
name = "리프레시 토큰"
|
||||
name_plural = "리프레시 토큰 목록"
|
||||
icon = "fa-solid fa-key"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"is_revoked",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"token_hash",
|
||||
"expires_at",
|
||||
"is_revoked",
|
||||
"created_at",
|
||||
"revoked_at",
|
||||
"user_agent",
|
||||
"ip_address",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["created_at", "user"]
|
||||
|
||||
column_searchable_list = [
|
||||
RefreshToken.user_id,
|
||||
RefreshToken.token_hash,
|
||||
RefreshToken.ip_address,
|
||||
]
|
||||
|
||||
column_default_sort = (RefreshToken.created_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
RefreshToken.id,
|
||||
RefreshToken.user_id,
|
||||
RefreshToken.is_revoked,
|
||||
RefreshToken.expires_at,
|
||||
RefreshToken.created_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"token_hash": "토큰 해시",
|
||||
"expires_at": "만료일시",
|
||||
"is_revoked": "폐기됨",
|
||||
"created_at": "생성일시",
|
||||
"revoked_at": "폐기일시",
|
||||
"user_agent": "User Agent",
|
||||
"ip_address": "IP 주소",
|
||||
}
|
||||
|
||||
|
||||
class SocialAccountAdmin(ModelView, model=SocialAccount):
|
||||
name = "소셜 계정"
|
||||
name_plural = "소셜 계정 목록"
|
||||
icon = "fa-solid fa-share-nodes"
|
||||
category = "사용자 관리"
|
||||
page_size = 20
|
||||
|
||||
column_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_username",
|
||||
"is_active",
|
||||
"connected_at",
|
||||
]
|
||||
|
||||
column_details_list = [
|
||||
"id",
|
||||
"user_id",
|
||||
"platform",
|
||||
"platform_user_id",
|
||||
"platform_username",
|
||||
"platform_data",
|
||||
"scope",
|
||||
"token_expires_at",
|
||||
"is_active",
|
||||
"connected_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
form_excluded_columns = ["connected_at", "updated_at", "user"]
|
||||
|
||||
column_searchable_list = [
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.platform_user_id,
|
||||
SocialAccount.platform_username,
|
||||
]
|
||||
|
||||
column_default_sort = (SocialAccount.connected_at, True)
|
||||
|
||||
column_sortable_list = [
|
||||
SocialAccount.id,
|
||||
SocialAccount.user_id,
|
||||
SocialAccount.platform,
|
||||
SocialAccount.is_active,
|
||||
SocialAccount.connected_at,
|
||||
]
|
||||
|
||||
column_labels = {
|
||||
"id": "ID",
|
||||
"user_id": "사용자 ID",
|
||||
"platform": "플랫폼",
|
||||
"platform_user_id": "플랫폼 사용자 ID",
|
||||
"platform_username": "플랫폼 사용자명",
|
||||
"platform_data": "플랫폼 데이터",
|
||||
"scope": "권한 범위",
|
||||
"token_expires_at": "토큰 만료일시",
|
||||
"is_active": "활성화",
|
||||
"connected_at": "연동일시",
|
||||
"updated_at": "수정일시",
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ from datetime import datetime, timezone
|
|||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import prj_settings
|
||||
|
|
@ -114,7 +115,7 @@ class AuthService:
|
|||
user.last_login_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
redirect_url = f"https://{prj_settings.PROJECT_DOMAIN}"
|
||||
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
|
||||
logger.info(f"[AUTH] 카카오 로그인 완료 - user_id: {user.id}, redirect_url: {redirect_url}")
|
||||
logger.debug(f"[AUTH] 응답 토큰 정보 - access_token: {access_token[:30]}..., refresh_token: {refresh_token[:30]}...")
|
||||
|
||||
|
|
@ -276,10 +277,44 @@ class AuthService:
|
|||
thumbnail_image_url=profile.thumbnail_image_url if profile else None,
|
||||
)
|
||||
session.add(new_user)
|
||||
|
||||
try:
|
||||
await session.flush()
|
||||
await session.refresh(new_user)
|
||||
logger.info(f"[AUTH] 신규 사용자 생성 완료 - user_id: {new_user.id}, is_new_user: True")
|
||||
return new_user, True
|
||||
except IntegrityError:
|
||||
# 동시 요청으로 인한 중복 삽입 시도 - 기존 사용자 조회
|
||||
logger.warning(
|
||||
f"[AUTH] IntegrityError 발생 (동시 요청 추정) - kakao_id: {kakao_id}, "
|
||||
"기존 사용자 재조회 시도"
|
||||
)
|
||||
await session.rollback()
|
||||
result = await session.execute(
|
||||
select(User).where(User.kakao_id == kakao_id)
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user is not None:
|
||||
logger.info(
|
||||
f"[AUTH] 기존 사용자 재조회 성공 - user_id: {existing_user.id}, "
|
||||
"is_new_user: False"
|
||||
)
|
||||
# 프로필 정보 업데이트
|
||||
if profile:
|
||||
existing_user.nickname = profile.nickname
|
||||
existing_user.profile_image_url = profile.profile_image_url
|
||||
existing_user.thumbnail_image_url = profile.thumbnail_image_url
|
||||
if kakao_account and kakao_account.email:
|
||||
existing_user.email = kakao_account.email
|
||||
await session.flush()
|
||||
return existing_user, False
|
||||
|
||||
# 재조회에도 실패한 경우 (매우 드문 경우)
|
||||
logger.error(
|
||||
f"[AUTH] IntegrityError 후 재조회 실패 - kakao_id: {kakao_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
async def _save_refresh_token(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1,36 +1,82 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
from pydantic import BaseModel
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings
|
||||
from config import apikey_settings, recovery_settings
|
||||
from app.utils.prompts.prompts import Prompt
|
||||
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("chatgpt")
|
||||
|
||||
# fmt: on
|
||||
|
||||
class ChatGPTResponseError(Exception):
|
||||
"""ChatGPT API 응답 에러"""
|
||||
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
||||
self.status = status
|
||||
self.error_code = error_code
|
||||
self.error_message = error_message
|
||||
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
||||
|
||||
|
||||
class ChatgptService:
|
||||
"""ChatGPT API 서비스 클래스
|
||||
|
||||
GPT 5.0 모델을 사용하여 마케팅 가사 및 분석을 생성합니다.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||
def __init__(self, timeout: float = None):
|
||||
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
||||
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||
timeout=self.timeout
|
||||
)
|
||||
|
||||
async def _call_structured_output_with_response_gpt_api(self, prompt: str, output_format : dict, model:str) -> dict:
|
||||
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||
content = [{"type": "input_text", "text": prompt}]
|
||||
response = await self.client.responses.create(
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries + 1):
|
||||
response = await self.client.responses.parse(
|
||||
model=model,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text = output_format
|
||||
text_format=output_format
|
||||
)
|
||||
structured_output = json.loads(response.output_text)
|
||||
return structured_output or {}
|
||||
# Response 디버그 로깅
|
||||
logger.debug(f"[ChatgptService] Response ID: {response.id}")
|
||||
logger.debug(f"[ChatgptService] Response status: {response.status}")
|
||||
logger.debug(f"[ChatgptService] Response model: {response.model}")
|
||||
|
||||
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
||||
if response.status == "completed":
|
||||
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
||||
structured_output = response.output_parsed
|
||||
return structured_output.model_dump() or {}
|
||||
|
||||
# 에러 상태 처리
|
||||
if response.status == "failed":
|
||||
error_code = getattr(response.error, 'code', None) if response.error else None
|
||||
error_message = getattr(response.error, 'message', None) if response.error else None
|
||||
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
||||
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
||||
|
||||
elif response.status == "incomplete":
|
||||
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
||||
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
||||
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
||||
|
||||
else:
|
||||
# cancelled, queued, in_progress 등 예상치 못한 상태
|
||||
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
||||
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < self.max_retries:
|
||||
logger.info(f"[ChatgptService] Retrying request...")
|
||||
|
||||
# 모든 재시도 실패
|
||||
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
|
||||
raise last_error
|
||||
|
||||
async def generate_structured_output(
|
||||
self,
|
||||
|
|
@ -43,5 +89,6 @@ class ChatgptService:
|
|||
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||
|
||||
# GPT API 호출
|
||||
response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -36,12 +36,29 @@ from typing import Literal
|
|||
import httpx
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings, creatomate_settings
|
||||
from config import apikey_settings, creatomate_settings, recovery_settings
|
||||
|
||||
# 로거 설정
|
||||
logger = get_logger("creatomate")
|
||||
|
||||
|
||||
class CreatomateResponseError(Exception):
|
||||
"""Creatomate API 응답 오류 시 발생하는 예외
|
||||
|
||||
Creatomate API 렌더링 실패 또는 비정상 응답 시 사용됩니다.
|
||||
재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다.
|
||||
|
||||
Attributes:
|
||||
message: 에러 메시지
|
||||
original_response: 원본 API 응답 (있는 경우)
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, original_response: dict | None = None):
|
||||
self.message = message
|
||||
self.original_response = original_response
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
# Orientation 타입 정의
|
||||
OrientationType = Literal["horizontal", "vertical"]
|
||||
|
||||
|
|
@ -135,7 +152,10 @@ async def get_shared_client() -> httpx.AsyncClient:
|
|||
global _shared_client
|
||||
if _shared_client is None or _shared_client.is_closed:
|
||||
_shared_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
timeout=httpx.Timeout(
|
||||
recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
connect=recovery_settings.CREATOMATE_CONNECT_TIMEOUT,
|
||||
),
|
||||
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
|
||||
)
|
||||
return _shared_client
|
||||
|
|
@ -217,7 +237,7 @@ class CreatomateService:
|
|||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
timeout: float = 30.0,
|
||||
timeout: float | None = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""HTTP 요청을 수행합니다.
|
||||
|
|
@ -225,7 +245,7 @@ class CreatomateService:
|
|||
Args:
|
||||
method: HTTP 메서드 ("GET", "POST", etc.)
|
||||
url: 요청 URL
|
||||
timeout: 요청 타임아웃 (초)
|
||||
timeout: 요청 타임아웃 (초). None이면 기본값 사용
|
||||
**kwargs: httpx 요청에 전달할 추가 인자
|
||||
|
||||
Returns:
|
||||
|
|
@ -236,15 +256,18 @@ class CreatomateService:
|
|||
"""
|
||||
logger.info(f"[Creatomate] {method} {url}")
|
||||
|
||||
# timeout이 None이면 기본 타임아웃 사용
|
||||
actual_timeout = timeout if timeout is not None else recovery_settings.CREATOMATE_DEFAULT_TIMEOUT
|
||||
|
||||
client = await get_shared_client()
|
||||
|
||||
if method.upper() == "GET":
|
||||
response = await client.get(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
url, headers=self.headers, timeout=actual_timeout, **kwargs
|
||||
)
|
||||
elif method.upper() == "POST":
|
||||
response = await client.post(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
url, headers=self.headers, timeout=actual_timeout, **kwargs
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
|
@ -255,7 +278,7 @@ class CreatomateService:
|
|||
async def get_all_templates_data(self) -> dict:
|
||||
"""모든 템플릿 정보를 조회합니다."""
|
||||
url = f"{self.BASE_URL}/v1/templates"
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response = await self._request("GET", url) # 기본 타임아웃 사용
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
@ -288,7 +311,7 @@ class CreatomateService:
|
|||
|
||||
# API 호출
|
||||
url = f"{self.BASE_URL}/v1/templates/{template_id}"
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response = await self._request("GET", url) # 기본 타임아웃 사용
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
|
@ -433,31 +456,148 @@ class CreatomateService:
|
|||
async def make_creatomate_call(
|
||||
self, template_id: str, modifications: dict
|
||||
) -> dict:
|
||||
"""Creatomate에 렌더링 요청을 보냅니다.
|
||||
"""Creatomate에 렌더링 요청을 보냅니다 (재시도 로직 포함).
|
||||
|
||||
Args:
|
||||
template_id: Creatomate 템플릿 ID
|
||||
modifications: 수정사항 딕셔너리
|
||||
|
||||
Returns:
|
||||
Creatomate API 응답 데이터
|
||||
|
||||
Raises:
|
||||
CreatomateResponseError: API 오류 또는 재시도 실패 시
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
data = {
|
||||
payload = {
|
||||
"template_id": template_id,
|
||||
"modifications": modifications,
|
||||
}
|
||||
response = await self._request("POST", url, timeout=60.0, json=data)
|
||||
response.raise_for_status()
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||
try:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
url,
|
||||
timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
if 400 <= response.status_code < 500:
|
||||
raise CreatomateResponseError(
|
||||
f"Client error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
# 재시도 가능한 오류 (5xx 서버 오류)
|
||||
last_error = CreatomateResponseError(
|
||||
f"Server error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(
|
||||
f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}"
|
||||
)
|
||||
last_error = e
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}")
|
||||
last_error = e
|
||||
|
||||
except CreatomateResponseError:
|
||||
raise # CreatomateResponseError는 재시도하지 않고 즉시 전파
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < recovery_settings.CREATOMATE_MAX_RETRIES:
|
||||
logger.info(
|
||||
f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})"
|
||||
)
|
||||
|
||||
# 모든 재시도 실패
|
||||
raise CreatomateResponseError(
|
||||
f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed",
|
||||
original_response={"last_error": str(last_error)},
|
||||
)
|
||||
|
||||
async def make_creatomate_custom_call(self, source: dict) -> dict:
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다 (재시도 로직 포함).
|
||||
|
||||
Args:
|
||||
source: 렌더링 소스 딕셔너리
|
||||
|
||||
Returns:
|
||||
Creatomate API 응답 데이터
|
||||
|
||||
Raises:
|
||||
CreatomateResponseError: API 오류 또는 재시도 실패 시
|
||||
|
||||
Note:
|
||||
response에 요청 정보가 있으니 폴링 필요
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v2/renders"
|
||||
response = await self._request("POST", url, timeout=60.0, json=source)
|
||||
response.raise_for_status()
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||
try:
|
||||
response = await self._request(
|
||||
"POST",
|
||||
url,
|
||||
timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
json=source,
|
||||
)
|
||||
|
||||
if response.status_code == 200 or response.status_code == 201:
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
if 400 <= response.status_code < 500:
|
||||
raise CreatomateResponseError(
|
||||
f"Client error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
# 재시도 가능한 오류 (5xx 서버 오류)
|
||||
last_error = CreatomateResponseError(
|
||||
f"Server error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(
|
||||
f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}"
|
||||
)
|
||||
last_error = e
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}")
|
||||
last_error = e
|
||||
|
||||
except CreatomateResponseError:
|
||||
raise # CreatomateResponseError는 재시도하지 않고 즉시 전파
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < recovery_settings.CREATOMATE_MAX_RETRIES:
|
||||
logger.info(
|
||||
f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})"
|
||||
)
|
||||
|
||||
# 모든 재시도 실패
|
||||
raise CreatomateResponseError(
|
||||
f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed",
|
||||
original_response={"last_error": str(last_error)},
|
||||
)
|
||||
|
||||
# 하위 호환성을 위한 별칭 (deprecated)
|
||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
||||
|
|
@ -485,7 +625,7 @@ class CreatomateService:
|
|||
- failed: 실패
|
||||
"""
|
||||
url = f"{self.BASE_URL}/v1/renders/{render_id}"
|
||||
response = await self._request("GET", url, timeout=30.0)
|
||||
response = await self._request("GET", url) # 기본 타임아웃 사용
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
|
|
|||
|
|
@ -254,14 +254,12 @@ def setup_uvicorn_logging() -> dict:
|
|||
# dictConfig 버전 (필수, 항상 1)
|
||||
# --------------------------------------------------------
|
||||
"version": 1,
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 기존 로거 비활성화 여부
|
||||
# False: 기존 로거 유지 (권장)
|
||||
# True: 기존 로거 모두 비활성화
|
||||
# --------------------------------------------------------
|
||||
"disable_existing_loggers": False,
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 포맷터 정의
|
||||
# 로그 메시지의 출력 형식을 지정합니다.
|
||||
|
|
@ -276,12 +274,11 @@ def setup_uvicorn_logging() -> dict:
|
|||
# HTTP 요청 로그용 포맷터
|
||||
# 사용 가능한 변수: client_addr, request_line, status_code
|
||||
"access": {
|
||||
"format": "[{asctime}] {levelname:8} [{name}] {client_addr} - \"{request_line}\" {status_code}",
|
||||
"format": '[{asctime}] {levelname:8} [{name}] {client_addr} - "{request_line}" {status_code}',
|
||||
"datefmt": LoggerConfig.DATE_FORMAT,
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 핸들러 정의
|
||||
# 로그를 어디에 출력할지 지정합니다.
|
||||
|
|
@ -300,7 +297,6 @@ def setup_uvicorn_logging() -> dict:
|
|||
"stream": "ext://sys.stdout",
|
||||
},
|
||||
},
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 로거 정의
|
||||
# Uvicorn 내부에서 사용하는 로거들을 설정합니다.
|
||||
|
|
@ -309,19 +305,19 @@ def setup_uvicorn_logging() -> dict:
|
|||
# Uvicorn 메인 로거
|
||||
"uvicorn": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"level": "DEBUG",
|
||||
"propagate": False, # 상위 로거로 전파 방지
|
||||
},
|
||||
# 에러/시작/종료 로그
|
||||
"uvicorn.error": {
|
||||
"handlers": ["default"],
|
||||
"level": "INFO",
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
# HTTP 요청 로그 (GET /path HTTP/1.1 200 등)
|
||||
"uvicorn.access": {
|
||||
"handlers": ["access"],
|
||||
"level": "INFO",
|
||||
"level": "DEBUG",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import asyncio
|
|||
from playwright.async_api import async_playwright
|
||||
from urllib import parse
|
||||
|
||||
class nvMapPwScraper():
|
||||
class NvMapPwScraper():
|
||||
# cls vars
|
||||
is_ready = False
|
||||
_playwright = None
|
||||
|
|
@ -107,7 +107,7 @@ patchedGetter.toString();''')
|
|||
if "/place/" in self.page.url:
|
||||
return self.page.url
|
||||
|
||||
if (count == self._max_retry / 2):
|
||||
raise Exception("Failed to identify place id. loading timeout")
|
||||
else:
|
||||
raise Exception("Failed to identify place id. item is ambiguous")
|
||||
# if (count == self._max_retry / 2):
|
||||
# raise Exception("Failed to identify place id. loading timeout")
|
||||
# else:
|
||||
# raise Exception("Failed to identify place id. item is ambiguous")
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info",
|
||||
"marketing_intelligence_summary",
|
||||
"language",
|
||||
"promotional_expression_example",
|
||||
"timing_rules"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "lyric",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lyric": {
|
||||
"type": "string"
|
||||
},
|
||||
"suno_prompt":{
|
||||
"type" : "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"lyric", "suno_prompt"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
{
|
||||
"model": "gpt-5-mini",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail_title": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail_description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"detail_title",
|
||||
"detail_description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"details"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"contents_advise": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags",
|
||||
"contents_advise"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
|
||||
[Role & Objective]
|
||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
||||
|
||||
[INPUT]
|
||||
- Business Name: {customer_name}
|
||||
- Region: {region}
|
||||
- Region Details: {detail_region_info}
|
||||
|
||||
[Core Analysis Requirements]
|
||||
Analyze the property based on:
|
||||
Location, concept, photos, online presence, and nearby environment
|
||||
Target customer behavior and reservation decision factors
|
||||
Include:
|
||||
- Target customer segments & personas
|
||||
- Unique Selling Propositions (USPs)
|
||||
- Competitive landscape (direct & indirect competitors)
|
||||
- Market positioning
|
||||
|
||||
[Key Selling Point Structuring – UI Optimized]
|
||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
||||
Rules:
|
||||
Focus only on factors that directly influence booking decisions
|
||||
Each selling point must be concise and visually scannable
|
||||
Language must be reusable for ads, short-form videos, and listing headlines
|
||||
Avoid full sentences in descriptions; use short selling phrases
|
||||
|
||||
Output format:
|
||||
[Category]
|
||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
||||
One-line selling phrase (not a full sentence)
|
||||
Limit:
|
||||
5 to 8 Key Selling Points only
|
||||
|
||||
[Content & Automation Readiness Check]
|
||||
Ensure that:
|
||||
Each tag keyword can directly map to a content theme
|
||||
Each selling phrase can be used as:
|
||||
- Video hook
|
||||
- Image headline
|
||||
- Ad copy snippet
|
||||
|
||||
|
||||
[Tag Generation Rules]
|
||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
||||
- The number of tags must be **exactly 5**
|
||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
||||
- The following categories must be **balanced and all represented**:
|
||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
||||
|
||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
||||
- The final output must strictly follow the JSON format below, with no additional text
|
||||
|
||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
{
|
||||
"model": "gpt-5.2",
|
||||
"prompt_variables": [
|
||||
"customer_name",
|
||||
"region",
|
||||
"detail_region_info"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "report",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"type": "string"
|
||||
},
|
||||
"selling_points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"report",
|
||||
"selling_points",
|
||||
"tags"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +1,57 @@
|
|||
import os, json
|
||||
from abc import ABCMeta
|
||||
from pydantic import BaseModel
|
||||
from config import prompt_settings
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.prompts.schemas import *
|
||||
|
||||
logger = get_logger("prompt")
|
||||
|
||||
class Prompt():
|
||||
prompt_name : str # ex) marketing_prompt
|
||||
prompt_template_path : str #프롬프트 경로
|
||||
prompt_template : str # fstring 포맷
|
||||
prompt_input : list
|
||||
prompt_output : dict
|
||||
prompt_model : str
|
||||
|
||||
def __init__(self, prompt_name, prompt_template_path):
|
||||
self.prompt_name = prompt_name
|
||||
prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
|
||||
prompt_output_class = BaseModel
|
||||
|
||||
def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
|
||||
self.prompt_template_path = prompt_template_path
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
self.prompt_input_class = prompt_input_class
|
||||
self.prompt_output_class = prompt_output_class
|
||||
self.prompt_template = self.read_prompt()
|
||||
self.prompt_model = prompt_model
|
||||
|
||||
def _reload_prompt(self):
|
||||
self.prompt_template, prompt_dict = self.read_prompt()
|
||||
self.prompt_input = prompt_dict['prompt_variables']
|
||||
self.prompt_output = prompt_dict['output_format']
|
||||
self.prompt_model = prompt_dict.get('model', "gpt-5-mini")
|
||||
self.prompt_template = self.read_prompt()
|
||||
|
||||
def read_prompt(self) -> tuple[str, dict]:
|
||||
template_text_path = self.prompt_template_path + ".txt"
|
||||
prompt_dict_path = self.prompt_template_path + ".json"
|
||||
with open(template_text_path, "r") as fp:
|
||||
with open(self.prompt_template_path, "r") as fp:
|
||||
prompt_template = fp.read()
|
||||
with open(prompt_dict_path, "r") as fp:
|
||||
prompt_dict = json.load(fp)
|
||||
|
||||
return prompt_template, prompt_dict
|
||||
return prompt_template
|
||||
|
||||
def build_prompt(self, input_data:dict) -> str:
|
||||
self.check_input(input_data)
|
||||
verified_input = self.prompt_input_class(**input_data)
|
||||
build_template = self.prompt_template
|
||||
build_template = build_template.format(**verified_input.model_dump())
|
||||
logger.debug(f"build_template: {build_template}")
|
||||
logger.debug(f"input_data: {input_data}")
|
||||
build_template = build_template.format(**input_data)
|
||||
return build_template
|
||||
|
||||
def check_input(self, input_data:dict) -> bool:
|
||||
missing_variables = input_data.keys() - set(self.prompt_input)
|
||||
if missing_variables:
|
||||
raise Exception(f"missing_variable for prompt {self.prompt_name} : {missing_variables}")
|
||||
|
||||
flooding_variables = set(self.prompt_input) - input_data.keys()
|
||||
if flooding_variables:
|
||||
raise Exception(f"flooding_variables for prompt {self.prompt_name} : {flooding_variables}")
|
||||
return True
|
||||
|
||||
marketing_prompt = Prompt(
|
||||
prompt_name=prompt_settings.MARKETING_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_NAME)
|
||||
)
|
||||
|
||||
summarize_prompt = Prompt(
|
||||
prompt_name=prompt_settings.SUMMARIZE_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.SUMMARIZE_PROMPT_NAME)
|
||||
prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
|
||||
prompt_input_class = MarketingPromptInput,
|
||||
prompt_output_class = MarketingPromptOutput,
|
||||
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
|
||||
)
|
||||
|
||||
lyric_prompt = Prompt(
|
||||
prompt_name=prompt_settings.LYLIC_PROMPT_NAME,
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYLIC_PROMPT_NAME)
|
||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
|
||||
prompt_input_class = LyricPromptInput,
|
||||
prompt_output_class = LyricPromptOutput,
|
||||
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
|
||||
)
|
||||
|
||||
def reload_all_prompt():
|
||||
marketing_prompt._reload_prompt()
|
||||
summarize_prompt._reload_prompt()
|
||||
lyric_prompt._reload_prompt()
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
from .lyric import LyricPromptInput, LyricPromptOutput
|
||||
from .marketing import MarketingPromptInput, MarketingPromptOutput
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
# Input 정의
|
||||
class LyricPromptInput(BaseModel):
|
||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||
region : str = Field(..., description = "마케팅 대상 지역")
|
||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||
marketing_intelligence_summary : str = Field(..., description = "마케팅 분석 정보 보고서")
|
||||
language : str= Field(..., description = "가사 언어")
|
||||
promotional_expression_example : str = Field(..., description = "판촉 가사 표현 예시")
|
||||
timing_rules : str = Field(..., description = "시간 제어문")
|
||||
|
||||
# Output 정의
|
||||
class LyricPromptOutput(BaseModel):
|
||||
lyric: str = Field(..., description="생성된 가사")
|
||||
suno_prompt: str = Field(..., description="Suno AI용 프롬프트")
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
# Input 정의
|
||||
class MarketingPromptInput(BaseModel):
|
||||
customer_name : str
|
||||
region : str
|
||||
detail_region_info : str
|
||||
|
||||
|
||||
# Output 정의
|
||||
class BrandIdentity(BaseModel):
|
||||
location_feature_analysis: str = Field(..., description="입지 특성 분석")
|
||||
concept_scalability: str = Field(..., description="컨셉 확장성")
|
||||
|
||||
|
||||
class MarketPositioning(BaseModel):
|
||||
category_definition: str = Field(..., description="마케팅 카테고리")
|
||||
core_value: str = Field(..., description="마케팅 포지션 핵심 가치")
|
||||
|
||||
class AgeRange(BaseModel):
|
||||
min_age : int = Field(..., ge=0, le=100)
|
||||
max_age : int = Field(..., ge=0, le=100)
|
||||
|
||||
|
||||
class TargetPersona(BaseModel):
|
||||
persona: str = Field(..., description="타겟 페르소나 이름/설명")
|
||||
age: AgeRange
|
||||
favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
|
||||
decision_trigger: str = Field(..., description="구매 결정 트리거")
|
||||
|
||||
|
||||
class SellingPoint(BaseModel):
|
||||
category: str = Field(..., description="셀링포인트 카테고리")
|
||||
description: str = Field(..., description="상세 설명")
|
||||
score: int = Field(..., ge=0, le=100, description="점수 (100점 만점)")
|
||||
|
||||
class MarketingPromptOutput(BaseModel):
|
||||
brand_identity: BrandIdentity
|
||||
market_positioning: MarketPositioning
|
||||
target_persona: List[TargetPersona]
|
||||
selling_points: List[SellingPoint]
|
||||
target_keywords: List[str] = Field(..., description="타겟 키워드 리스트")
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"prompt_variables": [
|
||||
"report",
|
||||
"selling_points"
|
||||
],
|
||||
"output_format": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"name": "tags",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag_keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"category",
|
||||
"tag_keywords",
|
||||
"description"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
|
||||
입력 :
|
||||
분석 보고서
|
||||
{report}
|
||||
|
||||
셀링 포인트
|
||||
{selling_points}
|
||||
|
||||
위 분석 결과를 바탕으로, 주요 셀링 포인트를 다음 구조로 재정리하라.
|
||||
|
||||
조건:
|
||||
각 셀링 포인트는 반드시 ‘카테고리 → 태그 키워드 → 한 줄 설명’ 구조를 가질 것
|
||||
태그 키워드는 UI 상에서 타원(oval) 형태의 시각적 태그로 사용될 것을 가정하여
|
||||
- 3 ~ 6단어 이내
|
||||
- 명사 또는 명사형 키워드로 작성
|
||||
- 설명은 문장이 아닌, 짧은 ‘셀링 문구’ 형태로 작성할 것
|
||||
- 광고·숏폼·상세페이지 어디에도 바로 재사용 가능해야 함
|
||||
- 전체 셀링 포인트 개수는 5~7개로 제한
|
||||
|
||||
출력 형식:
|
||||
[카테고리명]
|
||||
(태그 키워드)
|
||||
- 한 줄 설명 문구
|
||||
|
||||
예시:
|
||||
[공간 정체성]
|
||||
(100년 적산가옥 · 시간의 결)
|
||||
- 하루를 ‘숙박’이 아닌 ‘체류’로 바꾸는 공간
|
||||
|
||||
[입지 & 희소성]
|
||||
(말랭이마을 · 로컬 히든플레이스)
|
||||
- 관광지가 아닌, 군산을 아는 사람의 선택
|
||||
|
||||
[프라이버시]
|
||||
(독채 숙소 · 프라이빗 스테이)
|
||||
- 누구의 방해도 없는 완전한 휴식 구조
|
||||
|
||||
[비주얼 경쟁력]
|
||||
(감성 인테리어 · 자연광 스폿)
|
||||
- 찍는 순간 콘텐츠가 되는 공간 설계
|
||||
|
||||
[타깃 최적화]
|
||||
(커플 · 소규모 여행)
|
||||
- 둘에게 가장 이상적인 공간 밀도
|
||||
|
||||
[체류 경험]
|
||||
(아무것도 안 해도 되는 하루)
|
||||
- 일정 없이도 만족되는 하루 루틴
|
||||
|
||||
[브랜드 포지션]
|
||||
(호텔도 펜션도 아닌 아지트)
|
||||
- 다시 돌아오고 싶은 개인적 장소
|
||||
|
||||
|
|
@ -59,8 +59,28 @@ from typing import Any, List, Optional
|
|||
|
||||
import httpx
|
||||
|
||||
from config import apikey_settings
|
||||
from app.song.schemas.song_schema import PollingSongResponse, SongClipData
|
||||
from app.utils.logger import get_logger
|
||||
from config import apikey_settings, recovery_settings
|
||||
|
||||
logger = get_logger("suno")
|
||||
|
||||
|
||||
class SunoResponseError(Exception):
|
||||
"""Suno API 응답 오류 시 발생하는 예외
|
||||
|
||||
Suno API 거부 응답 또는 비정상 응답 시 사용됩니다.
|
||||
재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다.
|
||||
|
||||
Attributes:
|
||||
message: 에러 메시지
|
||||
original_response: 원본 API 응답 (있는 경우)
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, original_response: dict | None = None):
|
||||
self.message = message
|
||||
self.original_response = original_response
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class SunoService:
|
||||
|
|
@ -122,35 +142,75 @@ class SunoService:
|
|||
if genre:
|
||||
payload["style"] = genre
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.SUNO_MAX_RETRIES + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE_URL}/generate",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# 응답: {"code": 200, "msg": "success", "data": {"taskId": "..."}}
|
||||
# API 응답 검증
|
||||
if data is None:
|
||||
raise ValueError("Suno API returned empty response")
|
||||
raise SunoResponseError("Suno API returned empty response")
|
||||
|
||||
if data.get("code") != 200:
|
||||
error_msg = data.get("msg", "Unknown error")
|
||||
raise ValueError(f"Suno API error: {error_msg}")
|
||||
raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data)
|
||||
|
||||
response_data = data.get("data")
|
||||
if response_data is None:
|
||||
raise ValueError(f"Suno API response missing 'data' field: {data}")
|
||||
raise SunoResponseError(f"Suno API response missing 'data' field", original_response=data)
|
||||
|
||||
task_id = response_data.get("taskId")
|
||||
if task_id is None:
|
||||
raise ValueError(f"Suno API response missing 'taskId': {response_data}")
|
||||
raise SunoResponseError(f"Suno API response missing 'taskId'", original_response=response_data)
|
||||
|
||||
return task_id
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
if 400 <= response.status_code < 500:
|
||||
raise SunoResponseError(
|
||||
f"Client error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
# 재시도 가능한 오류 (5xx 서버 오류)
|
||||
last_error = SunoResponseError(
|
||||
f"Server error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text},
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(
|
||||
f"[Suno] Timeout on attempt {attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES + 1}"
|
||||
)
|
||||
last_error = e
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"[Suno] HTTP error on attempt {attempt + 1}: {e}")
|
||||
last_error = e
|
||||
|
||||
except SunoResponseError:
|
||||
raise # SunoResponseError는 재시도하지 않고 즉시 전파
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < recovery_settings.SUNO_MAX_RETRIES:
|
||||
logger.info(f"[Suno] Retrying... ({attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES})")
|
||||
|
||||
# 모든 재시도 실패
|
||||
raise SunoResponseError(
|
||||
f"All {recovery_settings.SUNO_MAX_RETRIES + 1} attempts failed",
|
||||
original_response={"last_error": str(last_error)},
|
||||
)
|
||||
|
||||
async def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
음악 생성 작업 상태 확인
|
||||
|
|
@ -170,7 +230,7 @@ class SunoService:
|
|||
f"{self.BASE_URL}/generate/record-info",
|
||||
headers=self.headers,
|
||||
params={"taskId": task_id},
|
||||
timeout=30.0,
|
||||
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
|
@ -192,24 +252,21 @@ class SunoService:
|
|||
data.alignedWords: 수노 가사 input - startS endS 시간 데이터 매핑
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"task_id" : task_id,
|
||||
"audio_id" : audio_id
|
||||
}
|
||||
payload = {"task_id": task_id, "audio_id": audio_id}
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE_URL}/generate/get-timestamped-lyrics",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
timeout=recovery_settings.SUNO_LYRIC_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data or not data['data']:
|
||||
if not data or not data["data"]:
|
||||
raise ValueError("Suno API returned empty response for task status")
|
||||
|
||||
return data['data']['alignedWords']
|
||||
return data["data"]["alignedWords"]
|
||||
|
||||
def parse_status_response(self, result: dict | None) -> PollingSongResponse:
|
||||
"""Suno API 상태 응답을 파싱하여 PollingSongResponse로 변환합니다.
|
||||
|
|
@ -234,7 +291,7 @@ class SunoService:
|
|||
)
|
||||
|
||||
code = result.get("code", 0)
|
||||
data = result.get("data", {})
|
||||
data = result.get("data") or {}
|
||||
|
||||
if code != 200:
|
||||
return PollingSongResponse(
|
||||
|
|
@ -295,7 +352,7 @@ class SunoService:
|
|||
word_ranges = [] # [(start_pos, end_pos, entry), ...]
|
||||
|
||||
for entry in word_data:
|
||||
word = entry['word']
|
||||
word = entry["word"]
|
||||
start_pos = len(full_text)
|
||||
full_text += word
|
||||
end_pos = len(full_text) - 1
|
||||
|
|
@ -306,9 +363,9 @@ class SunoService:
|
|||
meta_ranges = []
|
||||
i = 0
|
||||
while i < len(full_text):
|
||||
if full_text[i] == '[':
|
||||
if full_text[i] == "[":
|
||||
start = i
|
||||
while i < len(full_text) and full_text[i] != ']':
|
||||
while i < len(full_text) and full_text[i] != "]":
|
||||
i += 1
|
||||
meta_ranges.append((start, i + 1))
|
||||
i += 1
|
||||
|
|
@ -331,10 +388,10 @@ class SunoService:
|
|||
|
||||
# Step 4: 문장 매칭
|
||||
def normalize(text):
|
||||
return ''.join(c for c in text if c not in ' \n\t-')
|
||||
return "".join(c for c in text if c not in " \n\t-")
|
||||
|
||||
norm_clean = normalize(clean_text)
|
||||
norm_to_clean = [i for i, c in enumerate(clean_text) if c not in ' \n\t-']
|
||||
norm_to_clean = [i for i, c in enumerate(clean_text) if c not in " \n\t-"]
|
||||
|
||||
results = []
|
||||
search_pos = 0
|
||||
|
|
@ -353,14 +410,16 @@ class SunoService:
|
|||
word_start = get_word_at(old_start)
|
||||
word_end = get_word_at(old_end)
|
||||
|
||||
results.append({
|
||||
'text': sentence,
|
||||
'start_sec': word_start['startS'],
|
||||
'end_sec': word_end['endS'],
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"text": sentence,
|
||||
"start_sec": word_start["startS"],
|
||||
"end_sec": word_end["endS"],
|
||||
}
|
||||
)
|
||||
|
||||
search_pos = found_pos + len(norm_sentence)
|
||||
else:
|
||||
results.append({'text': sentence, 'start_sec': None, 'end_sec': None})
|
||||
results.append({"text": sentence, "start_sec": None, "end_sec": None})
|
||||
|
||||
return results
|
||||
|
|
@ -14,6 +14,7 @@ Video API Router
|
|||
app.include_router(router, prefix="/api/v1")
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||
|
|
@ -21,13 +22,13 @@ from sqlalchemy import func, select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.session import get_session
|
||||
from app.dependencies.pagination import (
|
||||
PaginationParams,
|
||||
get_pagination_params,
|
||||
)
|
||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
||||
from app.home.models import Image, Project
|
||||
from app.lyric.models import Lyric
|
||||
from app.song.models import Song, SongTimestamp
|
||||
from app.utils.creatomate import CreatomateService
|
||||
from app.utils.logger import get_logger
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.video.models import Video
|
||||
from app.video.schemas.video_schema import (
|
||||
DownloadVideoResponse,
|
||||
|
|
@ -37,9 +38,6 @@ from app.video.schemas.video_schema import (
|
|||
VideoRenderData,
|
||||
)
|
||||
from app.video.worker.video_task import download_and_upload_video_to_blob
|
||||
from app.utils.creatomate import CreatomateService
|
||||
from app.utils.pagination import PaginatedResponse
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("video")
|
||||
|
||||
|
|
@ -114,10 +112,13 @@ async def generate_video(
|
|||
따라서 쿼리는 순차적으로 실행합니다.
|
||||
"""
|
||||
import time
|
||||
|
||||
from app.database.session import AsyncSessionLocal
|
||||
|
||||
request_start = time.perf_counter()
|
||||
logger.info(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}")
|
||||
logger.info(
|
||||
f"[generate_video] START - task_id: {task_id}, orientation: {orientation}"
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
|
||||
|
|
@ -170,13 +171,17 @@ async def generate_video(
|
|||
)
|
||||
|
||||
query_time = time.perf_counter()
|
||||
logger.debug(f"[generate_video] Queries completed - task_id: {task_id}, "
|
||||
f"elapsed: {(query_time - request_start)*1000:.1f}ms")
|
||||
logger.debug(
|
||||
f"[generate_video] Queries completed - task_id: {task_id}, "
|
||||
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
# ===== 결과 처리: Project =====
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
|
||||
logger.warning(
|
||||
f"[generate_video] Project NOT FOUND - task_id: {task_id}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||
|
|
@ -248,8 +253,10 @@ async def generate_video(
|
|||
await session.commit()
|
||||
video_id = video.id
|
||||
stage1_time = time.perf_counter()
|
||||
logger.info(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, "
|
||||
f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms")
|
||||
logger.info(
|
||||
f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, "
|
||||
f"stage1_elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
|
||||
)
|
||||
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -269,15 +276,21 @@ async def generate_video(
|
|||
# ==========================================================================
|
||||
stage2_start = time.perf_counter()
|
||||
try:
|
||||
logger.info(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}")
|
||||
logger.info(
|
||||
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
|
||||
)
|
||||
creatomate_service = CreatomateService(
|
||||
orientation=orientation,
|
||||
target_duration=song_duration,
|
||||
)
|
||||
logger.debug(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})")
|
||||
logger.debug(
|
||||
f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})"
|
||||
)
|
||||
|
||||
# 6-1. 템플릿 조회 (비동기)
|
||||
template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id)
|
||||
template = await creatomate_service.get_one_template_data_async(
|
||||
creatomate_service.template_id
|
||||
)
|
||||
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
|
||||
|
||||
# 6-2. elements에서 리소스 매핑 생성
|
||||
|
|
@ -302,26 +315,49 @@ async def generate_video(
|
|||
template,
|
||||
creatomate_service.target_duration,
|
||||
)
|
||||
logger.debug(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}")
|
||||
logger.debug(
|
||||
f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}"
|
||||
)
|
||||
|
||||
# 이런거 추가해야하는데 AI가 자꾸 번호 달면 제가 번호를 다 밀어야 하나요?
|
||||
|
||||
song_timestamp_result = await session.execute(
|
||||
select(SongTimestamp)
|
||||
.where(SongTimestamp.suno_audio_id == song.suno_audio_id)
|
||||
select(SongTimestamp).where(
|
||||
SongTimestamp.suno_audio_id == song.suno_audio_id
|
||||
)
|
||||
)
|
||||
song_timestamp_list = song_timestamp_result.scalars().all()
|
||||
|
||||
logger.debug(
|
||||
f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}"
|
||||
)
|
||||
for i, ts in enumerate(song_timestamp_list):
|
||||
logger.debug(
|
||||
f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}"
|
||||
)
|
||||
|
||||
text_template = creatomate_service.get_text_template()
|
||||
for idx, aligned in enumerate(song_timestamp_list):
|
||||
caption = creatomate_service.lining_lyric(text_template, idx, aligned.lyric_line, aligned.start_time, aligned.end_time )
|
||||
final_template['source']['elements'].append(caption)
|
||||
caption = creatomate_service.lining_lyric(
|
||||
text_template,
|
||||
idx,
|
||||
aligned.lyric_line,
|
||||
aligned.start_time,
|
||||
aligned.end_time,
|
||||
)
|
||||
final_template["source"]["elements"].append(caption)
|
||||
|
||||
logger.debug(
|
||||
f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
# 6-5. 커스텀 렌더링 요청 (비동기)
|
||||
render_response = await creatomate_service.make_creatomate_custom_call_async(
|
||||
final_template["source"],
|
||||
)
|
||||
logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}")
|
||||
logger.debug(
|
||||
f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}"
|
||||
)
|
||||
|
||||
# 렌더 ID 추출
|
||||
if isinstance(render_response, list) and len(render_response) > 0:
|
||||
|
|
@ -335,13 +371,16 @@ async def generate_video(
|
|||
logger.info(
|
||||
f"[generate_video] Stage 2 DONE - task_id: {task_id}, "
|
||||
f"render_id: {creatomate_render_id}, "
|
||||
f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
|
||||
f"stage2_elapsed: {(stage2_time - stage2_start) * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
logger.error(
|
||||
f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}"
|
||||
)
|
||||
# 외부 API 실패 시 Video 상태를 failed로 업데이트
|
||||
from app.database.session import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as update_session:
|
||||
video_result = await update_session.execute(
|
||||
select(Video).where(Video.id == video_id)
|
||||
|
|
@ -365,6 +404,7 @@ async def generate_video(
|
|||
logger.info(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}")
|
||||
try:
|
||||
from app.database.session import AsyncSessionLocal
|
||||
|
||||
async with AsyncSessionLocal() as update_session:
|
||||
video_result = await update_session.execute(
|
||||
select(Video).where(Video.id == video_id)
|
||||
|
|
@ -378,12 +418,12 @@ async def generate_video(
|
|||
total_time = stage3_time - request_start
|
||||
logger.debug(
|
||||
f"[generate_video] Stage 3 DONE - task_id: {task_id}, "
|
||||
f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
|
||||
f"stage3_elapsed: {(stage3_time - stage3_start) * 1000:.1f}ms"
|
||||
)
|
||||
logger.info(
|
||||
f"[generate_video] SUCCESS - task_id: {task_id}, "
|
||||
f"render_id: {creatomate_render_id}, "
|
||||
f"total_time: {total_time*1000:.1f}ms"
|
||||
f"total_time: {total_time * 1000:.1f}ms"
|
||||
)
|
||||
|
||||
return GenerateVideoResponse(
|
||||
|
|
@ -395,7 +435,9 @@ async def generate_video(
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}")
|
||||
logger.error(
|
||||
f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}"
|
||||
)
|
||||
return GenerateVideoResponse(
|
||||
success=False,
|
||||
task_id=task_id,
|
||||
|
|
@ -454,11 +496,15 @@ async def get_video_status(
|
|||
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고
|
||||
Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다.
|
||||
"""
|
||||
logger.info(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}")
|
||||
logger.info(
|
||||
f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}"
|
||||
)
|
||||
try:
|
||||
creatomate_service = CreatomateService()
|
||||
result = await creatomate_service.get_render_status_async(creatomate_render_id)
|
||||
logger.debug(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}")
|
||||
logger.debug(
|
||||
f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}"
|
||||
)
|
||||
|
||||
status = result.get("status", "unknown")
|
||||
video_url = result.get("url")
|
||||
|
|
@ -496,7 +542,9 @@ async def get_video_status(
|
|||
store_name = project.store_name if project else "video"
|
||||
|
||||
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
|
||||
logger.info(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}")
|
||||
logger.info(
|
||||
f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}"
|
||||
)
|
||||
background_tasks.add_task(
|
||||
download_and_upload_video_to_blob,
|
||||
task_id=video.task_id,
|
||||
|
|
@ -504,7 +552,9 @@ async def get_video_status(
|
|||
store_name=store_name,
|
||||
)
|
||||
elif video and video.status == "completed":
|
||||
logger.debug(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}")
|
||||
logger.debug(
|
||||
f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}"
|
||||
)
|
||||
|
||||
render_data = VideoRenderData(
|
||||
id=result.get("id"),
|
||||
|
|
@ -513,7 +563,9 @@ async def get_video_status(
|
|||
snapshot_url=result.get("snapshot_url"),
|
||||
)
|
||||
|
||||
logger.info(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||
logger.info(
|
||||
f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}"
|
||||
)
|
||||
return PollingVideoResponse(
|
||||
success=True,
|
||||
status=status,
|
||||
|
|
@ -526,7 +578,9 @@ async def get_video_status(
|
|||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
|
||||
logger.error(
|
||||
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}"
|
||||
)
|
||||
return PollingVideoResponse(
|
||||
success=False,
|
||||
status="error",
|
||||
|
|
@ -598,7 +652,9 @@ async def download_video(
|
|||
error_message="Video not found",
|
||||
)
|
||||
|
||||
logger.debug(f"[download_video] Video found - task_id: {task_id}, status: {video.status}")
|
||||
logger.debug(
|
||||
f"[download_video] Video found - task_id: {task_id}, status: {video.status}"
|
||||
)
|
||||
|
||||
# processing 상태인 경우
|
||||
if video.status == "processing":
|
||||
|
|
@ -627,7 +683,9 @@ async def download_video(
|
|||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
|
||||
logger.info(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}")
|
||||
logger.info(
|
||||
f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}"
|
||||
)
|
||||
return DownloadVideoResponse(
|
||||
success=True,
|
||||
status="completed",
|
||||
|
|
@ -689,7 +747,9 @@ async def get_videos(
|
|||
pagination: PaginationParams = Depends(get_pagination_params),
|
||||
) -> PaginatedResponse[VideoListItem]:
|
||||
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
|
||||
logger.info(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}")
|
||||
logger.info(
|
||||
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
|
||||
)
|
||||
try:
|
||||
offset = (pagination.page - 1) * pagination.page_size
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class VideoAdmin(ModelView, model=Video):
|
|||
"lyric_id",
|
||||
"song_id",
|
||||
"task_id",
|
||||
"creatomate_render_id",
|
||||
"status",
|
||||
"result_movie_url",
|
||||
"created_at",
|
||||
|
|
@ -56,6 +57,7 @@ class VideoAdmin(ModelView, model=Video):
|
|||
"lyric_id": "가사 ID",
|
||||
"song_id": "노래 ID",
|
||||
"task_id": "작업 ID",
|
||||
"creatomate_render_id": "Creatomate 렌더 ID",
|
||||
"status": "상태",
|
||||
"result_movie_url": "영상 URL",
|
||||
"created_at": "생성일시",
|
||||
|
|
|
|||
69
config.py
|
|
@ -146,10 +146,70 @@ class CreatomateSettings(BaseSettings):
|
|||
model_config = _base_config
|
||||
|
||||
class PromptSettings(BaseSettings):
|
||||
PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts")
|
||||
MARKETING_PROMPT_NAME : str = Field(default="marketing_prompt")
|
||||
SUMMARIZE_PROMPT_NAME : str = Field(default="summarize_prompt")
|
||||
LYLIC_PROMPT_NAME : str = Field(default="lyric_prompt")
|
||||
PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts/templates")
|
||||
|
||||
MARKETING_PROMPT_FILE_NAME : str = Field(default="marketing_prompt.txt")
|
||||
MARKETING_PROMPT_MODEL : str = Field(default="gpt-5.2")
|
||||
|
||||
LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt")
|
||||
LYRIC_PROMPT_MODEL : str = Field(default="gpt-5-mini")
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
||||
class RecoverySettings(BaseSettings):
|
||||
"""외부 API 복구 및 타임아웃 설정
|
||||
|
||||
ChatGPT, Suno, Creatomate API의 타임아웃 및 재시도 설정을 관리합니다.
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# ChatGPT API 설정
|
||||
# ============================================================
|
||||
CHATGPT_TIMEOUT: float = Field(
|
||||
default=600.0,
|
||||
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
||||
)
|
||||
CHATGPT_MAX_RETRIES: int = Field(
|
||||
default=1,
|
||||
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Suno API 설정
|
||||
# ============================================================
|
||||
SUNO_DEFAULT_TIMEOUT: float = Field(
|
||||
default=30.0,
|
||||
description="Suno API 기본 요청 타임아웃 (초) - 음악 생성 요청, 상태 조회 등",
|
||||
)
|
||||
SUNO_LYRIC_TIMEOUT: float = Field(
|
||||
default=120.0,
|
||||
description="Suno API 가사 타임스탬프 요청 타임아웃 (초) - 가사 동기화 처리에 더 긴 시간 필요",
|
||||
)
|
||||
SUNO_MAX_RETRIES: int = Field(
|
||||
default=2,
|
||||
description="Suno API 응답 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Creatomate API 설정
|
||||
# ============================================================
|
||||
CREATOMATE_DEFAULT_TIMEOUT: float = Field(
|
||||
default=30.0,
|
||||
description="Creatomate API 기본 요청 타임아웃 (초) - 템플릿 조회, 상태 조회 등 일반 API 호출",
|
||||
)
|
||||
CREATOMATE_RENDER_TIMEOUT: float = Field(
|
||||
default=60.0,
|
||||
description="Creatomate API 렌더링 요청 타임아웃 (초) - 영상 렌더링 요청 시 더 긴 시간 필요",
|
||||
)
|
||||
CREATOMATE_CONNECT_TIMEOUT: float = Field(
|
||||
default=10.0,
|
||||
description="Creatomate API 연결 타임아웃 (초) - 서버 연결 수립까지의 대기 시간",
|
||||
)
|
||||
CREATOMATE_MAX_RETRIES: int = Field(
|
||||
default=2,
|
||||
description="Creatomate API 응답 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
|
||||
|
|
@ -386,3 +446,4 @@ prompt_settings = PromptSettings()
|
|||
log_settings = LogSettings()
|
||||
kakao_settings = KakaoSettings()
|
||||
jwt_settings = JWTSettings()
|
||||
recovery_settings = RecoverySettings()
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ async def get_item(
|
|||
**적용 엔드포인트:**
|
||||
- `GET /videos/` - 목록 조회
|
||||
- `GET /video/download/{task_id}` - 상태 조회
|
||||
- `GET /songs/` - 목록 조회
|
||||
|
||||
#### 패턴 2: 명시적 세션 관리 (외부 API 호출 포함)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,885 @@
|
|||
# DB Lock 및 Upsert 패턴 가이드 (MySQL 전용)
|
||||
|
||||
## 목차
|
||||
1. [현재 Insert 사용 현황](#1-현재-insert-사용-현황)
|
||||
2. [Upsert 패턴 설계](#2-upsert-패턴-설계)
|
||||
3. [DB Lock 전략](#3-db-lock-전략)
|
||||
4. [데드락 방지 전략](#4-데드락-방지-전략)
|
||||
5. [제안 코드](#5-제안-코드)
|
||||
6. [사용 예시](#6-사용-예시)
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 Insert 사용 현황
|
||||
|
||||
### 1.1 테이블별 Insert 분석 및 우선순위
|
||||
|
||||
| 테이블 | 파일 위치 | Unique 제약 | 동시 요청 가능성 | Upsert 우선순위 | 키 조합 |
|
||||
|--------|-----------|-------------|------------------|-----------------|---------|
|
||||
| **SongTimestamp** | song.py:477 | - | **높음** | **1순위** | suno_audio_id + order_idx |
|
||||
| **Image** | home.py:503,552,799,821 | - | **중간** | **2순위** | task_id + img_order |
|
||||
| **Song** | song.py:188 | - | 중간 | 3순위 | task_id |
|
||||
| **Video** | video.py:252 | - | 중간 | 4순위 | task_id |
|
||||
| **User** | auth.py:278 | `kakao_id` | **낮음** | ✅ 완료 | kakao_id |
|
||||
| **Project** | lyric.py:297 | - | 낮음 | 선택 | task_id |
|
||||
| **Lyric** | lyric.py:317 | - | 낮음 | 선택 | task_id |
|
||||
| **RefreshToken** | auth.py:315 | `token_hash` | - | 불필요 | - (항상 새로 생성) |
|
||||
|
||||
#### 동시 요청 가능성 분석
|
||||
|
||||
| 가능성 | 테이블 | 발생 시나리오 |
|
||||
|--------|--------|---------------|
|
||||
| **높음** | SongTimestamp | 클라이언트가 상태 조회 API를 여러 번 호출 (폴링) → 동일 데이터 중복 삽입 |
|
||||
| **중간** | Image | 네트워크 오류로 업로드 재시도, 클라이언트 중복 클릭 |
|
||||
| **중간** | Song/Video | 백그라운드 태스크 재실행, 상태 확인 중복 호출 |
|
||||
| **낮음** | User | OAuth 인가 코드 일회성으로 동시 요청 거의 불가능 |
|
||||
|
||||
> **참고**: User 테이블의 경우 카카오 OAuth 인가 코드(authorization code)가 **일회성**이므로,
|
||||
> 동일한 코드로 동시 요청은 불가능합니다. 다만 여러 탭에서 각각 로그인을 시작하는 극히 드문 경우에만 발생 가능합니다.
|
||||
|
||||
### 1.2 현재 코드 패턴의 문제점
|
||||
|
||||
**실제 문제가 발생하는 케이스: SongTimestamp**
|
||||
|
||||
```python
|
||||
# song.py:467-479 - 현재 패턴
|
||||
# 클라이언트가 /song/status/{song_id}를 여러 번 호출하면 중복 삽입 발생!
|
||||
|
||||
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
|
||||
song_timestamp = SongTimestamp(
|
||||
suno_audio_id=suno_audio_id,
|
||||
order_idx=order_idx,
|
||||
lyric_line=timestamped_lyric["text"],
|
||||
start_time=timestamped_lyric["start_sec"],
|
||||
end_time=timestamped_lyric["end_sec"],
|
||||
)
|
||||
session.add(song_timestamp) # 동일 suno_audio_id로 중복 삽입!
|
||||
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
**문제 시나리오:**
|
||||
1. 클라이언트가 노래 생성 상태 확인을 위해 폴링
|
||||
2. SUCCESS 응답을 받은 후 타임스탬프 저장 로직 실행
|
||||
3. 네트워크 지연으로 클라이언트가 재요청
|
||||
4. **동일한 데이터가 중복 삽입됨**
|
||||
|
||||
---
|
||||
|
||||
## 2. Upsert 패턴 설계 (MySQL 전용)
|
||||
|
||||
### 2.1 MySQL ON DUPLICATE KEY UPDATE (권장)
|
||||
|
||||
MySQL의 `INSERT ... ON DUPLICATE KEY UPDATE` 절을 사용한 원자적 Upsert:
|
||||
|
||||
```python
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
|
||||
stmt = mysql_insert(User).values(
|
||||
kakao_id=kakao_id,
|
||||
nickname=nickname,
|
||||
email=email,
|
||||
)
|
||||
stmt = stmt.on_duplicate_key_update(
|
||||
nickname=stmt.inserted.nickname, # MySQL은 stmt.inserted 사용
|
||||
email=stmt.inserted.email,
|
||||
updated_at=func.now(),
|
||||
)
|
||||
await session.execute(stmt)
|
||||
|
||||
# MySQL은 RETURNING 미지원 - 별도 조회 필요
|
||||
result = await session.execute(select(User).where(User.kakao_id == kakao_id))
|
||||
user = result.scalar_one()
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- 원자적 연산 (단일 쿼리)
|
||||
- 데드락 위험 최소화
|
||||
- 동시 요청에서도 안전
|
||||
|
||||
**전제 조건:**
|
||||
- Unique 인덱스 필수 (없으면 항상 INSERT만 됨)
|
||||
|
||||
### 2.3 비관적 잠금 (Pessimistic Locking)
|
||||
|
||||
`SELECT ... FOR UPDATE`를 사용한 행 수준 잠금:
|
||||
|
||||
```python
|
||||
result = await session.execute(
|
||||
select(User)
|
||||
.where(User.kakao_id == kakao_id)
|
||||
.with_for_update() # FOR UPDATE 잠금
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DB Lock 전략
|
||||
|
||||
### 3.1 잠금 유형
|
||||
|
||||
| 잠금 유형 | 사용 시점 | SQLAlchemy 구현 |
|
||||
|-----------|-----------|-----------------|
|
||||
| **공유 잠금 (Shared)** | 읽기 작업 | `.with_for_update(read=True)` |
|
||||
| **배타 잠금 (Exclusive)** | 쓰기 작업 | `.with_for_update()` |
|
||||
| **NOWAIT** | 즉시 실패 | `.with_for_update(nowait=True)` |
|
||||
| **SKIP LOCKED** | 잠긴 행 건너뛰기 | `.with_for_update(skip_locked=True)` |
|
||||
|
||||
### 3.2 잠금 범위 선택
|
||||
|
||||
```python
|
||||
# 1. 행 수준 잠금 (Row-level) - 권장
|
||||
select(User).where(User.id == user_id).with_for_update()
|
||||
|
||||
# 2. 키 범위 잠금 (Key-range) - 범위 조회 시
|
||||
select(Image).where(Image.task_id == task_id).with_for_update()
|
||||
|
||||
# 3. 테이블 잠금 - 피해야 함 (성능 저하)
|
||||
```
|
||||
|
||||
### 3.3 트랜잭션 격리 수준
|
||||
|
||||
```python
|
||||
from sqlalchemy import text
|
||||
|
||||
# 세션별 격리 수준 설정
|
||||
await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 데드락 방지 전략
|
||||
|
||||
### 4.1 핵심 원칙
|
||||
|
||||
1. **일관된 잠금 순서**: 항상 같은 순서로 리소스 접근
|
||||
2. **짧은 트랜잭션**: 잠금 유지 시간 최소화
|
||||
3. **타임아웃 설정**: 무한 대기 방지
|
||||
4. **재시도 로직**: 데드락 발생 시 자동 재시도
|
||||
|
||||
### 4.2 잠금 순서 규칙
|
||||
|
||||
```python
|
||||
# 올바른 순서: 항상 PK 또는 정렬된 순서로 잠금
|
||||
async def lock_resources_safely(session, resource_ids: list[int]):
|
||||
"""리소스를 ID 순서로 정렬하여 잠금"""
|
||||
sorted_ids = sorted(resource_ids) # 정렬!
|
||||
|
||||
for resource_id in sorted_ids:
|
||||
await session.execute(
|
||||
select(Resource)
|
||||
.where(Resource.id == resource_id)
|
||||
.with_for_update()
|
||||
)
|
||||
```
|
||||
|
||||
### 4.3 타임아웃 설정 (MySQL)
|
||||
|
||||
```python
|
||||
# MySQL 잠금 타임아웃 설정
|
||||
await session.execute(text("SET innodb_lock_wait_timeout = 5"))
|
||||
```
|
||||
|
||||
### 4.4 재시도 로직
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
async def execute_with_retry(
|
||||
session,
|
||||
operation,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 0.1,
|
||||
):
|
||||
"""지수 백오프를 사용한 재시도 로직"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await operation(session)
|
||||
except OperationalError as e:
|
||||
if "deadlock" in str(e).lower() and attempt < max_retries - 1:
|
||||
delay = base_delay * (2 ** attempt)
|
||||
await asyncio.sleep(delay)
|
||||
await session.rollback()
|
||||
continue
|
||||
raise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 제안 코드 (MySQL 전용)
|
||||
|
||||
### 5.1 공통 Upsert 유틸리티
|
||||
|
||||
`app/utils/db_utils.py` 파일 (✅ 이미 생성됨):
|
||||
|
||||
```python
|
||||
"""
|
||||
DB 유틸리티 - Upsert 및 Lock 관리 (MySQL 전용)
|
||||
|
||||
MySQL의 INSERT ... ON DUPLICATE KEY UPDATE를 사용한 안전한 Upsert 패턴과
|
||||
데드락 방지를 위한 잠금 관리 유틸리티를 제공합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, List, Optional, Type, TypeVar
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T", bound=DeclarativeBase)
|
||||
|
||||
|
||||
class UpsertResult:
|
||||
"""Upsert 결과를 담는 클래스"""
|
||||
|
||||
def __init__(self, entity: Any, created: bool):
|
||||
self.entity = entity
|
||||
self.created = created # True: INSERT, False: UPDATE
|
||||
|
||||
|
||||
async def upsert_by_unique_key(
|
||||
session: AsyncSession,
|
||||
model: Type[T],
|
||||
unique_columns: List[str],
|
||||
values: Dict[str, Any],
|
||||
update_columns: Optional[List[str]] = None,
|
||||
lock_timeout_sec: int = 5,
|
||||
) -> UpsertResult:
|
||||
"""
|
||||
Unique 키 기반 원자적 Upsert (MySQL ON DUPLICATE KEY UPDATE 사용)
|
||||
|
||||
Args:
|
||||
session: AsyncSession 인스턴스
|
||||
model: SQLAlchemy 모델 클래스
|
||||
unique_columns: Unique 제약 컬럼 목록
|
||||
values: INSERT/UPDATE 값 딕셔너리
|
||||
update_columns: UPDATE 시 변경할 컬럼 목록 (None이면 unique 제외 전체)
|
||||
lock_timeout_sec: 잠금 타임아웃 (초)
|
||||
|
||||
Returns:
|
||||
UpsertResult: 엔티티와 생성 여부
|
||||
|
||||
Example:
|
||||
result = await upsert_by_unique_key(
|
||||
session=session,
|
||||
model=User,
|
||||
unique_columns=['kakao_id'],
|
||||
values={'kakao_id': 12345, 'nickname': '홍길동', 'email': 'test@test.com'},
|
||||
update_columns=['nickname', 'email'],
|
||||
)
|
||||
if result.created:
|
||||
print("새 사용자 생성됨")
|
||||
else:
|
||||
print("기존 사용자 업데이트됨")
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
# MySQL 잠금 타임아웃 설정
|
||||
await session.execute(text(f"SET innodb_lock_wait_timeout = {lock_timeout_sec}"))
|
||||
|
||||
# UPDATE 컬럼 결정
|
||||
if update_columns is None:
|
||||
update_columns = [k for k in values.keys() if k not in unique_columns]
|
||||
|
||||
# MySQL INSERT ... ON DUPLICATE KEY UPDATE
|
||||
stmt = mysql_insert(model).values(**values)
|
||||
|
||||
update_dict = {col: stmt.inserted[col] for col in update_columns}
|
||||
if hasattr(model, 'updated_at'):
|
||||
update_dict['updated_at'] = func.now()
|
||||
|
||||
stmt = stmt.on_duplicate_key_update(**update_dict)
|
||||
|
||||
await session.execute(stmt)
|
||||
|
||||
# MySQL은 RETURNING 미지원 - 별도 조회 필요
|
||||
filter_conditions = {col: values[col] for col in unique_columns}
|
||||
result = await session.execute(select(model).filter_by(**filter_conditions))
|
||||
entity = result.scalar_one()
|
||||
|
||||
# created 여부 확인 (created_at == updated_at 비교)
|
||||
created = True
|
||||
if hasattr(entity, 'updated_at') and hasattr(entity, 'created_at'):
|
||||
if entity.created_at and entity.updated_at:
|
||||
created = abs((entity.updated_at - entity.created_at).total_seconds()) < 1
|
||||
|
||||
return UpsertResult(entity=entity, created=created)
|
||||
|
||||
|
||||
async def get_or_create_with_lock(
|
||||
session: AsyncSession,
|
||||
model: Type[T],
|
||||
filter_by: Dict[str, Any],
|
||||
defaults: Optional[Dict[str, Any]] = None,
|
||||
lock: bool = True,
|
||||
nowait: bool = False,
|
||||
) -> UpsertResult:
|
||||
"""
|
||||
SELECT FOR UPDATE를 사용한 안전한 Get or Create
|
||||
|
||||
동시 요청에서도 안전하게 작동하며,
|
||||
행이 존재하면 잠금 후 반환, 없으면 생성합니다.
|
||||
|
||||
Args:
|
||||
session: AsyncSession 인스턴스
|
||||
model: SQLAlchemy 모델 클래스
|
||||
filter_by: 조회 조건 딕셔너리
|
||||
defaults: 생성 시 추가할 기본값
|
||||
lock: FOR UPDATE 잠금 사용 여부
|
||||
nowait: 잠금 대기 안함 (즉시 예외 발생)
|
||||
|
||||
Returns:
|
||||
UpsertResult: 엔티티와 생성 여부
|
||||
|
||||
Example:
|
||||
result = await get_or_create_with_lock(
|
||||
session=session,
|
||||
model=User,
|
||||
filter_by={'kakao_id': 12345},
|
||||
defaults={'nickname': '홍길동'},
|
||||
)
|
||||
"""
|
||||
# 조회 쿼리 구성
|
||||
query = select(model).filter_by(**filter_by)
|
||||
|
||||
if lock:
|
||||
query = query.with_for_update(nowait=nowait)
|
||||
|
||||
result = await session.execute(query)
|
||||
entity = result.scalar_one_or_none()
|
||||
|
||||
if entity is not None:
|
||||
# 기존 엔티티 반환
|
||||
return UpsertResult(entity=entity, created=False)
|
||||
|
||||
# 새 엔티티 생성
|
||||
create_values = {**filter_by, **(defaults or {})}
|
||||
entity = model(**create_values)
|
||||
session.add(entity)
|
||||
|
||||
try:
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
# 동시 INSERT로 인한 충돌 - 다시 조회
|
||||
await session.rollback()
|
||||
result = await session.execute(select(model).filter_by(**filter_by))
|
||||
entity = result.scalar_one()
|
||||
return UpsertResult(entity=entity, created=False)
|
||||
|
||||
return UpsertResult(entity=entity, created=True)
|
||||
|
||||
|
||||
async def bulk_upsert(
|
||||
session: AsyncSession,
|
||||
model: Type[T],
|
||||
unique_columns: List[str],
|
||||
records: List[Dict[str, Any]],
|
||||
update_columns: Optional[List[str]] = None,
|
||||
) -> int:
|
||||
"""
|
||||
대량 Upsert (MySQL ON DUPLICATE KEY UPDATE 사용)
|
||||
|
||||
여러 레코드를 한 번에 Upsert합니다.
|
||||
데드락 방지를 위해 unique 키 기준으로 정렬 후 처리합니다.
|
||||
Unique 인덱스가 반드시 존재해야 합니다.
|
||||
|
||||
Args:
|
||||
session: AsyncSession 인스턴스
|
||||
model: SQLAlchemy 모델 클래스
|
||||
unique_columns: Unique 제약 컬럼 목록
|
||||
records: Upsert할 레코드 딕셔너리 목록
|
||||
update_columns: UPDATE 시 변경할 컬럼 목록
|
||||
|
||||
Returns:
|
||||
int: 처리된 레코드 수
|
||||
|
||||
Example:
|
||||
count = await bulk_upsert(
|
||||
session=session,
|
||||
model=SongTimestamp,
|
||||
unique_columns=['suno_audio_id', 'order_idx'],
|
||||
records=[
|
||||
{'suno_audio_id': 'abc', 'order_idx': 0, 'lyric_line': '가사1'},
|
||||
{'suno_audio_id': 'abc', 'order_idx': 1, 'lyric_line': '가사2'},
|
||||
],
|
||||
)
|
||||
"""
|
||||
if not records:
|
||||
return 0
|
||||
|
||||
# 데드락 방지: unique 키 기준 정렬
|
||||
sorted_records = sorted(
|
||||
records,
|
||||
key=lambda r: tuple(r.get(col, '') for col in unique_columns)
|
||||
)
|
||||
|
||||
# UPDATE 컬럼 결정
|
||||
if update_columns is None:
|
||||
all_columns = set(sorted_records[0].keys())
|
||||
update_columns = list(all_columns - set(unique_columns))
|
||||
|
||||
# MySQL INSERT ... ON DUPLICATE KEY UPDATE
|
||||
stmt = mysql_insert(model).values(sorted_records)
|
||||
|
||||
update_dict = {col: stmt.inserted[col] for col in update_columns}
|
||||
if hasattr(model, 'updated_at'):
|
||||
update_dict['updated_at'] = func.now()
|
||||
|
||||
stmt = stmt.on_duplicate_key_update(**update_dict)
|
||||
|
||||
await session.execute(stmt)
|
||||
return len(sorted_records)
|
||||
|
||||
|
||||
async def execute_with_retry(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 0.1,
|
||||
retry_on: tuple = (OperationalError,),
|
||||
) -> Any:
|
||||
"""
|
||||
지수 백오프를 사용한 재시도 래퍼
|
||||
|
||||
데드락이나 일시적 오류 발생 시 자동으로 재시도합니다.
|
||||
|
||||
Args:
|
||||
func: 실행할 비동기 함수 (인자 없음)
|
||||
max_retries: 최대 재시도 횟수
|
||||
base_delay: 기본 대기 시간 (초)
|
||||
retry_on: 재시도할 예외 타입들
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
|
||||
Example:
|
||||
async def do_work():
|
||||
async with AsyncSessionLocal() as session:
|
||||
await upsert_by_unique_key(...)
|
||||
await session.commit()
|
||||
|
||||
result = await execute_with_retry(do_work)
|
||||
"""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return await func()
|
||||
except retry_on as e:
|
||||
last_exception = e
|
||||
error_msg = str(e).lower()
|
||||
|
||||
# 데드락 또는 잠금 타임아웃인 경우만 재시도
|
||||
if "deadlock" in error_msg or "lock" in error_msg:
|
||||
if attempt < max_retries - 1:
|
||||
delay = base_delay * (2 ** attempt)
|
||||
logger.warning(
|
||||
f"DB 작업 실패 (시도 {attempt + 1}/{max_retries}), "
|
||||
f"{delay:.2f}초 후 재시도: {e}"
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
raise
|
||||
|
||||
raise last_exception
|
||||
|
||||
|
||||
class LockManager:
|
||||
"""
|
||||
분산 잠금 관리자 (MySQL 전용)
|
||||
|
||||
여러 리소스에 대한 잠금을 일관된 순서로 획득하여
|
||||
데드락을 방지합니다.
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession, timeout_sec: int = 5):
|
||||
self.session = session
|
||||
self.timeout_sec = timeout_sec
|
||||
self._locked_resources: List[tuple] = []
|
||||
|
||||
async def __aenter__(self):
|
||||
from sqlalchemy import text
|
||||
# MySQL 잠금 타임아웃 설정
|
||||
await self.session.execute(
|
||||
text(f"SET innodb_lock_wait_timeout = {self.timeout_sec}")
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
# 트랜잭션 종료 시 자동으로 잠금 해제됨
|
||||
self._locked_resources.clear()
|
||||
|
||||
async def lock_rows(
|
||||
self,
|
||||
model: Type[T],
|
||||
ids: List[Any],
|
||||
id_column: str = "id",
|
||||
nowait: bool = False,
|
||||
) -> List[T]:
|
||||
"""
|
||||
여러 행을 ID 순서로 잠금
|
||||
|
||||
Args:
|
||||
model: 모델 클래스
|
||||
ids: 잠금할 ID 목록
|
||||
id_column: ID 컬럼명
|
||||
nowait: 잠금 대기 안함
|
||||
|
||||
Returns:
|
||||
잠긴 엔티티 목록
|
||||
"""
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
# 데드락 방지: ID 정렬
|
||||
sorted_ids = sorted(ids)
|
||||
|
||||
column = getattr(model, id_column)
|
||||
query = (
|
||||
select(model)
|
||||
.where(column.in_(sorted_ids))
|
||||
.order_by(column) # 정렬 순서 유지
|
||||
.with_for_update(nowait=nowait)
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
entities = result.scalars().all()
|
||||
|
||||
self._locked_resources.append((model.__tablename__, sorted_ids))
|
||||
return list(entities)
|
||||
|
||||
async def lock_row(
|
||||
self,
|
||||
model: Type[T],
|
||||
id_value: Any,
|
||||
id_column: str = "id",
|
||||
nowait: bool = False,
|
||||
) -> Optional[T]:
|
||||
"""
|
||||
단일 행 잠금
|
||||
|
||||
Args:
|
||||
model: 모델 클래스
|
||||
id_value: 잠금할 ID
|
||||
id_column: ID 컬럼명
|
||||
nowait: 잠금 대기 안함
|
||||
|
||||
Returns:
|
||||
잠긴 엔티티 또는 None
|
||||
"""
|
||||
entities = await self.lock_rows(model, [id_value], id_column, nowait)
|
||||
return entities[0] if entities else None
|
||||
```
|
||||
|
||||
### 5.2 모델에 Unique Constraint 추가 (권장)
|
||||
|
||||
테이블별 Unique 제약 추가가 필요한 경우:
|
||||
|
||||
```python
|
||||
# app/home/models.py - Image 테이블
|
||||
class Image(Base):
|
||||
__tablename__ = "image"
|
||||
__table_args__ = (
|
||||
# task_id + img_order 조합 유니크
|
||||
Index("idx_image_task_order", "task_id", "img_order", unique=True),
|
||||
...
|
||||
)
|
||||
|
||||
# app/song/models.py - SongTimestamp 테이블
|
||||
class SongTimestamp(Base):
|
||||
__tablename__ = "song_timestamp"
|
||||
__table_args__ = (
|
||||
# suno_audio_id + order_idx 조합 유니크
|
||||
Index("idx_song_ts_audio_order", "suno_audio_id", "order_idx", unique=True),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 사용 예시
|
||||
|
||||
### 6.1 SongTimestamp Bulk Upsert (우선순위 1 - 실제 문제 발생)
|
||||
|
||||
**가장 먼저 적용해야 하는 케이스입니다.** 클라이언트의 상태 폴링으로 인해 동일 데이터가 중복 삽입될 수 있습니다.
|
||||
|
||||
```python
|
||||
# app/song/api/routers/v1/song.py
|
||||
|
||||
from app.utils.db_utils import bulk_upsert
|
||||
|
||||
# 기존 코드 (문제 있음 - 폴링 시 중복 삽입)
|
||||
# for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
|
||||
# song_timestamp = SongTimestamp(...)
|
||||
# session.add(song_timestamp)
|
||||
|
||||
# 개선된 코드 (Upsert로 중복 방지)
|
||||
records = [
|
||||
{
|
||||
'suno_audio_id': suno_audio_id,
|
||||
'order_idx': idx,
|
||||
'lyric_line': ts['text'],
|
||||
'start_time': ts['start_sec'],
|
||||
'end_time': ts['end_sec'],
|
||||
}
|
||||
for idx, ts in enumerate(timestamped_lyrics)
|
||||
]
|
||||
|
||||
await bulk_upsert(
|
||||
session=session,
|
||||
model=SongTimestamp,
|
||||
unique_columns=['suno_audio_id', 'order_idx'],
|
||||
records=records,
|
||||
update_columns=['lyric_line', 'start_time', 'end_time'],
|
||||
)
|
||||
```
|
||||
|
||||
### 6.2 Image 중복 방지 Upsert (우선순위 2)
|
||||
|
||||
```python
|
||||
# app/home/api/routers/v1/home.py
|
||||
|
||||
from app.utils.db_utils import get_or_create_with_lock
|
||||
|
||||
async def save_image(session: AsyncSession, task_id: str, img_url: str, img_order: int):
|
||||
"""이미지 저장 (중복 방지)"""
|
||||
result = await get_or_create_with_lock(
|
||||
session=session,
|
||||
model=Image,
|
||||
filter_by={'task_id': task_id, 'img_order': img_order},
|
||||
defaults={
|
||||
'img_name': f"image_{img_order}",
|
||||
'img_url': img_url,
|
||||
},
|
||||
)
|
||||
|
||||
if not result.created:
|
||||
# 기존 이미지 URL 업데이트
|
||||
result.entity.img_url = img_url
|
||||
|
||||
return result.entity
|
||||
```
|
||||
|
||||
### 6.3 User IntegrityError 처리 (✅ 적용 완료)
|
||||
|
||||
> **참고**: User 테이블은 OAuth 인가 코드의 일회성 특성상 동시 요청 가능성이 **매우 낮습니다**.
|
||||
> `kakao_id` UNIQUE 제약이 있어 중복 시 IntegrityError가 발생하므로,
|
||||
> IntegrityError 처리를 추가하여 500 에러 대신 기존 사용자를 재조회합니다.
|
||||
|
||||
```python
|
||||
# app/user/services/auth.py - 현재 적용된 코드
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
# 신규 사용자 생성 부분
|
||||
session.add(new_user)
|
||||
|
||||
try:
|
||||
await session.flush()
|
||||
await session.refresh(new_user)
|
||||
return new_user, True
|
||||
except IntegrityError:
|
||||
# 동시 요청으로 인한 중복 삽입 시도 - 기존 사용자 조회
|
||||
logger.warning(
|
||||
f"[AUTH] IntegrityError 발생 (동시 요청 추정) - kakao_id: {kakao_id}"
|
||||
)
|
||||
await session.rollback()
|
||||
result = await session.execute(
|
||||
select(User).where(User.kakao_id == kakao_id)
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user is not None:
|
||||
# 프로필 정보 업데이트
|
||||
if profile:
|
||||
existing_user.nickname = profile.nickname
|
||||
existing_user.profile_image_url = profile.profile_image_url
|
||||
existing_user.thumbnail_image_url = profile.thumbnail_image_url
|
||||
if kakao_account and kakao_account.email:
|
||||
existing_user.email = kakao_account.email
|
||||
await session.flush()
|
||||
return existing_user, False
|
||||
|
||||
# 재조회에도 실패한 경우 (매우 드문 경우)
|
||||
raise
|
||||
```
|
||||
|
||||
**이점:**
|
||||
- Upsert 패턴 없이도 안전하게 처리
|
||||
- 기존 코드 구조 유지
|
||||
- 드문 경우의 500 에러 방지
|
||||
|
||||
### 6.4 재시도 로직 사용
|
||||
|
||||
```python
|
||||
from app.utils.db_utils import execute_with_retry, bulk_upsert
|
||||
|
||||
async def safe_timestamp_upsert(suno_audio_id: str, timestamps: list):
|
||||
"""안전한 타임스탬프 Upsert (데드락 시 재시도)"""
|
||||
|
||||
async def do_upsert():
|
||||
async with AsyncSessionLocal() as session:
|
||||
records = [
|
||||
{
|
||||
'suno_audio_id': suno_audio_id,
|
||||
'order_idx': idx,
|
||||
'lyric_line': ts['text'],
|
||||
'start_time': ts['start_sec'],
|
||||
'end_time': ts['end_sec'],
|
||||
}
|
||||
for idx, ts in enumerate(timestamps)
|
||||
]
|
||||
count = await bulk_upsert(
|
||||
session=session,
|
||||
model=SongTimestamp,
|
||||
unique_columns=['suno_audio_id', 'order_idx'],
|
||||
records=records,
|
||||
)
|
||||
await session.commit()
|
||||
return count
|
||||
|
||||
return await execute_with_retry(do_upsert, max_retries=3)
|
||||
```
|
||||
|
||||
### 6.5 LockManager 사용
|
||||
|
||||
```python
|
||||
from app.utils.db_utils import LockManager
|
||||
|
||||
async def update_multiple_resources(session: AsyncSession, project_ids: list[int]):
|
||||
"""여러 프로젝트를 안전하게 업데이트"""
|
||||
|
||||
async with LockManager(session, timeout_sec=10) as lock:
|
||||
# 프로젝트들을 ID 순서로 잠금 (데드락 방지)
|
||||
projects = await lock.lock_rows(Project, project_ids)
|
||||
|
||||
for project in projects:
|
||||
project.status = "updated"
|
||||
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 마이그레이션 가이드
|
||||
|
||||
### 7.1 단계별 적용 순서 (우선순위 기반)
|
||||
|
||||
동시 요청 가능성이 높은 테이블부터 적용합니다:
|
||||
|
||||
1. **1단계**: `app/utils/db_utils.py` 파일 생성 ✅ (이미 완료)
|
||||
2. **2단계**: **SongTimestamp** - Unique 인덱스 추가 + `bulk_upsert` 적용 (우선순위 1)
|
||||
- 동시 요청 가능성 **높음** (폴링으로 인한 중복 삽입)
|
||||
3. **3단계**: **Image** - Unique 인덱스 추가 + `get_or_create_with_lock` 적용 (우선순위 2)
|
||||
- 동시 요청 가능성 **중간** (업로드 재시도)
|
||||
4. **4단계**: **Song/Video** - 필요시 `get_or_create_with_lock` 적용 (우선순위 3-4)
|
||||
- 동시 요청 가능성 중간 (백그라운드 태스크)
|
||||
5. **5단계**: **User** - ✅ IntegrityError 처리 추가 완료
|
||||
- 동시 요청 가능성 **낮음** (OAuth 인가 코드 일회성)
|
||||
- `kakao_id` UNIQUE 제약 + IntegrityError 발생 시 기존 사용자 재조회 처리
|
||||
|
||||
> **권장**: 1~3단계까지 우선 적용하고, 나머지는 필요에 따라 적용
|
||||
|
||||
### 7.2 Alembic 마이그레이션 예시
|
||||
|
||||
```python
|
||||
"""Add unique constraints for upsert support
|
||||
|
||||
Revision ID: xxxx
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
def upgrade():
|
||||
# SongTimestamp: suno_audio_id + order_idx unique
|
||||
op.create_index(
|
||||
'idx_song_ts_audio_order',
|
||||
'song_timestamp',
|
||||
['suno_audio_id', 'order_idx'],
|
||||
unique=True
|
||||
)
|
||||
|
||||
# Image: task_id + img_order unique (선택적)
|
||||
op.create_index(
|
||||
'idx_image_task_order',
|
||||
'image',
|
||||
['task_id', 'img_order'],
|
||||
unique=True
|
||||
)
|
||||
|
||||
def downgrade():
|
||||
op.drop_index('idx_song_ts_audio_order', table_name='song_timestamp')
|
||||
op.drop_index('idx_image_task_order', table_name='image')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 모니터링 및 디버깅
|
||||
|
||||
### 8.1 잠금 모니터링 쿼리 (MySQL)
|
||||
|
||||
```sql
|
||||
-- 현재 잠금 상태 확인 (InnoDB)
|
||||
SELECT
|
||||
r.trx_id AS waiting_trx_id,
|
||||
r.trx_mysql_thread_id AS waiting_thread,
|
||||
r.trx_query AS waiting_query,
|
||||
b.trx_id AS blocking_trx_id,
|
||||
b.trx_mysql_thread_id AS blocking_thread,
|
||||
b.trx_query AS blocking_query
|
||||
FROM information_schema.innodb_lock_waits w
|
||||
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
|
||||
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
|
||||
|
||||
-- 현재 실행 중인 트랜잭션 확인
|
||||
SELECT * FROM information_schema.innodb_trx;
|
||||
|
||||
-- 데드락 로그 확인
|
||||
SHOW ENGINE INNODB STATUS;
|
||||
|
||||
-- 잠금 대기 중인 쿼리 확인
|
||||
SELECT
|
||||
pl.id,
|
||||
pl.user,
|
||||
pl.state,
|
||||
pl.info AS query
|
||||
FROM information_schema.processlist pl
|
||||
WHERE pl.state LIKE '%lock%';
|
||||
```
|
||||
|
||||
### 8.2 로깅 설정
|
||||
|
||||
```python
|
||||
# config.py 또는 logging 설정
|
||||
import logging
|
||||
|
||||
# SQLAlchemy 엔진 로깅 (디버그 시)
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
||||
|
||||
# Upsert 유틸리티 로깅
|
||||
logging.getLogger('app.utils.db_utils').setLevel(logging.DEBUG)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 요약
|
||||
|
||||
| 상황 | 권장 패턴 | 함수 |
|
||||
|------|-----------|------|
|
||||
| 단일 레코드 Upsert (Unique 키 존재) | ON CONFLICT | `upsert_by_unique_key()` |
|
||||
| 단일 레코드 Get or Create | SELECT FOR UPDATE | `get_or_create_with_lock()` |
|
||||
| 대량 레코드 Upsert | Bulk ON CONFLICT | `bulk_upsert()` |
|
||||
| 데드락 방지 재시도 | 지수 백오프 | `execute_with_retry()` |
|
||||
| 다중 행 잠금 | 정렬된 순서 잠금 | `LockManager` |
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2026-01-26
|
||||
**작성자**: Claude Code (AI Assistant)
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
# API 타임아웃 및 재시도 로직 개선 계획
|
||||
|
||||
## 개요
|
||||
|
||||
외부 API 호출 시 타임아웃 미설정 및 재시도 로직 부재로 인한 안정성 문제를 해결합니다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| 모듈 | 외부 API | 타임아웃 | 재시도 |
|
||||
|------|----------|----------|--------|
|
||||
| Lyric | ChatGPT (OpenAI) | ❌ 미설정 (SDK 기본 ~600초) | ❌ 없음 |
|
||||
| Song | Suno API | ✅ 30-120초 | ❌ 없음 |
|
||||
| Video | Creatomate API | ✅ 30-60초 | ❌ 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 수정 계획
|
||||
|
||||
### 1. ChatGPT API 타임아웃 설정
|
||||
|
||||
**파일:** `app/utils/chatgpt_prompt.py`
|
||||
|
||||
**현재 코드:**
|
||||
```python
|
||||
class ChatgptService:
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=apikey_settings.CHATGPT_API_KEY)
|
||||
```
|
||||
|
||||
**수정 코드:**
|
||||
```python
|
||||
class ChatgptService:
|
||||
# 타임아웃 설정 (초)
|
||||
DEFAULT_TIMEOUT = 60.0 # 전체 타임아웃
|
||||
CONNECT_TIMEOUT = 10.0 # 연결 타임아웃
|
||||
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||
timeout=httpx.Timeout(
|
||||
self.DEFAULT_TIMEOUT,
|
||||
connect=self.CONNECT_TIMEOUT,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**필요한 import 추가:**
|
||||
```python
|
||||
import httpx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 재시도 유틸리티 함수 생성
|
||||
|
||||
**파일:** `app/utils/retry.py` (새 파일)
|
||||
|
||||
```python
|
||||
"""
|
||||
API 호출 재시도 유틸리티
|
||||
|
||||
지수 백오프(Exponential Backoff)를 사용한 재시도 로직을 제공합니다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import wraps
|
||||
from typing import Callable, Tuple, Type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RetryExhaustedError(Exception):
|
||||
"""모든 재시도 실패 시 발생하는 예외"""
|
||||
def __init__(self, message: str, last_exception: Exception):
|
||||
super().__init__(message)
|
||||
self.last_exception = last_exception
|
||||
|
||||
|
||||
async def retry_async(
|
||||
func: Callable,
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
exponential_base: float = 2.0,
|
||||
retry_on: Tuple[Type[Exception], ...] = (Exception,),
|
||||
on_retry: Callable[[int, Exception], None] | None = None,
|
||||
):
|
||||
"""
|
||||
비동기 함수 재시도 실행
|
||||
|
||||
Args:
|
||||
func: 실행할 비동기 함수 (인자 없음)
|
||||
max_retries: 최대 재시도 횟수 (기본: 3)
|
||||
base_delay: 첫 번째 재시도 대기 시간 (초)
|
||||
max_delay: 최대 대기 시간 (초)
|
||||
exponential_base: 지수 백오프 배수 (기본: 2.0)
|
||||
retry_on: 재시도할 예외 타입들
|
||||
on_retry: 재시도 시 호출될 콜백 (attempt, exception)
|
||||
|
||||
Returns:
|
||||
함수 실행 결과
|
||||
|
||||
Raises:
|
||||
RetryExhaustedError: 모든 재시도 실패 시
|
||||
|
||||
Example:
|
||||
result = await retry_async(
|
||||
lambda: api_call(),
|
||||
max_retries=3,
|
||||
retry_on=(httpx.TimeoutException, httpx.HTTPStatusError),
|
||||
)
|
||||
"""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
return await func()
|
||||
except retry_on as e:
|
||||
last_exception = e
|
||||
|
||||
if attempt == max_retries:
|
||||
break
|
||||
|
||||
# 지수 백오프 계산
|
||||
delay = min(base_delay * (exponential_base ** attempt), max_delay)
|
||||
|
||||
logger.warning(
|
||||
f"[retry_async] 시도 {attempt + 1}/{max_retries + 1} 실패, "
|
||||
f"{delay:.1f}초 후 재시도: {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
if on_retry:
|
||||
on_retry(attempt + 1, e)
|
||||
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
raise RetryExhaustedError(
|
||||
f"최대 재시도 횟수({max_retries + 1}회) 초과",
|
||||
last_exception,
|
||||
)
|
||||
|
||||
|
||||
def with_retry(
|
||||
max_retries: int = 3,
|
||||
base_delay: float = 1.0,
|
||||
max_delay: float = 30.0,
|
||||
retry_on: Tuple[Type[Exception], ...] = (Exception,),
|
||||
):
|
||||
"""
|
||||
재시도 데코레이터
|
||||
|
||||
Args:
|
||||
max_retries: 최대 재시도 횟수
|
||||
base_delay: 첫 번째 재시도 대기 시간 (초)
|
||||
max_delay: 최대 대기 시간 (초)
|
||||
retry_on: 재시도할 예외 타입들
|
||||
|
||||
Example:
|
||||
@with_retry(max_retries=3, retry_on=(httpx.TimeoutException,))
|
||||
async def call_api():
|
||||
...
|
||||
"""
|
||||
def decorator(func: Callable):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
return await retry_async(
|
||||
lambda: func(*args, **kwargs),
|
||||
max_retries=max_retries,
|
||||
base_delay=base_delay,
|
||||
max_delay=max_delay,
|
||||
retry_on=retry_on,
|
||||
)
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Suno API 재시도 로직 적용
|
||||
|
||||
**파일:** `app/utils/suno.py`
|
||||
|
||||
**수정 대상 메서드:**
|
||||
- `generate()` - 노래 생성 요청
|
||||
- `get_task_status()` - 상태 조회
|
||||
- `get_lyric_timestamp()` - 타임스탬프 조회
|
||||
|
||||
**수정 예시 (generate 메서드):**
|
||||
|
||||
```python
|
||||
# 상단 import 추가
|
||||
import httpx
|
||||
from app.utils.retry import retry_async
|
||||
|
||||
# 재시도 대상 예외 정의
|
||||
RETRY_EXCEPTIONS = (
|
||||
httpx.TimeoutException,
|
||||
httpx.ConnectError,
|
||||
httpx.ReadError,
|
||||
)
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
genre: str | None = None,
|
||||
callback_url: str | None = None,
|
||||
) -> str:
|
||||
# ... 기존 payload 구성 코드 ...
|
||||
|
||||
async def _call_api():
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE_URL}/generate",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# 재시도 로직 적용
|
||||
data = await retry_async(
|
||||
_call_api,
|
||||
max_retries=3,
|
||||
base_delay=1.0,
|
||||
retry_on=RETRY_EXCEPTIONS,
|
||||
)
|
||||
|
||||
# ... 기존 응답 처리 코드 ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Creatomate API 재시도 로직 적용
|
||||
|
||||
**파일:** `app/utils/creatomate.py`
|
||||
|
||||
**수정 대상:**
|
||||
- `_request()` 메서드 (모든 API 호출의 기반)
|
||||
|
||||
**수정 코드:**
|
||||
|
||||
```python
|
||||
# 상단 import 추가
|
||||
from app.utils.retry import retry_async
|
||||
|
||||
# 재시도 대상 예외 정의
|
||||
RETRY_EXCEPTIONS = (
|
||||
httpx.TimeoutException,
|
||||
httpx.ConnectError,
|
||||
httpx.ReadError,
|
||||
)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""HTTP 요청을 수행합니다 (재시도 로직 포함)."""
|
||||
logger.info(f"[Creatomate] {method} {url}")
|
||||
|
||||
async def _call():
|
||||
client = await get_shared_client()
|
||||
if method.upper() == "GET":
|
||||
response = await client.get(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
elif method.upper() == "POST":
|
||||
response = await client.post(
|
||||
url, headers=self.headers, timeout=timeout, **kwargs
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
response = await retry_async(
|
||||
_call,
|
||||
max_retries=max_retries,
|
||||
base_delay=1.0,
|
||||
retry_on=RETRY_EXCEPTIONS,
|
||||
)
|
||||
|
||||
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
|
||||
return response
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. ChatGPT API 재시도 로직 적용
|
||||
|
||||
**파일:** `app/utils/chatgpt_prompt.py`
|
||||
|
||||
**수정 코드:**
|
||||
|
||||
```python
|
||||
# 상단 import 추가
|
||||
import httpx
|
||||
from openai import APITimeoutError, APIConnectionError, RateLimitError
|
||||
from app.utils.retry import retry_async
|
||||
|
||||
# 재시도 대상 예외 정의
|
||||
RETRY_EXCEPTIONS = (
|
||||
APITimeoutError,
|
||||
APIConnectionError,
|
||||
RateLimitError,
|
||||
)
|
||||
|
||||
class ChatgptService:
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
CONNECT_TIMEOUT = 10.0
|
||||
MAX_RETRIES = 3
|
||||
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||
timeout=httpx.Timeout(
|
||||
self.DEFAULT_TIMEOUT,
|
||||
connect=self.CONNECT_TIMEOUT,
|
||||
),
|
||||
)
|
||||
|
||||
async def _call_structured_output_with_response_gpt_api(
|
||||
self, prompt: str, output_format: dict, model: str
|
||||
) -> dict:
|
||||
content = [{"type": "input_text", "text": prompt}]
|
||||
|
||||
async def _call():
|
||||
response = await self.client.responses.create(
|
||||
model=model,
|
||||
input=[{"role": "user", "content": content}],
|
||||
text=output_format,
|
||||
)
|
||||
return json.loads(response.output_text) or {}
|
||||
|
||||
return await retry_async(
|
||||
_call,
|
||||
max_retries=self.MAX_RETRIES,
|
||||
base_delay=2.0, # OpenAI Rate Limit 대비 더 긴 대기
|
||||
retry_on=RETRY_EXCEPTIONS,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 타임아웃 설정 권장값
|
||||
|
||||
| API | 용도 | 권장 타임아웃 | 재시도 횟수 | 재시도 간격 |
|
||||
|-----|------|---------------|-------------|-------------|
|
||||
| ChatGPT | 가사 생성 | 60초 | 3회 | 2초 → 4초 → 8초 |
|
||||
| Suno | 노래 생성 요청 | 30초 | 3회 | 1초 → 2초 → 4초 |
|
||||
| Suno | 상태 조회 | 30초 | 2회 | 1초 → 2초 |
|
||||
| Suno | 타임스탬프 | 120초 | 2회 | 2초 → 4초 |
|
||||
| Creatomate | 템플릿 조회 | 30초 | 2회 | 1초 → 2초 |
|
||||
| Creatomate | 렌더링 요청 | 60초 | 3회 | 1초 → 2초 → 4초 |
|
||||
| Creatomate | 상태 조회 | 30초 | 2회 | 1초 → 2초 |
|
||||
|
||||
---
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. **1단계: retry.py 유틸리티 생성**
|
||||
- 재사용 가능한 재시도 로직 구현
|
||||
- 단위 테스트 작성
|
||||
|
||||
2. **2단계: ChatGPT 타임아웃 설정**
|
||||
- 가장 시급한 문제 (현재 600초 기본값)
|
||||
- 타임아웃 + 재시도 동시 적용
|
||||
|
||||
3. **3단계: Suno API 재시도 적용**
|
||||
- generate(), get_task_status(), get_lyric_timestamp()
|
||||
|
||||
4. **4단계: Creatomate API 재시도 적용**
|
||||
- _request() 메서드 수정으로 전체 적용
|
||||
|
||||
---
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
각 수정 후 확인 사항:
|
||||
|
||||
- [ ] 정상 요청 시 기존과 동일하게 동작
|
||||
- [ ] 타임아웃 발생 시 지정된 시간 내 예외 발생
|
||||
- [ ] 일시적 오류 시 재시도 후 성공
|
||||
- [ ] 모든 재시도 실패 시 적절한 에러 메시지 반환
|
||||
- [ ] 로그에 재시도 시도 기록 확인
|
||||
|
||||
---
|
||||
|
||||
## 롤백 계획
|
||||
|
||||
문제 발생 시:
|
||||
1. retry.py 사용 코드 제거 (기존 직접 호출로 복구)
|
||||
2. ChatGPT 타임아웃 설정 제거 (SDK 기본값으로 복구)
|
||||
|
||||
---
|
||||
|
||||
## 참고 사항
|
||||
|
||||
- OpenAI SDK는 내부적으로 일부 재시도 로직이 있으나, 커스텀 제어가 제한적
|
||||
- httpx의 `TimeoutException`은 `ConnectTimeout`, `ReadTimeout`, `WriteTimeout`, `PoolTimeout`을 포함
|
||||
- Rate Limit 에러(429)는 재시도 시 더 긴 대기 시간 필요 (Retry-After 헤더 참고)
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
# INSERT → Upsert 변환 계획서
|
||||
|
||||
## 개요
|
||||
|
||||
이 문서는 프로젝트 내 INSERT 코드들을 **존재 여부 확인 후 INSERT 또는 DB Lock + UPDATE** 패턴으로 변환하기 위한 계획입니다.
|
||||
|
||||
### 적용 범위
|
||||
- ✅ 포함: video, song, lyric 모듈의 INSERT 코드
|
||||
- ❌ 제외: user 모듈, 이미지 업로드 엔드포인트
|
||||
|
||||
### 사용할 유틸리티
|
||||
- `app/utils/db_utils.py`의 `get_or_create_with_lock()` 함수
|
||||
- `app/utils/db_utils.py`의 `bulk_upsert()` 함수 (대량 INSERT용)
|
||||
|
||||
---
|
||||
|
||||
## 대상 INSERT 위치 목록
|
||||
|
||||
| # | 파일 | 라인 | 테이블 | 키 컬럼 | 우선순위 |
|
||||
|---|------|------|--------|---------|----------|
|
||||
| 1 | video.py | 252 | Video | task_id | 중 |
|
||||
| 2 | song.py | 188 | Song | task_id | 중 |
|
||||
| 3 | song.py | 477 | SongTimestamp | suno_audio_id + order_idx | **높음** |
|
||||
| 4 | lyric.py | 297 | Project | task_id | 중 |
|
||||
| 5 | lyric.py | 317 | Lyric | task_id | 중 |
|
||||
|
||||
---
|
||||
|
||||
## 1. Video INSERT (video.py:252)
|
||||
|
||||
### 현재 코드
|
||||
```python
|
||||
# app/video/api/routers/v1/video.py:244-254
|
||||
|
||||
video = Video(
|
||||
project_id=project_id,
|
||||
lyric_id=lyric_id,
|
||||
song_id=song_id,
|
||||
task_id=task_id,
|
||||
creatomate_render_id=None,
|
||||
status="processing",
|
||||
)
|
||||
session.add(video)
|
||||
await session.commit()
|
||||
video_id = video.id
|
||||
```
|
||||
|
||||
### 문제점
|
||||
- `task_id`로 기존 레코드 존재 여부를 확인하지 않음
|
||||
- 동일한 `task_id`로 재요청 시 중복 INSERT 발생 가능
|
||||
|
||||
### 수정 계획
|
||||
|
||||
```python
|
||||
# app/video/api/routers/v1/video.py
|
||||
|
||||
# 상단 import 추가
|
||||
from app.utils.db_utils import get_or_create_with_lock
|
||||
|
||||
# 기존 코드 대체 (244-254행)
|
||||
result = await get_or_create_with_lock(
|
||||
session=session,
|
||||
model=Video,
|
||||
filter_by={'task_id': task_id},
|
||||
defaults={
|
||||
'project_id': project_id,
|
||||
'lyric_id': lyric_id,
|
||||
'song_id': song_id,
|
||||
'creatomate_render_id': None,
|
||||
'status': 'processing',
|
||||
},
|
||||
lock=True,
|
||||
)
|
||||
|
||||
video = result.entity
|
||||
if not result.created:
|
||||
# 이미 존재하는 경우: 상태 업데이트
|
||||
video.project_id = project_id
|
||||
video.lyric_id = lyric_id
|
||||
video.song_id = song_id
|
||||
video.status = 'processing'
|
||||
video.creatomate_render_id = None
|
||||
|
||||
await session.commit()
|
||||
video_id = video.id
|
||||
```
|
||||
|
||||
### 필수 사전 작업
|
||||
- Video 테이블에 `task_id` UNIQUE 인덱스 추가 필요
|
||||
|
||||
```sql
|
||||
ALTER TABLE video ADD UNIQUE INDEX idx_video_task_id (task_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Song INSERT (song.py:188)
|
||||
|
||||
### 현재 코드
|
||||
```python
|
||||
# app/song/api/routers/v1/song.py:179-191
|
||||
|
||||
song = Song(
|
||||
project_id=project_id,
|
||||
lyric_id=lyric_id,
|
||||
task_id=task_id,
|
||||
suno_task_id=None,
|
||||
status="processing",
|
||||
song_prompt=song_prompt,
|
||||
language=request_body.language,
|
||||
)
|
||||
session.add(song)
|
||||
await session.commit()
|
||||
song_id = song.id
|
||||
```
|
||||
|
||||
### 문제점
|
||||
- `task_id`로 기존 레코드 존재 여부를 확인하지 않음
|
||||
- 동일한 `task_id`로 재요청 시 중복 INSERT 발생 가능
|
||||
|
||||
### 수정 계획
|
||||
|
||||
```python
|
||||
# app/song/api/routers/v1/song.py
|
||||
|
||||
# 상단 import 추가
|
||||
from app.utils.db_utils import get_or_create_with_lock
|
||||
|
||||
# 기존 코드 대체 (179-191행)
|
||||
result = await get_or_create_with_lock(
|
||||
session=session,
|
||||
model=Song,
|
||||
filter_by={'task_id': task_id},
|
||||
defaults={
|
||||
'project_id': project_id,
|
||||
'lyric_id': lyric_id,
|
||||
'suno_task_id': None,
|
||||
'status': 'processing',
|
||||
'song_prompt': song_prompt,
|
||||
'language': request_body.language,
|
||||
},
|
||||
lock=True,
|
||||
)
|
||||
|
||||
song = result.entity
|
||||
if not result.created:
|
||||
# 이미 존재하는 경우: 상태 업데이트
|
||||
song.project_id = project_id
|
||||
song.lyric_id = lyric_id
|
||||
song.status = 'processing'
|
||||
song.song_prompt = song_prompt
|
||||
song.language = request_body.language
|
||||
|
||||
await session.commit()
|
||||
song_id = song.id
|
||||
```
|
||||
|
||||
### 필수 사전 작업
|
||||
- Song 테이블에 `task_id` UNIQUE 인덱스 추가 필요
|
||||
|
||||
```sql
|
||||
ALTER TABLE song ADD UNIQUE INDEX idx_song_task_id (task_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. SongTimestamp INSERT (song.py:477) ⚠️ 높은 우선순위
|
||||
|
||||
### 현재 코드
|
||||
```python
|
||||
# app/song/api/routers/v1/song.py:467-479
|
||||
|
||||
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
|
||||
song_timestamp = SongTimestamp(
|
||||
suno_audio_id=suno_audio_id,
|
||||
order_idx=order_idx,
|
||||
lyric_line=timestamped_lyric["text"],
|
||||
start_time=timestamped_lyric["start_sec"],
|
||||
end_time=timestamped_lyric["end_sec"],
|
||||
)
|
||||
session.add(song_timestamp)
|
||||
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
### 문제점
|
||||
- **폴링 기반 요청으로 인해 중복 INSERT 위험이 높음**
|
||||
- `suno_audio_id` + `order_idx` 조합으로 존재 여부 확인 없음
|
||||
- 여러 행을 루프에서 개별 INSERT하여 비효율적
|
||||
|
||||
### 수정 계획 (bulk_upsert 사용)
|
||||
|
||||
```python
|
||||
# app/song/api/routers/v1/song.py
|
||||
|
||||
# 상단 import 추가
|
||||
from app.utils.db_utils import bulk_upsert
|
||||
|
||||
# 기존 코드 대체 (467-479행)
|
||||
timestamp_records = [
|
||||
{
|
||||
'suno_audio_id': suno_audio_id,
|
||||
'order_idx': order_idx,
|
||||
'lyric_line': timestamped_lyric["text"],
|
||||
'start_time': timestamped_lyric["start_sec"],
|
||||
'end_time': timestamped_lyric["end_sec"],
|
||||
}
|
||||
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics)
|
||||
]
|
||||
|
||||
await bulk_upsert(
|
||||
session=session,
|
||||
model=SongTimestamp,
|
||||
unique_columns=['suno_audio_id', 'order_idx'],
|
||||
records=timestamp_records,
|
||||
update_columns=['lyric_line', 'start_time', 'end_time'],
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
### 필수 사전 작업
|
||||
- SongTimestamp 테이블에 복합 UNIQUE 인덱스 추가 필요
|
||||
|
||||
```sql
|
||||
ALTER TABLE song_timestamp
|
||||
ADD UNIQUE INDEX idx_song_timestamp_audio_order (suno_audio_id, order_idx);
|
||||
```
|
||||
|
||||
### 대안: get_or_create_with_lock 사용 (개별 처리)
|
||||
|
||||
만약 `bulk_upsert`를 사용하지 않고 개별 처리가 필요한 경우:
|
||||
|
||||
```python
|
||||
from app.utils.db_utils import get_or_create_with_lock
|
||||
|
||||
for order_idx, timestamped_lyric in enumerate(timestamped_lyrics):
|
||||
result = await get_or_create_with_lock(
|
||||
session=session,
|
||||
model=SongTimestamp,
|
||||
filter_by={
|
||||
'suno_audio_id': suno_audio_id,
|
||||
'order_idx': order_idx,
|
||||
},
|
||||
defaults={
|
||||
'lyric_line': timestamped_lyric["text"],
|
||||
'start_time': timestamped_lyric["start_sec"],
|
||||
'end_time': timestamped_lyric["end_sec"],
|
||||
},
|
||||
lock=True,
|
||||
)
|
||||
|
||||
if not result.created:
|
||||
# 이미 존재하는 경우: 업데이트
|
||||
result.entity.lyric_line = timestamped_lyric["text"]
|
||||
result.entity.start_time = timestamped_lyric["start_sec"]
|
||||
result.entity.end_time = timestamped_lyric["end_sec"]
|
||||
|
||||
await session.commit()
|
||||
```
|
||||
|
||||
**권장사항**: `bulk_upsert` 사용 (성능 및 데드락 방지 측면에서 우수)
|
||||
|
||||
---
|
||||
|
||||
## 4. Project INSERT (lyric.py:297)
|
||||
|
||||
### 현재 코드
|
||||
```python
|
||||
# app/lyric/api/routers/v1/lyric.py:290-299
|
||||
|
||||
project = Project(
|
||||
store_name=request_body.customer_name,
|
||||
region=request_body.region,
|
||||
task_id=task_id,
|
||||
detail_region_info=request_body.detail_region_info,
|
||||
language=request_body.language,
|
||||
)
|
||||
session.add(project)
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
```
|
||||
|
||||
### 문제점
|
||||
- `task_id`로 기존 레코드 존재 여부를 확인하지 않음
|
||||
- 동일한 `task_id`로 재요청 시 중복 INSERT 발생 가능
|
||||
|
||||
### 수정 계획
|
||||
|
||||
```python
|
||||
# app/lyric/api/routers/v1/lyric.py
|
||||
|
||||
# 상단 import 추가
|
||||
from app.utils.db_utils import get_or_create_with_lock
|
||||
|
||||
# 기존 코드 대체 (290-299행)
|
||||
result = await get_or_create_with_lock(
|
||||
session=session,
|
||||
model=Project,
|
||||
filter_by={'task_id': task_id},
|
||||
defaults={
|
||||
'store_name': request_body.customer_name,
|
||||
'region': request_body.region,
|
||||
'detail_region_info': request_body.detail_region_info,
|
||||
'language': request_body.language,
|
||||
},
|
||||
lock=True,
|
||||
)
|
||||
|
||||
project = result.entity
|
||||
if not result.created:
|
||||
# 이미 존재하는 경우: 정보 업데이트
|
||||
project.store_name = request_body.customer_name
|
||||
project.region = request_body.region
|
||||
project.detail_region_info = request_body.detail_region_info
|
||||
project.language = request_body.language
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(project)
|
||||
```
|
||||
|
||||
### 필수 사전 작업
|
||||
- Project 테이블에 `task_id` UNIQUE 인덱스 추가 필요
|
||||
|
||||
```sql
|
||||
ALTER TABLE project ADD UNIQUE INDEX idx_project_task_id (task_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Lyric INSERT (lyric.py:317)
|
||||
|
||||
### 현재 코드
|
||||
```python
|
||||
# app/lyric/api/routers/v1/lyric.py:308-319
|
||||
|
||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||
lyric = Lyric(
|
||||
project_id=project.id,
|
||||
task_id=task_id,
|
||||
status="processing",
|
||||
lyric_prompt=estimated_prompt,
|
||||
lyric_result=None,
|
||||
language=request_body.language,
|
||||
)
|
||||
session.add(lyric)
|
||||
await session.commit()
|
||||
await session.refresh(lyric)
|
||||
```
|
||||
|
||||
### 문제점
|
||||
- `task_id`로 기존 레코드 존재 여부를 확인하지 않음
|
||||
- 동일한 `task_id`로 재요청 시 중복 INSERT 발생 가능
|
||||
|
||||
### 수정 계획
|
||||
|
||||
```python
|
||||
# app/lyric/api/routers/v1/lyric.py
|
||||
|
||||
# 상단 import (이미 추가되어 있음)
|
||||
from app.utils.db_utils import get_or_create_with_lock
|
||||
|
||||
# 기존 코드 대체 (308-319행)
|
||||
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||
|
||||
result = await get_or_create_with_lock(
|
||||
session=session,
|
||||
model=Lyric,
|
||||
filter_by={'task_id': task_id},
|
||||
defaults={
|
||||
'project_id': project.id,
|
||||
'status': 'processing',
|
||||
'lyric_prompt': estimated_prompt,
|
||||
'lyric_result': None,
|
||||
'language': request_body.language,
|
||||
},
|
||||
lock=True,
|
||||
)
|
||||
|
||||
lyric = result.entity
|
||||
if not result.created:
|
||||
# 이미 존재하는 경우: 정보 업데이트
|
||||
lyric.project_id = project.id
|
||||
lyric.status = 'processing'
|
||||
lyric.lyric_prompt = estimated_prompt
|
||||
lyric.lyric_result = None
|
||||
lyric.language = request_body.language
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(lyric)
|
||||
```
|
||||
|
||||
### 필수 사전 작업
|
||||
- Lyric 테이블에 `task_id` UNIQUE 인덱스 추가 필요
|
||||
|
||||
```sql
|
||||
ALTER TABLE lyric ADD UNIQUE INDEX idx_lyric_task_id (task_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 순서 권장
|
||||
|
||||
1. **1단계: DB 마이그레이션** (필수)
|
||||
```sql
|
||||
-- 모든 UNIQUE 인덱스 추가
|
||||
ALTER TABLE video ADD UNIQUE INDEX idx_video_task_id (task_id);
|
||||
ALTER TABLE song ADD UNIQUE INDEX idx_song_task_id (task_id);
|
||||
ALTER TABLE song_timestamp ADD UNIQUE INDEX idx_song_timestamp_audio_order (suno_audio_id, order_idx);
|
||||
ALTER TABLE project ADD UNIQUE INDEX idx_project_task_id (task_id);
|
||||
ALTER TABLE lyric ADD UNIQUE INDEX idx_lyric_task_id (task_id);
|
||||
```
|
||||
|
||||
2. **2단계: SongTimestamp (높은 우선순위)**
|
||||
- 폴링으로 인한 중복 INSERT 위험이 가장 높음
|
||||
- `bulk_upsert` 사용 권장
|
||||
|
||||
3. **3단계: 나머지 테이블 (중간 우선순위)**
|
||||
- Video, Song, Project, Lyric 순으로 적용
|
||||
- 모두 `get_or_create_with_lock` 사용
|
||||
|
||||
---
|
||||
|
||||
## 롤백 계획
|
||||
|
||||
문제 발생 시 원래 코드로 복구:
|
||||
|
||||
1. 코드 변경 사항 git revert
|
||||
2. UNIQUE 인덱스는 유지 (데이터 무결성에 도움됨)
|
||||
|
||||
---
|
||||
|
||||
## 테스트 체크리스트
|
||||
|
||||
각 수정 후 확인 사항:
|
||||
|
||||
- [ ] 새로운 task_id로 요청 시 정상 INSERT
|
||||
- [ ] 동일한 task_id로 재요청 시 UPDATE (에러 없이)
|
||||
- [ ] 동시 요청 테스트 (2개 이상 동시 요청)
|
||||
- [ ] 성능 저하 없는지 확인
|
||||
|
||||
---
|
||||
|
||||
## 참고 문서
|
||||
|
||||
- [db_lock.md](./db_lock.md) - DB Lock 및 Upsert 패턴 가이드
|
||||
- [db_utils.py](../../app/utils/db_utils.py) - 유틸리티 함수 구현체
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
# Database INSERT 코드 위치 정리
|
||||
|
||||
이 문서는 O2O Castad Backend 프로젝트에서 실제 동작하는 모든 DB INSERT 코드의 위치를 정리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. User 모듈 (`app/user`)
|
||||
|
||||
### 1.1 User 모델 INSERT
|
||||
- **파일**: `app/user/services/auth.py`
|
||||
- **라인**: 278
|
||||
- **메서드**: `_get_or_create_user()`
|
||||
- **API 엔드포인트**:
|
||||
- `GET /auth/kakao/callback` - 카카오 로그인 콜백
|
||||
- `POST /auth/kakao/verify` - 카카오 인가 코드 검증
|
||||
- **설명**: 카카오 로그인 시 신규 사용자 생성
|
||||
- **저장 필드**: `kakao_id`, `email`, `nickname`, `profile_image_url`, `thumbnail_image_url`
|
||||
|
||||
### 1.2 RefreshToken 모델 INSERT
|
||||
- **파일**: `app/user/services/auth.py`
|
||||
- **라인**: 315
|
||||
- **메서드**: `_save_refresh_token()`
|
||||
- **API 엔드포인트**:
|
||||
- `GET /auth/kakao/callback` - 카카오 로그인 콜백
|
||||
- `POST /auth/kakao/verify` - 카카오 인가 코드 검증
|
||||
- **설명**: 로그인 시 리프레시 토큰 저장
|
||||
- **저장 필드**: `user_id`, `token_hash`, `expires_at`, `user_agent`, `ip_address`
|
||||
|
||||
---
|
||||
|
||||
## 2. Lyric 모듈 (`app/lyric`)
|
||||
|
||||
### 2.1 Project 모델 INSERT
|
||||
- **파일**: `app/lyric/api/routers/v1/lyric.py`
|
||||
- **라인**: 297
|
||||
- **API 엔드포인트**: `POST /lyric/generate` - 가사 생성
|
||||
- **설명**: 가사 생성 요청 시 프로젝트 레코드 생성
|
||||
- **저장 필드**: `store_name`, `region`, `task_id`, `detail_region_info`, `language`
|
||||
|
||||
### 2.2 Lyric 모델 INSERT
|
||||
- **파일**: `app/lyric/api/routers/v1/lyric.py`
|
||||
- **라인**: 317
|
||||
- **API 엔드포인트**: `POST /lyric/generate` - 가사 생성
|
||||
- **설명**: 가사 생성 요청 시 가사 레코드 생성 (초기 status: `processing`)
|
||||
- **저장 필드**: `project_id`, `task_id`, `status`, `lyric_prompt`, `lyric_result`, `language`
|
||||
|
||||
---
|
||||
|
||||
## 3. Song 모듈 (`app/song`)
|
||||
|
||||
### 3.1 Song 모델 INSERT
|
||||
- **파일**: `app/song/api/routers/v1/song.py`
|
||||
- **라인**: 178
|
||||
- **API 엔드포인트**: `POST /song/generate/{task_id}` - 노래 생성
|
||||
- **설명**: 노래 생성 요청 시 노래 레코드 생성 (초기 status: `processing`)
|
||||
- **저장 필드**: `project_id`, `lyric_id`, `task_id`, `suno_task_id`, `status`, `song_prompt`, `language`
|
||||
|
||||
### 3.2 SongTimestamp 모델 INSERT
|
||||
- **파일**: `app/song/api/routers/v1/song.py`
|
||||
- **라인**: 459
|
||||
- **API 엔드포인트**: `GET /song/status/{song_id}` - 노래 생성 상태 조회
|
||||
- **설명**: Suno API에서 노래 생성 완료(SUCCESS) 시 가사 타임스탬프 저장 (반복문으로 다건 INSERT)
|
||||
- **저장 필드**: `suno_audio_id`, `order_idx`, `lyric_line`, `start_time`, `end_time`
|
||||
|
||||
---
|
||||
|
||||
## 4. Video 모듈 (`app/video`)
|
||||
|
||||
### 4.1 Video 모델 INSERT
|
||||
- **파일**: `app/video/api/routers/v1/video.py`
|
||||
- **라인**: 247
|
||||
- **API 엔드포인트**: `GET /video/generate/{task_id}` - 영상 생성
|
||||
- **설명**: 영상 생성 요청 시 영상 레코드 생성 (초기 status: `processing`)
|
||||
- **저장 필드**: `project_id`, `lyric_id`, `song_id`, `task_id`, `creatomate_render_id`, `status`
|
||||
|
||||
---
|
||||
|
||||
## 5. Home 모듈 (`app/home`) - 이미지 업로드
|
||||
|
||||
### 5.1 Image 모델 INSERT (URL 이미지 - 서버 저장)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 486
|
||||
- **API 엔드포인트**: `POST /image/upload` (tags: Image-Server)
|
||||
- **설명**: 외부 URL 이미지를 Image 테이블에 저장
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
### 5.2 Image 모델 INSERT (바이너리 파일 - 서버 저장)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 535
|
||||
- **API 엔드포인트**: `POST /image/upload` (tags: Image-Server)
|
||||
- **설명**: 업로드된 바이너리 파일을 media 폴더에 저장 후 Image 테이블에 저장
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
### 5.3 Image 모델 INSERT (URL 이미지 - Blob)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 782
|
||||
- **API 엔드포인트**: `POST /image/upload/blob` (tags: Image-Blob)
|
||||
- **설명**: 외부 URL 이미지를 Image 테이블에 저장 (Azure Blob 엔드포인트)
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
### 5.4 Image 모델 INSERT (Blob 업로드 결과)
|
||||
- **파일**: `app/home/api/routers/v1/home.py`
|
||||
- **라인**: 804
|
||||
- **API 엔드포인트**: `POST /image/upload/blob` (tags: Image-Blob)
|
||||
- **설명**: 바이너리 파일을 Azure Blob Storage에 업로드 후 Image 테이블에 저장
|
||||
- **저장 필드**: `task_id`, `img_name`, `img_url`, `img_order`
|
||||
|
||||
---
|
||||
|
||||
## 6. Base Services (미사용)
|
||||
|
||||
아래 파일들은 공통 `_add()` 메서드를 정의하고 있으나, 현재 실제 API에서 직접 호출되지 않습니다.
|
||||
|
||||
| 파일 | 라인 | 비고 |
|
||||
|------|------|------|
|
||||
| `app/lyric/services/base.py` | 15 | `_update()`에서만 내부 호출 |
|
||||
| `app/home/services/base.py` | 15 | `_update()`에서만 내부 호출 |
|
||||
| `app/song/services/base.py` | 15 | `_update()`에서만 내부 호출 |
|
||||
|
||||
---
|
||||
|
||||
## 요약 테이블
|
||||
|
||||
| 모듈 | 모델 | 파일:라인 | API 엔드포인트 |
|
||||
|------|------|-----------|----------------|
|
||||
| User | User | `auth.py:278` | `GET /auth/kakao/callback`, `POST /auth/kakao/verify` |
|
||||
| User | RefreshToken | `auth.py:315` | `GET /auth/kakao/callback`, `POST /auth/kakao/verify` |
|
||||
| Lyric | Project | `lyric.py:297` | `POST /lyric/generate` |
|
||||
| Lyric | Lyric | `lyric.py:317` | `POST /lyric/generate` |
|
||||
| Song | Song | `song.py:178` | `POST /song/generate/{task_id}` |
|
||||
| Song | SongTimestamp | `song.py:459` | `GET /song/status/{song_id}` |
|
||||
| Video | Video | `video.py:247` | `GET /video/generate/{task_id}` |
|
||||
| Home | Image | `home.py:486` | `POST /image/upload` |
|
||||
| Home | Image | `home.py:535` | `POST /image/upload` |
|
||||
| Home | Image | `home.py:782` | `POST /image/upload/blob` |
|
||||
| Home | Image | `home.py:804` | `POST /image/upload/blob` |
|
||||
|
||||
---
|
||||
|
||||
*생성일: 2026-01-23*
|
||||
|
|
@ -0,0 +1,571 @@
|
|||
# Suno API & Creatomate API 에러 처리 작업 계획서
|
||||
|
||||
## 현재 상태 분석
|
||||
|
||||
### ✅ 이미 구현된 항목
|
||||
| 항목 | Suno API | Creatomate API |
|
||||
|------|----------|----------------|
|
||||
| DB failed 상태 저장 | ✅ `song_task.py` | ✅ `video_task.py` |
|
||||
| Response failed 상태 반환 | ✅ `song_schema.py` | ✅ `video_schema.py` |
|
||||
|
||||
### ❌ 미구현 항목
|
||||
| 항목 | Suno API | Creatomate API |
|
||||
|------|----------|----------------|
|
||||
| 타임아웃 외부화 | ❌ 하드코딩됨 | ❌ 하드코딩됨 |
|
||||
| 재시도 로직 | ❌ 없음 | ❌ 없음 |
|
||||
| 커스텀 예외 클래스 | ❌ 없음 | ❌ 없음 |
|
||||
|
||||
---
|
||||
|
||||
## 1. RecoverySettings에 Suno/Creatomate 설정 추가
|
||||
|
||||
### 파일: `config.py`
|
||||
|
||||
**변경 전:**
|
||||
```python
|
||||
class RecoverySettings(BaseSettings):
|
||||
"""ChatGPT API 복구 및 타임아웃 설정"""
|
||||
|
||||
CHATGPT_TIMEOUT: float = Field(
|
||||
default=600.0,
|
||||
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
||||
)
|
||||
CHATGPT_MAX_RETRIES: int = Field(
|
||||
default=1,
|
||||
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
class RecoverySettings(BaseSettings):
|
||||
"""외부 API 복구 및 타임아웃 설정
|
||||
|
||||
ChatGPT, Suno, Creatomate API의 타임아웃 및 재시도 설정을 관리합니다.
|
||||
"""
|
||||
|
||||
# ============================================================
|
||||
# ChatGPT API 설정
|
||||
# ============================================================
|
||||
CHATGPT_TIMEOUT: float = Field(
|
||||
default=600.0,
|
||||
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
||||
)
|
||||
CHATGPT_MAX_RETRIES: int = Field(
|
||||
default=1,
|
||||
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Suno API 설정
|
||||
# ============================================================
|
||||
SUNO_DEFAULT_TIMEOUT: float = Field(
|
||||
default=30.0,
|
||||
description="Suno API 기본 요청 타임아웃 (초)",
|
||||
)
|
||||
SUNO_LYRIC_TIMEOUT: float = Field(
|
||||
default=120.0,
|
||||
description="Suno API 가사 타임스탬프 요청 타임아웃 (초)",
|
||||
)
|
||||
SUNO_MAX_RETRIES: int = Field(
|
||||
default=2,
|
||||
description="Suno API 응답 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Creatomate API 설정
|
||||
# ============================================================
|
||||
CREATOMATE_DEFAULT_TIMEOUT: float = Field(
|
||||
default=30.0,
|
||||
description="Creatomate API 기본 요청 타임아웃 (초)",
|
||||
)
|
||||
CREATOMATE_RENDER_TIMEOUT: float = Field(
|
||||
default=60.0,
|
||||
description="Creatomate API 렌더링 요청 타임아웃 (초)",
|
||||
)
|
||||
CREATOMATE_CONNECT_TIMEOUT: float = Field(
|
||||
default=10.0,
|
||||
description="Creatomate API 연결 타임아웃 (초)",
|
||||
)
|
||||
CREATOMATE_MAX_RETRIES: int = Field(
|
||||
default=2,
|
||||
description="Creatomate API 응답 실패 시 최대 재시도 횟수",
|
||||
)
|
||||
|
||||
model_config = _base_config
|
||||
```
|
||||
|
||||
**이유:** 모든 외부 API의 타임아웃/재시도 설정을 `RecoverySettings` 하나에서 통합 관리하여 일관성을 유지합니다.
|
||||
|
||||
### 파일: `.env`
|
||||
|
||||
**추가할 내용:**
|
||||
```env
|
||||
# ============================================================
|
||||
# 외부 API 타임아웃 및 재시도 설정 (RecoverySettings)
|
||||
# ============================================================
|
||||
|
||||
# ChatGPT API (기존)
|
||||
CHATGPT_TIMEOUT=600.0
|
||||
CHATGPT_MAX_RETRIES=1
|
||||
|
||||
# Suno API
|
||||
SUNO_DEFAULT_TIMEOUT=30.0
|
||||
SUNO_LYRIC_TIMEOUT=120.0
|
||||
SUNO_MAX_RETRIES=2
|
||||
|
||||
# Creatomate API
|
||||
CREATOMATE_DEFAULT_TIMEOUT=30.0
|
||||
CREATOMATE_RENDER_TIMEOUT=60.0
|
||||
CREATOMATE_CONNECT_TIMEOUT=10.0
|
||||
CREATOMATE_MAX_RETRIES=2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Suno API 커스텀 예외 클래스 추가
|
||||
|
||||
### 파일: `app/utils/suno.py`
|
||||
|
||||
**변경 전 (라인 1-20):**
|
||||
```python
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
logger = get_logger("suno")
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
import httpx
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import recovery_settings
|
||||
|
||||
logger = get_logger("suno")
|
||||
|
||||
|
||||
class SunoResponseError(Exception):
|
||||
"""Suno API 응답 오류 시 발생하는 예외
|
||||
|
||||
Suno API 거부 응답 또는 비정상 응답 시 사용됩니다.
|
||||
재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다.
|
||||
|
||||
Attributes:
|
||||
message: 에러 메시지
|
||||
original_response: 원본 API 응답 (있는 경우)
|
||||
"""
|
||||
def __init__(self, message: str, original_response: dict | None = None):
|
||||
self.message = message
|
||||
self.original_response = original_response
|
||||
super().__init__(self.message)
|
||||
```
|
||||
|
||||
**이유:** ChatGPT API와 동일하게 커스텀 예외 클래스를 추가하여 Suno API 오류를 명확히 구분하고 재시도 로직에서 활용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Suno API 타임아웃 적용
|
||||
|
||||
### 파일: `app/utils/suno.py`
|
||||
|
||||
**변경 전 (라인 130):**
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/generate",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/generate",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||
)
|
||||
```
|
||||
|
||||
**변경 전 (라인 173):**
|
||||
```python
|
||||
timeout=30.0,
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||
```
|
||||
|
||||
**변경 전 (라인 201):**
|
||||
```python
|
||||
timeout=120.0,
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
timeout=recovery_settings.SUNO_LYRIC_TIMEOUT,
|
||||
```
|
||||
|
||||
**이유:** 환경변수로 타임아웃을 관리하여 배포 환경별로 유연하게 조정할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Suno API 재시도 로직 추가
|
||||
|
||||
### 파일: `app/utils/suno.py`
|
||||
|
||||
**변경 전 - generate() 메서드:**
|
||||
```python
|
||||
async def generate(
|
||||
self,
|
||||
lyric: str,
|
||||
style: str,
|
||||
title: str,
|
||||
task_id: str,
|
||||
) -> dict:
|
||||
"""음악 생성 요청"""
|
||||
payload = {
|
||||
"prompt": lyric,
|
||||
"style": style,
|
||||
"title": title,
|
||||
"customMode": True,
|
||||
"callbackUrl": f"{self.callback_url}?task_id={task_id}",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/generate",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to generate music: {response.text}")
|
||||
raise Exception(f"Failed to generate music: {response.status_code}")
|
||||
|
||||
return response.json()
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
async def generate(
|
||||
self,
|
||||
lyric: str,
|
||||
style: str,
|
||||
title: str,
|
||||
task_id: str,
|
||||
) -> dict:
|
||||
"""음악 생성 요청 (재시도 로직 포함)
|
||||
|
||||
Args:
|
||||
lyric: 가사 텍스트
|
||||
style: 음악 스타일
|
||||
title: 곡 제목
|
||||
task_id: 작업 고유 식별자
|
||||
|
||||
Returns:
|
||||
Suno API 응답 데이터
|
||||
|
||||
Raises:
|
||||
SunoResponseError: API 오류 또는 재시도 실패 시
|
||||
"""
|
||||
payload = {
|
||||
"prompt": lyric,
|
||||
"style": style,
|
||||
"title": title,
|
||||
"customMode": True,
|
||||
"callbackUrl": f"{self.callback_url}?task_id={task_id}",
|
||||
}
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.SUNO_MAX_RETRIES + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/generate",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
timeout=recovery_settings.SUNO_DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
if 400 <= response.status_code < 500:
|
||||
raise SunoResponseError(
|
||||
f"Client error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text}
|
||||
)
|
||||
|
||||
# 재시도 가능한 오류 (5xx 서버 오류)
|
||||
last_error = SunoResponseError(
|
||||
f"Server error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text}
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(f"[Suno] Timeout on attempt {attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES + 1}")
|
||||
last_error = e
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"[Suno] HTTP error on attempt {attempt + 1}: {e}")
|
||||
last_error = e
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < recovery_settings.SUNO_MAX_RETRIES:
|
||||
logger.info(f"[Suno] Retrying... ({attempt + 1}/{recovery_settings.SUNO_MAX_RETRIES})")
|
||||
|
||||
# 모든 재시도 실패
|
||||
raise SunoResponseError(
|
||||
f"All {recovery_settings.SUNO_MAX_RETRIES + 1} attempts failed",
|
||||
original_response={"last_error": str(last_error)}
|
||||
)
|
||||
```
|
||||
|
||||
**이유:** 네트워크 오류나 일시적인 서버 오류 시 자동으로 재시도하여 안정성을 높입니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Creatomate API 커스텀 예외 클래스 추가
|
||||
|
||||
### 파일: `app/utils/creatomate.py`
|
||||
|
||||
**변경 전 (라인 1-20):**
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import creatomate_settings
|
||||
|
||||
logger = get_logger("creatomate")
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from config import creatomate_settings, recovery_settings
|
||||
|
||||
logger = get_logger("creatomate")
|
||||
|
||||
|
||||
class CreatomateResponseError(Exception):
|
||||
"""Creatomate API 응답 오류 시 발생하는 예외
|
||||
|
||||
Creatomate API 렌더링 실패 또는 비정상 응답 시 사용됩니다.
|
||||
재시도 로직에서 이 예외를 catch하여 재시도를 수행합니다.
|
||||
|
||||
Attributes:
|
||||
message: 에러 메시지
|
||||
original_response: 원본 API 응답 (있는 경우)
|
||||
"""
|
||||
def __init__(self, message: str, original_response: dict | None = None):
|
||||
self.message = message
|
||||
self.original_response = original_response
|
||||
super().__init__(self.message)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Creatomate API 타임아웃 적용
|
||||
|
||||
### 파일: `app/utils/creatomate.py`
|
||||
|
||||
**변경 전 (라인 138):**
|
||||
```python
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers=self._get_headers(),
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.BASE_URL,
|
||||
headers=self._get_headers(),
|
||||
timeout=httpx.Timeout(
|
||||
recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
connect=recovery_settings.CREATOMATE_CONNECT_TIMEOUT
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**변경 전 (라인 258, 291):**
|
||||
```python
|
||||
timeout=30.0
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
timeout=recovery_settings.CREATOMATE_DEFAULT_TIMEOUT
|
||||
```
|
||||
|
||||
**변경 전 (라인 446, 457):**
|
||||
```python
|
||||
timeout=60.0
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Creatomate API 재시도 로직 추가
|
||||
|
||||
### 파일: `app/utils/creatomate.py`
|
||||
|
||||
**변경 전 - render_with_json() 메서드 (라인 440~):**
|
||||
```python
|
||||
async def render_with_json(
|
||||
self,
|
||||
template_id: str,
|
||||
modifications: dict[str, Any],
|
||||
task_id: str,
|
||||
) -> dict:
|
||||
"""JSON 수정사항으로 렌더링 요청"""
|
||||
payload = {
|
||||
"template_id": template_id,
|
||||
"modifications": modifications,
|
||||
"webhook_url": f"{creatomate_settings.CREATOMATE_CALLBACK_URL}?task_id={task_id}",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE_URL}/v1/renders",
|
||||
headers=self._get_headers(),
|
||||
json=payload,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Failed to render: {response.text}")
|
||||
raise Exception(f"Failed to render: {response.status_code}")
|
||||
|
||||
return response.json()
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```python
|
||||
async def render_with_json(
|
||||
self,
|
||||
template_id: str,
|
||||
modifications: dict[str, Any],
|
||||
task_id: str,
|
||||
) -> dict:
|
||||
"""JSON 수정사항으로 렌더링 요청 (재시도 로직 포함)
|
||||
|
||||
Args:
|
||||
template_id: Creatomate 템플릿 ID
|
||||
modifications: 수정사항 딕셔너리
|
||||
task_id: 작업 고유 식별자
|
||||
|
||||
Returns:
|
||||
Creatomate API 응답 데이터
|
||||
|
||||
Raises:
|
||||
CreatomateResponseError: API 오류 또는 재시도 실패 시
|
||||
"""
|
||||
payload = {
|
||||
"template_id": template_id,
|
||||
"modifications": modifications,
|
||||
"webhook_url": f"{creatomate_settings.CREATOMATE_CALLBACK_URL}?task_id={task_id}",
|
||||
}
|
||||
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE_URL}/v1/renders",
|
||||
headers=self._get_headers(),
|
||||
json=payload,
|
||||
timeout=recovery_settings.CREATOMATE_RENDER_TIMEOUT,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
# 재시도 불가능한 오류 (4xx 클라이언트 오류)
|
||||
if 400 <= response.status_code < 500:
|
||||
raise CreatomateResponseError(
|
||||
f"Client error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text}
|
||||
)
|
||||
|
||||
# 재시도 가능한 오류 (5xx 서버 오류)
|
||||
last_error = CreatomateResponseError(
|
||||
f"Server error: {response.status_code}",
|
||||
original_response={"status": response.status_code, "text": response.text}
|
||||
)
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning(f"[Creatomate] Timeout on attempt {attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES + 1}")
|
||||
last_error = e
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning(f"[Creatomate] HTTP error on attempt {attempt + 1}: {e}")
|
||||
last_error = e
|
||||
|
||||
# 마지막 시도가 아니면 재시도
|
||||
if attempt < recovery_settings.CREATOMATE_MAX_RETRIES:
|
||||
logger.info(f"[Creatomate] Retrying... ({attempt + 1}/{recovery_settings.CREATOMATE_MAX_RETRIES})")
|
||||
|
||||
# 모든 재시도 실패
|
||||
raise CreatomateResponseError(
|
||||
f"All {recovery_settings.CREATOMATE_MAX_RETRIES + 1} attempts failed",
|
||||
original_response={"last_error": str(last_error)}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 작업 체크리스트
|
||||
|
||||
| 순번 | 작업 내용 | 파일 | 상태 |
|
||||
|------|----------|------|------|
|
||||
| 1 | RecoverySettings에 Suno/Creatomate 설정 추가 | `config.py` | ✅ |
|
||||
| 2 | .env에 타임아웃/재시도 환경변수 추가 | `.env` | ✅ |
|
||||
| 3 | SunoResponseError 예외 클래스 추가 | `app/utils/suno.py` | ✅ |
|
||||
| 4 | Suno 타임아웃 적용 (recovery_settings 사용) | `app/utils/suno.py` | ✅ |
|
||||
| 5 | Suno 재시도 로직 추가 | `app/utils/suno.py` | ✅ |
|
||||
| 6 | CreatomateResponseError 예외 클래스 추가 | `app/utils/creatomate.py` | ✅ |
|
||||
| 7 | Creatomate 타임아웃 적용 (recovery_settings 사용) | `app/utils/creatomate.py` | ✅ |
|
||||
| 8 | Creatomate 재시도 로직 추가 | `app/utils/creatomate.py` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 참고사항
|
||||
|
||||
- **DB failed 상태 저장**: `song_task.py`와 `video_task.py`에 이미 구현되어 있습니다.
|
||||
- **Response failed 상태**: 모든 스키마에 `success`, `status` 필드가 이미 존재합니다.
|
||||
- 재시도는 5xx 서버 오류와 타임아웃에만 적용되며, 4xx 클라이언트 오류는 즉시 실패 처리합니다.
|
||||
- 모든 타임아웃/재시도 설정은 `RecoverySettings`에서 통합 관리합니다.
|
||||
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 152 KiB |
14
main.py
|
|
@ -38,10 +38,10 @@ tags_metadata = [
|
|||
- **Refresh Token**: 7일 유효, Access Token 갱신 시 사용
|
||||
""",
|
||||
},
|
||||
{
|
||||
"name": "Home",
|
||||
"description": "홈 화면 및 프로젝트 관리 API",
|
||||
},
|
||||
# {
|
||||
# "name": "Home",
|
||||
# "description": "홈 화면 및 프로젝트 관리 API",
|
||||
# },
|
||||
{
|
||||
"name": "Crawling",
|
||||
"description": "네이버 지도 크롤링 API - 장소 정보 및 이미지 수집",
|
||||
|
|
@ -69,7 +69,6 @@ tags_metadata = [
|
|||
|
||||
1. `POST /api/v1/song/generate/{task_id}` - 노래 생성 요청
|
||||
2. `GET /api/v1/song/status/{song_id}` - Suno API 상태 확인
|
||||
3. `GET /api/v1/song/download/{task_id}` - 노래 다운로드 URL 조회
|
||||
""",
|
||||
},
|
||||
{
|
||||
|
|
@ -124,10 +123,7 @@ def custom_openapi():
|
|||
for method, operation in path_item.items():
|
||||
if method in ["get", "post", "put", "patch", "delete"]:
|
||||
# /auth/me, /auth/logout 등 인증이 필요한 엔드포인트
|
||||
if any(
|
||||
auth_path in path
|
||||
for auth_path in ["/auth/me", "/auth/logout"]
|
||||
):
|
||||
if any(auth_path in path for auth_path in ["/auth/me", "/auth/logout"]):
|
||||
operation["security"] = [{"BearerAuth": []}]
|
||||
|
||||
app.openapi_schema = openapi_schema
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ name = "o2o-castad-backend"
|
|||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"aiohttp>=3.13.2",
|
||||
|
|
@ -13,11 +13,13 @@ dependencies = [
|
|||
"fastapi-cli>=0.0.16",
|
||||
"fastapi[standard]>=0.125.0",
|
||||
"openai>=2.13.0",
|
||||
"playwright>=1.57.0",
|
||||
"pydantic-settings>=2.12.0",
|
||||
"python-jose[cryptography]>=3.5.0",
|
||||
"python-multipart>=0.0.21",
|
||||
"redis>=7.1.0",
|
||||
"ruff>=0.14.9",
|
||||
"scalar-fastapi>=1.5.0",
|
||||
"scalar-fastapi>=1.6.1",
|
||||
"sqladmin[full]>=0.22.0",
|
||||
"sqlalchemy[asyncio]>=2.0.45",
|
||||
"uuid7>=0.1.0",
|
||||
|
|
|
|||
625
uv.lock
|
|
@ -1,6 +1,6 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
|
|
@ -22,7 +22,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aiohttp"
|
||||
version = "3.13.2"
|
||||
version = "3.13.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohappyeyeballs" },
|
||||
|
|
@ -33,59 +33,42 @@ dependencies = [
|
|||
{ name = "propcache" },
|
||||
{ name = "yarl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -132,21 +115,21 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.0"
|
||||
version = "4.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncmy"
|
||||
version = "0.2.10"
|
||||
version = "0.2.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/76/55cc0577f9e838c5a5213bf33159b9e484c9d9820a2bafd4d6bfa631bf86/asyncmy-0.2.10.tar.gz", hash = "sha256:f4b67edadf7caa56bdaf1c2e6cf451150c0a86f5353744deabe4426fe27aff4e", size = 63889, upload-time = "2024-12-12T14:45:09.2Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/3c/d8297584c40f3d1af55365026bcdca7844ecfea1d917ad19df48f8331a26/asyncmy-0.2.11.tar.gz", hash = "sha256:c3d65d959dde62c911e39ecd1ad0f1339a5e6929fc411d48cfc2f82846190bf4", size = 62865, upload-time = "2026-01-15T11:32:30.368Z" }
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
|
|
@ -172,11 +155,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.11.12"
|
||||
version = "2026.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -188,18 +171,6 @@ dependencies = [
|
|||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
|
|
@ -346,7 +317,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.125.0"
|
||||
version = "0.128.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
|
|
@ -354,9 +325,9 @@ dependencies = [
|
|||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/71/2df15009fb4bdd522a069d2fbca6007c6c5487fce5cb965be00fc335f1d1/fastapi-0.125.0.tar.gz", hash = "sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0", size = 370550, upload-time = "2025-12-17T21:41:44.15Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl", hash = "sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1", size = 112888, upload-time = "2025-12-17T21:41:41.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -365,22 +336,24 @@ standard = [
|
|||
{ name = "fastapi-cli", extra = ["standard"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pydantic-extra-types" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-cli"
|
||||
version = "0.0.16"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "rich-toolkit" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/75/9407a6b452be4c988feacec9c9d2f58d8f315162a6c7258d5a649d933ebe/fastapi_cli-0.0.16.tar.gz", hash = "sha256:e8a2a1ecf7a4e062e3b2eec63ae34387d1e142d4849181d936b23c4bdfe29073", size = 19447, upload-time = "2025-11-10T19:01:07.856Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/43/678528c19318394320ee43757648d5e0a8070cf391b31f69d931e5c840d2/fastapi_cli-0.0.16-py3-none-any.whl", hash = "sha256:addcb6d130b5b9c91adbbf3f2947fe115991495fdb442fe3e51b5fc6327df9f4", size = 12312, upload-time = "2025-11-10T19:01:06.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -391,7 +364,7 @@ standard = [
|
|||
|
||||
[[package]]
|
||||
name = "fastapi-cloud-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastar" },
|
||||
|
|
@ -403,9 +376,9 @@ dependencies = [
|
|||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/0d/3b0d2991f481c122c552b4ae38a8b400a75ab0edbc85536f2a6224f72da2/fastapi_cloud_cli-0.7.0.tar.gz", hash = "sha256:8b025944475c3d53262105886dfe051f46383e4f287787a46892b524922ac0b6", size = 30906, upload-time = "2025-12-16T12:51:49.082Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/15/6c3d85d63964340fde6f36cc80f3f365d35f371e6a918d68ff3a3d588ef2/fastapi_cloud_cli-0.11.0.tar.gz", hash = "sha256:ecc83a5db106be35af528eccb01aa9bced1d29783efd48c8c1c831cf111eea99", size = 36170, upload-time = "2026-01-15T09:51:33.681Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/aa/a2319f008e123b178a5c4cba34935e8459e72d24d8c31214b244ab7266a6/fastapi_cloud_cli-0.7.0-py3-none-any.whl", hash = "sha256:9a152e80d08d465d4a6c4f5c75aa8871d1e8919f69ff131b0567f04e95d5f36c", size = 23376, upload-time = "2025-12-16T12:51:47.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/07/60f79270a3320780be7e2ae8a1740cb98a692920b569ba420b97bcc6e175/fastapi_cloud_cli-0.11.0-py3-none-any.whl", hash = "sha256:76857b0f09d918acfcb50ade34682ba3b2079ca0c43fda10215de301f185a7f8", size = 26884, upload-time = "2026-01-15T09:51:34.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -414,21 +387,6 @@ version = "0.8.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/4a/9573b87a0ef07580ed111e7230259aec31bb33ca3667963ebee77022ec61/fastar-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:50b36ce654ba44b0e13fae607ae17ee6e1597b69f71df1bee64bb8328d881dfc", size = 706041, upload-time = "2025-11-26T02:34:40.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/19/f95444a1d4f375333af49300aa75ee93afa3335c0e40fda528e460ed859c/fastar-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a892762683d7ab00df0227d5ea9677c62ff2cde9b875e666c0be569ed940f3", size = 628617, upload-time = "2025-11-26T02:34:24.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c9/b51481b38b7e3f16ef2b9e233b1a3623386c939d745d6e41bbd389eaae30/fastar-0.8.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4ae6a145c1bff592644bde13f2115e0239f4b7babaf506d14e7d208483cf01a5", size = 869299, upload-time = "2025-11-26T02:33:54.274Z" },
|
||||
|
|
@ -467,38 +425,6 @@ version = "1.8.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
|
||||
|
|
@ -540,14 +466,6 @@ version = "3.3.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
|
||||
|
|
@ -593,13 +511,6 @@ version = "0.7.1"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||
|
|
@ -669,24 +580,6 @@ version = "0.12.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294, upload-time = "2025-11-09T20:49:23.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658, upload-time = "2025-11-09T20:47:44.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605, upload-time = "2025-11-09T20:47:45.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803, upload-time = "2025-11-09T20:47:47.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120, upload-time = "2025-11-09T20:47:49.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918, upload-time = "2025-11-09T20:47:50.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008, upload-time = "2025-11-09T20:47:52.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785, upload-time = "2025-11-09T20:47:53.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108, upload-time = "2025-11-09T20:47:54.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937, upload-time = "2025-11-09T20:47:56.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853, upload-time = "2025-11-09T20:47:58.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699, upload-time = "2025-11-09T20:47:59.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258, upload-time = "2025-11-09T20:48:01.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503, upload-time = "2025-11-09T20:48:02.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965, upload-time = "2025-11-09T20:48:03.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831, upload-time = "2025-11-09T20:48:05.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272, upload-time = "2025-11-09T20:48:06.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604, upload-time = "2025-11-09T20:48:08.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628, upload-time = "2025-11-09T20:48:09.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478, upload-time = "2025-11-09T20:48:10.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706, upload-time = "2025-11-09T20:48:12.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894, upload-time = "2025-11-09T20:48:13.673Z" },
|
||||
|
|
@ -732,28 +625,6 @@ 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/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" },
|
||||
|
|
@ -793,42 +664,6 @@ version = "6.7.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" },
|
||||
|
|
@ -881,8 +716,10 @@ dependencies = [
|
|||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fastapi-cli" },
|
||||
{ name = "openai" },
|
||||
{ name = "playwright" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "redis" },
|
||||
{ name = "ruff" },
|
||||
{ name = "scalar-fastapi" },
|
||||
|
|
@ -907,11 +744,13 @@ requires-dist = [
|
|||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
||||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||
{ name = "openai", specifier = ">=2.13.0" },
|
||||
{ name = "playwright", specifier = ">=1.57.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.21" },
|
||||
{ name = "redis", specifier = ">=7.1.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.9" },
|
||||
{ name = "scalar-fastapi", specifier = ">=1.5.0" },
|
||||
{ name = "scalar-fastapi", specifier = ">=1.6.1" },
|
||||
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
|
||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
|
||||
{ name = "uuid7", specifier = ">=0.1.0" },
|
||||
|
|
@ -925,7 +764,7 @@ dev = [
|
|||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.14.0"
|
||||
version = "2.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
|
|
@ -937,18 +776,37 @@ dependencies = [
|
|||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/b1/12fe1c196bea326261718eb037307c1c1fe1dedc2d2d4de777df822e6238/openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952", size = 626938, upload-time = "2025-12-19T03:28:45.742Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/f4/4690ecb5d70023ce6bfcfeabfe717020f654bde59a775058ec6ac4692463/openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba", size = 627383, upload-time = "2026-01-09T22:10:08.603Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/4b/7c1a00c2c3fbd004253937f7520f692a9650767aa73894d7a34f0d65d3f4/openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183", size = 1067558, upload-time = "2025-12-19T03:28:43.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/df/c306f7375d42bafb379934c2df4c2fa3964656c8c782bac75ee10c102818/openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3", size = 1067879, upload-time = "2026-01-09T22:10:06.446Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.57.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/b6/e17543cea8290ae4dced10be21d5a43c360096aa2cce0aa7039e60c50df3/playwright-1.57.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:9351c1ac3dfd9b3820fe7fc4340d96c0d3736bb68097b9b7a69bd45d25e9370c", size = 41985039, upload-time = "2025-12-09T08:06:18.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/04/ef95b67e1ff59c080b2effd1a9a96984d6953f667c91dfe9d77c838fc956/playwright-1.57.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4a9d65027bce48eeba842408bcc1421502dfd7e41e28d207e94260fa93ca67e", size = 40775575, upload-time = "2025-12-09T08:06:22.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bd/5563850322a663956c927eefcf1457d12917e8f118c214410e815f2147d1/playwright-1.57.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:99104771abc4eafee48f47dac2369e0015516dc1ce8c409807d2dd440828b9a4", size = 41985042, upload-time = "2025-12-09T08:06:25.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/61/3a803cb5ae0321715bfd5247ea871d25b32c8f372aeb70550a90c5f586df/playwright-1.57.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:284ed5a706b7c389a06caa431b2f0ba9ac4130113c3a779767dda758c2497bb1", size = 45975252, upload-time = "2025-12-09T08:06:29.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d7/b72eb59dfbea0013a7f9731878df8c670f5f35318cedb010c8a30292c118/playwright-1.57.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a1bae6c0a07839cdeaddbc0756b3b2b85e476c07945f64ece08f1f956a86f1", size = 45706917, upload-time = "2025-12-09T08:06:32.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/09/3fc9ebd7c95ee54ba6a68d5c0bc23e449f7235f4603fc60534a364934c16/playwright-1.57.0-py3-none-win32.whl", hash = "sha256:1dd93b265688da46e91ecb0606d36f777f8eadcf7fbef12f6426b20bf0c9137c", size = 36553860, upload-time = "2025-12-09T08:06:35.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/d4/dcdfd2a33096aeda6ca0d15584800443dd2be64becca8f315634044b135b/playwright-1.57.0-py3-none-win_amd64.whl", hash = "sha256:6caefb08ed2c6f29d33b8088d05d09376946e49a73be19271c8cd5384b82b14c", size = 36553864, upload-time = "2025-12-09T08:06:38.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/60/fe31d7e6b8907789dcb0584f88be741ba388413e4fbce35f1eba4e3073de/playwright-1.57.0-py3-none-win_arm64.whl", hash = "sha256:5f065f5a133dbc15e6e7c71e7bc04f258195755b1c32a432b792e28338c8335e", size = 32837940, upload-time = "2025-12-09T08:06:42.268Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -966,36 +824,6 @@ version = "0.4.1"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
|
||||
|
|
@ -1031,20 +859,20 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.1"
|
||||
version = "0.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1076,20 +904,6 @@ dependencies = [
|
|||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||
|
|
@ -1120,6 +934,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-extra-types"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.12.0"
|
||||
|
|
@ -1134,6 +961,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
|
|
@ -1223,16 +1062,6 @@ version = "6.0.3"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
|
|
@ -1295,21 +1124,6 @@ version = "0.7.6"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" },
|
||||
|
|
@ -1356,50 +1170,50 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.10"
|
||||
version = "0.14.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scalar-fastapi"
|
||||
version = "1.5.0"
|
||||
version = "1.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/4897819ef5682f40cce1f9b552a1e002d394bf7e12e5d6fe62f843ffef51/scalar_fastapi-1.5.0.tar.gz", hash = "sha256:5ae887fcc0db63305dc41dc39d61f7be183256085872aa33b294e0d48e29901e", size = 7447, upload-time = "2025-12-04T19:44:12.04Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/56/bb31bc04d42b989db6933c2c3272d229d68baff0f95214e28e20886da8f6/scalar_fastapi-1.6.1.tar.gz", hash = "sha256:5d3cc96f0f384d388b58aba576a903b907e3c98dacab1339072210e946c4f033", size = 7753, upload-time = "2026-01-20T21:05:21.659Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/cc/c0712ac83a31ae96785d9916d7be54595914aa93375ea5df3ba3eadab8af/scalar_fastapi-1.5.0-py3-none-any.whl", hash = "sha256:8e712599ccdfbb614bff5583fdbee1bef03e5fac1e06520287337ff232cb667a", size = 6828, upload-time = "2025-12-04T19:44:11.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d9/6794a883410a01389ba6c7fece82f0586a57a7eaad9372b26016ad84e922/scalar_fastapi-1.6.1-py3-none-any.whl", hash = "sha256:880b3fa76b916fd2ee54b0fe340662944a1f95201f606902c2424b673c2cedaf", size = 7115, upload-time = "2026-01-20T21:05:20.457Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.48.0"
|
||||
version = "2.50.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f0/0e9dc590513d5e742d7799e2038df3a05167cba084c6ca4f3cdd75b55164/sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473", size = 384828, upload-time = "2025-12-16T14:55:41.722Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/8a/3c4f53d32c21012e9870913544e56bfa9e931aede080779a0f177513f534/sentry_sdk-2.50.0.tar.gz", hash = "sha256:873437a989ee1b8b25579847bae8384515bf18cfed231b06c591b735c1781fe3", size = 401233, upload-time = "2026-01-20T12:53:16.244Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/19/8d77f9992e5cbfcaa9133c3bf63b4fbbb051248802e1e803fed5c552fbb2/sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172", size = 414555, upload-time = "2025-12-16T14:55:40.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5b/cbc2bb9569f03c8e15d928357e7e6179e5cfab45544a3bbac8aec4caf9be/sentry_sdk-2.50.0-py2.py3-none-any.whl", hash = "sha256:0ef0ed7168657ceb5a0be081f4102d92042a125462d1d1a29277992e344e749e", size = 424961, upload-time = "2026-01-20T12:53:14.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1431,11 +1245,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.1"
|
||||
version = "2.8.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1461,31 +1275,26 @@ full = [
|
|||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.45"
|
||||
version = "2.0.46"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1519,7 +1328,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.20.0"
|
||||
version = "0.21.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
@ -1527,9 +1336,9 @@ dependencies = [
|
|||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1555,11 +1364,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.2"
|
||||
version = "2.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1573,15 +1382,15 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.38.0"
|
||||
version = "0.40.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1601,12 +1410,6 @@ version = "0.22.1"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
|
|
@ -1630,29 +1433,6 @@ dependencies = [
|
|||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
|
|
@ -1680,22 +1460,29 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1721,38 +1508,6 @@ dependencies = [
|
|||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
|
||||
|
|
|
|||