Compare commits

...

46 Commits
insta ... main

Author SHA1 Message Date
jaehwang 219e7ed7c0 오탈자 처리 2026-02-23 00:55:35 +00:00
jaehwang a876825f82 merge main 2026-02-20 08:28:43 +00:00
jaehwang a4db70c2e6 seo 경로 변경 2026-02-20 08:25:11 +00:00
jaehwang 172586e699 유튜브 자동 seo description redis 적용 2026-02-20 08:19:45 +00:00
jaehwang b3354d4ad1 유튜브 SEO 설명 추가 PoC 2026-02-20 01:11:57 +00:00
Dohyun Lim 1398546dac add logs for token 2026-02-13 17:41:27 +09:00
hbyang 9d074632bc margeting_inteligence -> marketing_intelligence 오타 수정 . 2026-02-13 10:09:02 +09:00
Dohyun Lim 7f0ae81351 remove endpoint at the video of get_videos 2026-02-12 17:52:51 +09:00
Dohyun Lim c89e510c98 Merge branch 'refresh'
리프레쉬 토큰 관련 기능 병합
2026-02-12 17:20:31 +09:00
Dohyun Lim ab8d362aa0 finished test for refresh token 2026-02-12 17:19:16 +09:00
hbyang 157d1b1ad9 서버 아키텍쳐 docs 커밋 . 2026-02-12 14:35:55 +09:00
jaehwang f1dd675ecb Merge branch 'main' into youtube-description 2026-02-12 05:10:32 +00:00
jaehwang ada5dfeeb4 가사 marketing Intel 삽입 2026-02-12 05:10:08 +00:00
jaehwang 18635d7995 update crawler retry and timeout setting 2026-02-11 07:09:34 +00:00
jaehwang 54e66e4682 add marketing intelligence db 2026-02-11 05:21:00 +00:00
Dohyun Lim bc2342163f merged get_videos 2026-02-11 11:17:59 +09:00
Dohyun Lim 34e0cada48 update get_videos 2026-02-10 14:58:45 +09:00
hbyang 4e87c76b35 timezone auth 비교 수정 . 2026-02-10 13:54:22 +09:00
hbyang bc777ba66c refresh token 수정 . 2026-02-09 16:53:15 +09:00
hbyang e29e10eb29 youtube bug fix, timezone 수정, lazyloading 수정 . 2026-02-09 13:15:20 +09:00
hbyang 40afe9392c Merge remote-tracking branch 'origin/main' - resolve conflict
- Resolved conflict in app/social/services.py
- Kept token auto-refresh logic with now() function for timezone support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 11:04:14 +09:00
hbyang 325fb9af69 social 계정 연동 refresh 기능 추가 . 2026-02-09 10:59:44 +09:00
Dohyun Lim e19c8c9d62 clean docs 2026-02-06 16:21:32 +09:00
Dohyun Lim 369e572b0a Merge branch 'timezone' 2026-02-06 15:17:51 +09:00
Dohyun Lim f6ce81e14e bugfix for suno error msg 2026-02-06 10:59:02 +09:00
dhlim 32c6c210a0 가사 템플릿 업데이트, 폰트 선택 기능 추가, 주소 injection업데이트 2026-02-04 10:28:47 +00:00
Dohyun Lim c207b8a48f update .env 2026-02-04 17:22:42 +09:00
Dohyun Lim 0d34aa7f99 remove duplicate index at token table 2026-02-04 16:58:50 +09:00
Dohyun Lim dd16013816 first commit 2026-02-04 16:35:08 +09:00
dhlim 9d92b5d42c timeout 시 한번 재시도 2026-02-04 02:21:17 +00:00
dhlim f24ff46b09 크롤링 디버그 로그 추가 2026-02-04 01:05:59 +00:00
hbyang 89ea0c783e db sql문 추가 . 2026-02-03 16:24:49 +09:00
dhlim c568f949c7 해시태그 #출력 제거(프롬프트), 자동완성 크롤링 내부 에러 발생 시 500출력, 재시도 로직 추가, 타임아웃 시간 30초로 증가 2026-02-03 06:45:16 +00:00
dhlim 96597dd555 fix pyproject.toml 2026-02-03 05:21:09 +00:00
jaehwang 5a77d22c9f scalar 추가, 프롬프트 최적화, 파이썬 3.13.11버전 고정 2026-02-03 05:11:40 +00:00
jaehwang f208e93420 셀링 포인트 폴리곤 최하 70점 제한 제거, category 영어와 한국어로 분화 2026-02-03 04:44:19 +00:00
Dohyun Lim 08a699648d add time 2026-02-02 19:00:54 +09:00
Dohyun Lim 29dd08081b fix bugs 2026-02-02 17:44:09 +09:00
Dohyun Lim 2cb9d67a70 merge with insta 2026-02-02 17:15:50 +09:00
hbyang e89709ce87 upload bug fix . 2026-02-02 17:10:35 +09:00
jaehwang e1386b891e Merge branch 'main' into creatomate 2026-02-02 07:58:19 +00:00
hbyang 8c7893d989 youtube upload 기능 작성 . 2026-02-02 16:42:38 +09:00
jaehwang f97ecb29e9 fix missing argument 2026-02-02 07:05:27 +00:00
jaehwang 5700965fae 비디오 automated lyric 가사 (animated)추가 2026-02-02 14:17:01 +09:00
hbyang ef203dc14d youtube 계정 연결 작업 완료 2026-02-02 11:13:08 +09:00
hbyang c92d6e2135 fix: 가사/노래/영상 재생성 시 올바른 레코드 업데이트되도록 수정 2026-01-30 15:19:26 +09:00
72 changed files with 8464 additions and 1128 deletions

View File

@ -1 +1 @@
3.14
3.13.11

View File

@ -37,7 +37,7 @@ router = APIRouter(prefix="/archive", tags=["Archive"])
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
## 반환 정보
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
- **items**: 영상 목록 (video_id, store_name, region, task_id, result_movie_url, created_at)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10
## 참고
- **본인이 소유한 프로젝트의 영상만 반환됩니다.**
- status가 'completed' 영상만 반환됩니다.
- 동일task_id가 있는 경우 가장 최근에 생성된 1반환됩니다.
- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
""",
response_model=PaginatedResponse[VideoListItem],
@ -70,133 +70,59 @@ async def get_videos(
) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
logger.info(
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
f"[get_videos] START - user: {current_user.user_uuid}, "
f"page: {pagination.page}, page_size: {pagination.page_size}"
)
logger.debug(f"[get_videos] current_user.user_uuid: {current_user.user_uuid}")
try:
offset = (pagination.page - 1) * pagination.page_size
# DEBUG: 각 조건별 데이터 수 확인
# 1) 전체 Video 수
all_videos_result = await session.execute(select(func.count(Video.id)))
all_videos_count = all_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 전체 Video 수: {all_videos_count}")
# 2) completed 상태 Video 수
completed_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.status == "completed")
)
completed_videos_count = completed_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - completed 상태 Video 수: {completed_videos_count}")
# 3) is_deleted=False인 Video 수
not_deleted_videos_result = await session.execute(
select(func.count(Video.id)).where(Video.is_deleted == False)
)
not_deleted_videos_count = not_deleted_videos_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - is_deleted=False Video 수: {not_deleted_videos_count}")
# 4) 전체 Project 수 및 user_uuid 값 확인
all_projects_result = await session.execute(
select(Project.id, Project.user_uuid, Project.is_deleted)
)
all_projects = all_projects_result.all()
logger.debug(f"[get_videos] DEBUG - 전체 Project 수: {len(all_projects)}")
for p in all_projects:
logger.debug(
f"[get_videos] DEBUG - Project: id={p.id}, user_uuid={p.user_uuid}, "
f"user_uuid_type={type(p.user_uuid)}, is_deleted={p.is_deleted}"
)
# 4-1) 현재 사용자 UUID 타입 확인
logger.debug(
f"[get_videos] DEBUG - current_user.user_uuid={current_user.user_uuid}, "
f"type={type(current_user.user_uuid)}"
)
# 4-2) 현재 사용자 소유 Project 수
user_projects_result = await session.execute(
select(func.count(Project.id)).where(
Project.user_uuid == current_user.user_uuid,
Project.is_deleted == False,
)
)
user_projects_count = user_projects_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - 현재 사용자 소유 Project 수: {user_projects_count}")
# 5) 현재 사용자 소유 + completed + 미삭제 Video 수
user_completed_videos_result = await session.execute(
select(func.count(Video.id))
# 서브쿼리: task_id별 최신 Video ID 추출
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
latest_video_ids = (
select(func.max(Video.id).label("latest_id"))
.join(Project, Video.project_id == Project.id)
.where(
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False,
Project.is_deleted == False,
Video.is_deleted == False, # noqa: E712
Project.is_deleted == False, # noqa: E712
)
)
user_completed_videos_count = user_completed_videos_result.scalar() or 0
logger.debug(
f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}"
)
# 기본 조건: 현재 사용자 소유, completed 상태, 미삭제
base_conditions = [
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
Video.is_deleted == False,
Project.is_deleted == False,
]
# 쿼리 1: 전체 개수 조회 (task_id 기준 고유 개수)
count_query = (
select(func.count(func.distinct(Video.task_id)))
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
logger.debug(f"[get_videos] DEBUG - task_id 기준 고유 개수 (total): {total}")
# 서브쿼리: task_id별 최신 Video의 id 조회
subquery = (
select(func.max(Video.id).label("max_id"))
.join(Project, Video.project_id == Project.id)
.where(*base_conditions)
.group_by(Video.task_id)
.subquery()
)
# DEBUG: 서브쿼리 결과 확인
subquery_debug_result = await session.execute(select(subquery.c.max_id))
subquery_ids = [row[0] for row in subquery_debug_result.all()]
logger.debug(f"[get_videos] DEBUG - 서브쿼리 결과 (max_id 목록): {subquery_ids}")
# 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만)
count_query = select(func.count(Video.id)).where(
Video.id.in_(select(latest_video_ids.c.latest_id))
)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회
query = (
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
data_query = (
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(Video.id.in_(select(subquery.c.max_id)))
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
result = await session.execute(query)
result = await session.execute(data_query)
rows = result.all()
logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}")
# VideoListItem으로 변환 (JOIN 결과에서 바로 추출)
items = []
for video, project in rows:
item = VideoListItem(
# VideoListItem으로 변환
items = [
VideoListItem(
video_id=video.id,
store_name=project.store_name,
region=project.region,
task_id=video.task_id,
result_movie_url=video.result_movie_url,
created_at=video.created_at,
)
items.append(item)
for video, project in rows
]
response = PaginatedResponse.create(
items=items,
@ -219,9 +145,94 @@ async def get_videos(
)
@router.delete(
"/videos/{video_id}",
summary="개별 영상 소프트 삭제",
description="""
## 개요
video_id에 해당하는 영상만 소프트 삭제합니다.
(is_deleted=True로 설정, 실제 데이터는 DB에 유지)
## 경로 파라미터
- **video_id**: 삭제할 영상의 ID (Video.id)
## 참고
- 본인이 소유한 프로젝트의 영상만 삭제할 있습니다.
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
- 프로젝트나 다른 관련 데이터(Song, Lyric ) 삭제되지 않습니다.
""",
responses={
200: {"description": "삭제 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
403: {"description": "삭제 권한 없음"},
404: {"description": "영상을 찾을 수 없음"},
500: {"description": "삭제 실패"},
},
)
async def delete_single_video(
video_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict:
"""video_id에 해당하는 개별 영상만 소프트 삭제합니다."""
logger.info(f"[delete_single_video] START - video_id: {video_id}, user: {current_user.user_uuid}")
try:
# Video 조회 (Project와 함께)
result = await session.execute(
select(Video, Project)
.join(Project, Video.project_id == Project.id)
.where(
Video.id == video_id,
Video.is_deleted == False,
)
)
row = result.one_or_none()
if row is None:
logger.warning(f"[delete_single_video] NOT FOUND - video_id: {video_id}")
raise HTTPException(
status_code=404,
detail="영상을 찾을 수 없습니다.",
)
video, project = row
# 소유권 검증
if project.user_uuid != current_user.user_uuid:
logger.warning(
f"[delete_single_video] FORBIDDEN - video_id: {video_id}, "
f"owner: {project.user_uuid}, requester: {current_user.user_uuid}"
)
raise HTTPException(
status_code=403,
detail="삭제 권한이 없습니다.",
)
# 소프트 삭제
video.is_deleted = True
await session.commit()
logger.info(f"[delete_single_video] SUCCESS - video_id: {video_id}")
return {
"success": True,
"message": "영상이 삭제되었습니다.",
"video_id": video_id,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[delete_single_video] EXCEPTION - video_id: {video_id}, error: {e}")
raise HTTPException(
status_code=500,
detail=f"삭제에 실패했습니다: {str(e)}",
)
@router.delete(
"/videos/delete/{task_id}",
summary="아카이브 영상 소프트 삭제",
summary="프로젝트 전체 소프트 삭제 (task_id 기준)",
description="""
## 개요
task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트 삭제합니다.
@ -242,6 +253,7 @@ task_id에 해당하는 프로젝트와 관련된 모든 데이터를 소프트
- 본인이 소유한 프로젝트만 삭제할 있습니다.
- 소프트 삭제 방식으로 데이터 복구가 가능합니다.
- 백그라운드에서 비동기로 처리됩니다.
- **개별 영상만 삭제하려면 DELETE /archive/videos/{video_id} 사용하세요.**
""",
responses={
200: {"description": "삭제 요청 성공"},

View File

@ -288,13 +288,33 @@ def add_exception_handlers(app: FastAPI):
),
)
# SocialException 핸들러 추가
from app.social.exceptions import SocialException
from app.social.exceptions import TokenExpiredError
@app.exception_handler(SocialException)
def social_exception_handler(request: Request, exc: SocialException) -> Response:
logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}")
content = {
"detail": exc.message,
"code": exc.code,
}
# TokenExpiredError인 경우 재연동 정보 추가
if isinstance(exc, TokenExpiredError):
content["platform"] = exc.platform
content["reconnect_url"] = f"/social/oauth/{exc.platform}/connect"
return JSONResponse(
status_code=exc.status_code,
content=content,
)
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
def internal_server_error_handler(request, exception):
# 에러 메시지 로깅 (한글 포함 가능)
logger.error(f"Internal Server Error: {exception}")
return JSONResponse(
content={"detail": "Something went wrong..."},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
headers={
"X-Error": f"{exception}",
}
)

View File

@ -4,11 +4,6 @@ from redis.asyncio import Redis
from app.config import db_settings
_token_blacklist = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=0,
)
_shipment_verification_codes = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
@ -16,15 +11,10 @@ _shipment_verification_codes = Redis(
decode_responses=True,
)
async def add_jti_to_blacklist(jti: str):
await _token_blacklist.set(jti, "blacklisted")
async def is_jti_blacklisted(jti: str) -> bool:
return await _token_blacklist.exists(jti)
async def add_shipment_verification_code(id: UUID, code: int):
await _shipment_verification_codes.set(str(id), code)
async def get_shipment_verification_code(id: UUID) -> str:
return str(await _shipment_verification_codes.get(str(id)))

View File

@ -73,11 +73,12 @@ async def create_db_tables():
# 모델 import (테이블 메타데이터 등록용)
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
from app.home.models import Image, Project # noqa: F401
from app.home.models import Image, Project, MarketingIntel # noqa: F401
from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video # noqa: F401
from app.sns.models import SNSUploadTask # noqa: F401
from app.social.models import SocialUpload # noqa: F401
# 생성할 테이블 목록
tables_to_create = [
@ -91,6 +92,8 @@ async def create_db_tables():
SongTimestamp.__table__,
Video.__table__,
SNSUploadTask.__table__,
SocialUpload.__table__,
MarketingIntel.__table__,
]
logger.info("Creating database tables...")
@ -123,7 +126,9 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
try:
yield session
except Exception as e:
import traceback
await session.rollback()
logger.error(traceback.format_exc())
logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"

View File

@ -11,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session, AsyncSessionLocal
from app.home.models import Image
from app.home.models import Image, MarketingIntel
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.home.schemas.home_schema import (
@ -33,7 +33,7 @@ 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.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt
from config import MEDIA_ROOT
@ -153,8 +153,10 @@ 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)
async def crawling(
request_body: CrawlingRequest,
session: AsyncSession = Depends(get_session)):
return await _crawling_logic(request_body.url, session)
@router.post(
"/autocomplete",
@ -187,11 +189,15 @@ async def crawling(request_body: CrawlingRequest):
},
tags=["Crawling"],
)
async def autocomplete_crawling(request_body: AutoCompleteRequest):
url = await _autocomplete_logic(request_body.dict())
return await _crawling_logic(url)
async def autocomplete_crawling(
request_body: AutoCompleteRequest,
session: AsyncSession = Depends(get_session)):
url = await _autocomplete_logic(request_body.model_dump())
return await _crawling_logic(url, session)
async def _crawling_logic(url:str):
async def _crawling_logic(
url:str,
session: AsyncSession):
request_start = time.perf_counter()
logger.info("[crawling] ========== START ==========")
logger.info(f"[crawling] URL: {url[:80]}...")
@ -281,6 +287,15 @@ async def _crawling_logic(url:str):
structured_report = await chatgpt_service.generate_structured_output(
marketing_prompt, input_marketing_data
)
marketing_intelligence = MarketingIntel(
place_id = scraper.place_id,
intel_result = structured_report.model_dump()
)
session.add(marketing_intelligence)
await session.commit()
await session.refresh(marketing_intelligence)
m_id = marketing_intelligence.id
logger.debug(f"[MarketingPrompt] INSERT placeid {marketing_intelligence.place_id}")
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
logger.info(
f"[crawling] Step 3-3: GPT API 호출 완료 - ({step3_3_elapsed:.1f}ms)"
@ -360,6 +375,7 @@ async def _crawling_logic(url:str):
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
"processed_info": processed_info,
"marketing_analysis": marketing_analysis,
"m_id" : m_id
}
@ -375,7 +391,7 @@ async def _autocomplete_logic(autocomplete_item:dict):
)
logger.exception("[crawling] Autocomplete 상세 오류:")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="자동완성 place id 추출 실패",
)

View File

@ -7,9 +7,9 @@ Home 모듈 SQLAlchemy 모델 정의
"""
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from typing import TYPE_CHECKING, List, Optional, Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -107,6 +107,12 @@ class Project(Base):
comment="상세 지역 정보",
)
marketing_intelligence: Mapped[Optional[str]] = mapped_column(
Integer,
nullable=True,
comment="마케팅 인텔리전스 결과 정보 저장",
)
language: Mapped[str] = mapped_column(
String(50),
nullable=False,
@ -249,3 +255,66 @@ class Image(Base):
return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
)
class MarketingIntel(Base):
"""
마케팅 인텔리전스 결과물 테이블
마케팅 분석 결과물 저장합니다.
Attributes:
id: 고유 식별자 (자동 증가)
place_id : 데이터 소스별 식별자
intel_result : 마케팅 분석 결과물 json
created_at: 생성 일시 (자동 설정)
"""
__tablename__ = "marketing"
__table_args__ = (
Index("idx_place_id", "place_id"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
}
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
place_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
comment="매장 소스별 고유 식별자",
)
intel_result : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=False,
comment="마케팅 인텔리전스 결과물",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
def __repr__(self) -> str:
task_id_str = (
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
)
img_name_str = (
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
)
return (
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
)

View File

@ -3,112 +3,6 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.utils.prompts.schemas import MarketingPromptOutput
class AttributeInfo(BaseModel):
"""음악 속성 정보"""
genre: str = Field(..., description="음악 장르")
vocal: str = Field(..., description="보컬 스타일")
tempo: str = Field(..., description="템포")
mood: str = Field(..., description="분위기")
class GenerateRequestImg(BaseModel):
"""이미지 URL 스키마"""
url: str = Field(..., description="이미지 URL")
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class GenerateRequestInfo(BaseModel):
"""생성 요청 정보 스키마 (이미지 제외)"""
customer_name: str = Field(..., description="고객명/가게명")
region: str = Field(..., description="지역명")
detail_region_info: Optional[str] = Field(None, description="상세 지역 정보")
attribute: AttributeInfo = Field(..., description="음악 속성 정보")
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
class GenerateRequest(GenerateRequestInfo):
"""기본 생성 요청 스키마 (이미지 없음, JSON body)
이미지 없이 프로젝트 정보만 전달합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
}
}
)
class GenerateUrlsRequest(GenerateRequestInfo):
"""URL 기반 생성 요청 스키마 (JSON body)
GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"attribute": {
"genre": "K-Pop",
"vocal": "Raspy",
"tempo": "110 BPM",
"mood": "happy",
},
"language": "Korean",
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
],
}
}
)
images: list[GenerateRequestImg] = Field(
..., description="이미지 URL 목록", min_length=1
)
class GenerateUploadResponse(BaseModel):
"""파일 업로드 기반 생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
uploaded_count: int = Field(..., description="업로드된 이미지 개수")
class GenerateResponse(BaseModel):
"""생성 응답 스키마"""
task_id: str = Field(..., description="작업 고유 식별자 (UUID7)")
status: Literal["processing", "completed", "failed"] = Field(
..., description="작업 상태"
)
message: str = Field(..., description="응답 메시지")
class CrawlingRequest(BaseModel):
"""크롤링 요청 스키마"""
@ -275,37 +169,44 @@ class CrawlingResponse(BaseModel):
],
"selling_points": [
{
"category": "LOCATION",
"english_category": "LOCATION",
"korean_category": "입지 환경",
"description": "군산 감성 동선",
"score": 88
},
{
"category": "HEALING",
"english_category": "HEALING",
"korean_category": "힐링 요소",
"description": "멈춤이 되는 쉼",
"score": 92
},
{
"category": "PRIVACY",
"english_category": "PRIVACY",
"korean_category": "프라이버시",
"description": "방해 없는 머뭄",
"score": 86
},
{
"category": "NIGHT MOOD",
"english_category": "NIGHT MOOD",
"korean_category": "야간 감성",
"description": "밤이 예쁜 조명",
"score": 84
},
{
"category": "PHOTO SPOT",
"english_category": "PHOTO SPOT",
"korean_category": "포토 스팟",
"description": "자연광 포토존",
"score": 83
},
{
"category": "SHORT GETAWAY",
"english_category": "SHORT GETAWAY",
"korean_category": "숏브레이크",
"description": "주말 리셋 스테이",
"score": 89
},
{
"category": "HOSPITALITY",
"english_category": "HOSPITALITY",
"korean_category": "서비스",
"description": "세심한 웰컴감",
"score": 80
}
@ -322,7 +223,8 @@ class CrawlingResponse(BaseModel):
"힐링스테이",
"스테이머뭄"
]
}
},
"m_id" : 1
}
}
)
@ -339,6 +241,7 @@ class CrawlingResponse(BaseModel):
marketing_analysis: Optional[MarketingPromptOutput] = Field(
None, description="마케팅 분석 결과 . 실패 시 null"
)
m_id : int = Field(..., description="마케팅 분석 결과 ID")
class ErrorResponse(BaseModel):
@ -362,29 +265,6 @@ class ImageUrlItem(BaseModel):
name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)")
class ImageUploadRequest(BaseModel):
"""이미지 업로드 요청 스키마 (JSON body 부분)
URL 이미지 목록을 전달합니다.
바이너리 파일은 multipart/form-data로 별도 전달됩니다.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"images": [
{"url": "https://example.com/images/image_001.jpg"},
{"url": "https://example.com/images/image_002.jpg", "name": "외관"},
]
}
}
)
images: Optional[list[ImageUrlItem]] = Field(
None, description="외부 이미지 URL 목록"
)
class ImageUploadResultItem(BaseModel):
"""업로드된 이미지 결과 아이템"""

View File

@ -30,7 +30,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.home.models import Project
from app.home.models import Project, MarketingIntel
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.lyric.models import Lyric
@ -48,6 +48,7 @@ from app.utils.pagination import PaginatedResponse, get_paginated
from app.utils.prompts.prompts import lyric_prompt
import traceback as tb
import json
# 로거 설정
logger = get_logger("lyric")
@ -278,35 +279,51 @@ async def generate_lyric(
Full verse flow, immersive mood
"""
}
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
marketing_intel = marketing_intel_result.scalar_one_or_none()
lyric_input_data = {
"customer_name" : request_body.customer_name,
"region" : request_body.region,
"detail_region_info" : request_body.detail_region_info or "",
"marketing_intelligence_summary" : None, # task_idx 변경 후 marketing intelligence summary DB에 저장하고 사용할 필요가 있음
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
"language" : request_body.language,
"promotional_expression_example" : promotional_expressions[request_body.language],
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
}
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
step1_elapsed = (time.perf_counter() - step1_start) * 1000
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
# ========== Step 2: Project 테이블에 데이터 저장 ==========
# ========== Step 2: Project 조회 또는 생성 ==========
step2_start = time.perf_counter()
logger.debug(f"[generate_lyric] Step 2: Project 저장...")
logger.debug(f"[generate_lyric] Step 2: Project 조회 또는 생성...")
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,
user_uuid=current_user.user_uuid,
# 기존 Project가 있는지 확인 (재생성 시 재사용)
existing_project_result = await session.execute(
select(Project).where(Project.task_id == task_id).limit(1)
)
session.add(project)
await session.commit()
await session.refresh(project)
project = existing_project_result.scalar_one_or_none()
if project:
# 기존 Project 재사용 (재생성 케이스)
logger.info(f"[generate_lyric] 기존 Project 재사용 - project_id: {project.id}, task_id: {task_id}")
else:
# 새 Project 생성 (최초 생성 케이스)
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,
user_uuid=current_user.user_uuid,
marketing_intelligence = request_body.m_id
)
session.add(project)
await session.commit()
await session.refresh(project)
logger.info(f"[generate_lyric] 새 Project 생성 - project_id: {project.id}, task_id: {task_id}")
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
@ -340,6 +357,7 @@ async def generate_lyric(
task_id=task_id,
prompt=lyric_prompt,
lyric_input_data=lyric_input_data,
lyric_id=lyric.id,
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000

View File

@ -41,7 +41,8 @@ class GenerateLyricRequest(BaseModel):
"customer_name": "스테이 머뭄",
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean"
"language": "Korean",
"m_id" : 1
}
"""
@ -53,6 +54,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean",
"m_id" : 1
}
}
)
@ -67,6 +69,7 @@ class GenerateLyricRequest(BaseModel):
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
class GenerateLyricResponse(BaseModel):

View File

@ -23,6 +23,7 @@ async def _update_lyric_status(
task_id: str,
status: str,
result: str | None = None,
lyric_id: int | None = None,
) -> bool:
"""Lyric 테이블의 상태를 업데이트합니다.
@ -30,18 +31,26 @@ async def _update_lyric_status(
task_id: 프로젝트 task_id
status: 변경할 상태 ("processing", "completed", "failed")
result: 가사 결과 또는 에러 메시지
lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용)
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
if lyric_id:
# lyric_id로 특정 레코드 조회 (재생성 시에도 정확한 레코드 업데이트)
query_result = await session.execute(
select(Lyric).where(Lyric.id == lyric_id)
)
else:
# 기존 방식: task_id로 최신 레코드 조회
query_result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
.order_by(Lyric.created_at.desc())
.limit(1)
)
lyric = query_result.scalar_one_or_none()
if lyric:
@ -49,31 +58,33 @@ async def _update_lyric_status(
if result is not None:
lyric.lyric_result = result
await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
return True
else:
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}, lyric_id: {lyric_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, lyric_id: {lyric_id}, error: {e}")
return False
async def generate_lyric_background(
task_id: str,
prompt: Prompt,
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
lyric_input_data: dict, # 프롬프트 메타데이터에서 정의된 Input
lyric_id: int | None = None,
) -> None:
"""백그라운드에서 ChatGPT를 통해 가사를 생성하고 Lyric 테이블을 업데이트합니다.
Args:
task_id: 프로젝트 task_id
prompt: ChatGPT에 전달할 프롬프트
language: 가사 언어
lyric_input_data: 프롬프트 입력 데이터
lyric_id: 특정 Lyric 레코드 ID (재생성 정확한 레코드 식별용)
"""
import time
@ -116,7 +127,7 @@ async def generate_lyric_background(
step3_start = time.perf_counter()
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
await _update_lyric_status(task_id, "completed", result)
await _update_lyric_status(task_id, "completed", result, lyric_id)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
@ -136,14 +147,14 @@ async def generate_lyric_background(
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}")
await _update_lyric_status(task_id, "failed", f"ChatGPT Error: {e.error_message}", lyric_id)
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)
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}", lyric_id)
except Exception as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)

View File

@ -10,6 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse
from app.user.dependencies.auth import get_current_user
from app.user.models import Platform, SocialAccount, User
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
from app.utils.logger import get_logger
from app.video.models import Video
logger = get_logger(__name__)
# =============================================================================
@ -80,13 +87,7 @@ class InstagramContainerError(SNSException):
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
from app.user.dependencies.auth import get_current_user
from app.user.models import Platform, SocialAccount, User
from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
from app.utils.logger import get_logger
from app.video.models import Video
logger = get_logger(__name__)
router = APIRouter(prefix="/sns", tags=["SNS"])

View File

@ -4,7 +4,7 @@ SNS API Schemas
Instagram 업로드 관련 Pydantic 스키마를 정의합니다.
"""
from datetime import datetime
from typing import Any, Optional
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@ -98,20 +98,6 @@ class Media(BaseModel):
children: Optional[list["Media"]] = None
class MediaList(BaseModel):
"""미디어 목록 응답"""
data: list[Media] = Field(default_factory=list)
paging: Optional[dict[str, Any]] = None
@property
def next_cursor(self) -> Optional[str]:
"""다음 페이지 커서"""
if self.paging and "cursors" in self.paging:
return self.paging["cursors"].get("after")
return None
class MediaContainer(BaseModel):
"""미디어 컨테이너 상태"""

15
app/social/__init__.py Normal file
View File

@ -0,0 +1,15 @@
"""
Social Media Integration Module
소셜 미디어 플랫폼 연동 영상 업로드 기능을 제공합니다.
지원 플랫폼:
- YouTube (구현됨)
- Instagram (추후 구현)
- Facebook (추후 구현)
- TikTok (추후 구현)
"""
from app.social.constants import SocialPlatform, UploadStatus
__all__ = ["SocialPlatform", "UploadStatus"]

View File

@ -0,0 +1,3 @@
"""
Social API Module
"""

View File

@ -0,0 +1,3 @@
"""
Social API Routers
"""

View File

@ -0,0 +1,8 @@
"""
Social API Routers v1
"""
from app.social.api.routers.v1.oauth import router as oauth_router
from app.social.api.routers.v1.upload import router as upload_router
from app.social.api.routers.v1.seo import router as seo_router
__all__ = ["oauth_router", "upload_router", "seo_router"]

View File

@ -0,0 +1,327 @@
"""
소셜 OAuth API 라우터
소셜 미디어 계정 연동 관련 엔드포인트를 제공합니다.
"""
import logging
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, Query
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from config import social_oauth_settings
from app.database.session import get_session
from app.social.constants import SocialPlatform
from app.social.schemas import (
MessageResponse,
SocialAccountListResponse,
SocialAccountResponse,
SocialConnectResponse,
)
from app.social.services import social_account_service
from app.user.dependencies import get_current_user
from app.user.models import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/oauth", tags=["Social OAuth"])
def _build_redirect_url(is_success: bool, params: dict) -> str:
"""OAuth 리다이렉트 URL 생성"""
base_url = social_oauth_settings.OAUTH_FRONTEND_URL.rstrip("/")
path = (
social_oauth_settings.OAUTH_SUCCESS_PATH
if is_success
else social_oauth_settings.OAUTH_ERROR_PATH
)
return f"{base_url}{path}?{urlencode(params)}"
@router.get(
"/{platform}/connect",
response_model=SocialConnectResponse,
summary="소셜 계정 연동 시작",
description="""
소셜 미디어 계정 연동을 시작합니다.
## 지원 플랫폼
- **youtube**: YouTube (Google OAuth)
- instagram, facebook, tiktok: 추후 지원 예정
## 플로우
1. 엔드포인트를 호출하여 `auth_url` `state` 받음
2. 프론트엔드에서 `auth_url` 사용자를 리다이렉트
3. 사용자가 플랫폼에서 권한 승인
4. 플랫폼이 `/callback` 엔드포인트로 리다이렉트
5. 연동 완료 프론트엔드로 리다이렉트
""",
)
async def start_connect(
platform: SocialPlatform,
current_user: User = Depends(get_current_user),
) -> SocialConnectResponse:
"""
소셜 계정 연동 시작
OAuth 인증 URL을 생성하고 state 토큰을 반환합니다.
프론트엔드에서 반환된 auth_url로 사용자를 리다이렉트하면 됩니다.
"""
logger.info(
f"[OAUTH_API] 소셜 연동 시작 - "
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
)
return await social_account_service.start_connect(
user_uuid=current_user.user_uuid,
platform=platform,
)
@router.get(
"/{platform}/callback",
summary="OAuth 콜백",
description="""
소셜 플랫폼의 OAuth 콜백을 처리합니다.
엔드포인트는 소셜 플랫폼에서 직접 호출되며,
사용자를 프론트엔드로 리다이렉트합니다.
""",
)
async def oauth_callback(
platform: SocialPlatform,
code: str | None = Query(None, description="OAuth 인가 코드"),
state: str | None = Query(None, description="CSRF 방지용 state 토큰"),
error: str | None = Query(None, description="OAuth 에러 코드 (사용자 취소 등)"),
error_description: str | None = Query(None, description="OAuth 에러 설명"),
session: AsyncSession = Depends(get_session),
) -> RedirectResponse:
"""
OAuth 콜백 처리
소셜 플랫폼에서 리다이렉트된 호출됩니다.
인가 코드로 토큰을 교환하고 계정을 연동합니다.
"""
# 사용자가 취소하거나 에러가 발생한 경우
if error:
logger.info(
f"[OAUTH_API] OAuth 취소/에러 - "
f"platform: {platform.value}, error: {error}, description: {error_description}"
)
# 에러 메시지 생성
if error == "access_denied":
error_message = "사용자가 연동을 취소했습니다."
else:
error_message = error_description or error
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": platform.value,
"error": error_message,
"cancelled": "true" if error == "access_denied" else "false",
},
)
return RedirectResponse(url=redirect_url, status_code=302)
# code나 state가 없는 경우
if not code or not state:
logger.warning(
f"[OAUTH_API] OAuth 콜백 파라미터 누락 - "
f"platform: {platform.value}, code: {bool(code)}, state: {bool(state)}"
)
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": platform.value,
"error": "잘못된 요청입니다. 다시 시도해주세요.",
},
)
return RedirectResponse(url=redirect_url, status_code=302)
logger.info(
f"[OAUTH_API] OAuth 콜백 수신 - "
f"platform: {platform.value}, code: {code[:20]}..."
)
try:
account = await social_account_service.handle_callback(
code=code,
state=state,
session=session,
)
# 성공 시 프론트엔드로 리다이렉트 (계정 정보 포함)
redirect_url = _build_redirect_url(
is_success=True,
params={
"platform": platform.value,
"account_id": account.id,
"channel_name": account.display_name or account.platform_username or "",
"profile_image": account.profile_image_url or "",
},
)
logger.info(f"[OAUTH_API] 연동 성공, 리다이렉트 - url: {redirect_url}")
return RedirectResponse(url=redirect_url, status_code=302)
except Exception as e:
logger.error(f"[OAUTH_API] OAuth 콜백 처리 실패 - error: {e}")
# 실패 시 에러 페이지로 리다이렉트
redirect_url = _build_redirect_url(
is_success=False,
params={
"platform": platform.value,
"error": str(e),
},
)
return RedirectResponse(url=redirect_url, status_code=302)
@router.get(
"/accounts",
response_model=SocialAccountListResponse,
summary="연동된 소셜 계정 목록 조회",
description="현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.",
)
async def get_connected_accounts(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialAccountListResponse:
"""
연동된 소셜 계정 목록 조회
현재 로그인한 사용자가 연동한 모든 소셜 계정을 조회합니다.
"""
logger.info(f"[OAUTH_API] 연동 계정 목록 조회 - user_uuid: {current_user.user_uuid}")
accounts = await social_account_service.get_connected_accounts(
user_uuid=current_user.user_uuid,
session=session,
)
return SocialAccountListResponse(
accounts=accounts,
total=len(accounts),
)
@router.get(
"/accounts/{platform}",
response_model=SocialAccountResponse,
summary="특정 플랫폼 연동 계정 조회",
description="특정 플랫폼에 연동된 계정 정보를 반환합니다.",
)
async def get_account_by_platform(
platform: SocialPlatform,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialAccountResponse:
"""
특정 플랫폼 연동 계정 조회
"""
logger.info(
f"[OAUTH_API] 특정 플랫폼 계정 조회 - "
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
)
account = await social_account_service.get_account_by_platform(
user_uuid=current_user.user_uuid,
platform=platform,
session=session,
)
if account is None:
from app.social.exceptions import SocialAccountNotFoundError
raise SocialAccountNotFoundError(platform=platform.value)
return social_account_service._to_response(account)
@router.delete(
"/accounts/{account_id}",
response_model=MessageResponse,
summary="소셜 계정 연동 해제 (account_id)",
description="""
소셜 미디어 계정 연동을 해제합니다.
## 경로 파라미터
- **account_id**: 연동 해제할 소셜 계정 ID (SocialAccount.id)
## 연동 해제 시
- 해당 플랫폼으로의 업로드가 불가능해집니다
- 기존 업로드 기록은 유지됩니다
- 재연동 동의 화면이 스킵됩니다
""",
)
async def disconnect_by_account_id(
account_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MessageResponse:
"""
소셜 계정 연동 해제 (account_id 기준)
account_id로 특정 소셜 계정의 연동을 해제합니다.
"""
logger.info(
f"[OAUTH_API] 소셜 연동 해제 (by account_id) - "
f"user_uuid: {current_user.user_uuid}, account_id: {account_id}"
)
platform = await social_account_service.disconnect_by_account_id(
user_uuid=current_user.user_uuid,
account_id=account_id,
session=session,
)
return MessageResponse(
success=True,
message=f"{platform} 계정 연동이 해제되었습니다.",
)
@router.delete(
"/{platform}/disconnect",
response_model=MessageResponse,
summary="소셜 계정 연동 해제 (platform)",
description="""
소셜 미디어 계정 연동을 해제합니다.
**주의**: API는 플랫폼당 1개의 계정만 연동된 경우에 사용합니다.
여러 채널이 연동된 경우 `DELETE /accounts/{account_id}` 사용하세요.
연동 해제 :
- 해당 플랫폼으로의 업로드가 불가능해집니다
- 기존 업로드 기록은 유지됩니다
""",
deprecated=True,
)
async def disconnect(
platform: SocialPlatform,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MessageResponse:
"""
소셜 계정 연동 해제 (platform 기준)
플랫폼으로 연동된 번째 계정을 해제합니다.
"""
logger.info(
f"[OAUTH_API] 소셜 연동 해제 - "
f"user_uuid: {current_user.user_uuid}, platform: {platform.value}"
)
await social_account_service.disconnect(
user_uuid=current_user.user_uuid,
platform=platform,
session=session,
)
return MessageResponse(
success=True,
message=f"{platform.value} 계정 연동이 해제되었습니다.",
)

View File

@ -0,0 +1,131 @@
import logging, json
from redis.asyncio import Redis
from config import social_oauth_settings, db_settings
from app.social.constants import YOUTUBE_SEO_HASH
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.social.schemas import (
YoutubeDescriptionRequest,
YoutubeDescriptionResponse,
)
from app.database.session import get_session
from app.user.dependencies import get_current_user
from app.user.models import User
from app.home.models import Project, MarketingIntel
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from fastapi import HTTPException, status
from app.utils.prompts.prompts import yt_upload_prompt
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
redis_seo_client = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=0,
decode_responses=True,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/seo", tags=["Social SEO"])
@router.post(
"/youtube",
response_model=YoutubeDescriptionResponse,
summary="유튜브 SEO descrption 생성",
description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성",
)
async def youtube_seo_description(
request_body: YoutubeDescriptionRequest,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse:
# TODO : 나중에 Session Task_id 검증 미들웨어 만들면 추가해주세요.
logger.info(
f"[youtube_seo_description] Try Cache - user: {current_user.user_uuid} / task_id : {request_body.task_id}"
)
cached = await get_yt_seo_in_redis(request_body.task_id)
if cached: # redis hit
return cached
logger.info(
f"[youtube_seo_description] Cache miss - user: {current_user.user_uuid} "
)
updated_seo = await make_youtube_seo_description(request_body.task_id, current_user, session)
await set_yt_seo_in_redis(request_body.task_id, updated_seo)
return updated_seo
async def make_youtube_seo_description(
task_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
logger.info(
f"[make_youtube_seo_description] START - user: {current_user.user_uuid} "
)
try:
project_query = await session.execute(
select(Project)
.where(
Project.task_id == task_id,
Project.user_uuid == current_user.user_uuid)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_query.scalar_one_or_none()
marketing_query = await session.execute(
select(MarketingIntel)
.where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_query.scalar_one_or_none()
hashtags = marketing_intelligence.intel_result["target_keywords"]
yt_seo_input_data = {
"customer_name" : project.store_name,
"detail_region_info" : project.detail_region_info,
"marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False),
"language" : project.language,
"target_keywords" : hashtags
}
chatgpt = ChatgptService()
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
result_dict = {
"title" : yt_seo_output.title,
"description" : yt_seo_output.description,
"keywords": hashtags
}
result = YoutubeDescriptionResponse(**result_dict)
return result
except Exception as e:
logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
)
async def get_yt_seo_in_redis(task_id:str) -> YoutubeDescriptionResponse | None:
field = f"task_id:{task_id}"
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
if yt_seo_info:
yt_seo = json.loads(yt_seo_info)
else:
return None
return YoutubeDescriptionResponse(**yt_seo)
async def set_yt_seo_in_redis(task_id:str, yt_seo : YoutubeDescriptionResponse) -> None:
field = f"task_id:{task_id}"
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
await redis_seo_client.hsetex(YOUTUBE_SEO_HASH, field, yt_seo_info, ex=3600)
return

View File

@ -0,0 +1,424 @@
"""
소셜 업로드 API 라우터
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
"""
import logging, json
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, Query
from fastapi import HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.social.constants import SocialPlatform, UploadStatus
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
from app.social.models import SocialUpload
from app.social.schemas import (
MessageResponse,
SocialUploadHistoryItem,
SocialUploadHistoryResponse,
SocialUploadRequest,
SocialUploadResponse,
SocialUploadStatusResponse,
)
from app.social.services import social_account_service
from app.social.worker.upload_task import process_social_upload
from app.user.dependencies import get_current_user
from app.user.models import User
from app.video.models import Video
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/upload", tags=["Social Upload"])
@router.post(
"",
response_model=SocialUploadResponse,
summary="소셜 플랫폼에 영상 업로드 요청",
description="""
영상을 소셜 미디어 플랫폼에 업로드합니다.
## 사전 조건
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
- 영상이 completed 상태여야 합니다 (result_movie_url 필요)
## 요청 필드
- **video_id**: 업로드할 영상 ID
- **social_account_id**: 업로드할 소셜 계정 ID (연동 계정 목록 조회 API에서 확인)
- **title**: 영상 제목 (최대 100)
- **description**: 영상 설명 (최대 5000)
- **tags**: 태그 목록
- **privacy_status**: 공개 상태 (public, unlisted, private)
- **scheduled_at**: 예약 게시 시간 (선택사항)
## 업로드 상태
업로드는 백그라운드에서 처리되며, 상태를 폴링하여 확인할 있습니다:
- `pending`: 업로드 대기
- `uploading`: 업로드 진행
- `processing`: 플랫폼에서 처리
- `completed`: 업로드 완료
- `failed`: 업로드 실패
""",
)
async def upload_to_social(
body: SocialUploadRequest,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialUploadResponse:
"""
소셜 플랫폼에 영상 업로드 요청
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
"""
logger.info(
f"[UPLOAD_API] 업로드 요청 - "
f"user_uuid: {current_user.user_uuid}, "
f"video_id: {body.video_id}, "
f"social_account_id: {body.social_account_id}"
)
# 1. 영상 조회 및 검증
video_result = await session.execute(
select(Video).where(Video.id == body.video_id)
)
video = video_result.scalar_one_or_none()
if not video:
logger.warning(f"[UPLOAD_API] 영상 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(video_id=body.video_id)
if not video.result_movie_url:
logger.warning(f"[UPLOAD_API] 영상 URL 없음 - video_id: {body.video_id}")
raise VideoNotFoundError(
video_id=body.video_id,
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
account = await social_account_service.get_account_by_id(
user_uuid=current_user.user_uuid,
account_id=body.social_account_id,
session=session,
)
if not account:
logger.warning(
f"[UPLOAD_API] 연동 계정 없음 - "
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
)
raise SocialAccountNotFoundError()
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
in_progress_result = await session.execute(
select(SocialUpload).where(
SocialUpload.video_id == body.video_id,
SocialUpload.social_account_id == account.id,
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
)
)
in_progress_upload = in_progress_result.scalar_one_or_none()
if in_progress_upload:
logger.info(
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
)
return SocialUploadResponse(
success=True,
upload_id=in_progress_upload.id,
platform=account.platform,
status=in_progress_upload.status,
message="이미 업로드가 진행 중입니다.",
)
# 4. 업로드 순번 계산 (동일 video + account 조합에서 최대 순번 + 1)
max_seq_result = await session.execute(
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
SocialUpload.video_id == body.video_id,
SocialUpload.social_account_id == account.id,
)
)
max_seq = max_seq_result.scalar() or 0
next_seq = max_seq + 1
# 5. 새 업로드 레코드 생성 (항상 새로 생성하여 이력 보존)
social_upload = SocialUpload(
user_uuid=current_user.user_uuid,
video_id=body.video_id,
social_account_id=account.id,
upload_seq=next_seq,
platform=account.platform,
status=UploadStatus.PENDING.value,
upload_progress=0,
title=body.title,
description=body.description,
tags=body.tags,
privacy_status=body.privacy_status.value,
platform_options={
**(body.platform_options or {}),
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
},
retry_count=0,
)
session.add(social_upload)
await session.commit()
await session.refresh(social_upload)
logger.info(
f"[UPLOAD_API] 업로드 레코드 생성 - "
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
)
# 6. 백그라운드 태스크 등록
background_tasks.add_task(process_social_upload, social_upload.id)
return SocialUploadResponse(
success=True,
upload_id=social_upload.id,
platform=account.platform,
status=social_upload.status,
message="업로드 요청이 접수되었습니다.",
)
@router.get(
"/{upload_id}/status",
response_model=SocialUploadStatusResponse,
summary="업로드 상태 조회",
description="특정 업로드 작업의 상태를 조회합니다.",
)
async def get_upload_status(
upload_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialUploadStatusResponse:
"""
업로드 상태 조회
"""
logger.info(f"[UPLOAD_API] 상태 조회 - upload_id: {upload_id}")
result = await session.execute(
select(SocialUpload).where(
SocialUpload.id == upload_id,
SocialUpload.user_uuid == current_user.user_uuid,
)
)
upload = result.scalar_one_or_none()
if not upload:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="업로드 정보를 찾을 수 없습니다.",
)
return SocialUploadStatusResponse(
upload_id=upload.id,
video_id=upload.video_id,
social_account_id=upload.social_account_id,
upload_seq=upload.upload_seq,
platform=upload.platform,
status=UploadStatus(upload.status),
upload_progress=upload.upload_progress,
title=upload.title,
platform_video_id=upload.platform_video_id,
platform_url=upload.platform_url,
error_message=upload.error_message,
retry_count=upload.retry_count,
created_at=upload.created_at,
uploaded_at=upload.uploaded_at,
)
@router.get(
"/history",
response_model=SocialUploadHistoryResponse,
summary="업로드 이력 조회",
description="사용자의 소셜 미디어 업로드 이력을 조회합니다.",
)
async def get_upload_history(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
platform: Optional[SocialPlatform] = Query(None, description="플랫폼 필터"),
status: Optional[UploadStatus] = Query(None, description="상태 필터"),
page: int = Query(1, ge=1, description="페이지 번호"),
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
) -> SocialUploadHistoryResponse:
"""
업로드 이력 조회
"""
logger.info(
f"[UPLOAD_API] 이력 조회 - "
f"user_uuid: {current_user.user_uuid}, page: {page}, size: {size}"
)
# 기본 쿼리
query = select(SocialUpload).where(
SocialUpload.user_uuid == current_user.user_uuid
)
count_query = select(func.count(SocialUpload.id)).where(
SocialUpload.user_uuid == current_user.user_uuid
)
# 필터 적용
if platform:
query = query.where(SocialUpload.platform == platform.value)
count_query = count_query.where(SocialUpload.platform == platform.value)
if status:
query = query.where(SocialUpload.status == status.value)
count_query = count_query.where(SocialUpload.status == status.value)
# 총 개수 조회
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# 페이지네이션 적용
query = (
query.order_by(SocialUpload.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
result = await session.execute(query)
uploads = result.scalars().all()
items = [
SocialUploadHistoryItem(
upload_id=upload.id,
video_id=upload.video_id,
social_account_id=upload.social_account_id,
upload_seq=upload.upload_seq,
platform=upload.platform,
status=upload.status,
title=upload.title,
platform_url=upload.platform_url,
created_at=upload.created_at,
uploaded_at=upload.uploaded_at,
)
for upload in uploads
]
return SocialUploadHistoryResponse(
items=items,
total=total,
page=page,
size=size,
)
@router.post(
"/{upload_id}/retry",
response_model=SocialUploadResponse,
summary="업로드 재시도",
description="실패한 업로드를 재시도합니다.",
)
async def retry_upload(
upload_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> SocialUploadResponse:
"""
업로드 재시도
실패한 업로드를 다시 시도합니다.
"""
logger.info(f"[UPLOAD_API] 재시도 요청 - upload_id: {upload_id}")
result = await session.execute(
select(SocialUpload).where(
SocialUpload.id == upload_id,
SocialUpload.user_uuid == current_user.user_uuid,
)
)
upload = result.scalar_one_or_none()
if not upload:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="업로드 정보를 찾을 수 없습니다.",
)
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
)
# 상태 초기화
upload.status = UploadStatus.PENDING.value
upload.upload_progress = 0
upload.error_message = None
await session.commit()
# 백그라운드 태스크 등록
background_tasks.add_task(process_social_upload, upload.id)
return SocialUploadResponse(
success=True,
upload_id=upload.id,
platform=upload.platform,
status=upload.status,
message="업로드 재시도가 요청되었습니다.",
)
@router.delete(
"/{upload_id}",
response_model=MessageResponse,
summary="업로드 취소",
description="대기 중인 업로드를 취소합니다.",
)
async def cancel_upload(
upload_id: int,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> MessageResponse:
"""
업로드 취소
대기 중인 업로드를 취소합니다.
이미 진행 중이거나 완료된 업로드는 취소할 없습니다.
"""
logger.info(f"[UPLOAD_API] 취소 요청 - upload_id: {upload_id}")
result = await session.execute(
select(SocialUpload).where(
SocialUpload.id == upload_id,
SocialUpload.user_uuid == current_user.user_uuid,
)
)
upload = result.scalar_one_or_none()
if not upload:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="업로드 정보를 찾을 수 없습니다.",
)
if upload.status != UploadStatus.PENDING.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="대기 중인 업로드만 취소할 수 있습니다.",
)
upload.status = UploadStatus.CANCELLED.value
await session.commit()
return MessageResponse(
success=True,
message="업로드가 취소되었습니다.",
)

125
app/social/constants.py Normal file
View File

@ -0,0 +1,125 @@
"""
Social Media Constants
소셜 미디어 플랫폼 관련 상수 Enum을 정의합니다.
"""
from enum import Enum
class SocialPlatform(str, Enum):
"""지원하는 소셜 미디어 플랫폼"""
YOUTUBE = "youtube"
INSTAGRAM = "instagram"
FACEBOOK = "facebook"
TIKTOK = "tiktok"
class UploadStatus(str, Enum):
"""업로드 상태"""
PENDING = "pending" # 업로드 대기 중
UPLOADING = "uploading" # 업로드 진행 중
PROCESSING = "processing" # 플랫폼에서 처리 중 (인코딩 등)
COMPLETED = "completed" # 업로드 완료
FAILED = "failed" # 업로드 실패
CANCELLED = "cancelled" # 취소됨
class PrivacyStatus(str, Enum):
"""영상 공개 상태"""
PUBLIC = "public" # 전체 공개
UNLISTED = "unlisted" # 일부 공개 (링크 있는 사람만)
PRIVATE = "private" # 비공개
# =============================================================================
# 플랫폼별 설정
# =============================================================================
PLATFORM_CONFIG = {
SocialPlatform.YOUTUBE: {
"name": "YouTube",
"display_name": "유튜브",
"max_file_size_mb": 256000, # 256GB
"supported_formats": ["mp4", "mov", "avi", "wmv", "flv", "3gp", "webm"],
"max_title_length": 100,
"max_description_length": 5000,
"max_tags": 500,
"supported_privacy": ["public", "unlisted", "private"],
"requires_channel": True,
},
SocialPlatform.INSTAGRAM: {
"name": "Instagram",
"display_name": "인스타그램",
"max_file_size_mb": 4096, # 4GB (Reels)
"supported_formats": ["mp4", "mov"],
"max_duration_seconds": 90, # Reels 최대 90초
"min_duration_seconds": 3,
"aspect_ratios": ["9:16", "1:1", "4:5"],
"max_caption_length": 2200,
"requires_business_account": True,
},
SocialPlatform.FACEBOOK: {
"name": "Facebook",
"display_name": "페이스북",
"max_file_size_mb": 10240, # 10GB
"supported_formats": ["mp4", "mov"],
"max_duration_seconds": 14400, # 4시간
"max_title_length": 255,
"max_description_length": 5000,
"requires_page": True,
},
SocialPlatform.TIKTOK: {
"name": "TikTok",
"display_name": "틱톡",
"max_file_size_mb": 4096, # 4GB
"supported_formats": ["mp4", "mov", "webm"],
"max_duration_seconds": 600, # 10분
"min_duration_seconds": 1,
"max_title_length": 150,
"requires_business_account": True,
},
}
# =============================================================================
# YouTube OAuth Scopes
# =============================================================================
YOUTUBE_SCOPES = [
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
]
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
# =============================================================================
# Instagram/Facebook OAuth Scopes (추후 구현)
# =============================================================================
# INSTAGRAM_SCOPES = [
# "instagram_basic",
# "instagram_content_publish",
# "pages_read_engagement",
# "business_management",
# ]
# FACEBOOK_SCOPES = [
# "pages_manage_posts",
# "pages_read_engagement",
# "publish_video",
# "pages_show_list",
# ]
# =============================================================================
# TikTok OAuth Scopes (추후 구현)
# =============================================================================
# TIKTOK_SCOPES = [
# "user.info.basic",
# "video.upload",
# "video.publish",
# ]

331
app/social/exceptions.py Normal file
View File

@ -0,0 +1,331 @@
"""
Social Media Exceptions
소셜 미디어 연동 관련 예외 클래스를 정의합니다.
"""
from fastapi import status
class SocialException(Exception):
"""소셜 미디어 기본 예외"""
def __init__(
self,
message: str,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
code: str = "SOCIAL_ERROR",
):
self.message = message
self.status_code = status_code
self.code = code
super().__init__(self.message)
# =============================================================================
# OAuth 관련 예외
# =============================================================================
class OAuthException(SocialException):
"""OAuth 관련 예외 기본 클래스"""
def __init__(
self,
message: str = "OAuth 인증 중 오류가 발생했습니다.",
status_code: int = status.HTTP_401_UNAUTHORIZED,
code: str = "OAUTH_ERROR",
):
super().__init__(message, status_code, code)
class InvalidStateError(OAuthException):
"""CSRF state 토큰 불일치"""
def __init__(self, message: str = "유효하지 않은 인증 세션입니다. 다시 시도해주세요."):
super().__init__(
message=message,
status_code=status.HTTP_400_BAD_REQUEST,
code="INVALID_STATE",
)
class OAuthStateExpiredError(OAuthException):
"""OAuth state 토큰 만료"""
def __init__(self, message: str = "인증 세션이 만료되었습니다. 다시 시도해주세요."):
super().__init__(
message=message,
status_code=status.HTTP_400_BAD_REQUEST,
code="STATE_EXPIRED",
)
class OAuthTokenError(OAuthException):
"""OAuth 토큰 교환 실패"""
def __init__(self, platform: str, message: str = ""):
error_message = f"{platform} 토큰 발급에 실패했습니다."
if message:
error_message += f" ({message})"
super().__init__(
message=error_message,
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_EXCHANGE_FAILED",
)
class TokenRefreshError(OAuthException):
"""토큰 갱신 실패"""
def __init__(self, platform: str):
super().__init__(
message=f"{platform} 토큰 갱신에 실패했습니다. 재연동이 필요합니다.",
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_REFRESH_FAILED",
)
class OAuthCodeExchangeError(OAuthException):
"""OAuth 인가 코드 교환 실패"""
def __init__(self, platform: str, detail: str = ""):
error_message = f"{platform} 인가 코드 교환에 실패했습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_401_UNAUTHORIZED,
code="CODE_EXCHANGE_FAILED",
)
class OAuthTokenRefreshError(OAuthException):
"""OAuth 토큰 갱신 실패"""
def __init__(self, platform: str, detail: str = ""):
error_message = f"{platform} 토큰 갱신에 실패했습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_REFRESH_FAILED",
)
class TokenExpiredError(OAuthException):
"""토큰 만료"""
def __init__(self, platform: str):
super().__init__(
message=f"{platform} 인증이 만료되었습니다. 재연동이 필요합니다.",
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_EXPIRED",
)
self.platform = platform
# =============================================================================
# 소셜 계정 관련 예외
# =============================================================================
class SocialAccountException(SocialException):
"""소셜 계정 관련 예외 기본 클래스"""
pass
class SocialAccountNotFoundError(SocialAccountException):
"""연동된 계정을 찾을 수 없음"""
def __init__(self, platform: str = ""):
message = f"{platform} 계정이 연동되어 있지 않습니다." if platform else "연동된 소셜 계정이 없습니다."
super().__init__(
message=message,
status_code=status.HTTP_404_NOT_FOUND,
code="SOCIAL_ACCOUNT_NOT_FOUND",
)
class SocialAccountAlreadyExistsError(SocialAccountException):
"""이미 연동된 계정이 존재함"""
def __init__(self, platform: str):
super().__init__(
message=f"이미 {platform} 계정이 연동되어 있습니다.",
status_code=status.HTTP_409_CONFLICT,
code="SOCIAL_ACCOUNT_EXISTS",
)
# Alias for backward compatibility
SocialAccountAlreadyConnectedError = SocialAccountAlreadyExistsError
class SocialAccountInactiveError(SocialAccountException):
"""비활성화된 소셜 계정"""
def __init__(self, platform: str):
super().__init__(
message=f"{platform} 계정이 비활성화 상태입니다. 재연동이 필요합니다.",
status_code=status.HTTP_403_FORBIDDEN,
code="SOCIAL_ACCOUNT_INACTIVE",
)
class SocialAccountError(SocialAccountException):
"""소셜 계정 일반 오류"""
def __init__(self, platform: str, detail: str = ""):
error_message = f"{platform} 계정 처리 중 오류가 발생했습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_400_BAD_REQUEST,
code="SOCIAL_ACCOUNT_ERROR",
)
# =============================================================================
# 업로드 관련 예외
# =============================================================================
class UploadException(SocialException):
"""업로드 관련 예외 기본 클래스"""
pass
class UploadError(UploadException):
"""업로드 일반 오류"""
def __init__(self, platform: str, detail: str = ""):
error_message = f"{platform} 업로드 중 오류가 발생했습니다."
if detail:
error_message += f" ({detail})"
super().__init__(
message=error_message,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="UPLOAD_ERROR",
)
class UploadValidationError(UploadException):
"""업로드 유효성 검사 실패"""
def __init__(self, message: str):
super().__init__(
message=message,
status_code=status.HTTP_400_BAD_REQUEST,
code="UPLOAD_VALIDATION_FAILED",
)
class VideoNotFoundError(UploadException):
"""영상을 찾을 수 없음"""
def __init__(self, video_id: int, detail: str = ""):
message = f"영상을 찾을 수 없습니다. (video_id: {video_id})"
if detail:
message = detail
super().__init__(
message=message,
status_code=status.HTTP_404_NOT_FOUND,
code="VIDEO_NOT_FOUND",
)
class VideoNotReadyError(UploadException):
"""영상이 준비되지 않음"""
def __init__(self, video_id: int):
super().__init__(
message=f"영상이 아직 준비되지 않았습니다. (video_id: {video_id})",
status_code=status.HTTP_400_BAD_REQUEST,
code="VIDEO_NOT_READY",
)
class UploadFailedError(UploadException):
"""업로드 실패"""
def __init__(self, platform: str, message: str = ""):
error_message = f"{platform} 업로드에 실패했습니다."
if message:
error_message += f" ({message})"
super().__init__(
message=error_message,
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="UPLOAD_FAILED",
)
class UploadQuotaExceededError(UploadException):
"""업로드 할당량 초과"""
def __init__(self, platform: str):
super().__init__(
message=f"{platform} 일일 업로드 할당량이 초과되었습니다.",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
code="UPLOAD_QUOTA_EXCEEDED",
)
class UploadNotFoundError(UploadException):
"""업로드 기록을 찾을 수 없음"""
def __init__(self, upload_id: int):
super().__init__(
message=f"업로드 기록을 찾을 수 없습니다. (upload_id: {upload_id})",
status_code=status.HTTP_404_NOT_FOUND,
code="UPLOAD_NOT_FOUND",
)
# =============================================================================
# 플랫폼 API 관련 예외
# =============================================================================
class PlatformAPIError(SocialException):
"""플랫폼 API 호출 오류"""
def __init__(self, platform: str, message: str = ""):
error_message = f"{platform} API 호출 중 오류가 발생했습니다."
if message:
error_message += f" ({message})"
super().__init__(
message=error_message,
status_code=status.HTTP_502_BAD_GATEWAY,
code="PLATFORM_API_ERROR",
)
class RateLimitError(PlatformAPIError):
"""API 요청 한도 초과"""
def __init__(self, platform: str, retry_after: int | None = None):
message = f"{platform} API 요청 한도가 초과되었습니다."
if retry_after:
message += f" {retry_after}초 후에 다시 시도해주세요."
super().__init__(
platform=platform,
message=message,
)
self.retry_after = retry_after
self.code = "RATE_LIMIT_EXCEEDED"
class UnsupportedPlatformError(SocialException):
"""지원하지 않는 플랫폼"""
def __init__(self, platform: str):
super().__init__(
message=f"지원하지 않는 플랫폼입니다: {platform}",
status_code=status.HTTP_400_BAD_REQUEST,
code="UNSUPPORTED_PLATFORM",
)

256
app/social/models.py Normal file
View File

@ -0,0 +1,256 @@
"""
Social Media Models
소셜 미디어 업로드 관련 SQLAlchemy 모델을 정의합니다.
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, func
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
if TYPE_CHECKING:
from app.user.models import SocialAccount
from app.video.models import Video
class SocialUpload(Base):
"""
소셜 미디어 업로드 기록 테이블
영상의 소셜 미디어 플랫폼별 업로드 상태를 추적합니다.
Attributes:
id: 고유 식별자 (자동 증가)
user_uuid: 사용자 UUID (User.user_uuid 참조)
video_id: Video 외래키
social_account_id: SocialAccount 외래키
upload_seq: 업로드 순번 (동일 영상+채널 조합 순번, 관리자 추적용)
platform: 플랫폼 구분 (youtube, instagram, facebook, tiktok)
status: 업로드 상태 (pending, uploading, processing, completed, failed)
upload_progress: 업로드 진행률 (0-100)
platform_video_id: 플랫폼에서 부여한 영상 ID
platform_url: 플랫폼에서의 영상 URL
title: 영상 제목
description: 영상 설명
tags: 태그 목록 (JSON)
privacy_status: 공개 상태 (public, unlisted, private)
platform_options: 플랫폼별 추가 옵션 (JSON)
error_message: 에러 메시지 (실패 )
retry_count: 재시도 횟수
uploaded_at: 업로드 완료 시간
created_at: 생성 일시
updated_at: 수정 일시
Relationships:
video: 연결된 Video
social_account: 연결된 SocialAccount
"""
__tablename__ = "social_upload"
__table_args__ = (
Index("idx_social_upload_user_uuid", "user_uuid"),
Index("idx_social_upload_video_id", "video_id"),
Index("idx_social_upload_social_account_id", "social_account_id"),
Index("idx_social_upload_platform", "platform"),
Index("idx_social_upload_status", "status"),
Index("idx_social_upload_created_at", "created_at"),
# 동일 영상+채널 조합 조회용 인덱스 (유니크 아님 - 여러 번 업로드 가능)
Index("idx_social_upload_video_account", "video_id", "social_account_id"),
# 순번 조회용 인덱스
Index("idx_social_upload_seq", "video_id", "social_account_id", "upload_seq"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
# ==========================================================================
# 기본 식별자
# ==========================================================================
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
# ==========================================================================
# 관계 필드
# ==========================================================================
user_uuid: Mapped[str] = mapped_column(
String(36),
ForeignKey("user.user_uuid", ondelete="CASCADE"),
nullable=False,
comment="사용자 UUID (User.user_uuid 참조)",
)
video_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("video.id", ondelete="CASCADE"),
nullable=False,
comment="Video 외래키",
)
social_account_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("social_account.id", ondelete="CASCADE"),
nullable=False,
comment="SocialAccount 외래키",
)
# ==========================================================================
# 업로드 순번 (관리자 추적용)
# ==========================================================================
upload_seq: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=1,
comment="업로드 순번 (동일 영상+채널 조합 내 순번, 1부터 시작)",
)
# ==========================================================================
# 플랫폼 정보
# ==========================================================================
platform: Mapped[str] = mapped_column(
String(20),
nullable=False,
comment="플랫폼 구분 (youtube, instagram, facebook, tiktok)",
)
# ==========================================================================
# 업로드 상태
# ==========================================================================
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
comment="업로드 상태 (pending, uploading, processing, completed, failed)",
)
upload_progress: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="업로드 진행률 (0-100)",
)
# ==========================================================================
# 플랫폼 결과
# ==========================================================================
platform_video_id: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="플랫폼에서 부여한 영상 ID",
)
platform_url: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
comment="플랫폼에서의 영상 URL",
)
# ==========================================================================
# 메타데이터
# ==========================================================================
title: Mapped[str] = mapped_column(
String(200),
nullable=False,
comment="영상 제목",
)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="영상 설명",
)
tags: Mapped[Optional[dict]] = mapped_column(
JSON,
nullable=True,
comment="태그 목록 (JSON 배열)",
)
privacy_status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="private",
comment="공개 상태 (public, unlisted, private)",
)
platform_options: Mapped[Optional[dict]] = mapped_column(
JSON,
nullable=True,
comment="플랫폼별 추가 옵션 (JSON)",
)
# ==========================================================================
# 에러 정보
# ==========================================================================
error_message: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="에러 메시지 (실패 시)",
)
retry_count: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
comment="재시도 횟수",
)
# ==========================================================================
# 시간 정보
# ==========================================================================
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="업로드 완료 시간",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="생성 일시",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="수정 일시",
)
# ==========================================================================
# Relationships
# ==========================================================================
video: Mapped["Video"] = relationship(
"Video",
lazy="selectin",
)
social_account: Mapped["SocialAccount"] = relationship(
"SocialAccount",
lazy="selectin",
)
def __repr__(self) -> str:
return (
f"<SocialUpload("
f"id={self.id}, "
f"video_id={self.video_id}, "
f"account_id={self.social_account_id}, "
f"seq={self.upload_seq}, "
f"platform='{self.platform}', "
f"status='{self.status}'"
f")>"
)

View File

@ -0,0 +1,46 @@
"""
Social OAuth Module
소셜 미디어 OAuth 클라이언트 모듈입니다.
"""
from app.social.constants import SocialPlatform
from app.social.oauth.base import BaseOAuthClient
def get_oauth_client(platform: SocialPlatform) -> BaseOAuthClient:
"""
플랫폼에 맞는 OAuth 클라이언트 반환
Args:
platform: 소셜 플랫폼
Returns:
BaseOAuthClient: OAuth 클라이언트 인스턴스
Raises:
ValueError: 지원하지 않는 플랫폼인 경우
"""
if platform == SocialPlatform.YOUTUBE:
from app.social.oauth.youtube import YouTubeOAuthClient
return YouTubeOAuthClient()
# 추후 확장
# elif platform == SocialPlatform.INSTAGRAM:
# from app.social.oauth.instagram import InstagramOAuthClient
# return InstagramOAuthClient()
# elif platform == SocialPlatform.FACEBOOK:
# from app.social.oauth.facebook import FacebookOAuthClient
# return FacebookOAuthClient()
# elif platform == SocialPlatform.TIKTOK:
# from app.social.oauth.tiktok import TikTokOAuthClient
# return TikTokOAuthClient()
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
__all__ = [
"BaseOAuthClient",
"get_oauth_client",
]

113
app/social/oauth/base.py Normal file
View File

@ -0,0 +1,113 @@
"""
Base OAuth Client
소셜 미디어 OAuth 클라이언트의 추상 기본 클래스입니다.
"""
from abc import ABC, abstractmethod
from typing import Optional
from app.social.constants import SocialPlatform
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
class BaseOAuthClient(ABC):
"""
소셜 미디어 OAuth 클라이언트 추상 기본 클래스
모든 플랫폼별 OAuth 클라이언트는 클래스를 상속받아 구현합니다.
Attributes:
platform: 소셜 플랫폼 종류
"""
platform: SocialPlatform
@abstractmethod
def get_authorization_url(self, state: str) -> str:
"""
OAuth 인증 URL 생성
Args:
state: CSRF 방지용 state 토큰
Returns:
str: OAuth 인증 페이지 URL
"""
pass
@abstractmethod
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""
인가 코드로 액세스 토큰 교환
Args:
code: OAuth 인가 코드
Returns:
OAuthTokenResponse: 액세스 토큰 리프레시 토큰
Raises:
OAuthCodeExchangeError: 토큰 교환 실패
"""
pass
@abstractmethod
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
"""
리프레시 토큰으로 액세스 토큰 갱신
Args:
refresh_token: 리프레시 토큰
Returns:
OAuthTokenResponse: 액세스 토큰
Raises:
OAuthTokenRefreshError: 토큰 갱신 실패
"""
pass
@abstractmethod
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
"""
플랫폼 사용자 정보 조회
Args:
access_token: 액세스 토큰
Returns:
PlatformUserInfo: 플랫폼 사용자 정보
Raises:
SocialAccountError: 사용자 정보 조회 실패
"""
pass
@abstractmethod
async def revoke_token(self, token: str) -> bool:
"""
토큰 폐기 (연동 해제 )
Args:
token: 폐기할 토큰
Returns:
bool: 폐기 성공 여부
"""
pass
def is_token_expired(self, expires_in: Optional[int]) -> bool:
"""
토큰 만료 여부 확인 (만료 10 전이면 True)
Args:
expires_in: 토큰 만료까지 남은 시간()
Returns:
bool: 갱신 필요 여부
"""
if expires_in is None:
return False
# 만료 10분(600초) 전이면 갱신 필요
return expires_in <= 600

326
app/social/oauth/youtube.py Normal file
View File

@ -0,0 +1,326 @@
"""
YouTube OAuth Client
Google OAuth를 사용한 YouTube 인증 클라이언트입니다.
"""
import logging
from urllib.parse import urlencode
import httpx
from config import social_oauth_settings
from app.social.constants import SocialPlatform, YOUTUBE_SCOPES
from app.social.exceptions import (
OAuthCodeExchangeError,
OAuthTokenRefreshError,
SocialAccountError,
)
from app.social.oauth.base import BaseOAuthClient
from app.social.schemas import OAuthTokenResponse, PlatformUserInfo
logger = logging.getLogger(__name__)
class YouTubeOAuthClient(BaseOAuthClient):
"""
YouTube OAuth 클라이언트
Google OAuth 2.0 사용하여 YouTube 계정 인증을 처리합니다.
"""
platform = SocialPlatform.YOUTUBE
# Google OAuth 엔드포인트
AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
YOUTUBE_CHANNEL_URL = "https://www.googleapis.com/youtube/v3/channels"
REVOKE_URL = "https://oauth2.googleapis.com/revoke"
def __init__(self) -> None:
self.client_id = social_oauth_settings.YOUTUBE_CLIENT_ID
self.client_secret = social_oauth_settings.YOUTUBE_CLIENT_SECRET
self.redirect_uri = social_oauth_settings.YOUTUBE_REDIRECT_URI
def get_authorization_url(self, state: str) -> str:
"""
Google OAuth 인증 URL 생성
Args:
state: CSRF 방지용 state 토큰
Returns:
str: Google OAuth 인증 페이지 URL
"""
params = {
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"response_type": "code",
"scope": " ".join(YOUTUBE_SCOPES),
"access_type": "offline", # refresh_token 받기 위해 필요
"prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만)
"state": state,
}
url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
logger.debug(f"[YOUTUBE_OAUTH] 인증 URL 생성: {url[:100]}...")
return url
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""
인가 코드로 액세스 토큰 교환
Args:
code: OAuth 인가 코드
Returns:
OAuthTokenResponse: 액세스 토큰 리프레시 토큰
Raises:
OAuthCodeExchangeError: 토큰 교환 실패
"""
logger.info(f"[YOUTUBE_OAUTH] 토큰 교환 시작 - code: {code[:20]}...")
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": self.redirect_uri,
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
self.TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
token_data = response.json()
logger.info("[YOUTUBE_OAUTH] 토큰 교환 성공")
logger.debug(
f"[YOUTUBE_OAUTH] 토큰 정보 - "
f"expires_in: {token_data.get('expires_in')}, "
f"scope: {token_data.get('scope')}"
)
return OAuthTokenResponse(
access_token=token_data["access_token"],
refresh_token=token_data.get("refresh_token"),
expires_in=token_data["expires_in"],
token_type=token_data.get("token_type", "Bearer"),
scope=token_data.get("scope"),
)
except httpx.HTTPStatusError as e:
error_detail = e.response.text if e.response else str(e)
logger.error(
f"[YOUTUBE_OAUTH] 토큰 교환 실패 - "
f"status: {e.response.status_code}, error: {error_detail}"
)
raise OAuthCodeExchangeError(
platform=self.platform.value,
detail=f"토큰 교환 실패: {error_detail}",
)
except Exception as e:
logger.error(f"[YOUTUBE_OAUTH] 토큰 교환 중 예외 발생: {e}")
raise OAuthCodeExchangeError(
platform=self.platform.value,
detail=str(e),
)
async def refresh_token(self, refresh_token: str) -> OAuthTokenResponse:
"""
리프레시 토큰으로 액세스 토큰 갱신
Args:
refresh_token: 리프레시 토큰
Returns:
OAuthTokenResponse: 액세스 토큰
Raises:
OAuthTokenRefreshError: 토큰 갱신 실패
"""
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 시작")
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": refresh_token,
"grant_type": "refresh_token",
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(
self.TOKEN_URL,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
token_data = response.json()
logger.info("[YOUTUBE_OAUTH] 토큰 갱신 성공")
return OAuthTokenResponse(
access_token=token_data["access_token"],
refresh_token=refresh_token, # Google은 refresh_token 재발급 안함
expires_in=token_data["expires_in"],
token_type=token_data.get("token_type", "Bearer"),
scope=token_data.get("scope"),
)
except httpx.HTTPStatusError as e:
error_detail = e.response.text if e.response else str(e)
logger.error(
f"[YOUTUBE_OAUTH] 토큰 갱신 실패 - "
f"status: {e.response.status_code}, error: {error_detail}"
)
raise OAuthTokenRefreshError(
platform=self.platform.value,
detail=f"토큰 갱신 실패: {error_detail}",
)
except Exception as e:
logger.error(f"[YOUTUBE_OAUTH] 토큰 갱신 중 예외 발생: {e}")
raise OAuthTokenRefreshError(
platform=self.platform.value,
detail=str(e),
)
async def get_user_info(self, access_token: str) -> PlatformUserInfo:
"""
YouTube 채널 정보 조회
Args:
access_token: 액세스 토큰
Returns:
PlatformUserInfo: YouTube 채널 정보
Raises:
SocialAccountError: 정보 조회 실패
"""
logger.info("[YOUTUBE_OAUTH] 사용자/채널 정보 조회 시작")
headers = {"Authorization": f"Bearer {access_token}"}
async with httpx.AsyncClient() as client:
try:
# 1. Google 사용자 기본 정보 조회
userinfo_response = await client.get(
self.USERINFO_URL,
headers=headers,
)
userinfo_response.raise_for_status()
userinfo = userinfo_response.json()
# 2. YouTube 채널 정보 조회
channel_params = {
"part": "snippet,statistics",
"mine": "true",
}
channel_response = await client.get(
self.YOUTUBE_CHANNEL_URL,
headers=headers,
params=channel_params,
)
channel_response.raise_for_status()
channel_data = channel_response.json()
# 채널이 없는 경우
if not channel_data.get("items"):
logger.warning("[YOUTUBE_OAUTH] YouTube 채널 없음")
raise SocialAccountError(
platform=self.platform.value,
detail="YouTube 채널이 없습니다. 채널을 먼저 생성해주세요.",
)
channel = channel_data["items"][0]
snippet = channel.get("snippet", {})
statistics = channel.get("statistics", {})
logger.info(
f"[YOUTUBE_OAUTH] 채널 정보 조회 성공 - "
f"channel_id: {channel['id']}, "
f"title: {snippet.get('title')}"
)
return PlatformUserInfo(
platform_user_id=channel["id"],
username=snippet.get("customUrl"), # @username 형태
display_name=snippet.get("title"),
profile_image_url=snippet.get("thumbnails", {})
.get("default", {})
.get("url"),
platform_data={
"channel_id": channel["id"],
"channel_title": snippet.get("title"),
"channel_description": snippet.get("description"),
"custom_url": snippet.get("customUrl"),
"subscriber_count": statistics.get("subscriberCount"),
"video_count": statistics.get("videoCount"),
"view_count": statistics.get("viewCount"),
"google_user_id": userinfo.get("id"),
"google_email": userinfo.get("email"),
},
)
except httpx.HTTPStatusError as e:
error_detail = e.response.text if e.response else str(e)
logger.error(
f"[YOUTUBE_OAUTH] 정보 조회 실패 - "
f"status: {e.response.status_code}, error: {error_detail}"
)
raise SocialAccountError(
platform=self.platform.value,
detail=f"사용자 정보 조회 실패: {error_detail}",
)
except SocialAccountError:
raise
except Exception as e:
logger.error(f"[YOUTUBE_OAUTH] 정보 조회 중 예외 발생: {e}")
raise SocialAccountError(
platform=self.platform.value,
detail=str(e),
)
async def revoke_token(self, token: str) -> bool:
"""
토큰 폐기 (연동 해제 )
Args:
token: 폐기할 토큰 (access_token 또는 refresh_token)
Returns:
bool: 폐기 성공 여부
"""
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 시작")
async with httpx.AsyncClient() as client:
try:
response = await client.post(
self.REVOKE_URL,
data={"token": token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code == 200:
logger.info("[YOUTUBE_OAUTH] 토큰 폐기 성공")
return True
else:
logger.warning(
f"[YOUTUBE_OAUTH] 토큰 폐기 실패 - "
f"status: {response.status_code}, body: {response.text}"
)
return False
except Exception as e:
logger.error(f"[YOUTUBE_OAUTH] 토큰 폐기 중 예외 발생: {e}")
return False
# 싱글톤 인스턴스
youtube_oauth_client = YouTubeOAuthClient()

324
app/social/schemas.py Normal file
View File

@ -0,0 +1,324 @@
"""
Social Media Schemas
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
"""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.social.constants import PrivacyStatus, SocialPlatform, UploadStatus
# =============================================================================
# OAuth 관련 스키마
# =============================================================================
class SocialConnectResponse(BaseModel):
"""소셜 계정 연동 시작 응답"""
auth_url: str = Field(..., description="OAuth 인증 URL")
state: str = Field(..., description="CSRF 방지용 state 토큰")
platform: str = Field(..., description="플랫폼명")
model_config = ConfigDict(
json_schema_extra={
"example": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
"state": "abc123xyz",
"platform": "youtube",
}
}
)
class SocialAccountResponse(BaseModel):
"""연동된 소셜 계정 정보"""
id: int = Field(..., description="소셜 계정 ID")
platform: str = Field(..., description="플랫폼명")
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
display_name: Optional[str] = Field(None, description="표시 이름")
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
is_active: bool = Field(..., description="활성화 상태")
connected_at: datetime = Field(..., description="연동 일시")
platform_data: Optional[dict[str, Any]] = Field(
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
)
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"id": 1,
"platform": "youtube",
"platform_user_id": "UC1234567890",
"platform_username": "my_channel",
"display_name": "My Channel",
"profile_image_url": "https://...",
"is_active": True,
"connected_at": "2024-01-15T12:00:00",
"platform_data": {
"channel_id": "UC1234567890",
"channel_title": "My Channel",
"subscriber_count": 1000,
},
}
}
)
class SocialAccountListResponse(BaseModel):
"""연동된 소셜 계정 목록 응답"""
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
total: int = Field(..., description="총 연동 계정 수")
model_config = ConfigDict(
json_schema_extra={
"example": {
"accounts": [
{
"id": 1,
"platform": "youtube",
"platform_user_id": "UC1234567890",
"platform_username": "my_channel",
"display_name": "My Channel",
"is_active": True,
"connected_at": "2024-01-15T12:00:00",
}
],
"total": 1,
}
}
)
# =============================================================================
# 내부 사용 스키마 (OAuth 토큰 응답)
# =============================================================================
class OAuthTokenResponse(BaseModel):
"""OAuth 토큰 응답 (내부 사용)"""
access_token: str
refresh_token: Optional[str] = None
expires_in: int
token_type: str = "Bearer"
scope: Optional[str] = None
class PlatformUserInfo(BaseModel):
"""플랫폼 사용자 정보 (내부 사용)"""
platform_user_id: str
username: Optional[str] = None
display_name: Optional[str] = None
profile_image_url: Optional[str] = None
platform_data: dict[str, Any] = Field(default_factory=dict)
# =============================================================================
# 업로드 관련 스키마
# =============================================================================
class SocialUploadRequest(BaseModel):
"""소셜 업로드 요청"""
video_id: int = Field(..., description="업로드할 영상 ID")
social_account_id: int = Field(..., description="업로드할 소셜 계정 ID (연동 계정 목록의 id)")
title: str = Field(..., min_length=1, max_length=100, description="영상 제목")
description: Optional[str] = Field(
None, max_length=5000, description="영상 설명"
)
tags: Optional[list[str]] = Field(None, description="태그 목록 (쉼표로 구분된 문자열도 가능)")
privacy_status: PrivacyStatus = Field(
default=PrivacyStatus.PRIVATE, description="공개 상태 (public, unlisted, private)"
)
scheduled_at: Optional[datetime] = Field(
None, description="예약 게시 시간 (없으면 즉시 게시)"
)
platform_options: Optional[dict[str, Any]] = Field(
None, description="플랫폼별 추가 옵션"
)
model_config = ConfigDict(
json_schema_extra={
"example": {
"video_id": 123,
"social_account_id": 1,
"title": "도그앤조이 애견펜션 2026.02.02",
"description": "영상 설명입니다.",
"tags": ["여행", "vlog", "애견펜션"],
"privacy_status": "public",
"scheduled_at": "2026-02-02T15:00:00",
"platform_options": {
"category_id": "22", # YouTube 카테고리
},
}
}
)
class SocialUploadResponse(BaseModel):
"""소셜 업로드 요청 응답"""
success: bool = Field(..., description="요청 성공 여부")
upload_id: int = Field(..., description="업로드 작업 ID")
platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태")
message: str = Field(..., description="응답 메시지")
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"upload_id": 456,
"platform": "youtube",
"status": "pending",
"message": "업로드 요청이 접수되었습니다.",
}
}
)
class SocialUploadStatusResponse(BaseModel):
"""업로드 상태 조회 응답"""
upload_id: int = Field(..., description="업로드 작업 ID")
video_id: int = Field(..., description="영상 ID")
social_account_id: int = Field(..., description="소셜 계정 ID")
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
platform: str = Field(..., description="플랫폼명")
status: UploadStatus = Field(..., description="업로드 상태")
upload_progress: int = Field(..., description="업로드 진행률 (0-100)")
title: str = Field(..., description="영상 제목")
platform_video_id: Optional[str] = Field(None, description="플랫폼 영상 ID")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
error_message: Optional[str] = Field(None, description="에러 메시지")
retry_count: int = Field(default=0, description="재시도 횟수")
created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
model_config = ConfigDict(
from_attributes=True,
json_schema_extra={
"example": {
"upload_id": 456,
"video_id": 123,
"social_account_id": 1,
"upload_seq": 2,
"platform": "youtube",
"status": "completed",
"upload_progress": 100,
"title": "나의 첫 영상",
"platform_video_id": "dQw4w9WgXcQ",
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"error_message": None,
"retry_count": 0,
"created_at": "2024-01-15T12:00:00",
"uploaded_at": "2024-01-15T12:05:00",
}
}
)
class SocialUploadHistoryItem(BaseModel):
"""업로드 이력 아이템"""
upload_id: int = Field(..., description="업로드 작업 ID")
video_id: int = Field(..., description="영상 ID")
social_account_id: int = Field(..., description="소셜 계정 ID")
upload_seq: int = Field(..., description="업로드 순번 (동일 영상+채널 조합 내 순번)")
platform: str = Field(..., description="플랫폼명")
status: str = Field(..., description="업로드 상태")
title: str = Field(..., description="영상 제목")
platform_url: Optional[str] = Field(None, description="플랫폼 영상 URL")
created_at: datetime = Field(..., description="생성 일시")
uploaded_at: Optional[datetime] = Field(None, description="업로드 완료 일시")
model_config = ConfigDict(from_attributes=True)
class SocialUploadHistoryResponse(BaseModel):
"""업로드 이력 목록 응답"""
items: list[SocialUploadHistoryItem] = Field(..., description="업로드 이력 목록")
total: int = Field(..., description="전체 개수")
page: int = Field(..., description="현재 페이지")
size: int = Field(..., description="페이지 크기")
model_config = ConfigDict(
json_schema_extra={
"example": {
"items": [
{
"upload_id": 456,
"video_id": 123,
"platform": "youtube",
"status": "completed",
"title": "나의 첫 영상",
"platform_url": "https://www.youtube.com/watch?v=xxx",
"created_at": "2024-01-15T12:00:00",
"uploaded_at": "2024-01-15T12:05:00",
}
],
"total": 1,
"page": 1,
"size": 20,
}
}
)
class YoutubeDescriptionRequest(BaseModel):
"""유튜브 SEO Description 제안 (자동완성) Request 모델"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"task_id" : "019c739f-65fc-7d15-8c88-b31be00e588e"
}
}
)
task_id: str = Field(..., description="작업 고유 식별자")
class YoutubeDescriptionResponse(BaseModel):
"""유튜브 SEO Description 제안 (자동완성) Response 모델"""
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
description : str = Field(..., description="제안된 유튜브 SEO Description")
keywords : list[str] = Field(..., description="해시태그 리스트")
model_config = ConfigDict(
json_schema_extra={
"example": {
"title" : "여기에 더미 타이틀",
"description": "여기에 더미 텍스트",
"keywords": ["여기에", "더미", "해시태그"]
}
}
)
# =============================================================================
# 공통 응답 스키마
# =============================================================================
class MessageResponse(BaseModel):
"""단순 메시지 응답"""
success: bool = Field(..., description="성공 여부")
message: str = Field(..., description="응답 메시지")
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "작업이 완료되었습니다.",
}
}
)

742
app/social/services.py Normal file
View File

@ -0,0 +1,742 @@
"""
Social Account Service
소셜 계정 연동 관련 비즈니스 로직을 처리합니다.
"""
import json
import logging
import secrets
from datetime import timedelta
from typing import Optional
from sqlalchemy import select
from app.utils.timezone import now
from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio import Redis
from config import social_oauth_settings, db_settings
from app.social.constants import SocialPlatform
# Social OAuth용 Redis 클라이언트 (DB 2 사용)
redis_client = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=2,
decode_responses=True,
)
from app.social.exceptions import (
OAuthStateExpiredError,
OAuthTokenRefreshError,
SocialAccountNotFoundError,
TokenExpiredError,
)
from app.social.oauth import get_oauth_client
from app.social.schemas import (
OAuthTokenResponse,
PlatformUserInfo,
SocialAccountResponse,
SocialConnectResponse,
)
from app.user.models import SocialAccount
logger = logging.getLogger(__name__)
class SocialAccountService:
"""
소셜 계정 연동 서비스
OAuth 인증, 계정 연동/해제, 토큰 관리 기능을 제공합니다.
"""
# Redis key prefix for OAuth state
STATE_KEY_PREFIX = "social:oauth:state:"
async def start_connect(
self,
user_uuid: str,
platform: SocialPlatform,
) -> SocialConnectResponse:
"""
소셜 계정 연동 시작
OAuth 인증 URL을 생성하고 state 토큰을 저장합니다.
Args:
user_uuid: 사용자 UUID
platform: 연동할 플랫폼
Returns:
SocialConnectResponse: OAuth 인증 URL state 토큰
"""
logger.info(
f"[SOCIAL] 소셜 계정 연동 시작 - "
f"user_uuid: {user_uuid}, platform: {platform.value}"
)
# 1. state 토큰 생성 (CSRF 방지)
state = secrets.token_urlsafe(32)
# 2. state를 Redis에 저장 (user_uuid 포함)
state_key = f"{self.STATE_KEY_PREFIX}{state}"
state_data = {
"user_uuid": user_uuid,
"platform": platform.value,
}
await redis_client.setex(
state_key,
social_oauth_settings.OAUTH_STATE_TTL_SECONDS,
json.dumps(state_data), # JSON으로 직렬화
)
logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}")
# 3. OAuth 클라이언트에서 인증 URL 생성
oauth_client = get_oauth_client(platform)
auth_url = oauth_client.get_authorization_url(state)
logger.info(f"[SOCIAL] OAuth URL 생성 완료 - platform: {platform.value}")
return SocialConnectResponse(
auth_url=auth_url,
state=state,
platform=platform.value,
)
async def handle_callback(
self,
code: str,
state: str,
session: AsyncSession,
) -> SocialAccountResponse:
"""
OAuth 콜백 처리
인가 코드로 토큰을 교환하고 소셜 계정을 저장합니다.
Args:
code: OAuth 인가 코드
state: CSRF 방지용 state 토큰
session: DB 세션
Returns:
SocialAccountResponse: 연동된 소셜 계정 정보
Raises:
OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우
"""
logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...")
# 1. state 검증 및 사용자 정보 추출
state_key = f"{self.STATE_KEY_PREFIX}{state}"
state_data_str = await redis_client.get(state_key)
if state_data_str is None:
logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...")
raise OAuthStateExpiredError()
# state 데이터 파싱 (JSON 역직렬화)
state_data = json.loads(state_data_str)
user_uuid = state_data["user_uuid"]
platform = SocialPlatform(state_data["platform"])
# state 삭제 (일회성)
await redis_client.delete(state_key)
logger.debug(f"[SOCIAL] state 토큰 사용 완료 및 삭제 - user_uuid: {user_uuid}")
# 2. OAuth 클라이언트로 토큰 교환
oauth_client = get_oauth_client(platform)
token_response = await oauth_client.exchange_code(code)
# 3. 플랫폼 사용자 정보 조회
user_info = await oauth_client.get_user_info(token_response.access_token)
# 4. 기존 연동 확인 (소프트 삭제된 계정 포함)
existing_account = await self._get_social_account(
user_uuid=user_uuid,
platform=platform,
platform_user_id=user_info.platform_user_id,
session=session,
)
if existing_account:
# 기존 계정 존재 (활성화 또는 비활성화 상태)
is_reactivation = False
if existing_account.is_active and not existing_account.is_deleted:
# 이미 활성화된 계정 - 토큰만 갱신
logger.info(
f"[SOCIAL] 기존 활성 계정 토큰 갱신 - "
f"account_id: {existing_account.id}"
)
else:
# 비활성화(소프트 삭제)된 계정 - 재활성화
logger.info(
f"[SOCIAL] 비활성 계정 재활성화 - "
f"account_id: {existing_account.id}"
)
existing_account.is_active = True
existing_account.is_deleted = False
is_reactivation = True
# 토큰 및 정보 업데이트
existing_account = await self._update_tokens(
account=existing_account,
token_response=token_response,
user_info=user_info,
session=session,
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
)
return self._to_response(existing_account)
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
social_account = await self._create_social_account(
user_uuid=user_uuid,
platform=platform,
token_response=token_response,
user_info=user_info,
session=session,
)
logger.info(
f"[SOCIAL] 소셜 계정 연동 완료 - "
f"account_id: {social_account.id}, platform: {platform.value}"
)
return self._to_response(social_account)
async def get_connected_accounts(
self,
user_uuid: str,
session: AsyncSession,
auto_refresh: bool = True,
) -> list[SocialAccountResponse]:
"""
연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함)
Args:
user_uuid: 사용자 UUID
session: DB 세션
auto_refresh: 토큰 자동 갱신 여부 (기본 True)
Returns:
list[SocialAccountResponse]: 연동된 계정 목록
"""
logger.info(f"[SOCIAL] 연동 계정 목록 조회 - user_uuid: {user_uuid}")
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user_uuid,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
accounts = result.scalars().all()
logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨")
# 토큰 자동 갱신
if auto_refresh:
for account in accounts:
await self._try_refresh_token(account, session)
return [self._to_response(account) for account in accounts]
async def refresh_all_tokens(
self,
user_uuid: str,
session: AsyncSession,
) -> dict[str, bool]:
"""
사용자의 모든 연동 계정 토큰 갱신 (로그인 호출)
Args:
user_uuid: 사용자 UUID
session: DB 세션
Returns:
dict[str, bool]: 플랫폼별 갱신 성공 여부
"""
logger.info(f"[SOCIAL] 모든 연동 계정 토큰 갱신 시작 - user_uuid: {user_uuid}")
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user_uuid,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
accounts = result.scalars().all()
refresh_results = {}
for account in accounts:
success = await self._try_refresh_token(account, session)
refresh_results[f"{account.platform}_{account.id}"] = success
logger.info(f"[SOCIAL] 토큰 갱신 완료 - results: {refresh_results}")
return refresh_results
async def _try_refresh_token(
self,
account: SocialAccount,
session: AsyncSession,
) -> bool:
"""
토큰 갱신 시도 (실패해도 예외 발생하지 않음)
Args:
account: 소셜 계정
session: DB 세션
Returns:
bool: 갱신 성공 여부
"""
# refresh_token이 없으면 갱신 불가
if not account.refresh_token:
logger.debug(
f"[SOCIAL] refresh_token 없음, 갱신 스킵 - account_id: {account.id}"
)
return False
# 만료 시간 확인 (만료 1시간 전이면 갱신)
should_refresh = False
if account.token_expires_at is None:
should_refresh = True
else:
# DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교
current_time = now().replace(tzinfo=None)
buffer_time = current_time + timedelta(hours=1)
if account.token_expires_at <= buffer_time:
should_refresh = True
if not should_refresh:
logger.debug(
f"[SOCIAL] 토큰 아직 유효, 갱신 스킵 - account_id: {account.id}"
)
return True
# 갱신 시도
try:
await self._refresh_account_token(account, session)
return True
except Exception as e:
logger.warning(
f"[SOCIAL] 토큰 갱신 실패 (재연동 필요) - "
f"account_id: {account.id}, error: {e}"
)
return False
async def get_account_by_platform(
self,
user_uuid: str,
platform: SocialPlatform,
session: AsyncSession,
) -> Optional[SocialAccount]:
"""
특정 플랫폼의 연동 계정 조회
Args:
user_uuid: 사용자 UUID
platform: 플랫폼
session: DB 세션
Returns:
SocialAccount: 소셜 계정 (없으면 None)
"""
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user_uuid,
SocialAccount.platform == platform.value,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
return result.scalar_one_or_none()
async def get_account_by_id(
self,
user_uuid: str,
account_id: int,
session: AsyncSession,
) -> Optional[SocialAccount]:
"""
account_id로 연동 계정 조회 (소유권 검증 포함)
Args:
user_uuid: 사용자 UUID
account_id: 소셜 계정 ID
session: DB 세션
Returns:
SocialAccount: 소셜 계정 (없으면 None)
"""
result = await session.execute(
select(SocialAccount).where(
SocialAccount.id == account_id,
SocialAccount.user_uuid == user_uuid,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
return result.scalar_one_or_none()
async def disconnect_by_account_id(
self,
user_uuid: str,
account_id: int,
session: AsyncSession,
) -> str:
"""
account_id로 소셜 계정 연동 해제
Args:
user_uuid: 사용자 UUID
account_id: 소셜 계정 ID
session: DB 세션
Returns:
str: 연동 해제된 플랫폼 이름
Raises:
SocialAccountNotFoundError: 연동된 계정이 없는 경우
"""
logger.info(
f"[SOCIAL] 소셜 계정 연동 해제 시작 (by account_id) - "
f"user_uuid: {user_uuid}, account_id: {account_id}"
)
# 1. account_id로 계정 조회 (user_uuid 소유권 확인 포함)
result = await session.execute(
select(SocialAccount).where(
SocialAccount.id == account_id,
SocialAccount.user_uuid == user_uuid,
SocialAccount.is_active == True, # noqa: E712
SocialAccount.is_deleted == False, # noqa: E712
)
)
account = result.scalar_one_or_none()
if account is None:
logger.warning(
f"[SOCIAL] 연동된 계정 없음 - "
f"user_uuid: {user_uuid}, account_id: {account_id}"
)
raise SocialAccountNotFoundError()
# 2. 소프트 삭제
platform = account.platform
account.is_active = False
account.is_deleted = True
await session.commit()
logger.info(
f"[SOCIAL] 소셜 계정 연동 해제 완료 - "
f"account_id: {account.id}, platform: {platform}"
)
return platform
async def disconnect(
self,
user_uuid: str,
platform: SocialPlatform,
session: AsyncSession,
) -> bool:
"""
소셜 계정 연동 해제 (platform 기준, deprecated)
Args:
user_uuid: 사용자 UUID
platform: 연동 해제할 플랫폼
session: DB 세션
Returns:
bool: 성공 여부
Raises:
SocialAccountNotFoundError: 연동된 계정이 없는 경우
"""
logger.info(
f"[SOCIAL] 소셜 계정 연동 해제 시작 - "
f"user_uuid: {user_uuid}, platform: {platform.value}"
)
# 1. 연동된 계정 조회
account = await self.get_account_by_platform(user_uuid, platform, session)
if account is None:
logger.warning(
f"[SOCIAL] 연동된 계정 없음 - "
f"user_uuid: {user_uuid}, platform: {platform.value}"
)
raise SocialAccountNotFoundError(platform=platform.value)
# 2. 소프트 삭제 (토큰 폐기하지 않음 - 재연결 시 동의 화면 스킵을 위해)
# 참고: 사용자가 완전히 앱 연결을 끊으려면 Google 계정 설정에서 직접 해제해야 함
account.is_active = False
account.is_deleted = True
await session.commit()
logger.info(f"[SOCIAL] 소셜 계정 연동 해제 완료 - account_id: {account.id}")
return True
async def ensure_valid_token(
self,
account: SocialAccount,
session: AsyncSession,
) -> str:
"""
토큰 유효성 확인 필요시 갱신
Args:
account: 소셜 계정
session: DB 세션
Returns:
str: 유효한 access_token
Raises:
TokenExpiredError: 토큰 갱신 실패 (재연동 필요)
"""
# 만료 시간 확인
is_expired = False
if account.token_expires_at is None:
is_expired = True
else:
current_time = now().replace(tzinfo=None)
buffer_time = current_time + timedelta(minutes=10)
if account.token_expires_at <= buffer_time:
is_expired = True
# 아직 유효하면 그대로 사용
if not is_expired:
return account.access_token
# 만료됐는데 refresh_token이 없으면 재연동 필요
if not account.refresh_token:
logger.warning(
f"[SOCIAL] access_token 만료 + refresh_token 없음, 재연동 필요 - "
f"account_id: {account.id}"
)
raise TokenExpiredError(platform=account.platform)
# refresh_token으로 갱신
logger.info(
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
)
return await self._refresh_account_token(account, session)
async def _refresh_account_token(
self,
account: SocialAccount,
session: AsyncSession,
) -> str:
"""
계정 토큰 갱신
Args:
account: 소셜 계정
session: DB 세션
Returns:
str: access_token
Raises:
TokenExpiredError: 갱신 실패 (재연동 필요)
"""
if not account.refresh_token:
logger.warning(
f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}"
)
raise TokenExpiredError(platform=account.platform)
platform = SocialPlatform(account.platform)
oauth_client = get_oauth_client(platform)
try:
token_response = await oauth_client.refresh_token(account.refresh_token)
except OAuthTokenRefreshError as e:
logger.error(
f"[SOCIAL] 토큰 갱신 실패, 재연동 필요 - "
f"account_id: {account.id}, error: {e}"
)
raise TokenExpiredError(platform=account.platform)
except Exception as e:
logger.error(
f"[SOCIAL] 토큰 갱신 중 예외 발생, 재연동 필요 - "
f"account_id: {account.id}, error: {e}"
)
raise TokenExpiredError(platform=account.platform)
# 토큰 업데이트
account.access_token = token_response.access_token
if token_response.refresh_token:
account.refresh_token = token_response.refresh_token
if token_response.expires_in:
# DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원)
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
seconds=token_response.expires_in
)
await session.commit()
await session.refresh(account)
logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}")
return account.access_token
async def _get_social_account(
self,
user_uuid: str,
platform: SocialPlatform,
platform_user_id: str,
session: AsyncSession,
) -> Optional[SocialAccount]:
"""
소셜 계정 조회 (platform_user_id 포함)
Args:
user_uuid: 사용자 UUID
platform: 플랫폼
platform_user_id: 플랫폼 사용자 ID
session: DB 세션
Returns:
SocialAccount: 소셜 계정 (없으면 None)
"""
result = await session.execute(
select(SocialAccount).where(
SocialAccount.user_uuid == user_uuid,
SocialAccount.platform == platform.value,
SocialAccount.platform_user_id == platform_user_id,
)
)
return result.scalar_one_or_none()
async def _create_social_account(
self,
user_uuid: str,
platform: SocialPlatform,
token_response: OAuthTokenResponse,
user_info: PlatformUserInfo,
session: AsyncSession,
) -> SocialAccount:
"""
소셜 계정 생성
Args:
user_uuid: 사용자 UUID
platform: 플랫폼
token_response: OAuth 토큰 응답
user_info: 플랫폼 사용자 정보
session: DB 세션
Returns:
SocialAccount: 생성된 소셜 계정
"""
# 토큰 만료 시간 계산 (DB에 naive datetime으로 저장)
token_expires_at = None
if token_response.expires_in:
token_expires_at = now().replace(tzinfo=None) + timedelta(
seconds=token_response.expires_in
)
social_account = SocialAccount(
user_uuid=user_uuid,
platform=platform.value,
access_token=token_response.access_token,
refresh_token=token_response.refresh_token,
token_expires_at=token_expires_at,
scope=token_response.scope,
platform_user_id=user_info.platform_user_id,
platform_username=user_info.username,
platform_data={
"display_name": user_info.display_name,
"profile_image_url": user_info.profile_image_url,
**user_info.platform_data,
},
is_active=True,
is_deleted=False,
)
session.add(social_account)
await session.commit()
await session.refresh(social_account)
return social_account
async def _update_tokens(
self,
account: SocialAccount,
token_response: OAuthTokenResponse,
user_info: PlatformUserInfo,
session: AsyncSession,
update_connected_at: bool = False,
) -> SocialAccount:
"""
기존 계정 토큰 업데이트
Args:
account: 기존 소셜 계정
token_response: OAuth 토큰 응답
user_info: 플랫폼 사용자 정보
session: DB 세션
update_connected_at: 연결 시간 업데이트 여부 (재연결 True)
Returns:
SocialAccount: 업데이트된 소셜 계정
"""
account.access_token = token_response.access_token
if token_response.refresh_token:
account.refresh_token = token_response.refresh_token
if token_response.expires_in:
# DB에 naive datetime으로 저장
account.token_expires_at = now().replace(tzinfo=None) + timedelta(
seconds=token_response.expires_in
)
if token_response.scope:
account.scope = token_response.scope
# 플랫폼 정보 업데이트
account.platform_username = user_info.username
account.platform_data = {
"display_name": user_info.display_name,
"profile_image_url": user_info.profile_image_url,
**user_info.platform_data,
}
# 재연결 시 연결 시간 업데이트
if update_connected_at:
account.connected_at = now().replace(tzinfo=None)
await session.commit()
await session.refresh(account)
return account
def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
"""
SocialAccount를 SocialAccountResponse로 변환
Args:
account: 소셜 계정
Returns:
SocialAccountResponse: 응답 스키마
"""
platform_data = account.platform_data or {}
return SocialAccountResponse(
id=account.id,
platform=account.platform,
platform_user_id=account.platform_user_id,
platform_username=account.platform_username,
display_name=platform_data.get("display_name"),
profile_image_url=platform_data.get("profile_image_url"),
is_active=account.is_active,
connected_at=account.connected_at,
platform_data=platform_data,
)
# 싱글톤 인스턴스
social_account_service = SocialAccountService()

View File

@ -0,0 +1,47 @@
"""
Social Uploader Module
소셜 미디어 영상 업로더 모듈입니다.
"""
from app.social.constants import SocialPlatform
from app.social.uploader.base import BaseSocialUploader, UploadResult
def get_uploader(platform: SocialPlatform) -> BaseSocialUploader:
"""
플랫폼에 맞는 업로더 반환
Args:
platform: 소셜 플랫폼
Returns:
BaseSocialUploader: 업로더 인스턴스
Raises:
ValueError: 지원하지 않는 플랫폼인 경우
"""
if platform == SocialPlatform.YOUTUBE:
from app.social.uploader.youtube import YouTubeUploader
return YouTubeUploader()
# 추후 확장
# elif platform == SocialPlatform.INSTAGRAM:
# from app.social.uploader.instagram import InstagramUploader
# return InstagramUploader()
# elif platform == SocialPlatform.FACEBOOK:
# from app.social.uploader.facebook import FacebookUploader
# return FacebookUploader()
# elif platform == SocialPlatform.TIKTOK:
# from app.social.uploader.tiktok import TikTokUploader
# return TikTokUploader()
raise ValueError(f"지원하지 않는 플랫폼입니다: {platform}")
__all__ = [
"BaseSocialUploader",
"UploadResult",
"get_uploader",
]

168
app/social/uploader/base.py Normal file
View File

@ -0,0 +1,168 @@
"""
Base Social Uploader
소셜 미디어 영상 업로더의 추상 기본 클래스입니다.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Callable, Optional
from app.social.constants import PrivacyStatus, SocialPlatform
@dataclass
class UploadMetadata:
"""
업로드 메타데이터
영상 업로드 필요한 메타데이터를 정의합니다.
Attributes:
title: 영상 제목
description: 영상 설명
tags: 태그 목록
privacy_status: 공개 상태
platform_options: 플랫폼별 추가 옵션
"""
title: str
description: Optional[str] = None
tags: Optional[list[str]] = None
privacy_status: PrivacyStatus = PrivacyStatus.PRIVATE
platform_options: Optional[dict[str, Any]] = None
@dataclass
class UploadResult:
"""
업로드 결과
Attributes:
success: 성공 여부
platform_video_id: 플랫폼에서 부여한 영상 ID
platform_url: 플랫폼에서의 영상 URL
error_message: 에러 메시지 (실패 )
platform_response: 플랫폼 원본 응답 (디버깅용)
"""
success: bool
platform_video_id: Optional[str] = None
platform_url: Optional[str] = None
error_message: Optional[str] = None
platform_response: Optional[dict[str, Any]] = None
class BaseSocialUploader(ABC):
"""
소셜 미디어 영상 업로더 추상 기본 클래스
모든 플랫폼별 업로더는 클래스를 상속받아 구현합니다.
Attributes:
platform: 소셜 플랫폼 종류
"""
platform: SocialPlatform
@abstractmethod
async def upload(
self,
video_path: str,
access_token: str,
metadata: UploadMetadata,
progress_callback: Optional[Callable[[int], None]] = None,
) -> UploadResult:
"""
영상 업로드
Args:
video_path: 업로드할 영상 파일 경로 (로컬 또는 URL)
access_token: OAuth 액세스 토큰
metadata: 업로드 메타데이터
progress_callback: 진행률 콜백 함수 (0-100)
Returns:
UploadResult: 업로드 결과
"""
pass
@abstractmethod
async def get_upload_status(
self,
platform_video_id: str,
access_token: str,
) -> dict[str, Any]:
"""
업로드 상태 조회
플랫폼에서 영상 처리 상태를 조회합니다.
Args:
platform_video_id: 플랫폼 영상 ID
access_token: OAuth 액세스 토큰
Returns:
dict: 업로드 상태 정보
"""
pass
@abstractmethod
async def delete_video(
self,
platform_video_id: str,
access_token: str,
) -> bool:
"""
업로드된 영상 삭제
Args:
platform_video_id: 플랫폼 영상 ID
access_token: OAuth 액세스 토큰
Returns:
bool: 삭제 성공 여부
"""
pass
def validate_metadata(self, metadata: UploadMetadata) -> None:
"""
메타데이터 유효성 검증
플랫폼별 제한사항을 확인합니다.
Args:
metadata: 검증할 메타데이터
Raises:
ValueError: 유효하지 않은 메타데이터
"""
if not metadata.title or len(metadata.title) == 0:
raise ValueError("제목은 필수입니다.")
if len(metadata.title) > 100:
raise ValueError("제목은 100자를 초과할 수 없습니다.")
if metadata.description and len(metadata.description) > 5000:
raise ValueError("설명은 5000자를 초과할 수 없습니다.")
def get_video_url(self, platform_video_id: str) -> str:
"""
플랫폼 영상 URL 생성
Args:
platform_video_id: 플랫폼 영상 ID
Returns:
str: 영상 URL
"""
if self.platform == SocialPlatform.YOUTUBE:
return f"https://www.youtube.com/watch?v={platform_video_id}"
elif self.platform == SocialPlatform.INSTAGRAM:
return f"https://www.instagram.com/reel/{platform_video_id}/"
elif self.platform == SocialPlatform.FACEBOOK:
return f"https://www.facebook.com/watch/?v={platform_video_id}"
elif self.platform == SocialPlatform.TIKTOK:
return f"https://www.tiktok.com/video/{platform_video_id}"
else:
return ""

View File

@ -0,0 +1,420 @@
"""
YouTube Uploader
YouTube Data API v3를 사용한 영상 업로더입니다.
Resumable Upload를 지원합니다.
"""
import json
import logging
import os
from typing import Any, Callable, Optional
import httpx
from config import social_upload_settings
from app.social.constants import PrivacyStatus, SocialPlatform
from app.social.exceptions import UploadError, UploadQuotaExceededError
from app.social.uploader.base import BaseSocialUploader, UploadMetadata, UploadResult
logger = logging.getLogger(__name__)
class YouTubeUploader(BaseSocialUploader):
"""
YouTube 영상 업로더
YouTube Data API v3의 Resumable Upload를 사용하여
대용량 영상을 안정적으로 업로드합니다.
"""
platform = SocialPlatform.YOUTUBE
# YouTube API 엔드포인트
UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"
# 청크 크기 (5MB - YouTube 권장)
CHUNK_SIZE = 5 * 1024 * 1024
def __init__(self) -> None:
self.timeout = social_upload_settings.UPLOAD_TIMEOUT_SECONDS
async def upload(
self,
video_path: str,
access_token: str,
metadata: UploadMetadata,
progress_callback: Optional[Callable[[int], None]] = None,
) -> UploadResult:
"""
YouTube에 영상 업로드 (Resumable Upload)
Args:
video_path: 업로드할 영상 파일 경로
access_token: OAuth 액세스 토큰
metadata: 업로드 메타데이터
progress_callback: 진행률 콜백 함수 (0-100)
Returns:
UploadResult: 업로드 결과
"""
logger.info(f"[YOUTUBE_UPLOAD] 업로드 시작 - video_path: {video_path}")
# 1. 메타데이터 유효성 검증
self.validate_metadata(metadata)
# 2. 파일 크기 확인
if not os.path.exists(video_path):
logger.error(f"[YOUTUBE_UPLOAD] 파일 없음 - path: {video_path}")
return UploadResult(
success=False,
error_message=f"파일을 찾을 수 없습니다: {video_path}",
)
file_size = os.path.getsize(video_path)
logger.info(f"[YOUTUBE_UPLOAD] 파일 크기: {file_size / (1024*1024):.2f} MB")
try:
# 3. Resumable upload 세션 시작
upload_url = await self._init_resumable_upload(
access_token=access_token,
metadata=metadata,
file_size=file_size,
)
# 4. 파일 업로드
video_id = await self._upload_file(
upload_url=upload_url,
video_path=video_path,
file_size=file_size,
progress_callback=progress_callback,
)
video_url = self.get_video_url(video_id)
logger.info(
f"[YOUTUBE_UPLOAD] 업로드 성공 - video_id: {video_id}, url: {video_url}"
)
return UploadResult(
success=True,
platform_video_id=video_id,
platform_url=video_url,
)
except UploadQuotaExceededError:
raise
except UploadError as e:
logger.error(f"[YOUTUBE_UPLOAD] 업로드 실패 - error: {e}")
return UploadResult(
success=False,
error_message=str(e),
)
except Exception as e:
logger.error(f"[YOUTUBE_UPLOAD] 예상치 못한 에러 - error: {e}")
return UploadResult(
success=False,
error_message=f"업로드 중 에러 발생: {str(e)}",
)
async def _init_resumable_upload(
self,
access_token: str,
metadata: UploadMetadata,
file_size: int,
) -> str:
"""
Resumable upload 세션 시작
Args:
access_token: OAuth 액세스 토큰
metadata: 업로드 메타데이터
file_size: 파일 크기
Returns:
str: 업로드 URL
Raises:
UploadError: 세션 시작 실패
"""
logger.debug("[YOUTUBE_UPLOAD] Resumable upload 세션 시작")
# YouTube API 요청 본문
body = {
"snippet": {
"title": metadata.title,
"description": metadata.description or "",
"tags": metadata.tags or [],
"categoryId": self._get_category_id(metadata),
},
"status": {
"privacyStatus": self._convert_privacy_status(metadata.privacy_status),
"selfDeclaredMadeForKids": False,
},
}
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json; charset=utf-8",
"X-Upload-Content-Type": "video/*",
"X-Upload-Content-Length": str(file_size),
}
params = {
"uploadType": "resumable",
"part": "snippet,status",
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
self.UPLOAD_URL,
params=params,
headers=headers,
json=body,
)
if response.status_code == 200:
upload_url = response.headers.get("location")
if upload_url:
logger.debug(
f"[YOUTUBE_UPLOAD] 세션 시작 성공 - upload_url: {upload_url[:50]}..."
)
return upload_url
# 에러 처리
error_data = response.json() if response.content else {}
error_reason = (
error_data.get("error", {}).get("errors", [{}])[0].get("reason", "")
)
if error_reason == "quotaExceeded":
logger.error("[YOUTUBE_UPLOAD] API 할당량 초과")
raise UploadQuotaExceededError(platform=self.platform.value)
error_message = error_data.get("error", {}).get(
"message", f"HTTP {response.status_code}"
)
logger.error(f"[YOUTUBE_UPLOAD] 세션 시작 실패 - error: {error_message}")
raise UploadError(
platform=self.platform.value,
detail=f"Resumable upload 세션 시작 실패: {error_message}",
)
async def _upload_file(
self,
upload_url: str,
video_path: str,
file_size: int,
progress_callback: Optional[Callable[[int], None]] = None,
) -> str:
"""
파일 청크 업로드
Args:
upload_url: Resumable upload URL
video_path: 영상 파일 경로
file_size: 파일 크기
progress_callback: 진행률 콜백
Returns:
str: YouTube 영상 ID
Raises:
UploadError: 업로드 실패
"""
uploaded_bytes = 0
async with httpx.AsyncClient(timeout=self.timeout) as client:
with open(video_path, "rb") as video_file:
while uploaded_bytes < file_size:
# 청크 읽기
chunk = video_file.read(self.CHUNK_SIZE)
chunk_size = len(chunk)
end_byte = uploaded_bytes + chunk_size - 1
headers = {
"Content-Type": "video/*",
"Content-Length": str(chunk_size),
"Content-Range": f"bytes {uploaded_bytes}-{end_byte}/{file_size}",
}
response = await client.put(
upload_url,
headers=headers,
content=chunk,
)
if response.status_code == 200 or response.status_code == 201:
# 업로드 완료
result = response.json()
video_id = result.get("id")
if video_id:
return video_id
raise UploadError(
platform=self.platform.value,
detail="응답에서 video ID를 찾을 수 없습니다.",
)
elif response.status_code == 308:
# 청크 업로드 성공, 계속 진행
uploaded_bytes += chunk_size
progress = int((uploaded_bytes / file_size) * 100)
if progress_callback:
progress_callback(progress)
logger.debug(
f"[YOUTUBE_UPLOAD] 청크 업로드 완료 - "
f"progress: {progress}%, "
f"uploaded: {uploaded_bytes}/{file_size}"
)
else:
# 에러
error_data = response.json() if response.content else {}
error_message = error_data.get("error", {}).get(
"message", f"HTTP {response.status_code}"
)
logger.error(
f"[YOUTUBE_UPLOAD] 청크 업로드 실패 - error: {error_message}"
)
raise UploadError(
platform=self.platform.value,
detail=f"청크 업로드 실패: {error_message}",
)
raise UploadError(
platform=self.platform.value,
detail="업로드가 완료되지 않았습니다.",
)
async def get_upload_status(
self,
platform_video_id: str,
access_token: str,
) -> dict[str, Any]:
"""
업로드 상태 조회
Args:
platform_video_id: YouTube 영상 ID
access_token: OAuth 액세스 토큰
Returns:
dict: 업로드 상태 정보
"""
logger.info(f"[YOUTUBE_UPLOAD] 상태 조회 - video_id: {platform_video_id}")
headers = {"Authorization": f"Bearer {access_token}"}
params = {
"part": "status,processingDetails",
"id": platform_video_id,
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.VIDEOS_URL,
headers=headers,
params=params,
)
if response.status_code == 200:
data = response.json()
items = data.get("items", [])
if items:
item = items[0]
status = item.get("status", {})
processing = item.get("processingDetails", {})
return {
"upload_status": status.get("uploadStatus"),
"privacy_status": status.get("privacyStatus"),
"processing_status": processing.get(
"processingStatus", "processing"
),
"processing_progress": processing.get(
"processingProgress", {}
),
}
return {"error": "영상을 찾을 수 없습니다."}
return {"error": f"상태 조회 실패: HTTP {response.status_code}"}
async def delete_video(
self,
platform_video_id: str,
access_token: str,
) -> bool:
"""
업로드된 영상 삭제
Args:
platform_video_id: YouTube 영상 ID
access_token: OAuth 액세스 토큰
Returns:
bool: 삭제 성공 여부
"""
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 - video_id: {platform_video_id}")
headers = {"Authorization": f"Bearer {access_token}"}
params = {"id": platform_video_id}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.delete(
self.VIDEOS_URL,
headers=headers,
params=params,
)
if response.status_code == 204:
logger.info(f"[YOUTUBE_UPLOAD] 영상 삭제 성공 - video_id: {platform_video_id}")
return True
else:
logger.warning(
f"[YOUTUBE_UPLOAD] 영상 삭제 실패 - "
f"video_id: {platform_video_id}, status: {response.status_code}"
)
return False
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
"""
PrivacyStatus를 YouTube API 형식으로 변환
Args:
privacy_status: 공개 상태
Returns:
str: YouTube API 공개 상태
"""
mapping = {
PrivacyStatus.PUBLIC: "public",
PrivacyStatus.UNLISTED: "unlisted",
PrivacyStatus.PRIVATE: "private",
}
return mapping.get(privacy_status, "private")
def _get_category_id(self, metadata: UploadMetadata) -> str:
"""
카테고리 ID 추출
platform_options에서 category_id를 추출하거나 기본값 반환
Args:
metadata: 업로드 메타데이터
Returns:
str: YouTube 카테고리 ID
"""
if metadata.platform_options and "category_id" in metadata.platform_options:
return str(metadata.platform_options["category_id"])
# 기본값: "22" (People & Blogs)
return "22"
# 싱글톤 인스턴스
youtube_uploader = YouTubeUploader()

View File

@ -0,0 +1,9 @@
"""
Social Worker Module
소셜 미디어 백그라운드 태스크 모듈입니다.
"""
from app.social.worker.upload_task import process_social_upload
__all__ = ["process_social_upload"]

View File

@ -0,0 +1,386 @@
"""
Social Upload Background Task
소셜 미디어 영상 업로드 백그라운드 태스크입니다.
"""
import logging
import os
import tempfile
from pathlib import Path
from typing import Optional
import aiofiles
from app.utils.timezone import now
import httpx
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from config import social_upload_settings
from app.database.session import BackgroundSessionLocal
from app.social.constants import SocialPlatform, UploadStatus
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
from app.social.models import SocialUpload
from app.social.services import social_account_service
from app.social.uploader import get_uploader
from app.social.uploader.base import UploadMetadata
from app.user.models import SocialAccount
from app.video.models import Video
logger = logging.getLogger(__name__)
async def _update_upload_status(
upload_id: int,
status: UploadStatus,
upload_progress: int = 0,
platform_video_id: Optional[str] = None,
platform_url: Optional[str] = None,
error_message: Optional[str] = None,
) -> bool:
"""
업로드 상태 업데이트
Args:
upload_id: SocialUpload ID
status: 업로드 상태
upload_progress: 업로드 진행률 (0-100)
platform_video_id: 플랫폼 영상 ID
platform_url: 플랫폼 영상 URL
error_message: 에러 메시지
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(SocialUpload).where(SocialUpload.id == upload_id)
)
upload = result.scalar_one_or_none()
if upload:
upload.status = status.value
upload.upload_progress = upload_progress
if platform_video_id:
upload.platform_video_id = platform_video_id
if platform_url:
upload.platform_url = platform_url
if error_message:
upload.error_message = error_message
if status == UploadStatus.COMPLETED:
upload.uploaded_at = now().replace(tzinfo=None)
await session.commit()
logger.info(
f"[SOCIAL_UPLOAD] 상태 업데이트 - "
f"upload_id: {upload_id}, status: {status.value}, progress: {upload_progress}%"
)
return True
else:
logger.warning(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[SOCIAL_UPLOAD] DB 에러 - upload_id: {upload_id}, error: {e}")
return False
async def _download_video(video_url: str, upload_id: int) -> bytes:
"""
영상 파일 다운로드
Args:
video_url: 영상 URL
upload_id: 업로드 ID (로그용)
Returns:
bytes: 영상 파일 내용
Raises:
httpx.HTTPError: 다운로드 실패
"""
logger.info(f"[SOCIAL_UPLOAD] 영상 다운로드 시작 - upload_id: {upload_id}")
async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.get(video_url)
response.raise_for_status()
logger.info(
f"[SOCIAL_UPLOAD] 영상 다운로드 완료 - "
f"upload_id: {upload_id}, size: {len(response.content)} bytes"
)
return response.content
async def _increment_retry_count(upload_id: int) -> int:
"""
재시도 횟수 증가
Args:
upload_id: SocialUpload ID
Returns:
int: 현재 재시도 횟수
"""
try:
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(SocialUpload).where(SocialUpload.id == upload_id)
)
upload = result.scalar_one_or_none()
if upload:
upload.retry_count += 1
await session.commit()
return upload.retry_count
return 0
except SQLAlchemyError:
return 0
async def process_social_upload(upload_id: int) -> None:
"""
소셜 미디어 업로드 처리
백그라운드에서 실행되며, 영상을 소셜 플랫폼에 업로드합니다.
Args:
upload_id: SocialUpload ID
"""
logger.info(f"[SOCIAL_UPLOAD] 업로드 태스크 시작 - upload_id: {upload_id}")
temp_file_path: Optional[Path] = None
try:
# 1. 업로드 정보 조회
async with BackgroundSessionLocal() as session:
result = await session.execute(
select(SocialUpload).where(SocialUpload.id == upload_id)
)
upload = result.scalar_one_or_none()
if not upload:
logger.error(f"[SOCIAL_UPLOAD] 업로드 레코드 없음 - upload_id: {upload_id}")
return
# 2. Video 정보 조회
video_result = await session.execute(
select(Video).where(Video.id == upload.video_id)
)
video = video_result.scalar_one_or_none()
if not video or not video.result_movie_url:
logger.error(
f"[SOCIAL_UPLOAD] 영상 없음 또는 URL 없음 - "
f"upload_id: {upload_id}, video_id: {upload.video_id}"
)
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.FAILED,
error_message="영상을 찾을 수 없거나 URL이 없습니다.",
)
return
# 3. SocialAccount 정보 조회
account_result = await session.execute(
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
)
account = account_result.scalar_one_or_none()
if not account or not account.is_active:
logger.error(
f"[SOCIAL_UPLOAD] 소셜 계정 없음 또는 비활성화 - "
f"upload_id: {upload_id}, account_id: {upload.social_account_id}"
)
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.FAILED,
error_message="연동된 소셜 계정이 없거나 비활성화 상태입니다.",
)
return
# 필요한 정보 저장
video_url = video.result_movie_url
platform = SocialPlatform(upload.platform)
upload_title = upload.title
upload_description = upload.description
upload_tags = upload.tags if isinstance(upload.tags, list) else None
upload_privacy = upload.privacy_status
upload_options = upload.platform_options
# 4. 상태 업데이트: uploading
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.UPLOADING,
upload_progress=0,
)
# 5. 토큰 유효성 확인 및 갱신
async with BackgroundSessionLocal() as session:
# account 다시 조회 (세션이 닫혔으므로)
account_result = await session.execute(
select(SocialAccount).where(SocialAccount.id == upload.social_account_id)
)
account = account_result.scalar_one_or_none()
if not account:
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.FAILED,
error_message="소셜 계정을 찾을 수 없습니다.",
)
return
access_token = await social_account_service.ensure_valid_token(
account=account,
session=session,
)
# 6. 영상 다운로드
video_content = await _download_video(video_url, upload_id)
# 7. 임시 파일 저장
temp_dir = Path(social_upload_settings.UPLOAD_TEMP_DIR) / str(upload_id)
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / "video.mp4"
async with aiofiles.open(str(temp_file_path), "wb") as f:
await f.write(video_content)
logger.info(
f"[SOCIAL_UPLOAD] 임시 파일 저장 완료 - "
f"upload_id: {upload_id}, path: {temp_file_path}"
)
# 8. 메타데이터 준비
from app.social.constants import PrivacyStatus
metadata = UploadMetadata(
title=upload_title,
description=upload_description,
tags=upload_tags,
privacy_status=PrivacyStatus(upload_privacy),
platform_options=upload_options,
)
# 9. 진행률 콜백 함수
async def progress_callback(progress: int) -> None:
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.UPLOADING,
upload_progress=progress,
)
# 10. 플랫폼에 업로드
uploader = get_uploader(platform)
# 동기 콜백으로 변환 (httpx 청크 업로드 내에서 호출되므로)
def sync_progress_callback(progress: int) -> None:
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.create_task(
_update_upload_status(
upload_id=upload_id,
status=UploadStatus.UPLOADING,
upload_progress=progress,
)
)
except Exception:
pass
result = await uploader.upload(
video_path=str(temp_file_path),
access_token=access_token,
metadata=metadata,
progress_callback=sync_progress_callback,
)
# 11. 결과 처리
if result.success:
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.COMPLETED,
upload_progress=100,
platform_video_id=result.platform_video_id,
platform_url=result.platform_url,
)
logger.info(
f"[SOCIAL_UPLOAD] 업로드 완료 - "
f"upload_id: {upload_id}, "
f"platform_video_id: {result.platform_video_id}, "
f"url: {result.platform_url}"
)
else:
retry_count = await _increment_retry_count(upload_id)
if retry_count < social_upload_settings.UPLOAD_MAX_RETRIES:
# 재시도 가능
logger.warning(
f"[SOCIAL_UPLOAD] 업로드 실패, 재시도 예정 - "
f"upload_id: {upload_id}, retry: {retry_count}"
)
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.PENDING,
upload_progress=0,
error_message=f"업로드 실패 (재시도 {retry_count}/{social_upload_settings.UPLOAD_MAX_RETRIES}): {result.error_message}",
)
else:
# 최대 재시도 초과
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.FAILED,
error_message=f"최대 재시도 횟수 초과: {result.error_message}",
)
logger.error(
f"[SOCIAL_UPLOAD] 업로드 최종 실패 - "
f"upload_id: {upload_id}, error: {result.error_message}"
)
except UploadQuotaExceededError as e:
logger.error(f"[SOCIAL_UPLOAD] API 할당량 초과 - upload_id: {upload_id}")
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.FAILED,
error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
)
except TokenExpiredError as e:
logger.error(
f"[SOCIAL_UPLOAD] 토큰 만료, 재연동 필요 - "
f"upload_id: {upload_id}, platform: {e.platform}"
)
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.FAILED,
error_message=f"{e.platform} 계정 인증이 만료되었습니다. 계정을 다시 연동해주세요.",
)
except Exception as e:
logger.error(
f"[SOCIAL_UPLOAD] 예상치 못한 에러 - "
f"upload_id: {upload_id}, error: {e}"
)
await _update_upload_status(
upload_id=upload_id,
status=UploadStatus.FAILED,
error_message=f"업로드 중 에러 발생: {str(e)}",
)
finally:
# 임시 파일 정리
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
temp_file_path.parent.rmdir()
logger.debug(f"[SOCIAL_UPLOAD] 임시 파일 삭제 - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[SOCIAL_UPLOAD] 임시 파일 삭제 실패 - error: {e}")

View File

@ -1,8 +1,5 @@
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional
from typing import Optional
from fastapi import Request
from pydantic import BaseModel, Field
@ -107,21 +104,6 @@ class GenerateSongResponse(BaseModel):
error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)")
class PollingSongRequest(BaseModel):
"""노래 생성 상태 조회 요청 스키마 (Legacy)
Note:
현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용.
Example Request:
{
"task_id": "abc123..."
}
"""
task_id: str = Field(..., description="Suno 작업 ID")
class SongClipData(BaseModel):
"""생성된 노래 클립 정보"""
@ -234,94 +216,3 @@ class PollingSongResponse(BaseModel):
song_result_url: Optional[str] = Field(
None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)"
)
# =============================================================================
# Dataclass Schemas (Legacy)
# =============================================================================
@dataclass
class StoreData:
id: int
created_at: datetime
store_name: str
store_category: str | None = None
store_region: str | None = None
store_address: str | None = None
store_phone_number: str | None = None
store_info: str | None = None
@dataclass
class AttributeData:
id: int
attr_category: str
attr_value: str
created_at: datetime
@dataclass
class SongSampleData:
id: int
ai: str
ai_model: str
sample_song: str
season: str | None = None
num_of_people: int | None = None
people_category: str | None = None
genre: str | None = None
@dataclass
class PromptTemplateData:
id: int
prompt: str
description: str | None = None
@dataclass
class SongFormData:
store_name: str
store_id: str
prompts: str
attributes: Dict[str, str] = field(default_factory=dict)
attributes_str: str = ""
lyrics_ids: List[int] = field(default_factory=list)
llm_model: str = "gpt-5-mini"
@classmethod
async def from_form(cls, request: Request):
"""Request의 form 데이터로부터 dataclass 인스턴스 생성"""
form_data = await request.form()
# 고정 필드명들
fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"}
# lyrics-{id} 형태의 모든 키를 찾아서 ID 추출
lyrics_ids = []
attributes = {}
for key, value in form_data.items():
if key.startswith("lyrics-"):
lyrics_id = key.split("-")[1]
lyrics_ids.append(int(lyrics_id))
elif key not in fixed_keys:
attributes[key] = value
# attributes를 문자열로 변환
attributes_str = (
"\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()])
if attributes
else ""
)
return cls(
store_name=form_data.get("store_info_name", ""),
store_id=form_data.get("store_id", ""),
attributes=attributes,
attributes_str=attributes_str,
lyrics_ids=lyrics_ids,
llm_model=form_data.get("llm_model", "gpt-5-mini"),
prompts=form_data.get("prompts", ""),
)

View File

@ -29,6 +29,7 @@ async def _update_song_status(
song_url: str | None = None,
suno_task_id: str | None = None,
duration: float | None = None,
song_id: int | None = None,
) -> bool:
"""Song 테이블의 상태를 업데이트합니다.
@ -38,13 +39,20 @@ async def _update_song_status(
song_url: 노래 URL
suno_task_id: Suno task ID (선택)
duration: 노래 길이 (선택)
song_id: 특정 Song 레코드 ID (재생성 정확한 레코드 식별용)
Returns:
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
if suno_task_id:
if song_id:
# song_id로 특정 레코드 조회 (가장 정확한 식별)
query_result = await session.execute(
select(Song).where(Song.id == song_id)
)
elif suno_task_id:
# suno_task_id로 조회 (Suno API 고유 ID)
query_result = await session.execute(
select(Song)
.where(Song.suno_task_id == suno_task_id)
@ -52,6 +60,7 @@ async def _update_song_status(
.limit(1)
)
else:
# 기존 방식: task_id로 최신 레코드 조회 (비권장)
query_result = await session.execute(
select(Song)
.where(Song.task_id == task_id)
@ -68,17 +77,17 @@ async def _update_song_status(
if duration is not None:
song.duration = duration
await session.commit()
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
logger.info(f"[Song] Status updated - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, status: {status}")
return True
else:
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, suno_task_id: {suno_task_id}, song_id: {song_id}, error: {e}")
return False

View File

@ -6,10 +6,11 @@
import logging
import random
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from app.utils.timezone import now
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import select
@ -22,21 +23,23 @@ logger = logging.getLogger(__name__)
from app.user.dependencies import get_current_user
from app.user.models import RefreshToken, User
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCodeRequest,
KakaoLoginResponse,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UserResponse,
)
from app.user.services import auth_service, kakao_client
from app.user.services.jwt import (
create_access_token,
create_refresh_token,
decode_token,
get_access_token_expire_seconds,
get_refresh_token_expires_at,
get_token_hash,
)
from app.social.services import social_account_service
from app.utils.common import generate_uuid
@ -140,6 +143,19 @@ async def kakao_callback(
ip_address=ip_address,
)
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
try:
payload = decode_token(result.access_token)
if payload and payload.get("sub"):
user_uuid = payload.get("sub")
await social_account_service.refresh_all_tokens(
user_uuid=user_uuid,
session=session,
)
except Exception as e:
# 토큰 갱신 실패해도 로그인은 성공 처리
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
# 프론트엔드로 토큰과 함께 리다이렉트
redirect_url = (
f"{prj_settings.PROJECT_DOMAIN}"
@ -205,32 +221,49 @@ 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}"
)
# 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신
try:
payload = decode_token(result.access_token)
if payload and payload.get("sub"):
user_uuid = payload.get("sub")
await social_account_service.refresh_all_tokens(
user_uuid=user_uuid,
session=session,
)
except Exception as e:
# 토큰 갱신 실패해도 로그인은 성공 처리
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - is_new_user: {result.is_new_user}")
return result
@router.post(
"/refresh",
response_model=AccessTokenResponse,
summary="토큰 갱신",
description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.",
response_model=TokenResponse,
summary="토큰 갱신 (Refresh Token Rotation)",
description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.",
)
async def refresh_token(
body: RefreshTokenRequest,
session: AsyncSession = Depends(get_session),
) -> AccessTokenResponse:
) -> TokenResponse:
"""
액세스 토큰 갱신
토큰 갱신 (Refresh Token Rotation)
유효한 리프레시 토큰을 제출하면 액세스 토큰발급합니다.
리프레시 토큰은 변경되지 않습니다.
유효한 리프레시 토큰을 제출하면 액세스 토큰 리프레시 토큰발급합니다.
사용된 기존 리프레시 토큰은 즉시 폐기(revoke)니다.
"""
return await auth_service.refresh_tokens(
logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}")
result = await auth_service.refresh_tokens(
refresh_token=body.refresh_token,
session=session,
)
logger.info(
f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, "
f"new_refresh: ...{result.refresh_token[-20:]}"
)
return result
@router.post(
@ -254,11 +287,16 @@ async def logout(
현재 사용 중인 리프레시 토큰을 폐기합니다.
해당 토큰으로는 이상 액세스 토큰을 갱신할 없습니다.
"""
logger.info(
f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, "
f"user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}"
)
await auth_service.logout(
user_id=current_user.id,
refresh_token=body.refresh_token,
session=session,
)
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -282,10 +320,15 @@ async def logout_all(
사용자의 모든 리프레시 토큰을 폐기합니다.
모든 기기에서 재로그인이 필요합니다.
"""
logger.info(
f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, "
f"user_uuid: {current_user.user_uuid}"
)
await auth_service.logout_all(
user_id=current_user.id,
session=session,
)
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
@ -434,7 +477,7 @@ async def generate_test_token(
session.add(db_refresh_token)
# 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now(timezone.utc)
user.last_login_at = now().replace(tzinfo=None)
await session.commit()
logger.info(

View File

@ -4,6 +4,7 @@
FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다.
"""
import logging
from typing import Optional
from fastapi import Depends
@ -22,6 +23,8 @@ from app.user.services.auth import (
)
from app.user.services.jwt import decode_token
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
@ -47,18 +50,28 @@ async def get_current_user(
UserInactiveError: 비활성화된 계정인 경우
"""
if credentials is None:
logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError")
raise MissingTokenError()
payload = decode_token(credentials.credentials)
token = credentials.credentials
logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}")
payload = decode_token(token)
if payload is None:
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
raise InvalidTokenError()
# 토큰 타입 확인
if payload.get("type") != "access":
logger.warning(
f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, "
f"got: {payload.get('type')}, sub: {payload.get('sub')}"
)
raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_uuid = payload.get("sub")
if user_uuid is None:
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
raise InvalidTokenError()
# 사용자 조회
@ -71,11 +84,18 @@ async def get_current_user(
user = result.scalar_one_or_none()
if user is None:
logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}")
raise UserNotFoundError()
if not user.is_active:
logger.warning(
f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}"
)
raise UserInactiveError()
logger.debug(
f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
)
return user
@ -96,17 +116,24 @@ async def get_current_user_optional(
User | None: 로그인한 사용자 또는 None
"""
if credentials is None:
logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음")
return None
payload = decode_token(credentials.credentials)
token = credentials.credentials
payload = decode_token(token)
if payload is None:
logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}")
return None
if payload.get("type") != "access":
logger.debug(
f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})"
)
return None
user_uuid = payload.get("sub")
if user_uuid is None:
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
return None
result = await session.execute(
@ -118,8 +145,14 @@ async def get_current_user_optional(
user = result.scalar_one_or_none()
if user is None or not user.is_active:
logger.debug(
f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}"
)
return None
logger.debug(
f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}"
)
return user

View File

@ -344,7 +344,6 @@ class RefreshToken(Base):
token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
unique=True,
comment="리프레시 토큰 SHA-256 해시값",
)
@ -392,6 +391,7 @@ class RefreshToken(Base):
user: Mapped["User"] = relationship(
"User",
back_populates="refresh_tokens",
lazy="selectin", # lazy loading 방지
)
def __repr__(self) -> str:
@ -522,7 +522,7 @@ class SocialAccount(Base):
# ==========================================================================
# 플랫폼 계정 식별 정보
# ==========================================================================
platform_user_id: Mapped[str] = mapped_column(
platform_user_id: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="플랫폼 내 사용자 고유 ID",
@ -563,6 +563,14 @@ class SocialAccount(Base):
# ==========================================================================
# 시간 정보
# ==========================================================================
connected_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=True,
server_default=func.now(),
onupdate=func.now(),
comment="연결 일시",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
@ -584,6 +592,7 @@ class SocialAccount(Base):
user: Mapped["User"] = relationship(
"User",
back_populates="social_accounts",
lazy="selectin", # lazy loading 방지
)
def __repr__(self) -> str:

View File

@ -1,5 +1,4 @@
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCodeRequest,
KakaoLoginResponse,
KakaoTokenResponse,
@ -12,7 +11,6 @@ from app.user.schemas.user_schema import (
)
__all__ = [
"AccessTokenResponse",
"KakaoCodeRequest",
"KakaoLoginResponse",
"KakaoTokenResponse",

View File

@ -64,24 +64,6 @@ class TokenResponse(BaseModel):
}
class AccessTokenResponse(BaseModel):
"""액세스 토큰 갱신 응답"""
access_token: str = Field(..., description="액세스 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
model_config = {
"json_schema_extra": {
"example": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token",
"token_type": "Bearer",
"expires_in": 3600
}
}
}
class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청"""

View File

@ -5,10 +5,11 @@
"""
import logging
from datetime import datetime
from typing import Optional
from fastapi import HTTPException, status
from app.utils.timezone import now
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -80,9 +81,9 @@ class AdminRequiredError(AuthException):
from app.user.models import RefreshToken, User
from app.utils.common import generate_uuid
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoUserInfo,
LoginResponse,
TokenResponse,
)
from app.user.services.jwt import (
create_access_token,
@ -167,7 +168,7 @@ class AuthService:
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트
user.last_login_at = datetime.now()
user.last_login_at = now().replace(tzinfo=None)
await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
@ -187,59 +188,129 @@ class AuthService:
self,
refresh_token: str,
session: AsyncSession,
) -> AccessTokenResponse:
) -> TokenResponse:
"""
리프레시 토큰으로 액세스 토큰 갱신
리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation)
기존 리프레시 토큰을 폐기하고, 액세스 토큰과 리프레시 토큰을 함께 발급합니다.
사용자가 서비스를 지속 사용하는 세션이 자동 유지됩니다.
Args:
refresh_token: 리프레시 토큰
session: DB 세션
Returns:
AccessTokenResponse: 액세스 토큰
TokenResponse: 액세스 토큰 + 리프레시 토큰
Raises:
InvalidTokenError: 토큰이 유효하지 않은 경우
TokenExpiredError: 토큰이 만료된 경우
TokenRevokedError: 토큰이 폐기된 경우
"""
logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}")
# 1. 토큰 디코딩 및 검증
payload = decode_token(refresh_token)
if payload is None:
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
raise InvalidTokenError()
if payload.get("type") != "refresh":
logger.warning(
f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, "
f"sub: {payload.get('sub')}"
)
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
logger.debug(
f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, "
f"exp: {payload.get('exp')}"
)
# 2. DB에서 리프레시 토큰 조회
token_hash = get_token_hash(refresh_token)
db_token = await self._get_refresh_token_by_hash(token_hash, session)
if db_token is None:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, "
f"token_hash: {token_hash[:16]}..."
)
raise InvalidTokenError()
logger.debug(
f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., "
f"user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, "
f"expires_at: {db_token.expires_at}"
)
# 3. 토큰 상태 확인
if db_token.is_revoked:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), "
f"token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, "
f"revoked_at: {db_token.revoked_at}"
)
raise TokenRevokedError()
if db_token.expires_at < datetime.now():
# 4. 만료 확인
if db_token.expires_at < now().replace(tzinfo=None):
logger.info(
f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, "
f"user_uuid: {db_token.user_uuid}"
)
raise TokenExpiredError()
# 4. 사용자 확인
# 5. 사용자 확인
user_uuid = payload.get("sub")
user = await self._get_user_by_uuid(user_uuid, session)
if user is None:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}"
)
raise UserNotFoundError()
if not user.is_active:
logger.warning(
f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, "
f"user_id: {user.id}"
)
raise UserInactiveError()
# 5. 새 액세스 토큰 발급
new_access_token = create_access_token(user.user_uuid)
# 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음)
db_token.is_revoked = True
db_token.revoked_at = now().replace(tzinfo=None)
logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...")
return AccessTokenResponse(
# 7. 새 토큰 발급
new_access_token = create_access_token(user.user_uuid)
new_refresh_token = create_refresh_token(user.user_uuid)
logger.debug(
f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, "
f"new_refresh: ...{new_refresh_token[-20:]}"
)
# 8. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행)
await self._save_refresh_token(
user_id=user.id,
user_uuid=user.user_uuid,
token=new_refresh_token,
session=session,
)
# 폐기 + 저장을 하나의 트랜잭션으로 커밋
await session.commit()
logger.info(
f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, "
f"user_id: {user.id}, old_hash: {token_hash[:16]}..., "
f"new_refresh: ...{new_refresh_token[-20:]}"
)
return TokenResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="Bearer",
expires_in=get_access_token_expire_seconds(),
)
@ -259,7 +330,12 @@ class AuthService:
session: DB 세션
"""
token_hash = get_token_hash(refresh_token)
logger.info(
f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., "
f"token: ...{refresh_token[-20:]}"
)
await self._revoke_refresh_token_by_hash(token_hash, session)
logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}")
async def logout_all(
self,
@ -273,7 +349,9 @@ class AuthService:
user_id: 사용자 ID
session: DB 세션
"""
logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}")
await self._revoke_all_user_tokens(user_id, session)
logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}")
async def _get_or_create_user(
self,
@ -403,6 +481,11 @@ class AuthService:
)
session.add(refresh_token)
await session.flush()
logger.debug(
f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, "
f"token_hash: {token_hash[:16]}..., expires_at: {expires_at}"
)
return refresh_token
async def _get_refresh_token_by_hash(
@ -482,7 +565,7 @@ class AuthService:
.where(RefreshToken.token_hash == token_hash)
.values(
is_revoked=True,
revoked_at=datetime.now(),
revoked_at=now().replace(tzinfo=None),
)
)
await session.commit()
@ -507,7 +590,7 @@ class AuthService:
)
.values(
is_revoked=True,
revoked_at=datetime.now(),
revoked_at=now().replace(tzinfo=None),
)
)
await session.commit()

View File

@ -5,13 +5,18 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니
"""
import hashlib
import logging
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from jose.exceptions import ExpiredSignatureError, JWTClaimsError
from app.utils.timezone import now
from config import jwt_settings
logger = logging.getLogger(__name__)
def create_access_token(user_uuid: str) -> str:
"""
@ -23,7 +28,7 @@ def create_access_token(user_uuid: str) -> str:
Returns:
JWT 액세스 토큰 문자열
"""
expire = datetime.now() + timedelta(
expire = now() + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
@ -31,11 +36,16 @@ def create_access_token(user_uuid: str) -> str:
"exp": expire,
"type": "access",
}
return jwt.encode(
token = jwt.encode(
to_encode,
jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM,
)
logger.debug(
f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, "
f"expires: {expire}, token: ...{token[-20:]}"
)
return token
def create_refresh_token(user_uuid: str) -> str:
@ -48,7 +58,7 @@ def create_refresh_token(user_uuid: str) -> str:
Returns:
JWT 리프레시 토큰 문자열
"""
expire = datetime.now() + timedelta(
expire = now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode = {
@ -56,11 +66,16 @@ def create_refresh_token(user_uuid: str) -> str:
"exp": expire,
"type": "refresh",
}
return jwt.encode(
token = jwt.encode(
to_encode,
jwt_settings.JWT_SECRET,
algorithm=jwt_settings.JWT_ALGORITHM,
)
logger.debug(
f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, "
f"expires: {expire}, token: ...{token[-20:]}"
)
return token
def decode_token(token: str) -> Optional[dict]:
@ -79,8 +94,25 @@ def decode_token(token: str) -> Optional[dict]:
jwt_settings.JWT_SECRET,
algorithms=[jwt_settings.JWT_ALGORITHM],
)
logger.debug(
f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, "
f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, "
f"token: ...{token[-20:]}"
)
return payload
except JWTError:
except ExpiredSignatureError:
logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}")
return None
except JWTClaimsError as e:
logger.warning(
f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}"
)
return None
except JWTError as e:
logger.warning(
f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, "
f"token: ...{token[-20:]}"
)
return None
@ -106,7 +138,7 @@ def get_refresh_token_expires_at() -> datetime:
Returns:
리프레시 토큰 만료 datetime (로컬 시간)
"""
return datetime.now() + timedelta(
return now().replace(tzinfo=None) + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)

View File

@ -82,7 +82,7 @@ class ChatgptService:
self,
prompt : Prompt,
input_data : dict,
) -> str:
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data)
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")

View File

@ -122,7 +122,38 @@ text_template_v_2 = {
}
]
}
text_template_v_3 = {
"type": "composition",
"track": 3,
"elements": [
{
"type": "text",
"time": 0,
"x": "0%",
"y": "80%",
"width": "100%",
"height": "15%",
"x_anchor": "0%",
"y_anchor": "0%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Noto Sans",
"font_weight": "700",
"font_size_maximum": "7 vmin",
"fill_color": "#ffffff",
"animations": [
{
"time": 0,
"duration": 1,
"easing": "quadratic-out",
"type": "text-wave",
"split": "line",
"overlap": "50%"
}
]
}
]
}
text_template_h_1 = {
"type": "composition",
"track": 3,
@ -147,6 +178,49 @@ text_template_h_1 = {
]
}
autotext_template_v_1 = {
"type": "text",
"track": 4,
"time": 0,
"y": "87.9086%",
"width": "100%",
"height": "40%",
"x_alignment": "50%",
"y_alignment": "50%",
"font_family": "Noto Sans",
"font_weight": "700",
"font_size": "8 vmin",
"background_color": "rgba(216,216,216,0)",
"background_x_padding": "33%",
"background_y_padding": "7%",
"background_border_radius": "28%",
"transcript_source": "audio-music", # audio-music과 연동됨
"transcript_effect": "karaoke",
"fill_color": "#ffffff",
"stroke_color": "rgba(51,51,51,1)",
"stroke_width": "0.6 vmin"
}
autotext_template_h_1 = {
"type": "text",
"track": 4,
"time": 0,
"x": "10%",
"y": "83.2953%",
"width": "80%",
"height": "15%",
"x_anchor": "0%",
"y_anchor": "0%",
"x_alignment": "50%",
"font_family": "Noto Sans",
"font_weight": "700",
"font_size": "5.9998 vmin",
"transcript_source": "audio-music",
"transcript_effect": "karaoke",
"fill_color": "#ffffff",
"stroke_color": "#333333",
"stroke_width": "0.2 vmin"
}
async def get_shared_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_client
@ -365,6 +439,7 @@ class CreatomateService:
image_url_list: list[str],
lyric: str,
music_url: str,
address: str = None
) -> dict:
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
@ -391,9 +466,8 @@ class CreatomateService:
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
if "address_input" in template_component_name:
modifications[template_component_name] = address
modifications["audio-music"] = music_url
@ -405,12 +479,11 @@ class CreatomateService:
image_url_list: list[str],
lyric: str,
music_url: str,
address: str = None
) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements)
lyric = lyric.replace("\r", "")
lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
@ -422,9 +495,8 @@ class CreatomateService:
idx % len(image_url_list)
]
case "text":
modifications[template_component_name] = lyric_splited[
idx % len(lyric_splited)
]
if "address_input" in template_component_name:
modifications[template_component_name] = address
modifications["audio-music"] = music_url
@ -443,7 +515,8 @@ class CreatomateService:
case "video":
element["source"] = modification[element["name"]]
case "text":
element["source"] = modification.get(element["name"], "")
#element["source"] = modification[element["name"]]
element["text"] = modification.get(element["name"], "")
case "composition":
for minor in element["elements"]:
recursive_modify(minor)
@ -661,8 +734,8 @@ class CreatomateService:
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다."""
target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
template["duration"] = target_duration
template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template)
@ -684,7 +757,7 @@ class CreatomateService:
return new_template
def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float) -> dict:
def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float, font_family: str = "Noto Sans") -> dict:
duration = end_sec - start_sec
text_scene = copy.deepcopy(text_template)
text_scene["name"] = f"Caption-{lyric_index}"
@ -692,13 +765,24 @@ class CreatomateService:
text_scene["time"] = start_sec
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
text_scene["elements"][0]["text"] = lyric_text
text_scene["elements"][0]["font_family"] = font_family
return text_scene
def auto_lyric(self, auto_text_template : dict):
text_scene = copy.deepcopy(auto_text_template)
return text_scene
def get_text_template(self):
match self.orientation:
case "vertical":
return text_template_v_2
return text_template_v_3
case "horizontal":
return text_template_h_1
def get_auto_text_template(self):
match self.orientation:
case "vertical":
return autotext_template_v_1
case "horizontal":
return autotext_template_h_1

View File

@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
import logging
import sys
from datetime import datetime
from functools import lru_cache
from logging.handlers import RotatingFileHandler
from typing import Literal
from app.utils.timezone import today_str
from config import log_settings
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
global _shared_file_handler
if _shared_file_handler is None:
today = datetime.today().strftime("%Y-%m-%d")
today = today_str()
log_file = LOG_DIR / f"{today}_app.log"
_shared_file_handler = RotatingFileHandler(
@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
global _shared_error_handler
if _shared_error_handler is None:
today = datetime.today().strftime("%Y-%m-%d")
today = today_str()
log_file = LOG_DIR / f"{today}_error.log"
_shared_error_handler = RotatingFileHandler(

View File

@ -1,6 +1,11 @@
import asyncio
from playwright.async_api import async_playwright
from urllib import parse
import time
from app.utils.logger import get_logger
# 로거 설정
logger = get_logger("pwscraper")
class NvMapPwScraper():
# cls vars
@ -10,7 +15,8 @@ class NvMapPwScraper():
_context = None
_win_width = 1280
_win_height = 720
_max_retry = 30 # place id timeout threshold seconds
_max_retry = 3
_timeout = 60 # place id timeout threshold seconds
# instance var
page = None
@ -90,22 +96,54 @@ patchedGetter.toString();''')
await page.goto(url, wait_until=wait_until, timeout=timeout)
async def get_place_id_url(self, selected):
count = 0
get_place_id_url_start = time.perf_counter()
while (count <= self._max_retry):
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
title = selected['title'].replace("<b>", "").replace("</b>", "")
address = selected.get('roadAddress', selected['address']).replace("<b>", "").replace("</b>", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
wait_first_start = time.perf_counter()
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
try:
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error(f"[ERROR] Can't Finish networkidle")
if "/place/" in self.page.url:
return self.page.url
url = self.page.url.replace("?","?isCorrectAnswer=true&")
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
wait_first_time = (time.perf_counter() - wait_first_start) * 1000
logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
if "/place/" in self.page.url:
return self.page.url
logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
wait_forced_correct_start = time.perf_counter()
url = self.page.url.replace("?","?isCorrectAnswer=true&")
try:
await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000)
except:
if "/place/" in self.page.url:
return self.page.url
logger.error(f"[ERROR] Can't Finish networkidle")
wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
if "/place/" in self.page.url:
return self.page.url
count += 1
logger.error("[ERROR] Not found url for {selected}")
return None # 404
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")

View File

@ -30,7 +30,7 @@ class NvMapScraper:
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
REQUEST_TIMEOUT = 120 # 초
data_source_identifier = "nv"
OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
@ -99,6 +99,8 @@ query getAccommodation($id: String!, $deviceType: String) {
data = await self._call_get_accommodation(place_id)
self.rawdata = data
fac_data = await self._get_facility_string(place_id)
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.place_id = self.data_source_identifier + place_id
self.rawdata["facilities"] = fac_data
self.image_link_list = [
nv_image["origin"]

View File

@ -52,6 +52,14 @@ lyric_prompt = Prompt(
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
)
yt_upload_prompt = Prompt(
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME),
prompt_input_class = YTUploadPromptInput,
prompt_output_class = YTUploadPromptOutput,
prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL
)
def reload_all_prompt():
marketing_prompt._reload_prompt()
lyric_prompt._reload_prompt()
yt_upload_prompt._reload_prompt()

View File

@ -1,2 +1,3 @@
from .lyric import LyricPromptInput, LyricPromptOutput
from .marketing import MarketingPromptInput, MarketingPromptOutput
from .youtube import YTUploadPromptInput, YTUploadPromptOutput

View File

@ -31,9 +31,10 @@ class TargetPersona(BaseModel):
class SellingPoint(BaseModel):
category: str = Field(..., description="셀링포인트 카테고리")
english_category: str = Field(..., description="셀링포인트 카테고리(영문)")
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")
description: str = Field(..., description="상세 설명")
score: int = Field(..., ge=70, le=99, description="점수 (100점 만점)")
score: int = Field(..., ge=0, le=100, description="점수 (100점 만점)")
class MarketingPromptOutput(BaseModel):
brand_identity: BrandIdentity = Field(..., description="브랜드 아이덴티티")

View File

@ -0,0 +1,16 @@
from pydantic import BaseModel, Field
from typing import List, Optional
# Input 정의
class YTUploadPromptInput(BaseModel):
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
language : str= Field(..., description = "영상 언어")
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
# Output 정의
class YTUploadPromptOutput(BaseModel):
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
description: str = Field(..., description = "유튜브 영상 설명 - SEO/AEO 최적화")

View File

@ -4,6 +4,7 @@ You are a content marketing expert, brand strategist, and creative songwriter
specializing in Korean pension / accommodation businesses.
You create lyrics strictly based on Brand & Marketing Intelligence analysis
and optimized for viral short-form video content.
Marketing Intelligence Report is background reference.
[INPUT]
Business Name: {customer_name}

View File

@ -9,8 +9,8 @@ Act as a Senior Brand Strategist and Marketing Data Analyst. Your goal is to ana
# Output Rules
1. **Language:** All descriptive content must be written in **Korean (한국어)**.
2. **Terminology:** Use professional marketing terminology suitable for the hospitality and stay industry.
3. **Strict Selection for `selling_points.category`:** You must select the value for the `category` field in `selling_points` strictly from the following English allowed list to ensure UI compatibility:
* `LOCATION`, `CONCEPT`, `PRIVACY`, `NIGHT MOOD`, `HEALING`, `PHOTO SPOT`, `SHORT GETAWAY`, `HOSPITALITY`, `SWIMMING POOL`, `JACUZZI`, `BBQ PARTY`, `FIRE PIT`, `GARDEN`, `BREAKFAST`, `KIDS FRIENDLY`, `PET FRIENDLY`, `OCEAN VIEW`, `PRIVATE POOL`.
3. **Strict Selection for `selling_points.english_category` and `selling_points.korean_category`:** You must select the value for both category field in `selling_points` strictly from the following English - Korean set allowed list to ensure UI compatibility:
* `LOCATION` (입지 환경), `CONCEPT` (브랜드 컨셉), `PRIVACY` (프라이버시), `NIGHT MOOD` (야간 감성), `HEALING` (힐링 요소), `PHOTO SPOT` (포토 스팟), `SHORT GETAWAY` (숏브레이크), `HOSPITALITY` (서비스), `SWIMMING POOL` (수영장), `JACUZZI` (자쿠지), `BBQ PARTY` (바베큐), `FIRE PIT` (불멍), `GARDEN` (정원), `BREAKFAST` (조식), `KIDS FRIENDLY` (키즈 케어), `PET FRIENDLY` (애견 동반), `OCEAN VIEW` (오션뷰), `PRIVATE POOL` (개별 수영장), `OCEAN VIEW`, `PRIVATE POOL`.
---
@ -32,10 +32,11 @@ Generate a list of personas based on the following:
* **`decision_trigger`**: Identify the specific "Hook" or facility that leads this persona to finalize a booking.
### 4. selling_points
Generate exactly 7 selling points:
* **`category`**: Strictly use one keyword from the English allowed list provided in the Output Rules.
* **`description`**: A short, punchy marketing phrase in Korean (max 20 characters).
* **`score`**: An integer (70-99) representing the strength of this feature based on the brand's potential.
Generate 5-8 selling points:
* **`english_category`**: Strictly use one keyword from the English allowed list provided in the Output Rules.
* **`korean category`**: Strictly use one keyword from the Korean allowed list provided in the Output Rules . It must be matched with english category.
* **`description`**: A short, punchy marketing phrase in Korean (15~30 characters).
* **`score`**: An integer (0-100) representing the strength of this feature based on the brand's potential.
### 5. target_keywords
* **`target_keywords`**: Provide a list of 10 highly relevant marketing keywords or hashtags for search engine optimization and social media targeting.
* **`target_keywords`**: Provide a list of 10 highly relevant marketing keywords or hashtags for search engine optimization and social media targeting. Do not insert # in front of hashtag.

View File

@ -0,0 +1,143 @@
[ROLE]
You are a YouTube SEO/AEO content strategist specialized in local stay, pension, and accommodation brands in Korea.
You create search-optimized, emotionally appealing, and action-driving titles and descriptions based on Brand & Marketing Intelligence.
Your goal is to:
Increase search visibility
Improve click-through rate
Reflect the brands positioning
Trigger emotional interest
Encourage booking or inquiry actions through subtle CTA
[INPUT]
Business Name: {customer_name}
Region Details: {detail_region_info}
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
Target Keywords: {target_keywords}
Output Language: {language}
[INTERNAL ANALYSIS DO NOT OUTPUT]
Analyze the following from the marketing intelligence:
Core brand concept
Main emotional promise
Primary target persona
Top 23 USP signals
Stay context (date, healing, local trip, etc.)
Search intent behind the target keywords
Main booking trigger
Emotional moment that would make the viewer want to stay
Use these to guide:
Title tone
Opening CTA line
Emotional hook in the first sentences
[TITLE GENERATION RULES]
The title must:
Include the business name or region when natural
Always wrap the business name in quotation marks
Example: “스테이 머뭄”
Include 12 high-intent keywords
Reflect emotional positioning
Suggest a desirable stay moment
Sound like a natural YouTube title, not an advertisement
Length rules:
Hard limit: 100 characters
Target range: 4565 characters
Place primary keyword in the first half
Avoid:
ALL CAPS
Excessive symbols
Price or promotion language
Hard-sell expressions
[DESCRIPTION GENERATION RULES]
Character rules:
Maximum length: 1,000 characters
Critical information must appear within the first 150 characters
Language style rules (mandatory):
Use polite Korean honorific style
Replace “있나요?” with “있으신가요?”
Do not start sentences with “이곳은”
Replace “선택이 됩니다” with “추천 드립니다”
Always wrap the business name in quotation marks
Example: “스테이 머뭄”
Avoid vague location words like “근대거리” alone
Use specific phrasing such as:
“군산 근대역사문화거리 일대”
Structure:
Opening CTA (first line)
Must be a question or gentle suggestion
Must use honorific tone
Example:
“조용히 쉴 수 있는 군산숙소를 찾고 있으신가요?”
Core Stay Introduction (within first 150 characters total)
Mention business name with quotation marks
Mention region
Include main keyword
Briefly describe the stay experience
Brand Experience
Core value and emotional promise
Based on marketing intelligence positioning
Key Highlights (34 short lines)
Derived from USP signals
Natural sentences
Focus on booking-trigger moments
Local Context
Mention nearby experiences
Use specific local references
Example:
“군산 근대역사문화거리 일대 산책이나 로컬 카페 투어”
Soft Closing Line
One gentle, non-salesy closing sentence
Must end with a recommendation tone
Example:
“군산에서 조용한 시간을 보내고 싶다면 ‘스테이 머뭄’을 추천 드립니다.”
[SEO & AEO RULES]
Naturally integrate 35 keywords from {target_keywords}
Avoid keyword stuffing
Use conversational, search-like phrasing
Optimize for:
YouTube search
Google video results
AI answer summaries
Keywords should appear in:
Title (12)
First 150 characters of description
Highlight or context sections
[LANGUAGE RULE]
All output must be written entirely in {language}.
No mixed languages.
[OUTPUT FORMAT STRICT]
title:
description:
No explanations.
No headings.
No extra text.

View File

@ -163,7 +163,8 @@ class SunoService:
if data.get("code") != 200:
error_msg = data.get("msg", "Unknown error")
raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data)
logger.error(f"[Suno] API error: {error_msg} | response: {data}")
raise SunoResponseError("api 에러입니다.", original_response=data)
response_data = data.get("data")
if response_data is None:

42
app/utils/timezone.py Normal file
View File

@ -0,0 +1,42 @@
"""
타임존 유틸리티
프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다.
모든 datetime.now() 호출은 모듈의 함수로 대체해야 합니다.
"""
from datetime import datetime
from config import TIMEZONE
def now() -> datetime:
"""
서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다.
Returns:
datetime: 서울 타임존이 적용된 현재 시간 (aware datetime)
Example:
>>> from app.utils.timezone import now
>>> current_time = now() # 2024-01-15 15:30:00+09:00
"""
return datetime.now(TIMEZONE)
def today_str(fmt: str = "%Y-%m-%d") -> str:
"""
서울 타임존 기준 오늘 날짜를 문자열로 반환합니다.
Args:
fmt: 날짜 포맷 (기본값: YYYY-MM-DD)
Returns:
str: 포맷된 날짜 문자열
Example:
>>> from app.utils.timezone import today_str
>>> today_str() # "2024-01-15"
>>> today_str("%Y/%m/%d") # "2024/01/15"
"""
return datetime.now(TIMEZONE).strftime(fmt)

View File

@ -7,7 +7,6 @@ Video API Router
- POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결)
- GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회
- GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling)
- GET /video/list: 완료된 영상 목록 조회 (페이지네이션)
사용 예시:
from app.video.api.routers.v1.video import router
@ -18,11 +17,10 @@ import json
from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
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.user.dependencies.auth import get_current_user
from app.user.models import User
from app.home.models import Image, Project
@ -30,17 +28,17 @@ 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,
GenerateVideoResponse,
PollingVideoResponse,
VideoListItem,
VideoRenderData,
)
from app.video.worker.video_task import download_and_upload_video_to_blob
from config import creatomate_settings
logger = get_logger("video")
router = APIRouter(prefix="/video", tags=["Video"])
@ -199,6 +197,7 @@ async def generate_video(
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
)
project_id = project.id
store_address = project.detail_region_info
# ===== 결과 처리: Lyric =====
lyric = lyric_result.scalar_one_or_none()
@ -209,6 +208,7 @@ async def generate_video(
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
)
lyric_id = lyric.id
lyric_language = lyric.language
# ===== 결과 처리: Song =====
song = song_result.scalar_one_or_none()
@ -311,6 +311,7 @@ async def generate_video(
image_url_list=image_urls,
lyric=lyrics,
music_url=music_url,
address=store_address
)
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
@ -331,8 +332,6 @@ async def generate_video(
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
@ -348,17 +347,30 @@ async def generate_video(
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)
match lyric_language:
case "English" :
lyric_font = "Noto Sans"
# lyric_font = "Pretendard" # 없어요
case _ :
lyric_font = "Noto Sans"
# LYRIC AUTO 결정부
if (creatomate_settings.DEBUG_AUTO_LYRIC):
auto_text_template = creatomate_service.get_auto_text_template()
final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template))
else :
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,
lyric_font
)
final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부
# logger.debug(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# )
@ -722,123 +734,3 @@ async def download_video(
message="영상 다운로드 조회에 실패했습니다.",
error_message=str(e),
)
@router.get(
"/list",
summary="생성된 영상 목록 조회",
description="""
완료된 영상 목록을 페이지네이션하여 조회합니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
## 쿼리 파라미터
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
- **page_size**: 페이지당 데이터 (기본값: 10, 최대: 100)
## 반환 정보
- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at)
- **total**: 전체 데이터
- **page**: 현재 페이지
- **page_size**: 페이지당 데이터
- **total_pages**: 전체 페이지
- **has_next**: 다음 페이지 존재 여부
- **has_prev**: 이전 페이지 존재 여부
## 사용 예시 (cURL)
```bash
curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\
-H "Authorization: Bearer {access_token}"
```
## 참고
- status가 'completed' 영상만 반환됩니다.
- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다.
- created_at 기준 내림차순 정렬됩니다.
""",
response_model=PaginatedResponse[VideoListItem],
responses={
200: {"description": "영상 목록 조회 성공"},
401: {"description": "인증 실패 (토큰 없음/만료)"},
500: {"description": "조회 실패"},
},
)
async def get_videos(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
logger.info(
f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}"
)
try:
offset = (pagination.page - 1) * pagination.page_size
# 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만)
subquery = (
select(func.max(Video.id).label("max_id"))
.where(Video.status == "completed")
.group_by(Video.task_id)
.subquery()
)
# 전체 개수 조회 (task_id별 최신 1개만)
count_query = select(func.count()).select_from(subquery)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순)
query = (
select(Video)
.where(Video.id.in_(select(subquery.c.max_id)))
.order_by(Video.created_at.desc())
.offset(offset)
.limit(pagination.page_size)
)
result = await session.execute(query)
videos = result.scalars().all()
# Project 정보 일괄 조회 (N+1 문제 해결)
project_ids = [v.project_id for v in videos if v.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()}
# VideoListItem으로 변환
items = []
for video in videos:
project = projects_map.get(video.project_id)
item = VideoListItem(
store_name=project.store_name if project else None,
region=project.region if project else None,
task_id=video.task_id,
result_movie_url=video.result_movie_url,
created_at=video.created_at,
)
items.append(item)
response = PaginatedResponse.create(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
logger.info(
f"[get_videos] 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_videos] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",
)

View File

@ -5,7 +5,7 @@ Video API Schemas
"""
from datetime import datetime
from typing import Any, Dict, Literal, Optional
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field
@ -141,6 +141,7 @@ class VideoListItem(BaseModel):
Example:
{
"video_id": 1,
"store_name": "스테이 머뭄",
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
@ -149,8 +150,11 @@ class VideoListItem(BaseModel):
}
"""
video_id: int = Field(..., description="영상 고유 ID")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
created_at: Optional[datetime] = Field(None, description="생성 일시")

View File

@ -115,7 +115,7 @@ async def download_and_upload_video_to_blob(
Args:
task_id: 프로젝트 task_id
video_url: 다운로드할 영상 URL
creatomate_render_id: Creatomate API 렌더 ID (파일명으로 )
creatomate_render_id: Creatomate API 렌더 ID (파일명 Video 식별)
user_uuid: 사용자 UUID (Azure Blob Storage 경로에 사용)
"""
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
@ -152,21 +152,21 @@ async def download_and_upload_video_to_blob(
blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Video 테이블 업데이트
await _update_video_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except Exception as e:
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed")
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
finally:
# 임시 파일 삭제

155
config.py
View File

@ -1,10 +1,19 @@
import os
from pathlib import Path
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent
# .env 파일 로드 (Settings 클래스보다 먼저 TIMEZONE을 사용하기 위함)
load_dotenv(PROJECT_DIR / ".env")
# 프로젝트 전역 타임존 설정 (기본값: 서울)
TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul"))
# 미디어 파일 저장 디렉토리
MEDIA_ROOT = PROJECT_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True)
@ -23,6 +32,10 @@ class ProjectSettings(BaseSettings):
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin")
DEBUG: bool = Field(default=True)
TIMEZONE: str = Field(
default="Asia/Seoul",
description="프로젝트 전역 타임존 (예: Asia/Seoul, UTC, America/New_York)",
)
model_config = _base_config
@ -155,6 +168,10 @@ class CreatomateSettings(BaseSettings):
default=30.0,
description="가로형 템플릿 기본 duration (초)",
)
DEBUG_AUTO_LYRIC: bool = Field(
default=False,
description="Creatomate 자동 가사 생성 기능 사용 여부",
)
model_config = _base_config
@ -167,6 +184,8 @@ class PromptSettings(BaseSettings):
LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt")
LYRIC_PROMPT_MODEL : str = Field(default="gpt-5-mini")
YOUTUBE_PROMPT_FILE_NAME : str = Field(default="yt_upload_prompt.txt")
YOUTUBE_PROMPT_MODEL : str = Field(default="gpt-5-mini")
model_config = _base_config
@ -448,10 +467,142 @@ class LogSettings(BaseSettings):
return default_log_dir
# =============================================================================
# Social Media OAuth Settings
# =============================================================================
class SocialOAuthSettings(BaseSettings):
"""소셜 미디어 OAuth 설정
YouTube, Instagram, Facebook 소셜 미디어 플랫폼 OAuth 인증 설정입니다.
플랫폼의 개발자 콘솔에서 앱을 생성하고 클라이언트 ID/Secret을 발급받아야 합니다.
YouTube (Google Cloud Console):
- https://console.cloud.google.com/
- YouTube Data API v3 활성화 필요
- OAuth 동의 화면 설정 필요
Instagram/Facebook (Meta for Developers):
- https://developers.facebook.com/
- Instagram Graph API 사용을 위해 Facebook 필요
- 비즈니스/크리에이터 계정 필요
TikTok (TikTok for Developers):
- https://developers.tiktok.com/
- Content Posting API 권한 필요
"""
# ============================================================
# YouTube (Google OAuth)
# ============================================================
YOUTUBE_CLIENT_ID: str = Field(
default="",
description="Google OAuth 클라이언트 ID (Google Cloud Console에서 발급)",
)
YOUTUBE_CLIENT_SECRET: str = Field(
default="",
description="Google OAuth 클라이언트 Secret",
)
YOUTUBE_REDIRECT_URI: str = Field(
default="http://localhost:8000/social/oauth/youtube/callback",
description="YouTube OAuth 콜백 URI",
)
# ============================================================
# Instagram (Meta/Facebook OAuth) - 추후 구현
# ============================================================
# INSTAGRAM_APP_ID: str = Field(default="", description="Facebook App ID")
# INSTAGRAM_APP_SECRET: str = Field(default="", description="Facebook App Secret")
# INSTAGRAM_REDIRECT_URI: str = Field(
# default="http://localhost:8000/social/instagram/callback",
# description="Instagram OAuth 콜백 URI",
# )
# ============================================================
# Facebook (Meta OAuth) - 추후 구현
# ============================================================
# FACEBOOK_APP_ID: str = Field(default="", description="Facebook App ID")
# FACEBOOK_APP_SECRET: str = Field(default="", description="Facebook App Secret")
# FACEBOOK_REDIRECT_URI: str = Field(
# default="http://localhost:8000/social/facebook/callback",
# description="Facebook OAuth 콜백 URI",
# )
# ============================================================
# TikTok - 추후 구현
# ============================================================
# TIKTOK_CLIENT_KEY: str = Field(default="", description="TikTok Client Key")
# TIKTOK_CLIENT_SECRET: str = Field(default="", description="TikTok Client Secret")
# TIKTOK_REDIRECT_URI: str = Field(
# default="http://localhost:8000/social/tiktok/callback",
# description="TikTok OAuth 콜백 URI",
# )
# ============================================================
# 공통 설정
# ============================================================
OAUTH_STATE_TTL_SECONDS: int = Field(
default=300,
description="OAuth state 토큰 유효 시간 (초). CSRF 방지용 state는 Redis에 저장됨",
)
# ============================================================
# 프론트엔드 리다이렉트 설정
# ============================================================
OAUTH_FRONTEND_URL: str = Field(
default="http://localhost:3000",
description="OAuth 완료 후 리다이렉트할 프론트엔드 URL (프로토콜 포함)",
)
OAUTH_SUCCESS_PATH: str = Field(
default="/social/connect/success",
description="OAuth 성공 시 리다이렉트 경로",
)
OAUTH_ERROR_PATH: str = Field(
default="/social/connect/error",
description="OAuth 실패/취소 시 리다이렉트 경로",
)
model_config = _base_config
class SocialUploadSettings(BaseSettings):
"""소셜 미디어 업로드 설정
영상 업로드 관련 타임아웃, 재시도, 임시 파일 관리 설정입니다.
"""
# ============================================================
# 업로드 설정
# ============================================================
UPLOAD_MAX_RETRIES: int = Field(
default=3,
description="업로드 실패 시 최대 재시도 횟수",
)
UPLOAD_RETRY_DELAY_SECONDS: int = Field(
default=10,
description="재시도 전 대기 시간 (초)",
)
UPLOAD_TIMEOUT_SECONDS: float = Field(
default=600.0,
description="업로드 타임아웃 (초). 대용량 영상 업로드를 위해 충분한 시간 설정",
)
# ============================================================
# 임시 파일 설정
# ============================================================
UPLOAD_TEMP_DIR: str = Field(
default="media/temp/social",
description="업로드 임시 파일 저장 디렉토리",
)
model_config = _base_config
prj_settings = ProjectSettings()
cors_settings = CORSSettings()
apikey_settings = APIKeySettings()
db_settings = DatabaseSettings()
cors_settings = CORSSettings()
crawler_settings = CrawlerSettings()
naver_api_settings = NaverAPISettings()
azure_blob_settings = AzureBlobSettings()
@ -461,3 +612,5 @@ log_settings = LogSettings()
kakao_settings = KakaoSettings()
jwt_settings = JWTSettings()
recovery_settings = RecoverySettings()
social_oauth_settings = SocialOAuthSettings()
social_upload_settings = SocialUploadSettings()

View File

@ -0,0 +1,601 @@
{
"info": {
"title": "Social Media Integration API",
"version": "1.0.0",
"description": "소셜 미디어 연동 및 영상 업로드 API 명세서",
"baseUrl": "http://localhost:8000"
},
"authentication": {
"type": "Bearer Token",
"header": "Authorization",
"format": "Bearer {access_token}",
"description": "카카오 로그인 후 발급받은 JWT access_token 사용"
},
"endpoints": {
"oauth": {
"connect": {
"name": "소셜 계정 연동 시작",
"method": "GET",
"url": "/social/oauth/{platform}/connect",
"description": "OAuth 인증 URL을 생성합니다. 반환된 auth_url로 사용자를 리다이렉트하세요.",
"authentication": true,
"pathParameters": {
"platform": {
"type": "string",
"enum": ["youtube"],
"description": "연동할 플랫폼 (현재 youtube만 지원)"
}
},
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
"response": {
"success": {
"status": 200,
"body": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=xxx&redirect_uri=xxx&response_type=code&scope=xxx&state=xxx",
"state": "abc123xyz789",
"platform": "youtube"
}
},
"error": {
"401": {
"detail": "인증이 필요합니다."
},
"422": {
"detail": "지원하지 않는 플랫폼입니다."
}
}
},
"frontendAction": "auth_url로 window.location.href 또는 새 창으로 리다이렉트"
},
"callback": {
"name": "OAuth 콜백 (백엔드 자동 처리)",
"method": "GET",
"url": "/social/oauth/{platform}/callback",
"description": "Google에서 자동으로 호출됩니다. 프론트엔드에서 직접 호출하지 마세요.",
"authentication": false,
"note": "연동 성공 시 프론트엔드의 /social/connect/success 페이지로 리다이렉트됩니다.",
"redirectOnSuccess": {
"url": "{PROJECT_DOMAIN}/social/connect/success",
"queryParams": {
"platform": "youtube",
"account_id": 1,
"channel_name": "My YouTube Channel",
"profile_image": "https://yt3.ggpht.com/..."
},
"example": "/social/connect/success?platform=youtube&account_id=1&channel_name=My+YouTube+Channel&profile_image=https%3A%2F%2Fyt3.ggpht.com%2F..."
},
"redirectOnError": {
"url": "{PROJECT_DOMAIN}/social/connect/error",
"queryParams": {
"platform": "youtube",
"error": "에러 메시지"
}
}
},
"getAccounts": {
"name": "연동된 계정 목록 조회",
"method": "GET",
"url": "/social/oauth/accounts",
"description": "현재 사용자가 연동한 모든 소셜 계정 목록을 반환합니다.",
"authentication": true,
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
"response": {
"success": {
"status": 200,
"body": {
"accounts": [
{
"id": 1,
"platform": "youtube",
"platform_user_id": "UC1234567890abcdef",
"platform_username": "@mychannel",
"display_name": "My YouTube Channel",
"profile_image_url": "https://yt3.ggpht.com/...",
"is_active": true,
"connected_at": "2024-01-15T12:00:00",
"platform_data": {
"channel_id": "UC1234567890abcdef",
"channel_title": "My YouTube Channel",
"subscriber_count": "1000",
"video_count": "50"
}
}
],
"total": 1
}
}
}
},
"getAccountByPlatform": {
"name": "특정 플랫폼 연동 계정 조회",
"method": "GET",
"url": "/social/oauth/accounts/{platform}",
"description": "특정 플랫폼에 연동된 계정 정보를 반환합니다.",
"authentication": true,
"pathParameters": {
"platform": {
"type": "string",
"enum": ["youtube"],
"description": "조회할 플랫폼"
}
},
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
"response": {
"success": {
"status": 200,
"body": {
"id": 1,
"platform": "youtube",
"platform_user_id": "UC1234567890abcdef",
"platform_username": "@mychannel",
"display_name": "My YouTube Channel",
"profile_image_url": "https://yt3.ggpht.com/...",
"is_active": true,
"connected_at": "2024-01-15T12:00:00",
"platform_data": {
"channel_id": "UC1234567890abcdef",
"channel_title": "My YouTube Channel",
"subscriber_count": "1000",
"video_count": "50"
}
}
},
"error": {
"404": {
"detail": "youtube 플랫폼에 연동된 계정이 없습니다."
}
}
}
},
"disconnect": {
"name": "소셜 계정 연동 해제",
"method": "DELETE",
"url": "/social/oauth/{platform}/disconnect",
"description": "소셜 미디어 계정 연동을 해제합니다.",
"authentication": true,
"pathParameters": {
"platform": {
"type": "string",
"enum": ["youtube"],
"description": "연동 해제할 플랫폼"
}
},
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
"response": {
"success": {
"status": 200,
"body": {
"success": true,
"message": "youtube 계정 연동이 해제되었습니다."
}
},
"error": {
"404": {
"detail": "youtube 플랫폼에 연동된 계정이 없습니다."
}
}
}
}
},
"upload": {
"create": {
"name": "소셜 플랫폼에 영상 업로드 요청",
"method": "POST",
"url": "/social/upload",
"description": "영상을 소셜 미디어 플랫폼에 업로드합니다. 백그라운드에서 처리됩니다.",
"authentication": true,
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"Content-Type": "application/json"
},
"body": {
"video_id": {
"type": "integer",
"required": true,
"description": "업로드할 영상 ID (Video 테이블의 id)",
"example": 123
},
"platform": {
"type": "string",
"required": true,
"enum": ["youtube"],
"description": "업로드할 플랫폼",
"example": "youtube"
},
"title": {
"type": "string",
"required": true,
"maxLength": 100,
"description": "영상 제목",
"example": "나의 첫 영상"
},
"description": {
"type": "string",
"required": false,
"maxLength": 5000,
"description": "영상 설명",
"example": "이 영상은 테스트 영상입니다."
},
"tags": {
"type": "array",
"required": false,
"items": "string",
"description": "태그 목록",
"example": ["여행", "vlog", "일상"]
},
"privacy_status": {
"type": "string",
"required": false,
"enum": ["public", "unlisted", "private"],
"default": "private",
"description": "공개 상태",
"example": "private"
},
"platform_options": {
"type": "object",
"required": false,
"description": "플랫폼별 추가 옵션",
"example": {
"category_id": "22"
}
}
},
"example": {
"video_id": 123,
"platform": "youtube",
"title": "나의 첫 영상",
"description": "이 영상은 테스트 영상입니다.",
"tags": ["여행", "vlog"],
"privacy_status": "private",
"platform_options": {
"category_id": "22"
}
}
},
"response": {
"success": {
"status": 200,
"body": {
"success": true,
"upload_id": 456,
"platform": "youtube",
"status": "pending",
"message": "업로드 요청이 접수되었습니다."
}
},
"error": {
"404_video": {
"detail": "영상을 찾을 수 없습니다."
},
"404_account": {
"detail": "youtube 플랫폼에 연동된 계정이 없습니다."
},
"400": {
"detail": "영상이 아직 준비되지 않았습니다."
}
}
},
"youtubeCategoryIds": {
"1": "Film & Animation",
"2": "Autos & Vehicles",
"10": "Music",
"15": "Pets & Animals",
"17": "Sports",
"19": "Travel & Events",
"20": "Gaming",
"22": "People & Blogs (기본값)",
"23": "Comedy",
"24": "Entertainment",
"25": "News & Politics",
"26": "Howto & Style",
"27": "Education",
"28": "Science & Technology",
"29": "Nonprofits & Activism"
}
},
"getStatus": {
"name": "업로드 상태 조회",
"method": "GET",
"url": "/social/upload/{upload_id}/status",
"description": "특정 업로드 작업의 상태를 조회합니다. 폴링으로 상태를 확인하세요.",
"authentication": true,
"pathParameters": {
"upload_id": {
"type": "integer",
"description": "업로드 ID"
}
},
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
"response": {
"success": {
"status": 200,
"body": {
"upload_id": 456,
"video_id": 123,
"platform": "youtube",
"status": "completed",
"upload_progress": 100,
"title": "나의 첫 영상",
"platform_video_id": "dQw4w9WgXcQ",
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"error_message": null,
"retry_count": 0,
"created_at": "2024-01-15T12:00:00",
"uploaded_at": "2024-01-15T12:05:00"
}
},
"error": {
"404": {
"detail": "업로드 정보를 찾을 수 없습니다."
}
}
},
"statusValues": {
"pending": "업로드 대기 중",
"uploading": "업로드 진행 중 (upload_progress 확인)",
"processing": "플랫폼에서 처리 중",
"completed": "업로드 완료 (platform_url 사용 가능)",
"failed": "업로드 실패 (error_message 확인)",
"cancelled": "업로드 취소됨"
},
"pollingRecommendation": {
"interval": "3초",
"maxAttempts": 100,
"stopConditions": ["completed", "failed", "cancelled"]
}
},
"getHistory": {
"name": "업로드 이력 조회",
"method": "GET",
"url": "/social/upload/history",
"description": "사용자의 소셜 미디어 업로드 이력을 조회합니다.",
"authentication": true,
"queryParameters": {
"platform": {
"type": "string",
"required": false,
"enum": ["youtube"],
"description": "플랫폼 필터"
},
"status": {
"type": "string",
"required": false,
"enum": ["pending", "uploading", "processing", "completed", "failed", "cancelled"],
"description": "상태 필터"
},
"page": {
"type": "integer",
"required": false,
"default": 1,
"description": "페이지 번호"
},
"size": {
"type": "integer",
"required": false,
"default": 20,
"min": 1,
"max": 100,
"description": "페이지 크기"
}
},
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
},
"exampleUrl": "/social/upload/history?platform=youtube&status=completed&page=1&size=20"
},
"response": {
"success": {
"status": 200,
"body": {
"items": [
{
"upload_id": 456,
"video_id": 123,
"platform": "youtube",
"status": "completed",
"title": "나의 첫 영상",
"platform_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"created_at": "2024-01-15T12:00:00",
"uploaded_at": "2024-01-15T12:05:00"
}
],
"total": 1,
"page": 1,
"size": 20
}
}
}
},
"retry": {
"name": "업로드 재시도",
"method": "POST",
"url": "/social/upload/{upload_id}/retry",
"description": "실패한 업로드를 재시도합니다.",
"authentication": true,
"pathParameters": {
"upload_id": {
"type": "integer",
"description": "업로드 ID"
}
},
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
"response": {
"success": {
"status": 200,
"body": {
"success": true,
"upload_id": 456,
"platform": "youtube",
"status": "pending",
"message": "업로드 재시도가 요청되었습니다."
}
},
"error": {
"400": {
"detail": "실패하거나 취소된 업로드만 재시도할 수 있습니다."
},
"404": {
"detail": "업로드 정보를 찾을 수 없습니다."
}
}
}
},
"cancel": {
"name": "업로드 취소",
"method": "DELETE",
"url": "/social/upload/{upload_id}",
"description": "대기 중인 업로드를 취소합니다.",
"authentication": true,
"pathParameters": {
"upload_id": {
"type": "integer",
"description": "업로드 ID"
}
},
"request": {
"headers": {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
},
"response": {
"success": {
"status": 200,
"body": {
"success": true,
"message": "업로드가 취소되었습니다."
}
},
"error": {
"400": {
"detail": "대기 중인 업로드만 취소할 수 있습니다."
},
"404": {
"detail": "업로드 정보를 찾을 수 없습니다."
}
}
}
}
}
},
"frontendPages": {
"required": [
{
"path": "/social/connect/success",
"description": "OAuth 연동 성공 후 리다이렉트되는 페이지",
"queryParams": {
"platform": "플랫폼명 (youtube)",
"account_id": "연동된 계정 ID",
"channel_name": "YouTube 채널 이름 (URL 인코딩됨)",
"profile_image": "프로필 이미지 URL (URL 인코딩됨)"
},
"action": "연동 성공 메시지 표시, 채널 정보 즉시 표시 가능, 이후 GET /social/oauth/accounts 호출로 전체 목록 갱신"
},
{
"path": "/social/connect/error",
"description": "OAuth 연동 실패 시 리다이렉트되는 페이지",
"queryParams": {
"platform": "플랫폼명 (youtube)",
"error": "에러 메시지 (URL 인코딩됨)"
},
"action": "에러 메시지 표시 및 재시도 옵션 제공"
}
],
"recommended": [
{
"path": "/settings/social",
"description": "소셜 계정 관리 페이지",
"features": ["연동된 계정 목록", "연동/해제 버튼", "업로드 이력"]
}
]
},
"flowExamples": {
"connectYouTube": {
"description": "YouTube 계정 연동 플로우",
"steps": [
{
"step": 1,
"action": "GET /social/oauth/youtube/connect 호출",
"result": "auth_url 반환"
},
{
"step": 2,
"action": "window.location.href = auth_url",
"result": "Google 로그인 페이지로 이동"
},
{
"step": 3,
"action": "사용자가 권한 승인",
"result": "백엔드 콜백 URL로 자동 리다이렉트"
},
{
"step": 4,
"action": "백엔드가 토큰 교환 후 프론트엔드로 리다이렉트",
"result": "/social/connect/success?platform=youtube&account_id=1&channel_name=My+Channel&profile_image=https%3A%2F%2F..."
},
{
"step": 5,
"action": "URL 파라미터에서 채널 정보 추출하여 즉시 표시",
"code": "const params = new URLSearchParams(window.location.search); const channelName = params.get('channel_name'); const profileImage = params.get('profile_image');"
},
{
"step": 6,
"action": "GET /social/oauth/accounts 호출로 전체 계정 목록 갱신",
"result": "연동된 계정 정보 표시"
}
]
},
"uploadVideo": {
"description": "YouTube 영상 업로드 플로우",
"steps": [
{
"step": 1,
"action": "POST /social/upload 호출",
"request": {
"video_id": 123,
"platform": "youtube",
"title": "영상 제목",
"privacy_status": "private"
},
"result": "upload_id 반환"
},
{
"step": 2,
"action": "GET /social/upload/{upload_id}/status 폴링 (3초 간격)",
"result": "status, upload_progress 확인"
},
{
"step": 3,
"action": "status === 'completed' 확인",
"result": "platform_url로 YouTube 링크 표시"
}
],
"pollingCode": "setInterval(() => checkUploadStatus(uploadId), 3000)"
}
}
}

788
docs/architecture.html Normal file
View File

@ -0,0 +1,788 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>O2O CastAD Backend - 인프라 아키텍처</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #232733;
--border: #2e3345;
--text: #e1e4ed;
--text-dim: #8b90a0;
--accent: #6c8cff;
--accent2: #a78bfa;
--green: #34d399;
--orange: #fb923c;
--red: #f87171;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.8;
}
/* 네비게이션 */
nav {
position: fixed;
top: 0;
width: 100%;
background: var(--surface);
border-bottom: 1px solid var(--border);
z-index: 100;
display: flex;
align-items: center;
padding: 0 40px;
height: 60px;
gap: 40px;
}
nav .logo {
font-size: 1.1rem;
font-weight: 700;
color: var(--accent);
white-space: nowrap;
}
nav a {
color: var(--text-dim);
text-decoration: none;
font-size: 0.9rem;
transition: color 0.2s;
}
nav a:hover { color: var(--accent); }
/* 메인 */
main {
max-width: 1400px;
margin: 0 auto;
padding: 100px 32px 80px;
}
.hero {
text-align: center;
margin-bottom: 60px;
}
.hero h1 {
font-size: 2.2rem;
margin-bottom: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero p {
color: var(--text-dim);
font-size: 1rem;
}
/* 섹션 */
section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 36px;
margin-bottom: 40px;
}
.section-header {
margin-bottom: 24px;
}
.section-header h2 {
font-size: 1.4rem;
margin-bottom: 4px;
color: var(--accent);
}
.section-header h2 .num {
display: inline-block;
background: var(--accent);
color: #fff;
width: 28px;
height: 28px;
border-radius: 50%;
text-align: center;
line-height: 28px;
font-size: 0.85rem;
margin-right: 8px;
}
.section-header .desc {
color: var(--text-dim);
font-size: 0.95rem;
}
/* 좌우 2컬럼 (테이블/텍스트용) */
.cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
align-items: start;
}
.col { min-width: 0; }
/* 다이어그램 - 항상 풀 와이드, 아래 배치 */
.diagram-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 32px;
margin-top: 28px;
overflow-x: auto;
}
.diagram-box .mermaid {
display: flex;
justify-content: center;
}
.diagram-box .mermaid svg {
max-width: 100%;
height: auto;
min-height: 300px;
}
.diagram-label {
text-align: center;
font-size: 0.85rem;
color: var(--text-dim);
margin-top: 12px;
}
/* 서브 타이틀 */
h3 {
font-size: 1rem;
color: var(--text);
margin: 20px 0 10px;
}
h3:first-child { margin-top: 0; }
/* 테이블 */
table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 0.88rem;
}
th, td {
padding: 9px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th {
background: var(--surface2);
color: var(--accent);
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
white-space: nowrap;
}
td { color: var(--text-dim); }
/* 태그 */
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
}
.tag-green { background: rgba(52,211,153,0.15); color: var(--green); }
.tag-orange { background: rgba(251,146,60,0.15); color: var(--orange); }
.tag-red { background: rgba(248,113,113,0.15); color: var(--red); }
/* 비용 카드 */
.cost-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin: 16px 0 24px;
}
.cost-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
text-align: center;
}
.cost-card .stage {
font-size: 0.85rem;
color: var(--text-dim);
margin-bottom: 4px;
}
.cost-card .amount {
font-size: 1.4rem;
font-weight: 700;
}
.cost-card .krw {
font-size: 0.85rem;
color: var(--text-dim);
margin-top: 2px;
}
.cost-card.s1 { border-top: 3px solid var(--green); }
.cost-card.s1 .amount { color: var(--green); }
.cost-card.s2 { border-top: 3px solid var(--orange); }
.cost-card.s2 .amount { color: var(--orange); }
.cost-card.s3 { border-top: 3px solid var(--red); }
.cost-card.s3 .amount { color: var(--red); }
/* 리스트 */
ul {
padding-left: 18px;
margin: 10px 0;
}
ul li {
color: var(--text-dim);
margin-bottom: 5px;
font-size: 0.92rem;
}
ul li strong { color: var(--text); }
/* 노트 */
.note {
background: rgba(108,140,255,0.08);
border-left: 3px solid var(--accent);
padding: 12px 16px;
border-radius: 0 8px 8px 0;
font-size: 0.88rem;
color: var(--text-dim);
margin-top: 20px;
}
code {
background: var(--surface2);
border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 4px;
font-size: 0.84rem;
color: var(--green);
}
/* PDF 다운로드 버튼 */
.btn-pdf {
margin-left: auto;
padding: 6px 16px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.btn-pdf:hover { background: #5a7ae6; }
/* 반응형 */
@media (max-width: 1024px) {
main { padding: 80px 20px 60px; }
.cols { grid-template-columns: 1fr; }
.cost-cards { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
nav { padding: 0 16px; gap: 16px; }
nav a { font-size: 0.8rem; }
section { padding: 20px; }
.hero h1 { font-size: 1.6rem; }
table { font-size: 0.8rem; }
th, td { padding: 6px 8px; }
.diagram-box { padding: 16px; }
}
/* 인쇄 / PDF 저장 — 화면과 동일한 다크 테마 유지 */
@media print {
@page {
size: A3 landscape;
margin: 10mm;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
nav { display: none !important; }
.btn-pdf { display: none !important; }
main {
max-width: 100% !important;
padding: 10px !important;
}
section {
break-inside: auto;
page-break-inside: auto;
}
.diagram-box {
break-inside: auto;
page-break-inside: auto;
}
.hero {
margin-bottom: 20px;
}
section {
margin-bottom: 20px;
padding: 20px;
}
.cols {
grid-template-columns: 1fr 1fr !important;
}
.cost-cards {
grid-template-columns: repeat(3, 1fr) !important;
}
}
</style>
</head>
<body>
<nav>
<div class="logo">O2O CastAD</div>
<a href="#load-balancing">부하 분산</a>
<a href="#architecture">아키텍처</a>
<a href="#cost">비용 산출</a>
<button class="btn-pdf" onclick="downloadPDF()">PDF 다운로드</button>
</nav>
<main>
<div class="hero">
<h1>O2O CastAD Backend</h1>
<p>인프라 아키텍처 및 비용 산출 문서</p>
</div>
<!-- ==================== 1. 부하 분산 ==================== -->
<section id="load-balancing">
<div class="section-header">
<h2><span class="num">1</span>DB 및 서버 부하 분산 방법</h2>
<p class="desc">Nginx 로드밸런싱, 커넥션 풀 관리, 단계별 수평 확장 전략</p>
</div>
<div class="cols">
<div class="col">
<h3>현재 구현 현황 (단일 인스턴스)</h3>
<ul>
<li><strong>API 커넥션 풀</strong>: pool_size=20, max_overflow=20 → 최대 <code>40</code></li>
<li><strong>백그라운드 풀</strong>: pool_size=10, max_overflow=10 → 최대 <code>20</code></li>
<li><strong>인스턴스당 총 DB 연결</strong>: <code>40 + 20 = 60</code></li>
<li><strong>풀 리사이클</strong>: 280초 (MySQL wait_timeout 300초 이전), pre-ping 활성화</li>
</ul>
<h3>단계별 확장 전략</h3>
<table>
<thead>
<tr>
<th>단계</th>
<th>동시접속</th>
<th>App Server</th>
<th>LB</th>
<th>DB ( MySQL Flexible)</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="tag tag-green">S1</span></td>
<td>~50명</td>
<td>x1</td>
<td>Nginx x1</td>
<td>Burstable B1ms</td>
</tr>
<tr>
<td><span class="tag tag-orange">S2</span></td>
<td>50~200명</td>
<td>x2~4</td>
<td>Nginx</td>
<td>GP D2ds_v4 + Replica x1</td>
</tr>
<tr>
<td><span class="tag tag-red">S3</span></td>
<td>200~1,000명</td>
<td><span style="font-size:0.75rem; line-height:1.4;">API ServerxN<br/>+ Scheduler</span></td>
<td>Nginx</td>
<td>BC D4ds_v4 + Replica x2 + Redis P1</td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<h3>커넥션 풀 수치 계산</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Stage 1 (1대)</th>
<th>Stage 2 (4대)</th>
<th>Stage 3 (8대)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Main Pool / 인스턴스</strong></td>
<td>20+20 = 40</td>
<td>10+10 = 20</td>
<td>5+5 = 10</td>
</tr>
<tr>
<td><strong>BG Pool / 인스턴스</strong></td>
<td>10+10 = 20</td>
<td>5+5 = 10</td>
<td>3+3 = 6</td>
</tr>
<tr>
<td><strong>인스턴스당 소계</strong></td>
<td><code>60</code></td>
<td><code>30</code></td>
<td><code>16</code></td>
</tr>
<tr>
<td><strong>Primary 총 연결</strong></td>
<td>60</td>
<td>4 x 30 = <code>120</code></td>
<td>8 x 16 = <code>128</code></td>
</tr>
<tr>
<td><strong>max_connections 권장</strong></td>
<td>100</td>
<td>200</td>
<td>300</td>
</tr>
</tbody>
</table>
<div class="note" style="margin-top: 16px;">
<strong>핵심:</strong>
JWT Stateless 설계로 Nginx 세션 어피니티 불필요 (round-robin / least_conn).
Stage 2부터 Read Replica로 읽기 분산, Redis는 Stage 3에서 캐싱/Rate Limiting 도입.
</div>
</div>
</div>
<!-- 다이어그램: 내용 아래 풀 와이드 -->
<div class="diagram-box">
<pre class="mermaid">
graph TB
subgraph S1["Stage 1: ~50명"]
direction LR
S1N["Nginx<br/>(Reverse Proxy)"] --> S1A["App Server x1"]
S1A --> S1D[" MySQL<br/>Burstable B1ms"]
end
subgraph S2["Stage 2: 50~200명"]
direction LR
S2N["Nginx<br/>(Reverse Proxy)"] --> S2API["APP Server<br/>x 1 ~ 2"]
S2N --> S2WK["Scheduler<br/>Server"]
S2API --> S2P["MySQL BC<br/>Primary<br/>(D4ds_v4)"]
S2API --> S2R1["Read Replica<br/>x1"]
S2WK --> S2P
S2WK --> S2R1
end
subgraph S3["Stage 3: 200~1,000명"]
direction LR
S3N["Nginx<br/>(Reverse Proxy)"] --> S3API["APP Server<br/>x N"]
S3N --> S3WK["Scheduler<br/>Server"]
S3API --> S3P["MySQL BC<br/>Primary<br/>(D4ds_v4)"]
S3API --> S3R1["Read Replica<br/>xN"]
S3API --> S3RD["Redis<br/>Premium P1"]
S3WK --> S3P
S3WK --> S3R1
end
S1 ~~~ S2 ~~~ S3
style S1 fill:#0d3320,stroke:#34d399,stroke-width:2px,color:#e1e4ed
style S2 fill:#3b2506,stroke:#fb923c,stroke-width:2px,color:#e1e4ed
style S3 fill:#3b1010,stroke:#f87171,stroke-width:2px,color:#e1e4ed
</pre>
</div>
</section>
<!-- ==================== 2. 아키텍처 ==================== -->
<section id="architecture">
<div class="section-header">
<h2><span class="num">2</span>전체 아키텍처 다이어그램</h2>
<p class="desc">Nginx + FastAPI App Server 구성과 외부 서비스 연동 구조</p>
</div>
<div class="cols">
<div class="col">
<ul>
<li><strong>로드밸런서</strong>: Nginx (Reverse Proxy, L7 LB, SSL 종단)</li>
<li><strong>App Server</strong>: FastAPI (Python 3.13) — Auth, Home, Lyric, Song, Video, Social, SNS, Archive, Admin, Background Worker</li>
<li><strong>DB</strong>: Database for MySQL Flexible Server — Stage 2+ Read Replica</li>
</ul>
</div>
<div class="col">
<ul>
<li><strong>캐시</strong>: Cache for Redis (Stage 3 도입)</li>
<li><strong>콘텐츠 생성</strong>: 가사(ChatGPT) → 음악(Suno AI) → 영상(Creatomate) → SNS 업로드</li>
<li><strong>외부 연동</strong>: Kakao OAuth, Naver Map/Search API, Blob Storage</li>
</ul>
</div>
</div>
<!-- 다이어그램: 내용 아래 풀 와이드 -->
<div class="diagram-box">
<pre class="mermaid">
graph TB
Client["클라이언트<br/>(Web / App)"]
LB["Nginx<br/>(Reverse Proxy + SSL 종단)"]
subgraph APP["App Server (FastAPI)"]
direction LR
Auth["Auth"] --- Home["Home"] --- Lyric["Lyric"] --- Song["Song"] --- Video["Video"]
Social["Social"] --- SNS["SNS"] --- Archive["Archive"] --- Admin["Admin"] --- BG["BG Worker"]
end
subgraph DB[" MySQL Flexible Server"]
direction LR
Primary["Primary (R/W)"]
Replica["Read Replica"]
end
subgraph AI["AI 콘텐츠 생성 파이프라인"]
direction LR
ChatGPT["ChatGPT<br/>(가사 생성)"]
Suno["Suno AI<br/>(음악 생성)"]
Creatomate["Creatomate<br/>(영상 생성)"]
ChatGPT --> Suno --> Creatomate
end
subgraph EXT["외부 서비스"]
direction LR
Blob[" Blob<br/>Storage"]
Kakao["Kakao<br/>OAuth"]
YT["YouTube /<br/>Instagram"]
Naver["Naver Map /<br/>Search API"]
end
Redis[" Cache for Redis<br/>(Stage 3 도입)"]
Client -->|HTTPS| LB
LB --> APP
APP --> Primary
APP -->|"읽기 전용"| Replica
APP -.->|"Stage 3"| Redis
APP --> AI
APP --> Blob
APP --> Kakao
APP --> YT
APP --> Naver
style Client fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed
style LB fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed
style APP fill:#1a2744,stroke:#6c8cff,stroke-width:2px,color:#e1e4ed
style DB fill:#2a1f00,stroke:#fb923c,stroke-width:2px,color:#e1e4ed
style AI fill:#2a0f2a,stroke:#a78bfa,stroke-width:2px,color:#e1e4ed
style EXT fill:#0d2a2a,stroke:#34d399,stroke-width:2px,color:#e1e4ed
style Redis fill:#3b1010,stroke:#f87171,stroke-width:1px,color:#e1e4ed
</pre>
</div>
<p class="diagram-label">전체 시스템 아키텍처 구성도</p>
<div class="note">
<strong>콘텐츠 생성 흐름:</strong> 사용자 요청 → Naver 크롤링 → ChatGPT 가사 생성 → Suno AI 음악 생성 → Creatomate 영상 생성 → Blob 저장 → YouTube/Instagram 업로드
</div>
</section>
<!-- ==================== 3. 비용 산출 ==================== -->
<section id="cost">
<div class="section-header">
<h2><span class="num">3</span>예상 리소스 및 비용</h2>
<p class="desc"> 기반 단계별 월 예상 비용 (인프라 + 외부 API)</p>
</div>
<div class="cost-cards">
<div class="cost-card s1">
<div class="stage">Stage 1 · 동시 ~50명</div>
<div class="amount">$170~390</div>
<div class="krw">약 22~51만원/월</div>
</div>
<div class="cost-card s2">
<div class="stage">Stage 2 · 동시 50~200명</div>
<div class="amount">$960~2,160</div>
<div class="krw">약 125~280만원/월</div>
</div>
<div class="cost-card s3">
<div class="stage">Stage 3 · 동시 200~1,000명</div>
<div class="amount">$3,850~8,500</div>
<div class="krw">약 500~1,100만원/월</div>
</div>
</div>
<div class="cols">
<div class="col">
<h3>항목별 비용 상세</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>Stage 1</th>
<th>Stage 2</th>
<th>Stage 3</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>App Server</strong></td>
<td>$50~70</td>
<td>$200~400</td>
<td>$600~1,000</td>
</tr>
<tr>
<td><strong>Nginx</strong></td>
<td>-</td>
<td>포함 / VM $15~30</td>
<td>VM $30~60</td>
</tr>
<tr>
<td><strong>MySQL Primary</strong></td>
<td>B1ms $15~25</td>
<td>GP $130~160</td>
<td>BC $350~450</td>
</tr>
<tr>
<td><strong>MySQL Replica</strong></td>
<td>-</td>
<td>GP x1 $130~160</td>
<td>BC x2 $260~360</td>
</tr>
<tr>
<td><strong>Redis</strong></td>
<td>-</td>
<td>-</td>
<td>P1 $225</td>
</tr>
<tr>
<td><strong>스토리지/네트워크</strong></td>
<td>$10~20</td>
<td>$55~100</td>
<td>$160~270</td>
</tr>
<tr>
<td><strong>AI API (합계)</strong></td>
<td>$90~280</td>
<td>$400~1,250</td>
<td>$2,100~5,800</td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<h3>DB 용량 예측 (1년 후)</h3>
<table>
<thead>
<tr>
<th></th>
<th>Stage 1 (500명)</th>
<th>Stage 2 (5,000명)</th>
<th>Stage 3 (50,000명)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>DB 용량</strong></td>
<td>~1.2GB</td>
<td>~12GB</td>
<td>~120GB</td>
</tr>
<tr>
<td><strong>Blob 스토리지</strong></td>
<td>~1.1TB</td>
<td>~11TB</td>
<td>~110TB</td>
</tr>
<tr>
<td><strong>MySQL 추천</strong></td>
<td>32GB SSD</td>
<td>128GB SSD</td>
<td>512GB SSD</td>
</tr>
</tbody>
</table>
<div class="note" style="margin-top: 16px;">
<strong>비용 최적화 팁:</strong>
3rd party 의존도 낮춰야함
<br>
Blob Lifecycle Policy (30일 미접근 → Cool 티어),
</div>
</div>
</div>
<!-- 다이어그램: 내용 아래 풀 와이드 -->
<div class="diagram-box">
<pre class="mermaid">
pie title Stage 3 월 비용 구성 비중
"App Server (APP+Scheduler)" : 800
"Nginx" : 45
"MySQL Primary" : 400
"MySQL Replica x2" : 310
"Redis Premium" : 225
"스토리지/네트워크" : 215
"OpenAI API" : 550
"Suno AI" : 1400
"Creatomate" : 2000
</pre>
</div>
<p class="diagram-label">Stage 3 월간 비용 구성 비율 — AI API 비중이 전체의 약 66%</p>
</section>
</main>
<script>
function downloadPDF() {
// Mermaid SVG가 렌더링된 후 인쇄
window.print();
}
mermaid.initialize({
startOnLoad: true,
theme: 'dark',
themeVariables: {
primaryColor: '#1a2744',
primaryTextColor: '#e1e4ed',
primaryBorderColor: '#6c8cff',
lineColor: '#6c8cff',
secondaryColor: '#232733',
tertiaryColor: '#2e3345',
pieSectionTextColor: '#e1e4ed',
pieLegendTextColor: '#e1e4ed',
pieTitleTextColor: '#e1e4ed',
pieStrokeColor: '#2e3345'
}
});
</script>
</body>
</html>

BIN
docs/architecture.pptx Normal file

Binary file not shown.

View File

@ -0,0 +1,58 @@
-- ===================================================================
-- social_upload 테이블 생성 마이그레이션
-- 소셜 미디어 업로드 기록을 저장하는 테이블
-- 생성일: 2026-02-02
-- ===================================================================
-- social_upload 테이블 생성
CREATE TABLE IF NOT EXISTS social_upload (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자',
-- 관계 필드
user_uuid VARCHAR(36) NOT NULL COMMENT '사용자 UUID (User.user_uuid 참조)',
video_id INT NOT NULL COMMENT 'Video 외래키',
social_account_id INT NOT NULL COMMENT 'SocialAccount 외래키',
-- 플랫폼 정보
platform VARCHAR(20) NOT NULL COMMENT '플랫폼 구분 (youtube, instagram, facebook, tiktok)',
-- 업로드 상태
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '업로드 상태 (pending, uploading, processing, completed, failed)',
upload_progress INT NOT NULL DEFAULT 0 COMMENT '업로드 진행률 (0-100)',
-- 플랫폼 결과
platform_video_id VARCHAR(100) NULL COMMENT '플랫폼에서 부여한 영상 ID',
platform_url VARCHAR(500) NULL COMMENT '플랫폼에서의 영상 URL',
-- 메타데이터
title VARCHAR(200) NOT NULL COMMENT '영상 제목',
description TEXT NULL COMMENT '영상 설명',
tags JSON NULL COMMENT '태그 목록 (JSON 배열)',
privacy_status VARCHAR(20) NOT NULL DEFAULT 'private' COMMENT '공개 상태 (public, unlisted, private)',
platform_options JSON NULL COMMENT '플랫폼별 추가 옵션 (JSON)',
-- 에러 정보
error_message TEXT NULL COMMENT '에러 메시지 (실패 시)',
retry_count INT NOT NULL DEFAULT 0 COMMENT '재시도 횟수',
-- 시간 정보
uploaded_at DATETIME NULL COMMENT '업로드 완료 시간',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 일시',
-- 외래키 제약조건
CONSTRAINT fk_social_upload_user FOREIGN KEY (user_uuid) REFERENCES user(user_uuid) ON DELETE CASCADE,
CONSTRAINT fk_social_upload_video FOREIGN KEY (video_id) REFERENCES video(id) ON DELETE CASCADE,
CONSTRAINT fk_social_upload_account FOREIGN KEY (social_account_id) REFERENCES social_account(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='소셜 미디어 업로드 기록 테이블';
-- 인덱스 생성
CREATE INDEX idx_social_upload_user_uuid ON social_upload(user_uuid);
CREATE INDEX idx_social_upload_video_id ON social_upload(video_id);
CREATE INDEX idx_social_upload_social_account_id ON social_upload(social_account_id);
CREATE INDEX idx_social_upload_platform ON social_upload(platform);
CREATE INDEX idx_social_upload_status ON social_upload(status);
CREATE INDEX idx_social_upload_created_at ON social_upload(created_at);
-- 유니크 인덱스 (동일 영상 + 동일 계정 조합은 하나만 존재)
CREATE UNIQUE INDEX uq_social_upload_video_platform ON social_upload(video_id, social_account_id);

View File

@ -0,0 +1,23 @@
-- ===================================================================
-- social_account 테이블에 connected_at, created_at 컬럼 추가 마이그레이션
-- 생성일: 2026-02-03
-- ===================================================================
-- 1. created_at 컬럼 추가 (없는 경우)
ALTER TABLE social_account
ADD COLUMN created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시';
-- 2. connected_at 컬럼 추가
ALTER TABLE social_account
ADD COLUMN connected_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '연결 일시' AFTER is_deleted;
-- 3. 기존 데이터의 connected_at을 created_at 또는 updated_at 값으로 설정
UPDATE social_account
SET connected_at = COALESCE(created_at, updated_at, NOW())
WHERE connected_at IS NULL;
-- ===================================================================
-- 확인 쿼리 (실행 후 검증용)
-- ===================================================================
-- DESCRIBE social_account;
-- SELECT id, platform, connected_at, created_at, updated_at FROM social_account;

View File

@ -0,0 +1,35 @@
-- ===================================================================
-- social_upload 테이블 수정 마이그레이션
-- 동일 영상 + 동일 채널 조합으로 여러 번 업로드 가능하도록 변경
-- 관리자 추적을 위한 upload_seq 컬럼 추가
-- 생성일: 2026-02-02
-- ===================================================================
-- 1. 기존 유니크 인덱스 제거
DROP INDEX uq_social_upload_video_platform ON social_upload;
-- 2. 업로드 순번 컬럼 추가 (관리자 추적용)
-- upload_seq: 동일 video_id + social_account_id 조합 내에서의 업로드 순번
ALTER TABLE social_upload
ADD COLUMN upload_seq INT NOT NULL DEFAULT 1 COMMENT '업로드 순번 (동일 영상+채널 조합 내 순번)' AFTER social_account_id;
-- 3. 추적을 위한 복합 인덱스 추가 (유니크 아님)
CREATE INDEX idx_social_upload_video_account ON social_upload(video_id, social_account_id);
-- 4. 순번 조회를 위한 인덱스 추가
CREATE INDEX idx_social_upload_seq ON social_upload(video_id, social_account_id, upload_seq);
-- ===================================================================
-- 확인 쿼리 (실행 후 검증용)
-- ===================================================================
-- 테이블 구조 확인
-- DESCRIBE social_upload;
-- 인덱스 확인
-- SHOW INDEX FROM social_upload;
-- 특정 영상의 업로드 이력 조회 예시
-- SELECT id, video_id, social_account_id, upload_seq, title, status, platform_url, created_at
-- FROM social_upload
-- WHERE video_id = 17
-- ORDER BY upload_seq DESC;

551
docs/generate_ppt.py Normal file
View File

@ -0,0 +1,551 @@
"""
O2O CastAD Backend - 인프라 아키텍처 PPT 생성 스크립트
실행: python3 docs/generate_ppt.py
출력: docs/architecture.pptx
"""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
# ── 색상 팔레트 (HTML 다크 테마 매칭) ──
BG = RGBColor(0x0F, 0x11, 0x17)
SURFACE = RGBColor(0x1A, 0x1D, 0x27)
SURFACE2 = RGBColor(0x23, 0x27, 0x33)
BORDER = RGBColor(0x2E, 0x33, 0x45)
TEXT = RGBColor(0xE1, 0xE4, 0xED)
TEXT_DIM = RGBColor(0x8B, 0x90, 0xA0)
ACCENT = RGBColor(0x6C, 0x8C, 0xFF)
ACCENT2 = RGBColor(0xA7, 0x8B, 0xFA)
GREEN = RGBColor(0x34, 0xD3, 0x99)
ORANGE = RGBColor(0xFB, 0x92, 0x3C)
RED = RGBColor(0xF8, 0x71, 0x71)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)
def set_slide_bg(slide, color):
bg = slide.background
fill = bg.fill
fill.solid()
fill.fore_color.rgb = color
def add_textbox(slide, left, top, width, height, text, font_size=14,
color=TEXT, bold=False, alignment=PP_ALIGN.LEFT, font_name="맑은 고딕"):
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.color.rgb = color
p.font.bold = bold
p.font.name = font_name
p.alignment = alignment
return txBox
def add_bullet_list(slide, left, top, width, height, items, font_size=13):
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
for i, item in enumerate(items):
if i == 0:
p = tf.paragraphs[0]
else:
p = tf.add_paragraph()
p.space_after = Pt(4)
p.font.size = Pt(font_size)
p.font.color.rgb = TEXT_DIM
p.font.name = "맑은 고딕"
# bold 부분 처리
if isinstance(item, tuple):
run_bold = p.add_run()
run_bold.text = item[0]
run_bold.font.bold = True
run_bold.font.color.rgb = TEXT
run_bold.font.size = Pt(font_size)
run_bold.font.name = "맑은 고딕"
run_normal = p.add_run()
run_normal.text = item[1]
run_normal.font.color.rgb = TEXT_DIM
run_normal.font.size = Pt(font_size)
run_normal.font.name = "맑은 고딕"
else:
p.text = f"{item}"
return txBox
def add_table(slide, left, top, width, height, headers, rows, col_widths=None):
n_rows = len(rows) + 1
n_cols = len(headers)
table_shape = slide.shapes.add_table(n_rows, n_cols, left, top, width, height)
table = table_shape.table
# 컬럼 폭 설정
if col_widths:
for i, w in enumerate(col_widths):
table.columns[i].width = w
# 헤더 행
for j, h in enumerate(headers):
cell = table.cell(0, j)
cell.text = h
for paragraph in cell.text_frame.paragraphs:
paragraph.font.size = Pt(11)
paragraph.font.bold = True
paragraph.font.color.rgb = ACCENT
paragraph.font.name = "맑은 고딕"
paragraph.alignment = PP_ALIGN.CENTER
cell.fill.solid()
cell.fill.fore_color.rgb = SURFACE2
# 데이터 행
for i, row in enumerate(rows):
for j, val in enumerate(row):
cell = table.cell(i + 1, j)
cell.text = str(val)
for paragraph in cell.text_frame.paragraphs:
paragraph.font.size = Pt(10)
paragraph.font.color.rgb = TEXT_DIM
paragraph.font.name = "맑은 고딕"
paragraph.alignment = PP_ALIGN.CENTER
cell.fill.solid()
cell.fill.fore_color.rgb = SURFACE if i % 2 == 0 else BG
# 테이블 테두리 제거 (깔끔하게)
for i in range(n_rows):
for j in range(n_cols):
cell = table.cell(i, j)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
for border_name in ['top', 'bottom', 'left', 'right']:
border = getattr(cell, f'border_{border_name}' if hasattr(cell, f'border_{border_name}') else border_name, None)
return table_shape
def add_rounded_rect(slide, left, top, width, height, fill_color, border_color=None, text="",
font_size=12, text_color=TEXT, bold=False):
shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height)
shape.fill.solid()
shape.fill.fore_color.rgb = fill_color
if border_color:
shape.line.color.rgb = border_color
shape.line.width = Pt(1.5)
else:
shape.line.fill.background()
if text:
tf = shape.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = text
p.font.size = Pt(font_size)
p.font.color.rgb = text_color
p.font.bold = bold
p.font.name = "맑은 고딕"
p.alignment = PP_ALIGN.CENTER
tf.paragraphs[0].space_before = Pt(0)
tf.paragraphs[0].space_after = Pt(0)
return shape
def add_section_number(slide, left, top, number, color=ACCENT):
shape = slide.shapes.add_shape(MSO_SHAPE.OVAL, left, top, Inches(0.4), Inches(0.4))
shape.fill.solid()
shape.fill.fore_color.rgb = color
shape.line.fill.background()
tf = shape.text_frame
p = tf.paragraphs[0]
p.text = str(number)
p.font.size = Pt(14)
p.font.color.rgb = WHITE
p.font.bold = True
p.font.name = "맑은 고딕"
p.alignment = PP_ALIGN.CENTER
return shape
def add_arrow(slide, x1, y1, x2, y2, color=ACCENT):
connector = slide.shapes.add_connector(1, x1, y1, x2, y2) # 1 = straight
connector.line.color.rgb = color
connector.line.width = Pt(1.5)
connector.end_x = x2
connector.end_y = y2
return connector
# ══════════════════════════════════════════════════════════════════
# PPT 생성 시작
# ══════════════════════════════════════════════════════════════════
prs = Presentation()
prs.slide_width = SLIDE_W
prs.slide_height = SLIDE_H
blank_layout = prs.slide_layouts[6] # Blank
# ── Slide 1: 타이틀 ──
slide = prs.slides.add_slide(blank_layout)
set_slide_bg(slide, BG)
add_textbox(slide, Inches(0), Inches(2.2), SLIDE_W, Inches(1),
"O2O CastAD Backend", font_size=44, color=ACCENT, bold=True,
alignment=PP_ALIGN.CENTER)
add_textbox(slide, Inches(0), Inches(3.3), SLIDE_W, Inches(0.6),
"인프라 아키텍처 및 비용 산출 문서", font_size=20, color=TEXT_DIM,
alignment=PP_ALIGN.CENTER)
# 하단 구분선
line = slide.shapes.add_connector(1, Inches(4.5), Inches(4.2), Inches(8.8), Inches(4.2))
line.line.color.rgb = ACCENT
line.line.width = Pt(2)
add_textbox(slide, Inches(0), Inches(4.5), SLIDE_W, Inches(0.5),
"Nginx + FastAPI + MySQL + AI Pipeline", font_size=14, color=TEXT_DIM,
alignment=PP_ALIGN.CENTER)
# ── Slide 2: 부하 분산 - 현재 구현 & 확장 전략 ──
slide = prs.slides.add_slide(blank_layout)
set_slide_bg(slide, BG)
add_section_number(slide, Inches(0.5), Inches(0.4), "1")
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
"DB 및 서버 부하 분산 방법", font_size=26, color=ACCENT, bold=True)
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
"Nginx 로드밸런싱, 커넥션 풀 관리, 단계별 수평 확장 전략", font_size=12, color=TEXT_DIM)
# 좌측: 현재 구현 현황
add_textbox(slide, Inches(0.5), Inches(1.5), Inches(5), Inches(0.4),
"현재 구현 현황 (단일 인스턴스)", font_size=16, color=TEXT, bold=True)
items = [
("API 커넥션 풀: ", "pool_size=20, max_overflow=20 → 최대 40"),
("백그라운드 풀: ", "pool_size=10, max_overflow=10 → 최대 20"),
("인스턴스당 총 DB 연결: ", "40 + 20 = 60"),
("풀 리사이클: ", "280초 (MySQL wait_timeout 300초 이전)"),
("Pre-ping: ", "활성화 (죽은 커넥션 자동 복구)"),
]
add_bullet_list(slide, Inches(0.5), Inches(2.0), Inches(5.5), Inches(2.5), items, font_size=12)
# 우측: 확장 전략 테이블
add_textbox(slide, Inches(6.8), Inches(1.5), Inches(6), Inches(0.4),
"단계별 확장 전략", font_size=16, color=TEXT, bold=True)
headers = ["단계", "동시접속", "App Server", "LB", "DB (MySQL Flexible)"]
rows = [
["S1", "~50명", "x1", "Nginx x1", "Burstable B1ms"],
["S2", "50~200명", "x2~4", "Nginx", "GP D2ds + Replica x1"],
["S3", "200~1,000명", "API xN\n+ Scheduler", "Nginx", "BC D4ds + Replica x2\n+ Redis P1"],
]
add_table(slide, Inches(6.8), Inches(2.0), Inches(6), Inches(2.0), headers, rows)
# 하단: 핵심 노트
note_shape = add_rounded_rect(slide, Inches(0.5), Inches(4.6), Inches(12.3), Inches(0.7),
SURFACE2, ACCENT)
tf = note_shape.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = ""
run = p.add_run()
run.text = "핵심: "
run.font.bold = True
run.font.color.rgb = ACCENT
run.font.size = Pt(11)
run.font.name = "맑은 고딕"
run = p.add_run()
run.text = "JWT Stateless 설계로 Nginx 세션 어피니티 불필요 (round-robin / least_conn). Stage 2부터 Read Replica로 읽기 분산, Redis는 Stage 3에서 캐싱/Rate Limiting 도입."
run.font.color.rgb = TEXT_DIM
run.font.size = Pt(11)
run.font.name = "맑은 고딕"
# ── Slide 3: 커넥션 풀 수치 계산 ──
slide = prs.slides.add_slide(blank_layout)
set_slide_bg(slide, BG)
add_section_number(slide, Inches(0.5), Inches(0.4), "1")
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
"커넥션 풀 수치 계산", font_size=26, color=ACCENT, bold=True)
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
"인스턴스 수 증가에 따른 인스턴스당 풀 사이즈 축소 및 총 DB 커넥션 관리", font_size=12, color=TEXT_DIM)
headers = ["항목", "Stage 1 (1대)", "Stage 2 (4대)", "Stage 3 (8대)"]
rows = [
["Main Pool / 인스턴스", "20+20 = 40", "10+10 = 20", "5+5 = 10"],
["BG Pool / 인스턴스", "10+10 = 20", "5+5 = 10", "3+3 = 6"],
["인스턴스당 소계", "60", "30", "16"],
["Primary 총 연결", "60", "4 x 30 = 120", "8 x 16 = 128"],
["max_connections 권장", "100", "200", "300"],
]
add_table(slide, Inches(1.5), Inches(1.6), Inches(10.3), Inches(2.8), headers, rows)
# 시각적 요약 박스
stages = [
("Stage 1", "1대 × 60 = 60", GREEN, Inches(2)),
("Stage 2", "4대 × 30 = 120", ORANGE, Inches(5.5)),
("Stage 3", "8대 × 16 = 128", RED, Inches(9)),
]
for label, val, color, left in stages:
add_rounded_rect(slide, left, Inches(4.8), Inches(2.3), Inches(0.9),
SURFACE2, color, f"{label}\n{val}", font_size=13, text_color=color, bold=True)
# ── Slide 4: 아키텍처 다이어그램 (상세 블록) ──
slide = prs.slides.add_slide(blank_layout)
set_slide_bg(slide, BG)
add_section_number(slide, Inches(0.5), Inches(0.4), "2")
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
"전체 아키텍처 다이어그램", font_size=26, color=ACCENT, bold=True)
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
"Nginx + FastAPI App Server 구성과 외부 서비스 연동 구조", font_size=12, color=TEXT_DIM)
# 클라이언트
add_rounded_rect(slide, Inches(5.5), Inches(1.4), Inches(2.3), Inches(0.6),
RGBColor(0x1A, 0x3A, 0x1A), GREEN, "클라이언트 (Web / App)",
font_size=11, text_color=TEXT)
# Nginx
add_rounded_rect(slide, Inches(5.5), Inches(2.3), Inches(2.3), Inches(0.6),
RGBColor(0x1A, 0x3A, 0x1A), GREEN, "Nginx\n(Reverse Proxy + SSL)",
font_size=10, text_color=TEXT)
# App Server
app_box = add_rounded_rect(slide, Inches(2.5), Inches(3.2), Inches(8.3), Inches(1.2),
RGBColor(0x1A, 0x27, 0x44), ACCENT, "", font_size=10)
add_textbox(slide, Inches(2.7), Inches(3.15), Inches(3), Inches(0.3),
"App Server (FastAPI)", font_size=11, color=ACCENT, bold=True)
modules = ["Auth", "Home", "Lyric", "Song", "Video", "Social", "SNS", "Archive", "Admin", "BG Worker"]
for i, mod in enumerate(modules):
col = i % 10
x = Inches(2.7 + col * 0.8)
y = Inches(3.55)
add_rounded_rect(slide, x, y, Inches(0.7), Inches(0.5),
SURFACE2, BORDER, mod, font_size=8, text_color=TEXT_DIM)
# DB
add_rounded_rect(slide, Inches(1.0), Inches(4.85), Inches(3.5), Inches(0.7),
RGBColor(0x2A, 0x1F, 0x00), ORANGE, "MySQL Flexible Server\nPrimary (R/W) + Read Replica",
font_size=10, text_color=TEXT)
# Redis
add_rounded_rect(slide, Inches(5.0), Inches(4.85), Inches(2.3), Inches(0.7),
RGBColor(0x3B, 0x10, 0x10), RED, "Cache for Redis\n(Stage 3 도입)",
font_size=10, text_color=TEXT)
# AI Pipeline
add_rounded_rect(slide, Inches(7.8), Inches(4.85), Inches(4.5), Inches(0.7),
RGBColor(0x2A, 0x0F, 0x2A), ACCENT2, "AI Pipeline: ChatGPT → Suno AI → Creatomate",
font_size=10, text_color=TEXT)
# 외부 서비스
ext_items = [("Blob\nStorage", Inches(1.0)), ("Kakao\nOAuth", Inches(3.2)),
("YouTube /\nInstagram", Inches(5.4)), ("Naver Map /\nSearch", Inches(7.6))]
for label, x in ext_items:
add_rounded_rect(slide, x, Inches(6.0), Inches(1.8), Inches(0.7),
RGBColor(0x0D, 0x2A, 0x2A), GREEN, label,
font_size=9, text_color=TEXT_DIM)
# 콘텐츠 생성 흐름 노트
note_shape = add_rounded_rect(slide, Inches(1.0), Inches(6.85), Inches(11.3), Inches(0.45),
SURFACE2, ACCENT)
tf = note_shape.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
run = p.add_run()
run.text = "콘텐츠 생성 흐름: "
run.font.bold = True
run.font.color.rgb = ACCENT
run.font.size = Pt(10)
run.font.name = "맑은 고딕"
run = p.add_run()
run.text = "사용자 요청 → Naver 크롤링 → ChatGPT 가사 → Suno AI 음악 → Creatomate 영상 → Blob 저장 → YouTube/Instagram 업로드"
run.font.color.rgb = TEXT_DIM
run.font.size = Pt(10)
run.font.name = "맑은 고딕"
# ── Slide 5: Stage별 인프라 스케일링 ──
slide = prs.slides.add_slide(blank_layout)
set_slide_bg(slide, BG)
add_section_number(slide, Inches(0.5), Inches(0.4), "2")
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
"단계별 인프라 스케일링", font_size=26, color=ACCENT, bold=True)
# Stage 1
stage1_box = add_rounded_rect(slide, Inches(0.5), Inches(1.3), Inches(3.8), Inches(5.2),
RGBColor(0x0D, 0x33, 0x20), GREEN)
add_textbox(slide, Inches(0.7), Inches(1.4), Inches(3.4), Inches(0.3),
"Stage 1: ~50명", font_size=16, color=GREEN, bold=True)
items = [
("Nginx: ", "Reverse Proxy x1"),
("App Server: ", "x1"),
("MySQL: ", "Burstable B1ms"),
("Redis: ", "미사용"),
("월 비용: ", "$170~390"),
]
add_bullet_list(slide, Inches(0.7), Inches(1.9), Inches(3.4), Inches(3), items, font_size=12)
# Stage 2
stage2_box = add_rounded_rect(slide, Inches(4.7), Inches(1.3), Inches(3.8), Inches(5.2),
RGBColor(0x3B, 0x25, 0x06), ORANGE)
add_textbox(slide, Inches(4.9), Inches(1.4), Inches(3.4), Inches(0.3),
"Stage 2: 50~200명", font_size=16, color=ORANGE, bold=True)
items = [
("Nginx: ", "Reverse Proxy (LB)"),
("App Server: ", "x2~4"),
("Scheduler: ", "Server x1"),
("MySQL: ", "GP D2ds + Replica x1"),
("Redis: ", "미사용"),
("월 비용: ", "$960~2,160"),
]
add_bullet_list(slide, Inches(4.9), Inches(1.9), Inches(3.4), Inches(3.5), items, font_size=12)
# Stage 3
stage3_box = add_rounded_rect(slide, Inches(8.9), Inches(1.3), Inches(3.8), Inches(5.2),
RGBColor(0x3B, 0x10, 0x10), RED)
add_textbox(slide, Inches(9.1), Inches(1.4), Inches(3.4), Inches(0.3),
"Stage 3: 200~1,000명", font_size=16, color=RED, bold=True)
items = [
("Nginx: ", "Reverse Proxy (LB)"),
("API Server: ", "x N (Auto Scale)"),
("Scheduler: ", "Server x1"),
("MySQL: ", "BC D4ds + Replica x2"),
("Redis: ", "Premium P1 (캐싱)"),
("월 비용: ", "$3,850~8,500"),
]
add_bullet_list(slide, Inches(9.1), Inches(1.9), Inches(3.4), Inches(3.5), items, font_size=12)
# ── Slide 6: 비용 산출 개요 ──
slide = prs.slides.add_slide(blank_layout)
set_slide_bg(slide, BG)
add_section_number(slide, Inches(0.5), Inches(0.4), "3")
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
"예상 리소스 및 비용", font_size=26, color=ACCENT, bold=True)
add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3),
"단계별 월 예상 비용 (인프라 + 외부 API)", font_size=12, color=TEXT_DIM)
# 비용 카드 3개
cost_data = [
("Stage 1", "동시 ~50명", "$170~390", "약 22~51만원/월", GREEN),
("Stage 2", "동시 50~200명", "$960~2,160", "약 125~280만원/월", ORANGE),
("Stage 3", "동시 200~1,000명", "$3,850~8,500", "약 500~1,100만원/월", RED),
]
for i, (stage, users, usd, krw, color) in enumerate(cost_data):
x = Inches(1.0 + i * 3.9)
card = add_rounded_rect(slide, x, Inches(1.5), Inches(3.5), Inches(1.8), SURFACE2, color)
add_textbox(slide, x, Inches(1.6), Inches(3.5), Inches(0.3),
f"{stage} · {users}", font_size=12, color=TEXT_DIM, alignment=PP_ALIGN.CENTER)
add_textbox(slide, x, Inches(2.0), Inches(3.5), Inches(0.5),
usd, font_size=28, color=color, bold=True, alignment=PP_ALIGN.CENTER)
add_textbox(slide, x, Inches(2.6), Inches(3.5), Inches(0.3),
krw, font_size=12, color=TEXT_DIM, alignment=PP_ALIGN.CENTER)
# 항목별 비용 상세 테이블
add_textbox(slide, Inches(0.5), Inches(3.6), Inches(5), Inches(0.4),
"항목별 비용 상세", font_size=16, color=TEXT, bold=True)
headers = ["항목", "Stage 1", "Stage 2", "Stage 3"]
rows = [
["App Server", "$50~70", "$200~400", "$600~1,000"],
["Nginx", "포함", "포함 / VM $15~30", "VM $30~60"],
["MySQL Primary", "B1ms $15~25", "GP $130~160", "BC $350~450"],
["MySQL Replica", "-", "GP x1 $130~160", "BC x2 $260~360"],
["Redis", "-", "-", "P1 $225"],
["스토리지/네트워크", "$10~20", "$55~100", "$160~270"],
["AI API (합계)", "$90~280", "$400~1,250", "$2,100~5,800"],
]
add_table(slide, Inches(0.5), Inches(4.1), Inches(12.3), Inches(3.0), headers, rows)
# ── Slide 7: 비용 구성 비중 & DB 용량 ──
slide = prs.slides.add_slide(blank_layout)
set_slide_bg(slide, BG)
add_section_number(slide, Inches(0.5), Inches(0.4), "3")
add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5),
"Stage 3 비용 구성 & DB 용량", font_size=26, color=ACCENT, bold=True)
# 좌측: Stage 3 비용 구성 (수평 바 차트 시뮬레이션)
add_textbox(slide, Inches(0.5), Inches(1.2), Inches(5), Inches(0.4),
"Stage 3 월 비용 구성 비중", font_size=16, color=TEXT, bold=True)
cost_items = [
("Creatomate", 2000, RED),
("Suno AI", 1400, ORANGE),
("App Server", 800, ACCENT),
("OpenAI API", 550, ACCENT2),
("MySQL Primary", 400, ORANGE),
("MySQL Replica x2", 310, ORANGE),
("Redis Premium", 225, RED),
("스토리지/네트워크", 215, GREEN),
("Nginx", 45, GREEN),
]
total = sum(v for _, v, _ in cost_items)
max_bar_width = 5.0 # inches
y_start = Inches(1.7)
for i, (label, value, color) in enumerate(cost_items):
y = y_start + Emu(int(i * Inches(0.45)))
bar_w = Inches(max_bar_width * value / total)
# 바
bar = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(2.2), y, bar_w, Inches(0.32))
bar.fill.solid()
bar.fill.fore_color.rgb = color
bar.line.fill.background()
# 레이블
add_textbox(slide, Inches(0.5), y, Inches(1.6), Inches(0.32),
label, font_size=9, color=TEXT_DIM, alignment=PP_ALIGN.RIGHT)
# 값
pct = value / total * 100
val_x = Inches(2.3) + bar_w
add_textbox(slide, val_x, y, Inches(1.5), Inches(0.32),
f"${value:,} ({pct:.0f}%)", font_size=9, color=TEXT_DIM)
add_textbox(slide, Inches(0.5), y_start + Emu(int(len(cost_items) * Inches(0.45))), Inches(6), Inches(0.3),
f"AI API 비중: 전체의 약 66% (${(2000+1400+550):,} / ${total:,})",
font_size=11, color=ACCENT, bold=True)
# 우측: DB 용량 예측
add_textbox(slide, Inches(7.5), Inches(1.2), Inches(5), Inches(0.4),
"DB 용량 예측 (1년 후)", font_size=16, color=TEXT, bold=True)
headers = ["", "Stage 1\n(500명)", "Stage 2\n(5,000명)", "Stage 3\n(50,000명)"]
rows = [
["DB 용량", "~1.2GB", "~12GB", "~120GB"],
["Blob 스토리지", "~1.1TB", "~11TB", "~110TB"],
["MySQL 추천", "32GB SSD", "128GB SSD", "512GB SSD"],
]
add_table(slide, Inches(7.5), Inches(1.7), Inches(5.3), Inches(1.8), headers, rows)
# 비용 최적화 팁
add_textbox(slide, Inches(7.5), Inches(3.8), Inches(5), Inches(0.4),
"비용 최적화 팁", font_size=16, color=TEXT, bold=True)
items = [
("3rd party 의존도: ", "낮춰야 함 (AI API가 전체 비용의 66%)"),
("Blob Lifecycle: ", "30일 미접근 미디어 → Cool 티어 자동 이전"),
("App Server: ", "비활성 시간대(야간) 인스턴스 축소"),
("OpenAI Batch API: ", "비실시간 가사생성은 50% 절감 가능"),
("Reserved Instances: ", "1년 예약 시 ~30% 할인"),
]
add_bullet_list(slide, Inches(7.5), Inches(4.3), Inches(5.3), Inches(3), items, font_size=11)
# ── 저장 ──
output_path = "/Users/marineyang/Desktop/work/code/o2o-castad-backend/docs/architecture.pptx"
prs.save(output_path)
print(f"PPT 생성 완료: {output_path}")

226
docs/plan/timezone_plan.md Normal file
View File

@ -0,0 +1,226 @@
# 타임존 설정 작업 계획
## 개요
프로젝트 전체에 서울 타임존(Asia/Seoul, UTC+9)을 일관되게 적용합니다.
---
## 현재 상태
### 완료된 설정
| 구성 요소 | 상태 | 비고 |
|----------|------|------|
| **MySQL 데이터베이스** | ✅ 완료 | DB 생성 시 Asia/Seoul로 설정됨 |
| **config.py** | ✅ 완료 | `TIMEZONE = ZoneInfo("Asia/Seoul")` 설정됨 |
| **SQLAlchemy 모델** | ✅ 정상 | `server_default=func.now()` → DB 타임존(서울) 따름 |
### 문제점
- Python 코드에서 `datetime.now()` (naive) 또는 `datetime.now(timezone.utc)` (UTC) 혼용
- 타임존이 적용된 현재 시간을 얻는 공통 유틸리티 없음
---
## 수정 대상 파일
| 파일 | 라인 | 현재 코드 | 문제 |
|------|------|----------|------|
| `app/user/services/jwt.py` | 26, 51, 109 | `datetime.now()` | naive datetime |
| `app/user/services/auth.py` | 170, 225, 485, 510 | `datetime.now()` | naive datetime |
| `app/user/api/routers/v1/auth.py` | 437 | `datetime.now(timezone.utc)` | UTC 사용 (서울 아님) |
| `app/social/services.py` | 408, 448, 509, 562, 578 | `datetime.now()` | naive datetime |
| `app/social/worker/upload_task.py` | 73 | `datetime.now()` | naive datetime |
| `app/utils/logger.py` | 89, 119 | `datetime.today()` | naive datetime |
---
## 작업 단계
### 1단계: 타임존 유틸리티 생성 (신규 파일)
**파일**: `app/utils/timezone.py`
```python
"""
타임존 유틸리티
프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다.
모든 datetime.now() 호출은 이 모듈의 함수로 대체해야 합니다.
"""
from datetime import datetime
from config import TIMEZONE
def now() -> datetime:
"""
서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다.
Returns:
datetime: 서울 타임존이 적용된 현재 시간 (aware datetime)
Example:
>>> from app.utils.timezone import now
>>> current_time = now() # 2024-01-15 15:30:00+09:00
"""
return datetime.now(TIMEZONE)
def today_str(fmt: str = "%Y-%m-%d") -> str:
"""
서울 타임존 기준 오늘 날짜를 문자열로 반환합니다.
Args:
fmt: 날짜 포맷 (기본값: YYYY-MM-DD)
Returns:
str: 포맷된 날짜 문자열
Example:
>>> from app.utils.timezone import today_str
>>> today_str() # "2024-01-15"
>>> today_str("%Y/%m/%d") # "2024/01/15"
"""
return datetime.now(TIMEZONE).strftime(fmt)
```
### 2단계: 기존 코드 수정
모든 `datetime.now()`, `datetime.today()`, `datetime.now(timezone.utc)`를 타임존 유틸리티 함수로 교체합니다.
#### 2.1 app/user/services/jwt.py
```python
# Before
from datetime import datetime, timedelta
expire = datetime.now() + timedelta(...)
# After
from datetime import timedelta
from app.utils.timezone import now
expire = now() + timedelta(...)
```
#### 2.2 app/user/services/auth.py
```python
# Before
from datetime import datetime
user.last_login_at = datetime.now()
if db_token.expires_at < datetime.now():
revoked_at=datetime.now()
# After
from app.utils.timezone import now
user.last_login_at = now()
if db_token.expires_at < now():
revoked_at=now()
```
#### 2.3 app/user/api/routers/v1/auth.py
```python
# Before
from datetime import datetime, timezone
user.last_login_at = datetime.now(timezone.utc)
# After
from app.utils.timezone import now
user.last_login_at = now()
```
#### 2.4 app/social/services.py
```python
# Before
from datetime import datetime, timedelta
buffer_time = datetime.now() + timedelta(minutes=10)
account.token_expires_at = datetime.now() + timedelta(...)
account.connected_at = datetime.now()
# After
from datetime import timedelta
from app.utils.timezone import now
buffer_time = now() + timedelta(minutes=10)
account.token_expires_at = now() + timedelta(...)
account.connected_at = now()
```
#### 2.5 app/social/worker/upload_task.py
```python
# Before
from datetime import datetime
upload.uploaded_at = datetime.now()
# After
from app.utils.timezone import now
upload.uploaded_at = now()
```
#### 2.6 app/utils/logger.py
```python
# Before
from datetime import datetime
today = datetime.today().strftime("%Y-%m-%d")
# After
from app.utils.timezone import today_str
today = today_str()
```
> **참고**: logger.py에서는 **날짜만 사용하는 것이 맞습니다.**
> 로그 파일명이 `{날짜}_app.log`, `{날짜}_error.log` 형식이므로 일별 로그 파일 관리에 적합합니다.
> 시간까지 포함하면 매 시간/분마다 새 파일이 생성되어 로그 관리가 어려워집니다.
---
## 작업 체크리스트
| 순서 | 작업 | 파일 | 상태 |
|------|------|------|------|
| 1 | 타임존 유틸리티 생성 | `app/utils/timezone.py` | ✅ 완료 |
| 2 | JWT 서비스 수정 | `app/user/services/jwt.py` | ✅ 완료 |
| 3 | Auth 서비스 수정 | `app/user/services/auth.py` | ✅ 완료 |
| 4 | Auth 라우터 수정 | `app/user/api/routers/v1/auth.py` | ✅ 완료 |
| 5 | Social 서비스 수정 | `app/social/services.py` | ✅ 완료 |
| 6 | Upload Task 수정 | `app/social/worker/upload_task.py` | ✅ 완료 |
| 7 | Logger 유틸리티 수정 | `app/utils/logger.py` | ✅ 완료 |
---
## 예상 작업 범위
- **신규 파일**: 1개 (`app/utils/timezone.py`)
- **수정 파일**: 6개
- **수정 위치**: 약 15곳
---
## 참고 사항
### naive datetime vs aware datetime
- **naive datetime**: 타임존 정보가 없는 datetime (예: `datetime.now()`)
- **aware datetime**: 타임존 정보가 있는 datetime (예: `datetime.now(TIMEZONE)`)
### 왜 서울 타임존을 사용하는가?
1. 데이터베이스가 Asia/Seoul로 설정됨
2. 서비스 대상 지역이 한국
3. Python 코드와 DB 간 시간 일관성 확보
### 주의사항
- 기존 DB에 저장된 시간 데이터는 이미 서울 시간이므로 마이그레이션 불필요
- JWT 토큰 만료 시간 비교 시 타임존 일관성 필수
- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일

60
main.py
View File

@ -19,6 +19,9 @@ from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router
from app.sns.api.routers.v1.sns import router as sns_router
from app.video.api.routers.v1.video import router as video_router
from app.social.api.routers.v1.oauth import router as social_oauth_router
from app.social.api.routers.v1.upload import router as social_upload_router
from app.social.api.routers.v1.seo import router as social_seo_router
from app.utils.cors import CustomCORSMiddleware
from config import prj_settings
@ -168,6 +171,55 @@ tags_metadata = [
- created_at 기준 내림차순 정렬됩니다.
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
""",
},
{
"name": "Social OAuth",
"description": """소셜 미디어 계정 연동 API
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 지원 플랫폼
- **YouTube**: Google OAuth 2.0 기반 연동
## 연동 흐름
1. `GET /social/oauth/{platform}/connect` - OAuth 인증 URL 획득
2. 사용자를 auth_url로 리다이렉트 플랫폼 로그인
3. 플랫폼에서 권한 승인 콜백 URL로 리다이렉트
4. 연동 완료 프론트엔드로 리다이렉트
## 계정 관리
- `GET /social/oauth/accounts` - 연동된 계정 목록 조회
- `DELETE /social/oauth/{platform}/disconnect` - 계정 연동 해제
""",
},
{
"name": "Social Upload",
"description": """소셜 미디어 영상 업로드 API
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
## 사전 조건
- 해당 플랫폼에 계정이 연동되어 있어야 합니다
- 영상이 completed 상태여야 합니다
## 업로드 흐름
1. `POST /social/upload` - 업로드 요청 (백그라운드 처리)
2. `GET /social/upload/{upload_id}/status` - 업로드 상태 폴링
3. `GET /social/upload/history` - 업로드 이력 조회
## 업로드 상태
- `pending`: 업로드 대기
- `uploading`: 업로드 진행
- `processing`: 플랫폼에서 처리
- `completed`: 업로드 완료
- `failed`: 업로드 실패
""",
},
{
@ -252,6 +304,7 @@ def custom_openapi():
"/crawling",
"/autocomplete",
"/search", # 숙박 검색 자동완성
"/social/oauth/youtube/callback", # OAuth 콜백 (플랫폼에서 직접 호출)
]
# 보안이 필요한 엔드포인트에 security 적용
@ -295,6 +348,10 @@ def get_scalar_docs():
)
# 예외 핸들러 등록
from app.core.exceptions import add_exception_handlers
add_exception_handlers(app)
app.include_router(home_router)
app.include_router(auth_router, prefix="/user") # Auth API 라우터 추가
app.include_router(social_account_router, prefix="/user") # Social Account API 라우터 추가
@ -302,6 +359,9 @@ app.include_router(lyric_router)
app.include_router(song_router)
app.include_router(video_router)
app.include_router(archive_router) # Archive API 라우터 추가
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
app.include_router(sns_router) # SNS API 라우터 추가
# DEBUG 모드에서만 테스트 라우터 등록

View File

@ -3,7 +3,7 @@ name = "o2o-castad-backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
requires-python = "==3.13.11"
dependencies = [
"aiofiles>=25.1.0",
"aiohttp>=3.13.2",
@ -15,6 +15,7 @@ dependencies = [
"openai>=2.13.0",
"playwright>=1.57.0",
"pydantic-settings>=2.12.0",
"python-dotenv>=1.0.0",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.21",
"redis>=7.1.0",

422
token_log_plan.md Normal file
View File

@ -0,0 +1,422 @@
# 서버 JWT 토큰 라이프사이클 로깅 강화 계획
## 1. 토큰 라이프사이클 개요
서버가 직접 발급/관리하는 JWT 토큰의 전체 흐름:
```
[발급] kakao_login() / generate_test_token()
├── Access Token 생성 (sub=user_uuid, type=access, exp=60분)
├── Refresh Token 생성 (sub=user_uuid, type=refresh, exp=7일)
└── Refresh Token DB 저장 (token_hash, user_id, expires_at)
[검증] get_current_user() — 매 요청마다 Access Token 검증
├── Bearer 헤더에서 토큰 추출
├── decode_token() → payload (sub, type, exp)
├── type == "access" 확인
└── user_uuid로 사용자 조회/활성 확인
[갱신] refresh_tokens() — Access Token 만료 시 Refresh Token으로 갱신
├── 기존 Refresh Token 디코딩 → payload
├── token_hash로 DB 조회 → is_revoked / expires_at 확인
├── 기존 Refresh Token 폐기 (is_revoked=True)
├── 새 Access Token + 새 Refresh Token 발급
└── 새 Refresh Token DB 저장
[폐기] logout() / logout_all()
├── 단일: token_hash로 해당 Refresh Token 폐기
└── 전체: user_id로 모든 Refresh Token 폐기
```
---
## 2. 현황 분석 — 로깅 공백 지점
### 발급 단계
| 위치 | 함수 | 현재 로깅 | 부족한 정보 |
|------|------|----------|------------|
| `jwt.py` | `create_access_token()` | 없음 | 발급 대상(user_uuid), 만료시간 |
| `jwt.py` | `create_refresh_token()` | 없음 | 발급 대상(user_uuid), 만료시간 |
| `auth.py` (service) | `_save_refresh_token()` | 없음 | DB 저장 결과(token_hash, expires_at) |
| `auth.py` (service) | `kakao_login()` | `debug`로 토큰 앞 30자 출력 | 충분 (변경 불필요) |
### 검증 단계
| 위치 | 함수 | 현재 로깅 | 부족한 정보 |
|------|------|----------|------------|
| `jwt.py` | `decode_token()` | 없음 | 디코딩 성공 시 payload 내용, 실패 시 원인 |
| `auth.py` (dependency) | `get_current_user()` | 없음 | 검증 각 단계 통과/실패 사유, 토큰 내 정보 |
| `auth.py` (dependency) | `get_current_user_optional()` | 없음 | 위와 동일 |
### 갱신 단계
| 위치 | 함수 | 현재 로깅 | 부족한 정보 |
|------|------|----------|------------|
| `auth.py` (router) | `refresh_token()` | 없음 | 수신 토큰 정보, 갱신 결과 |
| `auth.py` (service) | `refresh_tokens()` | 진입/완료 `info` 1줄씩 | 각 단계 실패 사유, DB 토큰 상태, 신규 토큰 정보 |
### 폐기 단계
| 위치 | 함수 | 현재 로깅 | 부족한 정보 |
|------|------|----------|------------|
| `auth.py` (router) | `logout()`, `logout_all()` | 없음 | 요청 수신, 대상 사용자 |
| `auth.py` (service) | `logout()`, `logout_all()` | 없음 | 폐기 대상, 폐기 결과 |
---
## 3. 수정 대상 파일
| # | 파일 | 수정 내용 |
|---|------|----------|
| 1 | `app/user/services/jwt.py` | 토큰 발급 로그 + `decode_token()` 실패 원인 분류 |
| 2 | `app/user/dependencies/auth.py` | Access Token 검증 과정 로깅 |
| 3 | `app/user/services/auth.py` | `refresh_tokens()`, `_save_refresh_token()`, `logout()`, `logout_all()` 로깅 |
| 4 | `app/user/api/routers/v1/auth.py` | `refresh_token()`, `logout()`, `logout_all()` 라우터 로깅 |
---
## 4. 상세 구현 계획
### 4-1. `jwt.py` — 토큰 발급 로그 + 디코딩 실패 원인 분류
**import 추가:**
```python
import logging
from jose import JWTError, ExpiredSignatureError, JWTClaimsError, jwt
logger = logging.getLogger(__name__)
```
**`create_access_token()` — 발급 로그 추가:**
```python
def create_access_token(user_uuid: str) -> str:
expire = now() + timedelta(minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"sub": user_uuid, "exp": expire, "type": "access"}
token = jwt.encode(to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM)
logger.debug(f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, expires: {expire}, token: ...{token[-20:]}")
return token
```
**`create_refresh_token()` — 발급 로그 추가:**
```python
def create_refresh_token(user_uuid: str) -> str:
expire = now() + timedelta(days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {"sub": user_uuid, "exp": expire, "type": "refresh"}
token = jwt.encode(to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM)
logger.debug(f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, expires: {expire}, token: ...{token[-20:]}")
return token
```
**`decode_token()` — 성공/실패 분류 로그:**
```python
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, jwt_settings.JWT_SECRET, algorithms=[jwt_settings.JWT_ALGORITHM])
logger.debug(
f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, "
f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, "
f"token: ...{token[-20:]}"
)
return payload
except ExpiredSignatureError:
logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}")
return None
except JWTClaimsError as e:
logger.warning(f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}")
return None
except JWTError as e:
logger.warning(f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, token: ...{token[-20:]}")
return None
```
### 4-2. `dependencies/auth.py` — Access Token 검증 로깅
**import 추가:**
```python
import logging
logger = logging.getLogger(__name__)
```
**`get_current_user()` — 검증 과정 로그:**
```python
async def get_current_user(...) -> User:
if credentials is None:
logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError")
raise MissingTokenError()
token = credentials.credentials
logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}")
payload = decode_token(token)
if payload is None:
logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}")
raise InvalidTokenError()
if payload.get("type") != "access":
logger.warning(f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, got: {payload.get('type')}, sub: {payload.get('sub')}")
raise InvalidTokenError("액세스 토큰이 아닙니다.")
user_uuid = payload.get("sub")
if user_uuid is None:
logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}")
raise InvalidTokenError()
# 사용자 조회
result = await session.execute(...)
user = result.scalar_one_or_none()
if user is None:
logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}")
raise UserNotFoundError()
if not user.is_active:
logger.warning(f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}")
raise UserInactiveError()
logger.debug(f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}")
return user
```
**`get_current_user_optional()` — 동일 패턴, `debug` 레벨:**
```python
async def get_current_user_optional(...) -> Optional[User]:
if credentials is None:
logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음")
return None
token = credentials.credentials
payload = decode_token(token)
if payload is None:
logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}")
return None
if payload.get("type") != "access":
logger.debug(f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})")
return None
user_uuid = payload.get("sub")
if user_uuid is None:
logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음")
return None
result = await session.execute(...)
user = result.scalar_one_or_none()
if user is None or not user.is_active:
logger.debug(f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}")
return None
logger.debug(f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}")
return user
```
### 4-3. `services/auth.py` — Refresh Token 갱신/폐기 로깅
**`refresh_tokens()` — 전체 흐름 로그:**
```python
async def refresh_tokens(self, refresh_token: str, session: AsyncSession) -> TokenResponse:
logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}")
# 1. 디코딩
payload = decode_token(refresh_token)
if payload is None:
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}")
raise InvalidTokenError()
if payload.get("type") != "refresh":
logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, sub: {payload.get('sub')}")
raise InvalidTokenError("리프레시 토큰이 아닙니다.")
logger.debug(f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, exp: {payload.get('exp')}")
# 2. DB 조회
token_hash = get_token_hash(refresh_token)
db_token = await self._get_refresh_token_by_hash(token_hash, session)
if db_token is None:
logger.warning(f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, token_hash: {token_hash[:16]}...")
raise InvalidTokenError()
logger.debug(f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, expires_at: {db_token.expires_at}")
# 3. 폐기 여부
if db_token.is_revoked:
logger.warning(f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, revoked_at: {db_token.revoked_at}")
raise TokenRevokedError()
# 4. 만료 확인
if db_token.expires_at < now().replace(tzinfo=None):
logger.info(f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, user_uuid: {db_token.user_uuid}")
raise TokenExpiredError()
# 5. 사용자 확인
user_uuid = payload.get("sub")
user = await self._get_user_by_uuid(user_uuid, session)
if user is None:
logger.warning(f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}")
raise UserNotFoundError()
if not user.is_active:
logger.warning(f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, user_id: {user.id}")
raise UserInactiveError()
# 6. 기존 토큰 폐기
db_token.is_revoked = True
db_token.revoked_at = now().replace(tzinfo=None)
logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...")
# 7. 새 토큰 발급
new_access_token = create_access_token(user.user_uuid)
new_refresh_token = create_refresh_token(user.user_uuid)
logger.debug(f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, new_refresh: ...{new_refresh_token[-20:]}")
# 8. 새 Refresh Token DB 저장 + 커밋
await self._save_refresh_token(user_id=user.id, user_uuid=user.user_uuid, token=new_refresh_token, session=session)
await session.commit()
logger.info(f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, user_id: {user.id}, old_hash: {token_hash[:16]}..., new_refresh: ...{new_refresh_token[-20:]}")
return TokenResponse(...)
```
**`_save_refresh_token()` — DB 저장 로그:**
```python
async def _save_refresh_token(self, ...) -> RefreshToken:
token_hash = get_token_hash(token)
expires_at = get_refresh_token_expires_at()
refresh_token = RefreshToken(...)
session.add(refresh_token)
await session.flush()
logger.debug(f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, token_hash: {token_hash[:16]}..., expires_at: {expires_at}")
return refresh_token
```
**`logout()` — 단일 로그아웃 로그:**
```python
async def logout(self, user_id: int, refresh_token: str, session: AsyncSession) -> None:
token_hash = get_token_hash(refresh_token)
logger.info(f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., token: ...{refresh_token[-20:]}")
await self._revoke_refresh_token_by_hash(token_hash, session)
logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}")
```
**`logout_all()` — 전체 로그아웃 로그:**
```python
async def logout_all(self, user_id: int, session: AsyncSession) -> None:
logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}")
await self._revoke_all_user_tokens(user_id, session)
logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}")
```
### 4-4. `routers/v1/auth.py` — 라우터 진입/완료 로깅
```python
# POST /auth/refresh
async def refresh_token(body, session) -> TokenResponse:
logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}")
result = await auth_service.refresh_tokens(refresh_token=body.refresh_token, session=session)
logger.info(f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, new_refresh: ...{result.refresh_token[-20:]}")
return result
# POST /auth/logout
async def logout(body, current_user, session) -> Response:
logger.info(f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}")
await auth_service.logout(user_id=current_user.id, refresh_token=body.refresh_token, session=session)
logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
# POST /auth/logout/all
async def logout_all(current_user, session) -> Response:
logger.info(f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, user_uuid: {current_user.user_uuid}")
await auth_service.logout_all(user_id=current_user.id, session=session)
logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}")
return Response(status_code=status.HTTP_204_NO_CONTENT)
```
---
## 5. 보안 원칙
| 원칙 | 적용 방법 | 이유 |
|------|----------|------|
| 토큰 전체 노출 금지 | 뒷 20자만: `...{token[-20:]}` | 토큰 탈취 시 세션 하이재킹 가능 |
| 해시값 부분 노출 | 앞 16자만: `{hash[:16]}...` | DB 레코드 식별에 충분 |
| user_uuid 전체 허용 | 전체 출력 | 내부 식별자, 토큰이 아님 |
| 페이로드 내용 출력 | `sub`, `type`, `exp` 출력 | 디버깅에 필수, 민감정보 아님 |
| DB 토큰 상태 출력 | `is_revoked`, `expires_at`, `revoked_at` | 토큰 라이프사이클 추적 |
| 로그 레벨 구분 | 하단 표 참조 | 운영 환경에서 불필요한 로그 억제 |
### 로그 레벨 기준
| 레벨 | 사용 기준 | 예시 |
|------|----------|------|
| `debug` | 정상 처리 과정 상세 (운영환경에서 비활성) | 토큰 발급, 디코딩 성공, 검증 통과 |
| `info` | 주요 이벤트 (운영환경에서 활성) | 갱신 시작/완료, 로그아웃, 만료로 인한 실패 |
| `warning` | 비정상/의심 상황 | 디코딩 실패, 폐기된 토큰 사용, 사용자 미존재 |
---
## 6. 구현 순서
| 순서 | 파일 | 이유 |
|------|------|------|
| 1 | `app/user/services/jwt.py` | 최하위 유틸리티. 토큰 발급/디코딩의 기본 로그 |
| 2 | `app/user/dependencies/auth.py` | 모든 인증 API의 공통 진입점 |
| 3 | `app/user/services/auth.py` | 갱신/폐기 비즈니스 로직 |
| 4 | `app/user/api/routers/v1/auth.py` | 라우터 진입/완료 + 응답 토큰 정보 |
---
## 7. 기대 효과 — 시나리오별 로그 출력 예시
### 시나리오 1: 정상 토큰 갱신
```
[ROUTER] POST /auth/refresh - token: ...7d90-aac8-ecf1385c
[AUTH] 토큰 갱신 시작 (Rotation) - token: ...7d90-aac8-ecf1385c
[JWT] 토큰 디코딩 성공 - type: refresh, sub: 019c5452-b1cf-7d90-aac8-ecf1385c9dc4, exp: 1739450400
[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: 019c5452-..., exp: 1739450400
[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: a1b2c3d4e5f6g7h8..., is_revoked: False, expires_at: 2026-02-20 11:46:36
[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: a1b2c3d4e5f6g7h8...
[JWT] Access Token 발급 - user_uuid: 019c5452-..., expires: 2026-02-13 12:46:36
[JWT] Refresh Token 발급 - user_uuid: 019c5452-..., expires: 2026-02-20 11:46:36
[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...xNewAccess12345, new_refresh: ...xNewRefresh6789
[AUTH] Refresh Token DB 저장 - user_uuid: 019c5452-..., token_hash: f8e9d0c1b2a3..., expires_at: 2026-02-20 11:46:36
[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: 019c5452-..., user_id: 42, old_hash: a1b2c3d4e5f6g7h8..., new_refresh: ...xNewRefresh6789
[ROUTER] POST /auth/refresh 완료 - new_access: ...xNewAccess12345, new_refresh: ...xNewRefresh6789
```
### 시나리오 2: 만료된 Refresh Token으로 갱신 시도
```
[ROUTER] POST /auth/refresh - token: ...expiredToken12345
[AUTH] 토큰 갱신 시작 (Rotation) - token: ...expiredToken12345
[JWT] 토큰 만료 - token: ...expiredToken12345
[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...expiredToken12345
→ 401 InvalidTokenError 응답
```
### 시나리오 3: 이미 폐기된 Refresh Token 재사용 (Replay Attack)
```
[ROUTER] POST /auth/refresh - token: ...revokedToken98765
[AUTH] 토큰 갱신 시작 (Rotation) - token: ...revokedToken98765
[JWT] 토큰 디코딩 성공 - type: refresh, sub: 019c5452-..., exp: 1739450400
[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: c3d4e5f6..., is_revoked: True, expires_at: 2026-02-20
[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - replay attack 의심, token_hash: c3d4e5f6..., user_uuid: 019c5452-..., revoked_at: 2026-02-13 10:30:00
→ 401 TokenRevokedError 응답
```
### 시나리오 4: Access Token 검증 (매 API 요청)
```
[AUTH-DEP] Access Token 검증 시작 - token: ...validAccess12345
[JWT] 토큰 디코딩 성공 - type: access, sub: 019c5452-..., exp: 1739450400
[AUTH-DEP] Access Token 검증 성공 - user_uuid: 019c5452-..., user_id: 42
```
### 시나리오 5: 로그아웃
```
[ROUTER] POST /auth/logout - user_id: 42, user_uuid: 019c5452-..., token: ...refreshToRevoke99
[AUTH] 로그아웃 - user_id: 42, token_hash: d5e6f7g8..., token: ...refreshToRevoke99
[AUTH] 로그아웃 완료 - user_id: 42
[ROUTER] POST /auth/logout 완료 - user_id: 42
```

771
uv.lock
View File

@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.14"
requires-python = "==3.13.11"
[[package]]
name = "aiofiles"
@ -35,40 +35,23 @@ dependencies = [
]
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/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" },
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
]
[[package]]
@ -130,6 +113,16 @@ name = "asyncmy"
version = "0.2.11"
source = { registry = "https://pypi.org/simple" }
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" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/9a/b5b77690f7287acb0a284319e85378c6f4063cd3617dd5311e00f332d628/asyncmy-0.2.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a48be02bdae5e5324ac2d142d7afc6dd9c6af546fd892804c9d8e58d8107980", size = 1727740, upload-time = "2026-01-15T11:32:07.443Z" },
{ url = "https://files.pythonhosted.org/packages/10/28/7b168dc84704edb0b60f7906bfba3a451fd90c0cb2443edbb377b1a11d20/asyncmy-0.2.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:babed99ea1cf7edb476dba23c27560b2a042de46e61678c0cfa3bc017e5f49e4", size = 1706138, upload-time = "2026-01-15T11:32:08.898Z" },
{ url = "https://files.pythonhosted.org/packages/ec/27/ac7363e8ab95f2048852851bbbef12d4eee62363d202d7e566291023ece4/asyncmy-0.2.11-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:709cc8147edad072b5176d87878a985323c87cc017c460073414f2b7d5ae9d01", size = 4942591, upload-time = "2026-01-15T11:32:10.127Z" },
{ url = "https://files.pythonhosted.org/packages/08/81/092314cc97e3732535804f2d3e1b966daeaa3a33a8e9a686328cf09498ad/asyncmy-0.2.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:373aecf8cd17662c13bab69dc36db7242be8e956242164469b8733886fb2ec0a", size = 5178039, upload-time = "2026-01-15T11:32:12.088Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9b/b884404bac62d9b6efbc9006c4b80ad55e8b0bb6f585b44eee1eceb07b1c/asyncmy-0.2.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e23ea478e6638e479dfab2674d2c39a21160c7d750d5c8cf2a0e205d947a63b7", size = 4987628, upload-time = "2026-01-15T11:32:13.979Z" },
{ url = "https://files.pythonhosted.org/packages/00/65/68e576aecd2a43d383123e3a66339e6a3535495b0e81443e374a3d3c356d/asyncmy-0.2.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:577272e238aff9b985eb880b49b1ba009e1fd1133b754fc71c833ab5bd9561ee", size = 5143375, upload-time = "2026-01-15T11:32:15.38Z" },
{ url = "https://files.pythonhosted.org/packages/4e/e4/cd30ea75ab96e5c6fe0daf6bd1871753fe5a1677515530fa0bc1a807dd6c/asyncmy-0.2.11-cp313-cp313-win32.whl", hash = "sha256:29536a08bf8c96437188ae4080fdd09c5a82cbe93794d0996cd0dd238f632664", size = 1559106, upload-time = "2026-01-15T11:32:16.895Z" },
{ url = "https://files.pythonhosted.org/packages/0c/3e/497e3ac839d7d18e79770b977f90e6f17a87181f95b8aed59359ff4aba0c/asyncmy-0.2.11-cp313-cp313-win_amd64.whl", hash = "sha256:f095af7b980505158609ca0bcdd0d14d1e48893e43fc1856c7cecfd9439f498c", size = 1635619, upload-time = "2026-01-15T11:32:18.241Z" },
]
[[package]]
name = "attrs"
@ -171,28 +164,18 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
{ 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" },
]
[[package]]
@ -240,21 +223,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
@ -387,36 +355,21 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/bf/02/3ba1267ee5ba7314e29c431cf82eaa68586f2c40cdfa08be3632b7d07619/fastar-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae0ff7c0a1c7e1428404b81faee8aebef466bfd0be25bfe4dabf5d535c68741", size = 764667, upload-time = "2025-11-26T02:32:49.606Z" },
{ url = "https://files.pythonhosted.org/packages/1b/84/bf33530fd015b5d7c2cc69e0bce4a38d736754a6955487005aab1af6adcd/fastar-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbfd87dbd217b45c898b2dbcd0169aae534b2c1c5cbe3119510881f6a5ac8ef5", size = 763993, upload-time = "2025-11-26T02:33:05.782Z" },
{ url = "https://files.pythonhosted.org/packages/da/e0/9564d24e7cea6321a8d921c6d2a457044a476ef197aa4708e179d3d97f0d/fastar-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5abd99fcba83ef28c8fe6ae2927edc79053db43a0457a962ed85c9bf150d37", size = 930153, upload-time = "2025-11-26T02:33:21.53Z" },
{ url = "https://files.pythonhosted.org/packages/35/b1/6f57fcd8d6e192cfebf97e58eb27751640ad93784c857b79039e84387b51/fastar-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91d4c685620c3a9d6b5ae091dbabab4f98b20049b7ecc7976e19cc9016c0d5d6", size = 821177, upload-time = "2025-11-26T02:33:35.839Z" },
{ url = "https://files.pythonhosted.org/packages/b3/78/9e004ea9f3aa7466f5ddb6f9518780e1d2f0ed3ca55f093632982598bace/fastar-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f77c2f2cad76e9dc7b6701297adb1eba87d0485944b416fc2ccf5516c01219a3", size = 820652, upload-time = "2025-11-26T02:34:09.776Z" },
{ url = "https://files.pythonhosted.org/packages/42/95/b604ed536544005c9f1aee7c4c74b00150db3d8d535cd8232dc20f947063/fastar-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7f07c4a3dada7757a8fc430a5b4a29e6ef696d2212747213f57086ffd970316", size = 985961, upload-time = "2025-11-26T02:34:56.401Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7b/fa9d4d96a5d494bdb8699363bb9de8178c0c21a02e1d89cd6f913d127018/fastar-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90c0c3fe55105c0aed8a83135dbdeb31e683455dbd326a1c48fa44c378b85616", size = 1039316, upload-time = "2025-11-26T02:35:13.807Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f9/8462789243bc3f33e8401378ec6d54de4e20cfa60c96a0e15e3e9d1389bb/fastar-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fb9ee51e5bffe0dab3d3126d3a4fac8d8f7235cedcb4b8e74936087ce1c157f3", size = 1045028, upload-time = "2025-11-26T02:35:31.079Z" },
{ url = "https://files.pythonhosted.org/packages/a5/71/9abb128777e616127194b509e98fcda3db797d76288c1a8c23dd22afc14f/fastar-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e380b1e8d30317f52406c43b11e98d11e1d68723bbd031e18049ea3497b59a6d", size = 994677, upload-time = "2025-11-26T02:35:49.391Z" },
{ url = "https://files.pythonhosted.org/packages/de/c1/b81b3f194853d7ad232a67a1d768f5f51a016f165cfb56cb31b31bbc6177/fastar-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1c4ffc06e9c4a8ca498c07e094670d8d8c0d25b17ca6465b9774da44ea997ab1", size = 456687, upload-time = "2025-11-26T02:36:30.205Z" },
{ url = "https://files.pythonhosted.org/packages/cb/87/9e0cd4768a98181d56f0cdbab2363404cc15deb93f4aad3b99cd2761bbaa/fastar-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:5517a8ad4726267c57a3e0e2a44430b782e00b230bf51c55b5728e758bb3a692", size = 490578, upload-time = "2025-11-26T02:36:16.218Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/580a76cf91847654f2ad6520e956e93218f778540975bc4190d363f709e2/fastar-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:58030551046ff4a8616931e52a36c83545ff05996db5beb6e0cd2b7e748aa309", size = 461473, upload-time = "2025-11-26T02:36:06.373Z" },
{ url = "https://files.pythonhosted.org/packages/58/4c/bdb5c6efe934f68708529c8c9d4055ebef5c4be370621966438f658b29bd/fastar-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1e7d29b6bfecb29db126a08baf3c04a5ab667f6cea2b7067d3e623a67729c4a6", size = 705570, upload-time = "2025-11-26T02:34:42.01Z" },
{ url = "https://files.pythonhosted.org/packages/6d/78/f01ac7e71d5a37621bd13598a26e948a12b85ca8042f7ee1a0a8c9f59cda/fastar-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05eb7b96940f9526b485f1d0b02393839f0f61cac4b1f60024984f8b326d2640", size = 627761, upload-time = "2025-11-26T02:34:26.152Z" },
{ url = "https://files.pythonhosted.org/packages/06/45/6df0ecda86ea9d2e95053c1a655d153dee55fc121b6e13ea6d1e246a50b6/fastar-0.8.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:619352d8ac011794e2345c462189dc02ba634750d23cd9d86a9267dd71b1f278", size = 869414, upload-time = "2025-11-26T02:33:55.618Z" },
{ url = "https://files.pythonhosted.org/packages/b2/72/486421f5a8c0c377cc82e7a50c8a8ea899a6ec2aa72bde8f09fb667a2dc8/fastar-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74ebfecef3fe6d7a90355fac1402fd30636988332a1d33f3e80019a10782bb24", size = 763863, upload-time = "2025-11-26T02:32:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/d4/64/39f654dbb41a3867fb1f2c8081c014d8f1d32ea10585d84cacbef0b32995/fastar-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2975aca5a639e26a3ab0d23b4b0628d6dd6d521146c3c11486d782be621a35aa", size = 763065, upload-time = "2025-11-26T02:33:07.274Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bd/c011a34fb3534c4c3301f7c87c4ffd7e47f6113c904c092ddc8a59a303ea/fastar-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afc438eaed8ff0dcdd9308268be5cb38c1db7e94c3ccca7c498ca13a4a4535a3", size = 930530, upload-time = "2025-11-26T02:33:23.117Z" },
{ url = "https://files.pythonhosted.org/packages/55/9d/aa6e887a7033c571b1064429222bbe09adc9a3c1e04f3d1788ba5838ebd5/fastar-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ced0a5399cc0a84a858ef0a31ca2d0c24d3bbec4bcda506a9192d8119f3590a", size = 820572, upload-time = "2025-11-26T02:33:37.542Z" },
{ url = "https://files.pythonhosted.org/packages/ad/9c/7a3a2278a1052e1a5d98646de7c095a00cffd2492b3b84ce730e2f1cd93a/fastar-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9b23da8c4c039da3fe2e358973c66976a0c8508aa06d6626b4403cb5666c19", size = 820649, upload-time = "2025-11-26T02:34:11.108Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/d38edc1f4438cd047e56137c26d94783ffade42e1b3bde620ccf17b771ef/fastar-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dfba078fcd53478032fd0ceed56960ec6b7ff0511cfc013a8a3a4307e3a7bac4", size = 985653, upload-time = "2025-11-26T02:34:57.884Z" },
{ url = "https://files.pythonhosted.org/packages/69/d9/2147d0c19757e165cd62d41cec3f7b38fad2ad68ab784978b5f81716c7ea/fastar-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ade56c94c14be356d295fecb47a3fcd473dd43a8803ead2e2b5b9e58feb6dcfa", size = 1038140, upload-time = "2025-11-26T02:35:15.778Z" },
{ url = "https://files.pythonhosted.org/packages/7f/1d/ec4c717ffb8a308871e9602ec3197d957e238dc0227127ac573ec9bca952/fastar-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e48d938f9366db5e59441728f70b7f6c1ccfab7eff84f96f9b7e689b07786c52", size = 1045195, upload-time = "2025-11-26T02:35:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/637334dc8c8f3bb391388b064ae13f0ad9402bc5a6c3e77b8887d0c31921/fastar-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:79c441dc1482ff51a54fb3f57ae6f7bb3d2cff88fa2cc5d196c519f8aab64a56", size = 994686, upload-time = "2025-11-26T02:35:51.392Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" },
{ url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" },
{ url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" },
{ 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" },
]
[[package]]
@ -425,38 +378,38 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ 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/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
@ -466,21 +419,14 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
{ 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" },
]
[[package]]
@ -511,13 +457,13 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
{ 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" },
]
[[package]]
@ -580,31 +526,24 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714, upload-time = "2025-11-09T20:48:15.083Z" },
{ url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989, upload-time = "2025-11-09T20:48:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615, upload-time = "2025-11-09T20:48:18.614Z" },
{ url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745, upload-time = "2025-11-09T20:48:20.117Z" },
{ url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502, upload-time = "2025-11-09T20:48:21.543Z" },
{ url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845, upload-time = "2025-11-09T20:48:22.964Z" },
{ url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701, upload-time = "2025-11-09T20:48:24.483Z" },
{ url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029, upload-time = "2025-11-09T20:48:25.749Z" },
{ url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960, upload-time = "2025-11-09T20:48:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529, upload-time = "2025-11-09T20:48:29.125Z" },
{ url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974, upload-time = "2025-11-09T20:48:30.87Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932, upload-time = "2025-11-09T20:48:32.658Z" },
{ url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243, upload-time = "2025-11-09T20:48:34.093Z" },
{ url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315, upload-time = "2025-11-09T20:48:35.507Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714, upload-time = "2025-11-09T20:48:40.014Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168, upload-time = "2025-11-09T20:48:41.462Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893, upload-time = "2025-11-09T20:48:42.921Z" },
{ url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828, upload-time = "2025-11-09T20:48:44.278Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009, upload-time = "2025-11-09T20:48:45.726Z" },
{ url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110, upload-time = "2025-11-09T20:48:47.033Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223, upload-time = "2025-11-09T20:48:49.076Z" },
{ url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564, upload-time = "2025-11-09T20:48:50.376Z" },
{ 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" },
]
[[package]]
@ -625,28 +564,28 @@ 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/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
{ 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" },
]
[[package]]
@ -664,42 +603,42 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" },
{ url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" },
{ url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" },
{ url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" },
{ url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" },
{ url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" },
{ url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" },
{ url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" },
{ url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" },
{ url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" },
{ url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" },
{ url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" },
{ url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" },
{ url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" },
{ url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" },
{ url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" },
{ url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" },
{ url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" },
{ url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" },
{ url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" },
{ url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" },
{ url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" },
{ url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" },
{ url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" },
{ url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" },
{ url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" },
{ 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/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" },
]
@ -718,6 +657,7 @@ dependencies = [
{ name = "openai" },
{ name = "playwright" },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "redis" },
@ -746,6 +686,7 @@ requires-dist = [
{ name = "openai", specifier = ">=2.13.0" },
{ name = "playwright", specifier = ">=1.57.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.21" },
{ name = "redis", specifier = ">=7.1.0" },
@ -824,36 +765,36 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
{ url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
{ url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
{ url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
{ url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
{ url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
{ url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
{ url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
{ url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
{ url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
{ url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
{ url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
{ url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
{ url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
{ url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
{ url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
{ url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
{ url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
{ url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
{ url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
{ url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
{ url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
{ 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/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
@ -904,34 +845,20 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ 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" },
{ 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" },
]
[[package]]
@ -1062,24 +989,16 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
{ 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" },
]
[[package]]
@ -1124,36 +1043,21 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" },
{ url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" },
{ url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" },
{ url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" },
{ url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" },
{ url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" },
{ url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" },
{ url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" },
{ url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" },
{ url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" },
{ url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" },
{ url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" },
{ url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" },
{ url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" },
{ url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" },
{ url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" },
{ url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" },
{ url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
{ 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" },
]
[[package]]
@ -1283,17 +1187,17 @@ dependencies = [
]
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/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/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
{ 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" },
]
@ -1410,18 +1314,12 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
{ 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" },
]
[[package]]
@ -1433,29 +1331,29 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
{ 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" },
]
[[package]]
@ -1464,24 +1362,15 @@ version = "16.0"
source = { registry = "https://pypi.org/simple" }
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/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/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ 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" },
]
@ -1508,37 +1397,37 @@ 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/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" },
{ url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
{ url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
{ url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
{ url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
{ url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
{ url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
{ url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
{ url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
{ url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
{ url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
{ url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
{ url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
{ url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
{ url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
{ url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
{ url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
{ url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
{ url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
{ url = "https://files.pythonhosted.org/packages/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/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
]