Compare commits
46 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
219e7ed7c0 | |
|
|
a876825f82 | |
|
|
a4db70c2e6 | |
|
|
172586e699 | |
|
|
b3354d4ad1 | |
|
|
1398546dac | |
|
|
9d074632bc | |
|
|
7f0ae81351 | |
|
|
c89e510c98 | |
|
|
ab8d362aa0 | |
|
|
157d1b1ad9 | |
|
|
f1dd675ecb | |
|
|
ada5dfeeb4 | |
|
|
18635d7995 | |
|
|
54e66e4682 | |
|
|
bc2342163f | |
|
|
34e0cada48 | |
|
|
4e87c76b35 | |
|
|
bc777ba66c | |
|
|
e29e10eb29 | |
|
|
40afe9392c | |
|
|
325fb9af69 | |
|
|
e19c8c9d62 | |
|
|
369e572b0a | |
|
|
f6ce81e14e | |
|
|
32c6c210a0 | |
|
|
c207b8a48f | |
|
|
0d34aa7f99 | |
|
|
dd16013816 | |
|
|
9d92b5d42c | |
|
|
f24ff46b09 | |
|
|
89ea0c783e | |
|
|
c568f949c7 | |
|
|
96597dd555 | |
|
|
5a77d22c9f | |
|
|
f208e93420 | |
|
|
08a699648d | |
|
|
29dd08081b | |
|
|
2cb9d67a70 | |
|
|
e89709ce87 | |
|
|
e1386b891e | |
|
|
8c7893d989 | |
|
|
f97ecb29e9 | |
|
|
5700965fae | |
|
|
ef203dc14d | |
|
|
c92d6e2135 |
|
|
@ -1 +1 @@
|
|||
3.14
|
||||
3.13.11
|
||||
|
|
|
|||
|
|
@ -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": "삭제 요청 성공"},
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -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)))
|
||||
return str(await _shipment_verification_codes.get(str(id)))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]}...")
|
||||
|
|
@ -280,7 +286,16 @@ async def _crawling_logic(url:str):
|
|||
step3_3_start = time.perf_counter()
|
||||
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 추출 실패",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}')>"
|
||||
)
|
||||
|
|
@ -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):
|
||||
"""업로드된 이미지 결과 아이템"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""미디어 컨테이너 상태"""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
"""
|
||||
Social Media Integration Module
|
||||
|
||||
소셜 미디어 플랫폼 연동 및 영상 업로드 기능을 제공합니다.
|
||||
|
||||
지원 플랫폼:
|
||||
- YouTube (구현됨)
|
||||
- Instagram (추후 구현)
|
||||
- Facebook (추후 구현)
|
||||
- TikTok (추후 구현)
|
||||
"""
|
||||
|
||||
from app.social.constants import SocialPlatform, UploadStatus
|
||||
|
||||
__all__ = ["SocialPlatform", "UploadStatus"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Social API Module
|
||||
"""
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Social API Routers
|
||||
"""
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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} 계정 연동이 해제되었습니다.",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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="업로드가 취소되었습니다.",
|
||||
)
|
||||
|
|
@ -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",
|
||||
# ]
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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")>"
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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": "작업이 완료되었습니다.",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
Social Worker Module
|
||||
|
||||
소셜 미디어 백그라운드 태스크 모듈입니다.
|
||||
"""
|
||||
|
||||
from app.social.worker.upload_task import process_social_upload
|
||||
|
||||
__all__ = ["process_social_upload"]
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""토큰 갱신 요청"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)})")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
wait_first_start = time.perf_counter()
|
||||
|
||||
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_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
|
||||
|
||||
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}"
|
||||
|
||||
await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
|
||||
|
||||
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)
|
||||
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
lyric_prompt._reload_prompt()
|
||||
yt_upload_prompt._reload_prompt()
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
from .lyric import LyricPromptInput, LyricPromptOutput
|
||||
from .marketing import MarketingPromptInput, MarketingPromptOutput
|
||||
from .marketing import MarketingPromptInput, MarketingPromptOutput
|
||||
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
|
||||
|
|
@ -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="브랜드 아이덴티티")
|
||||
|
|
|
|||
|
|
@ -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 최적화")
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 brand’s 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 2–3 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 1–2 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: 45–65 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 (3–4 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 3–5 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 (1–2)
|
||||
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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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)}",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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="생성 일시")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
155
config.py
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Binary file not shown.
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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
60
main.py
|
|
@ -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 모드에서만 테스트 라우터 등록
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
771
uv.lock
|
|
@ -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" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue